Compare commits
6 Commits
c4cd5fdc90
...
44f7af2017
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f7af2017 | ||
|
|
9670af62b6 | ||
|
|
a8b9b0f5e8 | ||
|
|
3c36cf1c40 | ||
|
|
2a249b8a48 | ||
|
|
a7c7c44843 |
5
Android/.gitignore
vendored
5
Android/.gitignore
vendored
@ -14,3 +14,8 @@ local.properties
|
|||||||
*.aab
|
*.aab
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
|
||||||
|
# machine (and the published companion download) signs debug builds identically —
|
||||||
|
# updates then install over the top without an uninstall. Debug keys are not
|
||||||
|
# secret (well-known password "android"); never commit a real release keystore.
|
||||||
|
!/app/debug.keystore
|
||||||
|
|||||||
@ -11,20 +11,40 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "0.4.8"
|
versionName = "0.4.9"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
|
||||||
|
// machine — and the published companion download — signs debug builds with
|
||||||
|
// the SAME key. Without this, Gradle falls back to each machine's
|
||||||
|
// ~/.android/debug.keystore, so a build from a different machine has a
|
||||||
|
// different signature and the phone rejects the update ("App not installed").
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
|
||||||
|
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
|
||||||
|
// a v2-only sideload with "App not installed" — keep v1 for max compat.
|
||||||
|
enableV1Signing = true
|
||||||
|
enableV2Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
// Separate app ID so a debug/test build installs alongside the
|
// Separate app ID so a debug/test build installs alongside the
|
||||||
// release app instead of colliding on signature.
|
// release app instead of colliding on signature.
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
versionNameSuffix = "-debug"
|
versionNameSuffix = "-debug"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
|||||||
BIN
Android/app/debug.keystore
Normal file
BIN
Android/app/debug.keystore
Normal file
Binary file not shown.
@ -1,33 +1,21 @@
|
|||||||
package com.archipelago.app.ui.screens
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Base64
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.URLUtil
|
|
||||||
import android.webkit.ValueCallback
|
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -41,29 +29,24 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -71,6 +54,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@ -82,6 +67,8 @@ import com.archipelago.app.ui.theme.BitcoinOrange
|
|||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||||
@ -143,159 +130,6 @@ private fun WebView.applyArchipelagoSettings() {
|
|||||||
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mainHandler() = android.os.Handler(android.os.Looper.getMainLooper())
|
|
||||||
|
|
||||||
private fun toast(context: Context, msg: String) {
|
|
||||||
mainHandler().post { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save raw bytes (decoded from a base64 blob/data download) to the device's
|
|
||||||
* Downloads. Uses MediaStore on API 29+ (no permission needed) and the app's
|
|
||||||
* external files dir on older devices (also permission-free). */
|
|
||||||
private fun saveBase64ToDownloads(
|
|
||||||
context: Context,
|
|
||||||
base64: String,
|
|
||||||
mime: String,
|
|
||||||
filename: String,
|
|
||||||
): Boolean {
|
|
||||||
return try {
|
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
|
||||||
val name = filename.ifBlank { "download_${System.currentTimeMillis()}" }
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
|
||||||
if (mime.isNotBlank()) put(MediaStore.Downloads.MIME_TYPE, mime)
|
|
||||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
|
||||||
}
|
|
||||||
val resolver = context.contentResolver
|
|
||||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
|
||||||
?: return false
|
|
||||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false
|
|
||||||
values.clear()
|
|
||||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
|
||||||
resolver.update(uri, values, null, null)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
if (dir != null && !dir.exists()) dir.mkdirs()
|
|
||||||
java.io.File(dir, name).outputStream().use { it.write(bytes) }
|
|
||||||
true
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hand an http(s) download to the system DownloadManager, forwarding the
|
|
||||||
* WebView's session cookies so authenticated node files (e.g. peer-file
|
|
||||||
* streams) download instead of 401-ing. */
|
|
||||||
private fun enqueueHttpDownload(
|
|
||||||
context: Context,
|
|
||||||
url: String,
|
|
||||||
userAgent: String?,
|
|
||||||
contentDisposition: String?,
|
|
||||||
mimeType: String?,
|
|
||||||
) {
|
|
||||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
|
||||||
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
|
||||||
setMimeType(mimeType)
|
|
||||||
CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
|
|
||||||
if (!userAgent.isNullOrEmpty()) addRequestHeader("User-Agent", userAgent)
|
|
||||||
setTitle(name)
|
|
||||||
setDescription("Downloading…")
|
|
||||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
|
|
||||||
} else {
|
|
||||||
setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
dm.enqueue(request)
|
|
||||||
toast(context, "Downloading $name")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** JS that reads a blob: URL the WebView itself created (DownloadManager can't
|
|
||||||
* fetch blob URLs) and hands the bytes back via the ArchipelagoDownload bridge. */
|
|
||||||
private fun blobToBase64Js(blobUrl: String, mime: String, filename: String): String = """
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('GET', '$blobUrl', true);
|
|
||||||
xhr.responseType = 'blob';
|
|
||||||
xhr.onload = function() {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onloadend = function() {
|
|
||||||
var dataUrl = reader.result || '';
|
|
||||||
var base64 = dataUrl.indexOf(',') >= 0 ? dataUrl.split(',')[1] : '';
|
|
||||||
var type = (xhr.response && xhr.response.type) ? xhr.response.type : '$mime';
|
|
||||||
ArchipelagoDownload.saveBase64(base64, type, '$filename');
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(xhr.response);
|
|
||||||
};
|
|
||||||
xhr.send();
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
/** Enable file downloads for this WebView: a JS bridge for blob/data URLs plus a
|
|
||||||
* DownloadListener that routes http(s) through DownloadManager and blob/data
|
|
||||||
* through MediaStore. Safe to call once per WebView. */
|
|
||||||
@SuppressLint("JavascriptInterface")
|
|
||||||
private fun WebView.enableFileDownloads(context: Context) {
|
|
||||||
addJavascriptInterface(
|
|
||||||
object {
|
|
||||||
@android.webkit.JavascriptInterface
|
|
||||||
fun saveBase64(base64: String, mime: String, filename: String) {
|
|
||||||
val ok = saveBase64ToDownloads(context, base64, mime, filename)
|
|
||||||
toast(context, if (ok) "Saved $filename to Downloads" else "Save failed")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ArchipelagoDownload",
|
|
||||||
)
|
|
||||||
setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
|
||||||
try {
|
|
||||||
when {
|
|
||||||
url.startsWith("blob:") -> {
|
|
||||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
|
||||||
evaluateJavascript(blobToBase64Js(url, mimeType ?: "", name), null)
|
|
||||||
}
|
|
||||||
url.startsWith("data:") -> {
|
|
||||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
|
||||||
val comma = url.indexOf(',')
|
|
||||||
val b64 = if (comma >= 0) url.substring(comma + 1) else ""
|
|
||||||
val ok = saveBase64ToDownloads(context, b64, mimeType ?: "", name)
|
|
||||||
toast(context, if (ok) "Saved $name to Downloads" else "Save failed")
|
|
||||||
}
|
|
||||||
else -> enqueueHttpDownload(context, url, userAgent, contentDisposition, mimeType)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
toast(context, "Download failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
|
||||||
* can be shown on the loading splash before the WebView reports onReceivedIcon
|
|
||||||
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
|
||||||
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
|
||||||
return try {
|
|
||||||
val u = Uri.parse(pageUrl)
|
|
||||||
val scheme = u.scheme ?: return null
|
|
||||||
val host = u.host ?: return null
|
|
||||||
val portPart = if (u.port > 0) ":${u.port}" else ""
|
|
||||||
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
|
||||||
as java.net.HttpURLConnection).apply {
|
|
||||||
connectTimeout = 4000
|
|
||||||
readTimeout = 4000
|
|
||||||
instanceFollowRedirects = true
|
|
||||||
}
|
|
||||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||||
@Composable
|
@Composable
|
||||||
fun WebViewScreen(
|
fun WebViewScreen(
|
||||||
@ -308,37 +142,6 @@ fun WebViewScreen(
|
|||||||
var hasError by remember { mutableStateOf(false) }
|
var hasError by remember { mutableStateOf(false) }
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
// System file-picker plumbing for <input type="file">. onShowFileChooser
|
|
||||||
// stashes the WebView's callback here; the launcher result delivers the
|
|
||||||
// picked URIs back to it (or null on cancel, so the input doesn't hang).
|
|
||||||
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
|
|
||||||
val fileChooserLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
|
||||||
) { result ->
|
|
||||||
val cb = pendingFileCallback.value
|
|
||||||
pendingFileCallback.value = null
|
|
||||||
cb?.onReceiveValue(
|
|
||||||
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val showFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean =
|
|
||||||
{ callback, params ->
|
|
||||||
pendingFileCallback.value?.onReceiveValue(null)
|
|
||||||
pendingFileCallback.value = callback
|
|
||||||
try {
|
|
||||||
val intent = params?.createIntent()
|
|
||||||
?: Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "*/*"
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
}
|
|
||||||
fileChooserLauncher.launch(intent)
|
|
||||||
true
|
|
||||||
} catch (_: Exception) {
|
|
||||||
pendingFileCallback.value = null
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A node app that refused iframing, opened in a local WebView overlay.
|
// A node app that refused iframing, opened in a local WebView overlay.
|
||||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||||
// while this is shown, so closing it returns instantly with no reload.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
@ -427,7 +230,6 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
enableFileDownloads(context)
|
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
@ -539,13 +341,6 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the system file browser for <input type="file">.
|
|
||||||
override fun onShowFileChooser(
|
|
||||||
webView: WebView?,
|
|
||||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
|
||||||
fileChooserParams: FileChooserParams?,
|
|
||||||
): Boolean = showFileChooser(filePathCallback, fileChooserParams)
|
|
||||||
|
|
||||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||||
// for an app that can't be iframed. Capture the target
|
// for an app that can't be iframed. Capture the target
|
||||||
// URL via a throwaway WebView and route it ourselves.
|
// URL via a throwaway WebView and route it ourselves.
|
||||||
@ -640,33 +435,40 @@ fun WebViewScreen(
|
|||||||
url = target,
|
url = target,
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
onClose = { inAppUrl = null },
|
onClose = { inAppUrl = null },
|
||||||
onShowFileChooser = showFileChooser,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One control in the in-app browser's bottom bar — sized and styled to match
|
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
||||||
* the web app-session mobile bar (52dp tap target, 24dp glyph, muted white). */
|
* can be shown on the loading screen before the WebView reports onReceivedIcon
|
||||||
@Composable
|
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
||||||
private fun FooterButton(iconRes: Int, descRes: Int, onClick: () -> Unit) {
|
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||||
IconButton(onClick = onClick, modifier = Modifier.size(52.dp)) {
|
return try {
|
||||||
Icon(
|
val u = android.net.Uri.parse(pageUrl)
|
||||||
painter = painterResource(iconRes),
|
val scheme = u.scheme ?: return null
|
||||||
contentDescription = stringResource(descRes),
|
val host = u.host ?: return null
|
||||||
tint = Color.White.copy(alpha = 0.72f),
|
val portPart = if (u.port > 0) ":${u.port}" else ""
|
||||||
modifier = Modifier.size(24.dp),
|
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
||||||
)
|
as java.net.HttpURLConnection).apply {
|
||||||
|
connectTimeout = 4000
|
||||||
|
readTimeout = 4000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
||||||
* shown in an iframe. Loads the app in a local WebView with a loading splash
|
* shown in an iframe. Loads the app in a local WebView with a centered loading
|
||||||
* (app icon + progress) and a bottom control bar matching the web app-session
|
* screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
|
||||||
* mobile bar. Same-host navigation stays here; any genuinely external link
|
* web mobile-iframe footer (back / forward / reload / open-in-browser / close).
|
||||||
* escapes to the phone's browser.
|
* Same-host navigation stays here; any genuinely external link escapes to the
|
||||||
|
* phone's browser.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
@ -674,21 +476,23 @@ private fun InAppBrowser(
|
|||||||
url: String,
|
url: String,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onShowFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||||
|
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
// The launched app's icon, shown on the loading splash. Seeded by a
|
var canGoBack by remember { mutableStateOf(false) }
|
||||||
// best-effort favicon fetch, then upgraded to the real icon once the
|
var canGoForward by remember { mutableStateOf(false) }
|
||||||
// WebView reports onReceivedIcon.
|
|
||||||
var appIcon by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
|
|
||||||
|
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||||
|
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||||
|
// loader shows an icon right away instead of staying blank until the page
|
||||||
|
// parses its <head> (which is what made the loader look stuck).
|
||||||
LaunchedEffect(url) {
|
LaunchedEffect(url) {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
||||||
if (fetched != null && appIcon == null) appIcon = fetched
|
if (fetched != null && favicon == null) favicon = fetched
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back: walk the in-app history first, then close the overlay.
|
// Back: walk the in-app history first, then close the overlay.
|
||||||
@ -700,16 +504,11 @@ private fun InAppBrowser(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack),
|
.background(SurfaceBlack)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
// Content area: the app WebView, with a thin progress-bar loader pinned
|
// WebView + loading overlay fill the area above the bottom control bar.
|
||||||
// to the top edge while the page loads.
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.statusBars),
|
|
||||||
) {
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@ -723,7 +522,6 @@ private fun InAppBrowser(
|
|||||||
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
enableFileDownloads(ctx)
|
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
webChromeClient = object : WebChromeClient() {
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
@ -735,24 +533,24 @@ private fun InAppBrowser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||||
if (icon != null) appIcon = icon
|
if (icon != null) favicon = icon
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShowFileChooser(
|
|
||||||
webView: WebView?,
|
|
||||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
|
||||||
fileChooserParams: FileChooserParams?,
|
|
||||||
): Boolean = onShowFileChooser(filePathCallback, fileChooserParams)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
loading = true
|
loading = true
|
||||||
if (favicon != null) appIcon = favicon
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
loading = false
|
loading = false
|
||||||
|
canGoBack = view?.canGoBack() == true
|
||||||
|
canGoForward = view?.canGoForward() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||||
|
canGoBack = view?.canGoBack() == true
|
||||||
|
canGoForward = view?.canGoForward() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
@ -774,91 +572,93 @@ private fun InAppBrowser(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Loading splash — app icon + progress bar, covering the white
|
// Centered loading screen — app favicon (or spinner) + title + bar.
|
||||||
// first-paint flash until the app's own UI is ready.
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack)
|
.background(SurfaceBlack),
|
||||||
.padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
val icon = appIcon
|
Box(
|
||||||
if (icon != null) {
|
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
|
||||||
Image(
|
contentAlignment = Alignment.Center,
|
||||||
bitmap = icon.asImageBitmap(),
|
) {
|
||||||
contentDescription = null,
|
val fav = favicon
|
||||||
modifier = Modifier
|
if (fav != null) {
|
||||||
.size(64.dp)
|
Image(
|
||||||
.clip(RoundedCornerShape(14.dp)),
|
bitmap = fav.asImageBitmap(),
|
||||||
)
|
contentDescription = title,
|
||||||
} else {
|
modifier = Modifier.fillMaxSize(),
|
||||||
Icon(
|
)
|
||||||
imageVector = Icons.Default.Public,
|
} else {
|
||||||
contentDescription = null,
|
CircularProgressIndicator(color = BitcoinOrange)
|
||||||
tint = TextMuted,
|
}
|
||||||
modifier = Modifier.size(64.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { progress / 100f },
|
progress = { progress / 100f },
|
||||||
modifier = Modifier.fillMaxWidth(0.55f),
|
modifier = Modifier.width(220.dp),
|
||||||
color = BitcoinOrange,
|
color = BitcoinOrange,
|
||||||
trackColor = SurfaceBlack,
|
trackColor = TextMuted.copy(alpha = 0.2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile footer controls — matched to the web app-session bar: five
|
// Bottom control bar — mirrors the web mobile-iframe footer.
|
||||||
// evenly-spaced buttons over a translucent dark bar with a hairline top
|
Row(
|
||||||
// border, sitting above the system navigation bar.
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xF2141414)),
|
.height(56.dp)
|
||||||
|
.background(SurfaceBlack)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceAround,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
|
||||||
modifier = Modifier
|
Icon(
|
||||||
.fillMaxWidth()
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
.height(1.dp)
|
contentDescription = "Back",
|
||||||
.background(Color(0x14FFFFFF)),
|
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
Row(
|
}
|
||||||
modifier = Modifier
|
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
|
||||||
.fillMaxWidth()
|
Icon(
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
.heightIn(min = 64.dp)
|
contentDescription = "Forward",
|
||||||
.padding(vertical = 6.dp, horizontal = 8.dp),
|
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
}
|
||||||
) {
|
IconButton(onClick = { browser?.reload() }) {
|
||||||
FooterButton(R.drawable.ic_nav_back, R.string.back) {
|
Icon(
|
||||||
browser?.let { if (it.canGoBack()) it.goBack() }
|
imageVector = Icons.Default.Refresh,
|
||||||
}
|
contentDescription = "Reload",
|
||||||
FooterButton(R.drawable.ic_nav_forward, R.string.forward) {
|
tint = TextPrimary,
|
||||||
browser?.let { if (it.canGoForward()) it.goForward() }
|
)
|
||||||
}
|
}
|
||||||
FooterButton(R.drawable.ic_nav_refresh, R.string.refresh) {
|
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||||
browser?.reload()
|
Icon(
|
||||||
}
|
imageVector = Icons.Default.OpenInBrowser,
|
||||||
FooterButton(R.drawable.ic_nav_newtab, R.string.open_in_browser) {
|
contentDescription = stringResource(R.string.open_in_browser),
|
||||||
openExternalUrl(context, browser?.url ?: url)
|
tint = TextPrimary,
|
||||||
}
|
)
|
||||||
FooterButton(R.drawable.ic_nav_close, R.string.close) {
|
}
|
||||||
onClose()
|
IconButton(onClick = onClose) {
|
||||||
}
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.close),
|
||||||
|
tint = TextPrimary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
apps/netbird-dashboard/manifest.yml
Normal file
77
apps/netbird-dashboard/manifest.yml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
app:
|
||||||
|
id: netbird-dashboard
|
||||||
|
name: NetBird Dashboard
|
||||||
|
version: "2.38.0"
|
||||||
|
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption).
|
||||||
|
# Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to.
|
||||||
|
container_name: netbird-dashboard
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/netbirdio/dashboard:v2.38.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
network_aliases: [netbird-dashboard]
|
||||||
|
# The dashboard SPA bakes its API/OIDC base URL from these at container
|
||||||
|
# start. They must point at the proxy's public HTTPS origin (8087) so the
|
||||||
|
# browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15).
|
||||||
|
# {{HOST_IP}} is the node's primary host IP, resolved at apply time.
|
||||||
|
derived_env:
|
||||||
|
- key: NETBIRD_MGMT_API_ENDPOINT
|
||||||
|
template: "https://{{HOST_IP}}:8087"
|
||||||
|
- key: NETBIRD_MGMT_GRPC_API_ENDPOINT
|
||||||
|
template: "https://{{HOST_IP}}:8087"
|
||||||
|
- key: AUTH_AUTHORITY
|
||||||
|
template: "https://{{HOST_IP}}:8087/oauth2"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: netbird-server
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. The dashboard image runs
|
||||||
|
# nginx (master as root, drops workers) binding :80 — needs the worker-drop
|
||||||
|
# caps + NET_BIND_SERVICE for the privileged port.
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
# Internal only — reached container-to-container by the proxy via netbird-net.
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- AUTH_AUDIENCE=netbird-dashboard
|
||||||
|
- AUTH_CLIENT_ID=netbird-dashboard
|
||||||
|
- AUTH_CLIENT_SECRET=
|
||||||
|
- USE_AUTH0=false
|
||||||
|
- AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||||
|
- AUTH_REDIRECT_URI=/nb-auth
|
||||||
|
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||||
|
- NETBIRD_TOKEN_SOURCE=idToken
|
||||||
|
- NGINX_SSL_PORT=443
|
||||||
|
- LETSENCRYPT_DOMAIN=none
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/dashboard
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- dashboard
|
||||||
122
apps/netbird-server/manifest.yml
Normal file
122
apps/netbird-server/manifest.yml
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
app:
|
||||||
|
id: netbird-server
|
||||||
|
name: NetBird Server
|
||||||
|
version: "0.71.2"
|
||||||
|
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# Hyphen name matches the runtime references (crash_recovery / dependencies /
|
||||||
|
# config startup order) + the live container, so on an existing node the
|
||||||
|
# orchestrator ADOPTS the running server rather than recreating it (data +
|
||||||
|
# the sqlite store under /var/lib/netbird preserved). Alias `netbird-server`
|
||||||
|
# is the short hostname the proxy's nginx proxies/grpc-passes to.
|
||||||
|
container_name: netbird-server
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/netbirdio/netbird-server:0.71.2
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
network_aliases: [netbird-server]
|
||||||
|
# The relay authSecret and the sqlite store encryptionKey are base64 keys
|
||||||
|
# (the server base64-decodes them to recover raw bytes — hex would decode to
|
||||||
|
# the wrong value). Generated once and reused: ensure_generated_secrets
|
||||||
|
# no-ops when the file already exists, so a re-render of config.yaml on an
|
||||||
|
# adopted node keeps the same keys (regenerating would orphan the store).
|
||||||
|
generated_secrets:
|
||||||
|
- name: netbird-relay-auth-secret
|
||||||
|
kind: base64
|
||||||
|
- name: netbird-store-encryption-key
|
||||||
|
kind: base64
|
||||||
|
# Pass the rendered config explicitly, mirroring the legacy `--config` arg.
|
||||||
|
custom_args: ["--config", "/etc/netbird/config.yaml"]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. The server binds :80
|
||||||
|
# (management/signal/relay HTTP + gRPC) inside the container — a privileged
|
||||||
|
# port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged).
|
||||||
|
capabilities: [NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8086
|
||||||
|
container: 80
|
||||||
|
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
|
||||||
|
- host: 3478
|
||||||
|
container: 3478
|
||||||
|
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/data
|
||||||
|
target: /var/lib/netbird
|
||||||
|
options: [rw]
|
||||||
|
# The rendered config.yaml, read-only. Re-rendered on every reconcile from
|
||||||
|
# host facts + the base64 secrets; idempotent (stable bytes → no restart).
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/config.yaml
|
||||||
|
target: /etc/netbird/config.yaml
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
# The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's
|
||||||
|
# public origin is https on 8087 — the dashboard needs a secure context for
|
||||||
|
# OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir.
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/netbird/config.yaml
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
server:
|
||||||
|
listenAddress: ":80"
|
||||||
|
exposedAddress: "https://{{HOST_IP}}:8087"
|
||||||
|
stunPorts:
|
||||||
|
- 3478
|
||||||
|
metricsPort: 9090
|
||||||
|
healthcheckAddress: ":9000"
|
||||||
|
logLevel: "info"
|
||||||
|
logFile: "console"
|
||||||
|
authSecret: "{{secret:netbird-relay-auth-secret}}"
|
||||||
|
dataDir: "/var/lib/netbird"
|
||||||
|
auth:
|
||||||
|
issuer: "https://{{HOST_IP}}:8087/oauth2"
|
||||||
|
localAuthDisabled: false
|
||||||
|
signKeyRefreshEnabled: false
|
||||||
|
dashboardRedirectURIs:
|
||||||
|
- "https://{{HOST_IP}}:8087/nb-auth"
|
||||||
|
- "https://{{HOST_IP}}:8087/nb-silent-auth"
|
||||||
|
dashboardPostLogoutRedirectURIs:
|
||||||
|
- "https://{{HOST_IP}}:8087/"
|
||||||
|
cliRedirectURIs:
|
||||||
|
- "http://localhost:53000/"
|
||||||
|
store:
|
||||||
|
engine: "sqlite"
|
||||||
|
encryptionKey: "{{secret:netbird-store-encryption-key}}"
|
||||||
|
|
||||||
|
# TCP liveness on the management port. Binds at startup, stays green; an http
|
||||||
|
# check of /oauth2 would false-fail while the issuer warms up.
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/netbird
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- wireguard
|
||||||
|
- mesh
|
||||||
182
apps/netbird/manifest.yml
Normal file
182
apps/netbird/manifest.yml
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
app:
|
||||||
|
id: netbird
|
||||||
|
name: NetBird
|
||||||
|
version: "2.38.0"
|
||||||
|
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# The user-facing launcher (app_id + container both "netbird", matching the
|
||||||
|
# runtime references + the live container so the orchestrator adopts it). This
|
||||||
|
# is the nginx that terminates TLS on 8087 and fans out to the dashboard +
|
||||||
|
# server by their short aliases on netbird-net.
|
||||||
|
container_name: netbird
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/library/nginx:1.27-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
# Self-signed TLS cert materialised before create — the dashboard needs a
|
||||||
|
# secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy
|
||||||
|
# serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user
|
||||||
|
# accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost.
|
||||||
|
generated_certs:
|
||||||
|
- crt: /var/lib/archipelago/netbird/tls.crt
|
||||||
|
key: /var/lib/archipelago/netbird/tls.key
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: netbird-server
|
||||||
|
- app_id: netbird-dashboard
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||||
|
# workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE.
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# 8087 publishes the TLS listener (container :443). HTTPS is required for the
|
||||||
|
# dashboard's secure context (issue #15).
|
||||||
|
- host: 8087
|
||||||
|
container: 443
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/nginx.conf
|
||||||
|
target: /etc/nginx/conf.d/default.conf
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/tls.crt
|
||||||
|
target: /etc/nginx/tls.crt
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/tls.key
|
||||||
|
target: /etc/nginx/tls.key
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
# The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway =
|
||||||
|
# Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE
|
||||||
|
# upstreams so it re-resolves container names per request — without it nginx
|
||||||
|
# pins a container IP at startup and 502s forever once that IP moves on a
|
||||||
|
# restart/reboot (issue #15, observed live on .198). Every #15 fix below
|
||||||
|
# (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to
|
||||||
|
# index.html, /relay websocket) is preserved verbatim from the legacy config.
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/netbird/nginx.conf
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# netbird's dashboard needs a secure context (window.crypto.subtle for
|
||||||
|
# OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15).
|
||||||
|
ssl_certificate /etc/nginx/tls.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/tls.key;
|
||||||
|
|
||||||
|
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||||
|
# nginx resolves a literal upstream name ONCE at startup and caches it,
|
||||||
|
# so after the IP moves every request 502s with "host unreachable"
|
||||||
|
# (issue #15, observed live on .198: nginx pinned to a dead
|
||||||
|
# netbird-dashboard IP). Fix: point `resolver` at the netbird-net
|
||||||
|
# gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which
|
||||||
|
# forces nginx to re-resolve the container names at request time.
|
||||||
|
resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
location ~ ^/(relay|ws-proxy/) {
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
proxy_pass http://$nb_server:80;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(api|oauth2)(/|$) {
|
||||||
|
# The dashboard is a SPA whose API/OIDC base URL is baked at build
|
||||||
|
# time to one host:port. A single box is reached via several
|
||||||
|
# addresses, so those fetches are cross-origin and the browser
|
||||||
|
# blocks them with no Access-Control-Allow-Origin (#15, live on
|
||||||
|
# .198). Reflect the caller's Origin and answer the CORS preflight.
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
proxy_pass http://$nb_server:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
grpc_pass grpc://$nb_server:80;
|
||||||
|
grpc_read_timeout 1d;
|
||||||
|
grpc_send_timeout 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OIDC callback routes are client-side SPA routes with NO prebuilt page
|
||||||
|
# in the dashboard bundle, so proxying them straight through 404s —
|
||||||
|
# which crashes the dashboard's auth init and shows "Unauthenticated"
|
||||||
|
# with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth
|
||||||
|
# returned 404). Serve index.html at these paths (URL unchanged) so
|
||||||
|
# react-oidc boots and completes the login / silent-SSO.
|
||||||
|
location ~ ^/(nb-auth|nb-silent-auth) {
|
||||||
|
set $nb_dashboard netbird-dashboard;
|
||||||
|
rewrite ^.*$ /index.html break;
|
||||||
|
proxy_pass http://$nb_dashboard:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $nb_dashboard netbird-dashboard;
|
||||||
|
proxy_pass http://$nb_dashboard:80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:443
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Dashboard
|
||||||
|
description: Manage your self-hosted NetBird mesh VPN
|
||||||
|
type: ui
|
||||||
|
port: 8087
|
||||||
|
protocol: https
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/netbird
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- wireguard
|
||||||
|
- mesh
|
||||||
@ -696,6 +696,16 @@ fn immich_stack_app_ids() -> &'static [&'static str] {
|
|||||||
&["immich-postgres", "immich-redis", "immich"]
|
&["immich-postgres", "immich-redis", "immich"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn netbird_stack_app_ids() -> &'static [&'static str] {
|
||||||
|
// Dependency/startup order: the combined management/signal/relay server
|
||||||
|
// first (it owns the base64 relay/store secrets + the sqlite store, and is
|
||||||
|
// the OIDC issuer the others point at), then the dashboard SPA, then the
|
||||||
|
// user-facing TLS proxy ("netbird", which carries the self-signed cert +
|
||||||
|
// the templated nginx.conf and is the launcher). Mirrors the netbird
|
||||||
|
// startup_order in dependencies.rs.
|
||||||
|
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||||
|
}
|
||||||
|
|
||||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||||
// Dependency order: backends + their generated secrets first, then the api
|
// Dependency order: backends + their generated secrets first, then the api
|
||||||
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
||||||
@ -1828,6 +1838,23 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
||||||
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
// Manifest-driven path (#20 phase 4): render the 3-member stack from
|
||||||
|
// apps/netbird-*/manifest.yml via the orchestrator — dedicated
|
||||||
|
// netbird-net + network_aliases, base64 generated_secrets, a self-signed
|
||||||
|
// TLS cert (generated_certs) so the dashboard gets a secure context for
|
||||||
|
// OIDC PKCE (#15), and templated config.yaml/nginx.conf rendered from
|
||||||
|
// host facts + the netbird-net gateway. The manifests use the exact live
|
||||||
|
// container names, so on an existing node this ADOPTS the running stack
|
||||||
|
// rather than recreating it (the sqlite store + base64 keys are
|
||||||
|
// preserved — ensure_generated_secrets no-ops on existing files). Falls
|
||||||
|
// back to the legacy installer below only when the orchestrator doesn't
|
||||||
|
// know these app_ids (manifests not yet deployed to the node).
|
||||||
|
if let Some(orchestrated) =
|
||||||
|
install_stack_via_orchestrator(self, "netbird", netbird_stack_app_ids()).await?
|
||||||
|
{
|
||||||
|
return Ok(orchestrated);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(adopted) = adopt_stack_if_exists(
|
if let Some(adopted) = adopt_stack_if_exists(
|
||||||
"netbird",
|
"netbird",
|
||||||
"netbird",
|
"netbird",
|
||||||
|
|||||||
@ -285,7 +285,15 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
|||||||
|
|
||||||
async fn image_exists(image: &str) -> bool {
|
async fn image_exists(image: &str) -> bool {
|
||||||
let mut cmd = Command::new("podman");
|
let mut cmd = Command::new("podman");
|
||||||
cmd.args(["image", "inspect", image]);
|
// Only the exit status matters. WITHOUT a `--format`, `podman image inspect`
|
||||||
|
// prints the image's full multi-KB manifest JSON; `.status()` inherits the
|
||||||
|
// service's stdout, so on a hit that whole blob lands in the journal — once
|
||||||
|
// per companion image, every reconcile pass. That flood spikes journald +
|
||||||
|
// IO and starves the async runtime (UI websocket then drops → "connection
|
||||||
|
// lost"/reconnect). Discard the child's stdout/stderr; we read neither.
|
||||||
|
cmd.args(["image", "inspect", image])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
|
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
|
||||||
Ok(Ok(status)) => status.success(),
|
Ok(Ok(status)) => status.success(),
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
|
|||||||
@ -691,16 +691,37 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// netbird's dashboard launch URL: HTTPS on 8087 (the proxy terminates TLS —
|
||||||
|
/// the dashboard needs a secure context for OIDC PKCE, issue #15) at the node's
|
||||||
|
/// primary host IP so it's reachable from the LAN. Manifest-driven netbird no
|
||||||
|
/// longer writes `dashboard.env`, so this is derived from host facts (the same
|
||||||
|
/// `{{HOST_IP}}` the orchestrator bakes into the cert/config); it falls back to
|
||||||
|
/// the static localhost mapping when the host IP can't be read. URL shape is
|
||||||
|
/// identical to the legacy installer's, so the existing https reachability
|
||||||
|
/// wrapper still applies.
|
||||||
async fn netbird_configured_launch_url() -> Option<String> {
|
async fn netbird_configured_launch_url() -> Option<String> {
|
||||||
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
|
if let Some(ip) = first_host_ip().await {
|
||||||
|
return Some(format!("https://{ip}:8087"));
|
||||||
|
}
|
||||||
|
PodmanClient::lan_address_for("netbird")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
|
||||||
|
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
|
||||||
|
/// orchestrator renders for `{{HOST_IP}}`.
|
||||||
|
async fn first_host_ip() -> Option<String> {
|
||||||
|
let out = tokio::process::Command::new("hostname")
|
||||||
|
.arg("-I")
|
||||||
|
.output()
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
env.lines()
|
if !out.status.success() {
|
||||||
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
|
return None;
|
||||||
.map(str::trim)
|
}
|
||||||
.filter(|s| !s.is_empty())
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.or_else(|| PodmanClient::lan_address_for("netbird"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use archipelago_container::{
|
use archipelago_container::{
|
||||||
AppManifest, ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus,
|
AppManifest, ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus,
|
||||||
Dependency, GeneratedFile, HostFacts, ManifestError, ResolvedSource, SecretsProvider,
|
Dependency, HostFacts, ManifestError, ResolvedSource, SecretsProvider,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@ -1809,6 +1809,9 @@ impl ProdContainerOrchestrator {
|
|||||||
self.run_pre_start_hooks(&manifest.app.id).await?;
|
self.run_pre_start_hooks(&manifest.app.id).await?;
|
||||||
self.ensure_bind_mount_sockets(manifest).await?;
|
self.ensure_bind_mount_sockets(manifest).await?;
|
||||||
self.ensure_bind_mount_dirs(manifest).await?;
|
self.ensure_bind_mount_dirs(manifest).await?;
|
||||||
|
// Certs before files: a templated file may not need the cert, but the
|
||||||
|
// container's bind-mounts expect both present before create_container.
|
||||||
|
self.ensure_manifest_certs(manifest).await?;
|
||||||
self.ensure_manifest_files(manifest).await?;
|
self.ensure_manifest_files(manifest).await?;
|
||||||
self.apply_data_uid(manifest).await?;
|
self.apply_data_uid(manifest).await?;
|
||||||
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
||||||
@ -2750,7 +2753,14 @@ impl ProdContainerOrchestrator {
|
|||||||
async fn ensure_manifest_files(&self, manifest: &AppManifest) -> Result<HookOutcome> {
|
async fn ensure_manifest_files(&self, manifest: &AppManifest) -> Result<HookOutcome> {
|
||||||
let mut outcome = HookOutcome::Unchanged;
|
let mut outcome = HookOutcome::Unchanged;
|
||||||
for file in &manifest.app.files {
|
for file in &manifest.app.files {
|
||||||
if ensure_generated_file(file)
|
// Render templated placeholders before comparing/writing so the
|
||||||
|
// idempotency check is against the FINAL bytes (not the template),
|
||||||
|
// otherwise a rendered file would be rewritten every reconcile.
|
||||||
|
let rendered = self
|
||||||
|
.render_file_placeholders(manifest, &file.content)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("rendering manifest file {}", file.path))?;
|
||||||
|
if ensure_rendered_file(&file.path, &rendered, file.overwrite)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("ensure manifest file {}", file.path))?
|
.with_context(|| format!("ensure manifest file {}", file.path))?
|
||||||
== HookOutcome::Rewritten
|
== HookOutcome::Rewritten
|
||||||
@ -2760,23 +2770,185 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Substitute the allow-listed placeholders a manifest `GeneratedFile` may
|
||||||
|
/// carry. Keeps runtime-derived config (netbird's `config.yaml`/`nginx.conf`)
|
||||||
|
/// declarative instead of generated by per-app Rust:
|
||||||
|
/// - `{{HOST_IP}}` / `{{HOST_MDNS}}` — host facts (`hostname -I` / `.local`).
|
||||||
|
/// - `{{NETWORK_GATEWAY}}` — the gateway of the app's podman network, i.e.
|
||||||
|
/// aardvark's DNS address. nginx uses it as an explicit `resolver` so it
|
||||||
|
/// re-resolves container names per request instead of pinning a stale IP
|
||||||
|
/// and 502-ing after a restart/reboot (issue #15). The network is ensured
|
||||||
|
/// to exist first so the gateway is readable on a fresh install (this runs
|
||||||
|
/// before `install_fresh`'s own `ensure_container_network`; both idempotent).
|
||||||
|
/// - `{{secret:NAME}}` — a `0600` secret read from the service-owned secrets
|
||||||
|
/// dir (e.g. netbird's base64 relay/store keys). NEVER logged.
|
||||||
|
async fn render_file_placeholders(
|
||||||
|
&self,
|
||||||
|
manifest: &AppManifest,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut out = content.to_string();
|
||||||
|
if out.contains("{{HOST_IP}}") || out.contains("{{HOST_MDNS}}") {
|
||||||
|
let facts = self.detect_host_facts();
|
||||||
|
out = out
|
||||||
|
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||||
|
.replace("{{HOST_MDNS}}", &facts.host_mdns);
|
||||||
|
}
|
||||||
|
if out.contains("{{NETWORK_GATEWAY}}") {
|
||||||
|
self.ensure_container_network(manifest).await?;
|
||||||
|
let gw = self.network_gateway(manifest).await?;
|
||||||
|
out = out.replace("{{NETWORK_GATEWAY}}", &gw);
|
||||||
|
}
|
||||||
|
out = self.render_secret_placeholders(&out).await?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace every `{{secret:NAME}}` with the trimmed contents of
|
||||||
|
/// `<secrets_dir>/NAME`. `NAME` must be a bare filename (the same safety bar
|
||||||
|
/// as `secret_env`). The secret value is never placed in an error or log.
|
||||||
|
async fn render_secret_placeholders(&self, content: &str) -> Result<String> {
|
||||||
|
const OPEN: &str = "{{secret:";
|
||||||
|
let mut out = String::with_capacity(content.len());
|
||||||
|
let mut rest = content;
|
||||||
|
while let Some(start) = rest.find(OPEN) {
|
||||||
|
out.push_str(&rest[..start]);
|
||||||
|
let after = &rest[start + OPEN.len()..];
|
||||||
|
let end = after
|
||||||
|
.find("}}")
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unterminated {{secret:...}} placeholder"))?;
|
||||||
|
let name = &after[..end];
|
||||||
|
if name.is_empty() || name.contains('/') || name.contains("..") {
|
||||||
|
anyhow::bail!("invalid secret placeholder name '{name}' (must be a bare filename)");
|
||||||
|
}
|
||||||
|
let value = tokio::fs::read_to_string(self.secrets_dir.join(name))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
// Do not surface the path-with-value or io detail beyond the name.
|
||||||
|
anyhow::anyhow!("secret '{name}' referenced by a manifest file is missing")
|
||||||
|
})?;
|
||||||
|
out.push_str(value.trim());
|
||||||
|
rest = &after[end + 2..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gateway IP of the app's podman network — aardvark's DNS resolver
|
||||||
|
/// address. Mirrors the legacy `netbird_net_resolver_ip`; falls back to
|
||||||
|
/// podman's usual first-pool gateway if the inspect can't be parsed (the
|
||||||
|
/// network was just ensured to exist, so this is a belt-and-braces default).
|
||||||
|
async fn network_gateway(&self, manifest: &AppManifest) -> Result<String> {
|
||||||
|
let network = manifest
|
||||||
|
.app
|
||||||
|
.container
|
||||||
|
.network
|
||||||
|
.as_deref()
|
||||||
|
.filter(|n| !n.is_empty() && !is_builtin_network_mode(n))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("{{NETWORK_GATEWAY}} used but app has no dedicated network")
|
||||||
|
})?;
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"network",
|
||||||
|
"inspect",
|
||||||
|
network,
|
||||||
|
"--format",
|
||||||
|
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("inspecting podman network {network} for gateway"))?;
|
||||||
|
let gw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
return Ok(gw);
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
network,
|
||||||
|
"could not read network gateway; falling back to 10.89.0.1"
|
||||||
|
);
|
||||||
|
Ok("10.89.0.1".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialise manifest-declared self-signed TLS certs before the container
|
||||||
|
/// is created (so a bind-mounted cert path resolves to a real file). Skips an
|
||||||
|
/// entry whose crt+key already exist (idempotent / data-preserving). CN and
|
||||||
|
/// SAN templates are rendered against host facts; when omitted they default
|
||||||
|
/// to the node's host IP plus `127.0.0.1`/`localhost` so the cert is valid
|
||||||
|
/// however the box is reached locally. Mirrors the legacy
|
||||||
|
/// `ensure_netbird_tls_cert` (rsa:2048, 10-year, no per-app Rust).
|
||||||
|
async fn ensure_manifest_certs(&self, manifest: &AppManifest) -> Result<()> {
|
||||||
|
let facts = self.detect_host_facts();
|
||||||
|
let render = |s: &str| {
|
||||||
|
s.replace("{{HOST_IP}}", &facts.host_ip)
|
||||||
|
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||||
|
};
|
||||||
|
for cert in &manifest.app.container.generated_certs {
|
||||||
|
if tokio::fs::metadata(&cert.crt).await.is_ok()
|
||||||
|
&& tokio::fs::metadata(&cert.key).await.is_ok()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = Path::new(&cert.crt).parent() {
|
||||||
|
create_dir_all_or_sudo(parent).await?;
|
||||||
|
}
|
||||||
|
if let Some(parent) = Path::new(&cert.key).parent() {
|
||||||
|
create_dir_all_or_sudo(parent).await?;
|
||||||
|
}
|
||||||
|
let cn = render(cert.common_name.as_deref().unwrap_or("{{HOST_IP}}"));
|
||||||
|
let san = if cert.sans.is_empty() {
|
||||||
|
format!("IP:{},IP:127.0.0.1,DNS:localhost", facts.host_ip)
|
||||||
|
} else {
|
||||||
|
cert.sans
|
||||||
|
.iter()
|
||||||
|
.map(|s| render(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
};
|
||||||
|
let status = tokio::process::Command::new("openssl")
|
||||||
|
.args([
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
&cert.key,
|
||||||
|
"-out",
|
||||||
|
&cert.crt,
|
||||||
|
"-days",
|
||||||
|
"3650",
|
||||||
|
"-subj",
|
||||||
|
&format!("/CN={cn}"),
|
||||||
|
"-addext",
|
||||||
|
&format!("subjectAltName={san}"),
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("running openssl for manifest cert {}", cert.crt))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("openssl failed to generate manifest cert {}", cert.crt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_generated_file(file: &GeneratedFile) -> Result<HookOutcome> {
|
async fn ensure_rendered_file(path: &str, content: &str, overwrite: bool) -> Result<HookOutcome> {
|
||||||
let path = Path::new(&file.path);
|
let p = Path::new(path);
|
||||||
if let Ok(existing) = tokio::fs::read_to_string(path).await {
|
if let Ok(existing) = tokio::fs::read_to_string(p).await {
|
||||||
if existing == file.content || !file.overwrite {
|
if existing == content || !overwrite {
|
||||||
return Ok(HookOutcome::Unchanged);
|
return Ok(HookOutcome::Unchanged);
|
||||||
}
|
}
|
||||||
} else if path.exists() && !file.overwrite {
|
} else if p.exists() && !overwrite {
|
||||||
return Ok(HookOutcome::Unchanged);
|
return Ok(HookOutcome::Unchanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
let parent = path
|
let parent = p
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| anyhow::anyhow!("generated file path has no parent: {}", file.path))?;
|
.ok_or_else(|| anyhow::anyhow!("generated file path has no parent: {}", path))?;
|
||||||
create_dir_all_or_sudo(parent).await?;
|
create_dir_all_or_sudo(parent).await?;
|
||||||
write_generated_file_atomically(path, &file.content).await?;
|
write_generated_file_atomically(p, content).await?;
|
||||||
Ok(HookOutcome::Rewritten)
|
Ok(HookOutcome::Rewritten)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,7 @@ fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> {
|
|||||||
match gs.kind {
|
match gs.kind {
|
||||||
SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?,
|
SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?,
|
||||||
SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?,
|
SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?,
|
||||||
|
SecretGenKind::Base64 => write_secret(&dir.join(&gs.name), &random_base64(32))?,
|
||||||
SecretGenKind::Bcrypt => {
|
SecretGenKind::Bcrypt => {
|
||||||
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
||||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||||
@ -92,6 +93,15 @@ fn random_hex(bytes: usize) -> String {
|
|||||||
hex::encode(buf)
|
hex::encode(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `bytes` of entropy, standard base64 (with padding). For keys that a service
|
||||||
|
/// base64-decodes to recover the raw bytes (e.g. netbird's store encryptionKey).
|
||||||
|
fn random_base64(bytes: usize) -> String {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let mut buf = vec![0u8; bytes];
|
||||||
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
||||||
/// is atomic), fsynced, then renamed over the target.
|
/// is atomic), fsynced, then renamed over the target.
|
||||||
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
||||||
|
|||||||
@ -198,6 +198,24 @@ async fn main() -> Result<()> {
|
|||||||
(Some(trait_obj), Some(dev))
|
(Some(trait_obj), Some(dev))
|
||||||
} else {
|
} else {
|
||||||
let prod = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
|
let prod = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
|
||||||
|
// Pull the freshest signed app-catalog BEFORE loading manifests, so any
|
||||||
|
// registry-embedded manifest (the origin-wins overlay in load_manifests)
|
||||||
|
// is in place on THIS boot — not a restart later. Without this the boot
|
||||||
|
// would overlay the previous run's cached catalog and a newly-published
|
||||||
|
// app (e.g. a registry-only install) wouldn't appear until the next
|
||||||
|
// restart. Bounded + best-effort: on timeout/unreachable origin the
|
||||||
|
// last-cached catalog (or the disk manifests) still load — registry is
|
||||||
|
// an overlay on top of disk, never a hard dependency.
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(25),
|
||||||
|
crate::container::app_catalog::refresh_catalog(&config.data_dir),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(n)) => info!("🛰️ app-catalog refreshed before manifest load ({n} apps)"),
|
||||||
|
Ok(Err(e)) => tracing::debug!("app-catalog pre-load refresh failed (using cache): {e}"),
|
||||||
|
Err(_) => tracing::debug!("app-catalog pre-load refresh timed out (using cache)"),
|
||||||
|
}
|
||||||
// Best-effort manifest load; a missing /opt/archipelago/apps is
|
// Best-effort manifest load; a missing /opt/archipelago/apps is
|
||||||
// logged inside load_manifests and not fatal.
|
// logged inside load_manifests and not fatal.
|
||||||
match prod.load_manifests().await {
|
match prod.load_manifests().await {
|
||||||
|
|||||||
@ -8,8 +8,9 @@ pub mod runtime;
|
|||||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||||
pub use health_monitor::HealthMonitor;
|
pub use health_monitor::HealthMonitor;
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
|
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedCert,
|
||||||
GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks, ManifestError,
|
GeneratedFile, GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks,
|
||||||
|
ManifestError,
|
||||||
ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy,
|
ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy,
|
||||||
Volume,
|
Volume,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -223,6 +223,19 @@ pub struct ContainerConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub generated_secrets: Vec<GeneratedSecret>,
|
pub generated_secrets: Vec<GeneratedSecret>,
|
||||||
|
|
||||||
|
/// Self-signed TLS certificates the orchestrator materialises before the
|
||||||
|
/// container is created (so a bind-mounted cert path resolves to a real
|
||||||
|
/// file, not a stale/missing path). Like `generated_secrets`, this keeps an
|
||||||
|
/// app data-driven: a service that needs a secure context (e.g. netbird's
|
||||||
|
/// dashboard — OIDC PKCE / `window.crypto.subtle` only works over HTTPS,
|
||||||
|
/// issue #15) declares the cert here instead of relying on per-app Rust.
|
||||||
|
/// Idempotent: an entry whose `crt` and `key` already exist is left
|
||||||
|
/// untouched. SAN/CN templates are rendered against host facts at apply time.
|
||||||
|
///
|
||||||
|
/// Example: `- { crt: /var/lib/archipelago/netbird/tls.crt, key: /var/lib/archipelago/netbird/tls.key }`
|
||||||
|
#[serde(default)]
|
||||||
|
pub generated_certs: Vec<GeneratedCert>,
|
||||||
|
|
||||||
/// Rootless-mapped UID:GID applied to the container's data directory
|
/// Rootless-mapped UID:GID applied to the container's data directory
|
||||||
/// (the `bind`-mounted host path with `target` inside the container's
|
/// (the `bind`-mounted host path with `target` inside the container's
|
||||||
/// data root) before creation. Mirrors `SPEC_DATA_UID`.
|
/// data root) before creation. Mirrors `SPEC_DATA_UID`.
|
||||||
@ -261,6 +274,11 @@ pub enum SecretGenKind {
|
|||||||
Hex16,
|
Hex16,
|
||||||
/// 32 random bytes, lowercase hex (64 chars). Longer keys/cookies.
|
/// 32 random bytes, lowercase hex (64 chars). Longer keys/cookies.
|
||||||
Hex32,
|
Hex32,
|
||||||
|
/// 32 random bytes, standard base64 (44 chars incl. padding). For services
|
||||||
|
/// that require a base64-encoded key rather than hex — e.g. netbird's relay
|
||||||
|
/// `authSecret` and the SQLite store `encryptionKey`, which base64-decode
|
||||||
|
/// their configured value (hex would decode to the wrong bytes).
|
||||||
|
Base64,
|
||||||
/// A random password and its bcrypt hash. `<name>` holds the bcrypt hash
|
/// A random password and its bcrypt hash. `<name>` holds the bcrypt hash
|
||||||
/// (what a server is configured with); the plaintext is stored alongside as
|
/// (what a server is configured with); the plaintext is stored alongside as
|
||||||
/// `<name>.pw` for any client that must authenticate. `secret_env` injects
|
/// `<name>.pw` for any client that must authenticate. `secret_env` injects
|
||||||
@ -282,12 +300,31 @@ impl GeneratedSecret {
|
|||||||
/// (primary first). A consumer references one of these via `secret_env`.
|
/// (primary first). A consumer references one of these via `secret_env`.
|
||||||
pub fn target_files(&self) -> Vec<String> {
|
pub fn target_files(&self) -> Vec<String> {
|
||||||
match self.kind {
|
match self.kind {
|
||||||
SecretGenKind::Hex16 | SecretGenKind::Hex32 => vec![self.name.clone()],
|
SecretGenKind::Hex16 | SecretGenKind::Hex32 | SecretGenKind::Base64 => {
|
||||||
|
vec![self.name.clone()]
|
||||||
|
}
|
||||||
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
|
SecretGenKind::Bcrypt => vec![self.name.clone(), format!("{}.pw", self.name)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A self-signed TLS certificate materialised by the orchestrator. See
|
||||||
|
/// [`ContainerConfig::generated_certs`]. `crt`/`key` are absolute host paths
|
||||||
|
/// (typically under `/var/lib/archipelago/<app>/`) that the container
|
||||||
|
/// bind-mounts read-only. `common_name` and `sans` are rendered against host
|
||||||
|
/// facts (`{{HOST_IP}}`) at apply time; when omitted they default to the
|
||||||
|
/// node's host IP plus `IP:127.0.0.1,DNS:localhost` so the cert is valid for
|
||||||
|
/// however the box is reached locally.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GeneratedCert {
|
||||||
|
pub crt: String,
|
||||||
|
pub key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sans: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_pull_policy() -> String {
|
fn default_pull_policy() -> String {
|
||||||
"if-not-present".to_string()
|
"if-not-present".to_string()
|
||||||
}
|
}
|
||||||
@ -665,6 +702,18 @@ impl AppManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generated_certs: crt/key must be non-empty absolute paths with no
|
||||||
|
// traversal (they become bind-mount sources, same safety bar as files).
|
||||||
|
for (i, c) in self.app.container.generated_certs.iter().enumerate() {
|
||||||
|
for (field, val) in [("crt", &c.crt), ("key", &c.key)] {
|
||||||
|
if val.is_empty() || !val.starts_with('/') || val.contains("..") {
|
||||||
|
return Err(ManifestError::Invalid(format!(
|
||||||
|
"container.generated_certs[{i}].{field} must be an absolute path with no '..', got '{val}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// data_uid: if set, must look like "NNNNN:NNNNN".
|
// data_uid: if set, must look like "NNNNN:NNNNN".
|
||||||
if let Some(u) = &self.app.container.data_uid {
|
if let Some(u) = &self.app.container.data_uid {
|
||||||
let parts: Vec<&str> = u.split(':').collect();
|
let parts: Vec<&str> = u.split(':').collect();
|
||||||
@ -1711,6 +1760,7 @@ app:
|
|||||||
],
|
],
|
||||||
secret_env: vec![],
|
secret_env: vec![],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
|
generated_certs: vec![],
|
||||||
data_uid: None,
|
data_uid: None,
|
||||||
};
|
};
|
||||||
let facts = HostFacts {
|
let facts = HostFacts {
|
||||||
@ -1762,6 +1812,7 @@ app:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
|
generated_certs: vec![],
|
||||||
data_uid: None,
|
data_uid: None,
|
||||||
};
|
};
|
||||||
let p = MapSecretsProvider {
|
let p = MapSecretsProvider {
|
||||||
@ -1799,6 +1850,7 @@ app:
|
|||||||
secret_file: "bitcoin-rpc-password".to_string(),
|
secret_file: "bitcoin-rpc-password".to_string(),
|
||||||
}],
|
}],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
|
generated_certs: vec![],
|
||||||
data_uid: None,
|
data_uid: None,
|
||||||
};
|
};
|
||||||
let p = MapSecretsProvider {
|
let p = MapSecretsProvider {
|
||||||
|
|||||||
@ -124,7 +124,9 @@ impl PodmanClient {
|
|||||||
"nginx-proxy-manager" => "http://localhost:8081",
|
"nginx-proxy-manager" => "http://localhost:8081",
|
||||||
"fedimint-gateway" => "http://localhost:8176",
|
"fedimint-gateway" => "http://localhost:8176",
|
||||||
"endurain" => "http://localhost:8080",
|
"endurain" => "http://localhost:8080",
|
||||||
"netbird" => "http://localhost:8087",
|
// HTTPS: netbird's dashboard needs a secure context for OIDC PKCE
|
||||||
|
// (window.crypto.subtle), so the proxy serves TLS on 8087 (issue #15).
|
||||||
|
"netbird" => "https://localhost:8087",
|
||||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
@ -275,10 +277,18 @@ impl PodmanClient {
|
|||||||
// Build the container spec for the API
|
// Build the container spec for the API
|
||||||
let mut port_mappings = Vec::new();
|
let mut port_mappings = Vec::new();
|
||||||
for port in &manifest.app.ports {
|
for port in &manifest.app.ports {
|
||||||
|
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||||
|
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||||
|
// silently break relay discovery.
|
||||||
|
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||||
|
"udp" => "udp",
|
||||||
|
"sctp" => "sctp",
|
||||||
|
_ => "tcp",
|
||||||
|
};
|
||||||
port_mappings.push(serde_json::json!({
|
port_mappings.push(serde_json::json!({
|
||||||
"container_port": port.container,
|
"container_port": port.container,
|
||||||
"host_port": port.host,
|
"host_port": port.host,
|
||||||
"protocol": "tcp",
|
"protocol": protocol,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,12 +69,12 @@
|
|||||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
||||||
<!-- Loading indicator -->
|
<!-- Loading indicator -->
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
<AppLoadingScreen
|
||||||
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
v-if="iframeLoading"
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
:icon="overlayIcon"
|
||||||
<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>
|
:title="store.title || 'App'"
|
||||||
</svg>
|
:progress="loadProgress"
|
||||||
</div>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<iframe
|
<iframe
|
||||||
ref="iframeRef"
|
ref="iframeRef"
|
||||||
@ -184,10 +184,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||||
|
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||||
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
interface PaymentRequest {
|
interface PaymentRequest {
|
||||||
@ -207,6 +209,39 @@ const isRefreshing = ref(false)
|
|||||||
const iframeLoading = ref(true)
|
const iframeLoading = ref(true)
|
||||||
const iframeBlocked = ref(false)
|
const iframeBlocked = ref(false)
|
||||||
|
|
||||||
|
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
|
||||||
|
// when present; AppLoadingScreen's <img> falls back to the default icon if the
|
||||||
|
// guessed asset 404s.
|
||||||
|
const overlayIcon = computed(() => {
|
||||||
|
const url = store.url
|
||||||
|
if (!url) return DEFAULT_APP_ICON
|
||||||
|
try {
|
||||||
|
const m = new URL(url, window.location.origin).pathname.match(/^\/app\/([a-z0-9._-]+)/i)
|
||||||
|
if (m?.[1]) return `/assets/img/app-icons/${m[1].toLowerCase()}.png`
|
||||||
|
} catch { /* not a parseable URL */ }
|
||||||
|
return DEFAULT_APP_ICON
|
||||||
|
})
|
||||||
|
|
||||||
|
// Faux load progress (cross-origin iframes give no real progress events): ease
|
||||||
|
// toward ~92% while loading, snap to 100% on load.
|
||||||
|
const loadProgress = ref(0)
|
||||||
|
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
function stopProgress() {
|
||||||
|
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||||||
|
}
|
||||||
|
function startProgress() {
|
||||||
|
stopProgress()
|
||||||
|
loadProgress.value = 8
|
||||||
|
progressTimer = setInterval(() => {
|
||||||
|
loadProgress.value += Math.max(0.4, (92 - loadProgress.value) * 0.08)
|
||||||
|
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
watch(iframeLoading, (loading) => {
|
||||||
|
if (loading) startProgress()
|
||||||
|
else { stopProgress(); loadProgress.value = 100 }
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// Nostr identity picker state
|
// Nostr identity picker state
|
||||||
const showIdentityPicker = ref(false)
|
const showIdentityPicker = ref(false)
|
||||||
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
|
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
|
||||||
@ -573,6 +608,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearTimers()
|
clearTimers()
|
||||||
|
stopProgress()
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('message', onMessage)
|
window.removeEventListener('message', onMessage)
|
||||||
})
|
})
|
||||||
|
|||||||
81
neode-ui/src/components/AppLoadingScreen.vue
Normal file
81
neode-ui/src/components/AppLoadingScreen.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-loading-screen absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||||
|
<div class="app-loading-icon">
|
||||||
|
<img :src="icon" :alt="title" @error="handleImageError" />
|
||||||
|
</div>
|
||||||
|
<p class="app-loading-title">{{ title }}</p>
|
||||||
|
<div class="app-loading-bar">
|
||||||
|
<div class="app-loading-fill" :style="{ width: `${clampedProgress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<p class="app-loading-hint">{{ hint }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { handleImageError } from '@/views/apps/appsConfig'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
progress: number
|
||||||
|
hint?: string
|
||||||
|
}>(), {
|
||||||
|
hint: 'Loading…',
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.progress)))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-loading-screen {
|
||||||
|
gap: 18px;
|
||||||
|
background: #0b0d12;
|
||||||
|
}
|
||||||
|
.app-loading-icon {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
animation: app-loading-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.app-loading-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.app-loading-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.app-loading-bar {
|
||||||
|
width: min(240px, 60vw);
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.app-loading-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.app-loading-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
@keyframes app-loading-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.05); opacity: 0.85; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -55,7 +55,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses route-based app sessions on mobile instead of panel mode', () => {
|
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
value: 390,
|
value: 390,
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -65,8 +65,10 @@ describe('useAppLauncherStore', () => {
|
|||||||
|
|
||||||
store.openSession('indeedhub')
|
store.openSession('indeedhub')
|
||||||
|
|
||||||
expect(store.panelAppId).toBe(null)
|
// Mobile now uses the store-driven panel like desktop panel mode so the
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
|
// underlying page/tab never changes and closing returns to the origin.
|
||||||
|
expect(store.panelAppId).toBe('indeedhub')
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes localhost launch URLs to current host before resolving', () => {
|
it('normalizes localhost launch URLs to current host before resolving', () => {
|
||||||
@ -117,7 +119,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes desktop new-tab apps into app session on mobile', () => {
|
it('opens tab-only apps directly on mobile (new tab in PWA, no interstitial)', () => {
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
value: 390,
|
value: 390,
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -127,10 +129,17 @@ describe('useAppLauncherStore', () => {
|
|||||||
|
|
||||||
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
||||||
|
|
||||||
|
// Tab-only app on mobile-web: open directly in a new browser tab (the
|
||||||
|
// companion would use the in-app WebView). No session, no route push, no
|
||||||
|
// "this app opens in a tab" interstitial.
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
expect(store.panelAppId).toBe(null)
|
expect(store.panelAppId).toBe(null)
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
'http://192.168.1.228:8081',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||||
@ -264,7 +273,7 @@ describe('useAppLauncherStore', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes prepackaged websites into app session on mobile', () => {
|
it('opens prepackaged websites in the store-driven panel on mobile', () => {
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
value: 390,
|
value: 390,
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -274,9 +283,12 @@ describe('useAppLauncherStore', () => {
|
|||||||
|
|
||||||
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
|
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
|
||||||
|
|
||||||
|
// Iframeable prepackaged sites stay in-app via the store panel (no route
|
||||||
|
// change, no background swap) just like every other mobile launch.
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
|
expect(store.panelAppId).toBe('arch-presentation')
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } })
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('routes HTTPS same-host apps via session view', () => {
|
it('routes HTTPS same-host apps via session view', () => {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
import { requestExternalOpen } from '@/api/remote-relay'
|
||||||
|
import { openInAppOrNewTab } from '@/utils/openExternal'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||||
@ -222,14 +223,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
function openSession(appId: string) {
|
function openSession(appId: string) {
|
||||||
recordAppLaunch(appId)
|
recordAppLaunch(appId)
|
||||||
const mobile = isMobileViewport()
|
const mobile = isMobileViewport()
|
||||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
|
||||||
if (launchUrl && !mobile) {
|
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
|
||||||
openExternal(launchUrl)
|
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
|
||||||
return
|
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
|
||||||
|
if (NEW_TAB_APP_IDS.has(appId)) {
|
||||||
|
const launchUrl = directAppUrl(appId)
|
||||||
|
if (launchUrl) {
|
||||||
|
if (mobile) openInAppOrNewTab(launchUrl)
|
||||||
|
else openExternal(launchUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iframeable apps. Mobile and desktop-panel mode both use the store-driven
|
||||||
|
// panel so the underlying page/tab never changes (no background swap) and
|
||||||
|
// closing returns the user to wherever they launched from. Only desktop
|
||||||
|
// overlay/fullscreen modes use a routed session.
|
||||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||||
if (mode === 'panel' && !mobile) {
|
if (mobile || mode === 'panel') {
|
||||||
panelAppId.value = appId
|
panelAppId.value = appId
|
||||||
} else {
|
} else {
|
||||||
panelAppId.value = null
|
panelAppId.value = null
|
||||||
|
|||||||
@ -164,6 +164,20 @@ select:focus-visible {
|
|||||||
|
|
||||||
/* Mobile: override with tab bar clearance */
|
/* Mobile: override with tab bar clearance */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
/* Mobile web browsers report 100vh taller than the visible area (the dynamic
|
||||||
|
URL/toolbar chrome). The dashboard is the containing block for the fixed,
|
||||||
|
container-relative panes (the mesh chat/tools panes), so a 100vh-tall
|
||||||
|
container pushes their `bottom` offset below the visible viewport — they
|
||||||
|
slide under the bottom tab bar (which is body-teleported and viewport-fixed,
|
||||||
|
so it stays put). Pin the dashboard to the *dynamic* viewport so the two
|
||||||
|
reference frames line up. No-op in the companion WebView (no browser chrome
|
||||||
|
→ dvh == vh), so its layout is unchanged. Doubled class beats Tailwind's
|
||||||
|
`.min-h-screen` (100vh) utility on specificity. */
|
||||||
|
.dashboard-view.dashboard-view {
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-scroll-pad {
|
.mobile-scroll-pad {
|
||||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,37 @@
|
|||||||
*/
|
*/
|
||||||
interface ArchipelagoNativeBridge {
|
interface ArchipelagoNativeBridge {
|
||||||
openExternal?: (url: string) => void
|
openExternal?: (url: string) => void
|
||||||
|
openInApp?: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeBridge(): ArchipelagoNativeBridge | undefined {
|
||||||
|
return (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }).ArchipelagoNative
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openExternalUrl(url: string): void {
|
export function openExternalUrl(url: string): void {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
|
const native = nativeBridge()
|
||||||
.ArchipelagoNative
|
|
||||||
if (native && typeof native.openExternal === 'function') {
|
if (native && typeof native.openExternal === 'function') {
|
||||||
native.openExternal(url)
|
native.openExternal(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch an app that can't be embedded in an iframe (X-Frame-Options) from a
|
||||||
|
* mobile surface — with NO "this app opens in a tab" interstitial.
|
||||||
|
*
|
||||||
|
* - Android companion: hand it to the in-app WebView (`openInApp`) so it stays
|
||||||
|
* inside Archipelago with the native back/forward/reload/close controls.
|
||||||
|
* - Plain mobile browser (PWA): open directly in a new browser tab.
|
||||||
|
*/
|
||||||
|
export function openInAppOrNewTab(url: string): void {
|
||||||
|
if (!url) return
|
||||||
|
const native = nativeBridge()
|
||||||
|
if (native && typeof native.openInApp === 'function') {
|
||||||
|
native.openInApp(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-session-root">
|
<div class="app-session-root">
|
||||||
<Teleport to="body" :disabled="isInlinePanel">
|
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
|
||||||
<div
|
<div
|
||||||
:class="backdropClasses"
|
:class="backdropClasses"
|
||||||
@click.self="handleBackdropClick"
|
@click.self="handleBackdropClick"
|
||||||
@ -27,6 +27,7 @@
|
|||||||
:app-url="appUrl"
|
:app-url="appUrl"
|
||||||
:app-id="appId"
|
:app-id="appId"
|
||||||
:app-title="appTitle"
|
:app-title="appTitle"
|
||||||
|
:app-icon="appIcon"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:iframe-blocked="iframeBlocked"
|
:iframe-blocked="iframeBlocked"
|
||||||
:must-open-new-tab="mustOpenNewTab"
|
:must-open-new-tab="mustOpenNewTab"
|
||||||
@ -104,10 +105,10 @@ import {
|
|||||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||||
resolveAppUrl, resolveAppTitle,
|
resolveAppUrl, resolveAppTitle,
|
||||||
} from './appSession/appSessionConfig'
|
} from './appSession/appSessionConfig'
|
||||||
import { launchBlockedReason } from './apps/appsConfig'
|
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||||
import { openExternalUrl } from '@/utils/openExternal'
|
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -154,9 +155,17 @@ const appId = computed(() => {
|
|||||||
|
|
||||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||||
|
const appIcon = computed(() =>
|
||||||
|
packageEntry.value
|
||||||
|
? resolveAppIcon(appId.value, packageEntry.value)
|
||||||
|
: `/assets/img/app-icons/${appId.value}.png`
|
||||||
|
)
|
||||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
||||||
|
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||||||
|
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||||||
|
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||||
|
|
||||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||||
@ -241,16 +250,18 @@ function setMode(mode: DisplayMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive classes based on display mode
|
// Reactive classes based on display mode. On mobile the store-driven panel
|
||||||
|
// renders as a full-screen overlay (teleported to body) so it covers the nav
|
||||||
|
// and the underlying page never changes — desktop keeps the inline panel.
|
||||||
const backdropClasses = computed(() => {
|
const backdropClasses = computed(() => {
|
||||||
if (isInlinePanel.value) return 'app-session-backdrop-inline'
|
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
|
||||||
return 'app-session-backdrop-overlay'
|
return 'app-session-backdrop-overlay'
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelClasses = computed(() => {
|
const panelClasses = computed(() => {
|
||||||
const base = 'app-session-panel glass-card'
|
const base = 'app-session-panel glass-card'
|
||||||
if (isInlinePanel.value) return `${base} app-session-inline`
|
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
|
||||||
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
|
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
||||||
return `${base} app-session-overlay`
|
return `${base} app-session-overlay`
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -370,10 +381,13 @@ watch(displayMode, (mode) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Apps that block iframes open externally on desktop. On mobile, keep the
|
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
|
||||||
// session surface visible so launcher taps do not bounce straight out.
|
// Open them directly instead of showing a "this app opens in a tab"
|
||||||
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
|
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
|
||||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
// or new tab (PWA). Then dismiss the (empty) session surface.
|
||||||
|
if (mustOpenNewTab.value && appUrl.value) {
|
||||||
|
if (isMobile.value) openInAppOrNewTab(appUrl.value)
|
||||||
|
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||||
if (isInlinePanel.value) emit('close')
|
if (isInlinePanel.value) emit('close')
|
||||||
else closeRouteSession()
|
else closeRouteSession()
|
||||||
return
|
return
|
||||||
@ -381,8 +395,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
|
window.addEventListener('resize', updateIsMobile)
|
||||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
|
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
iframeBlocked.value = true
|
iframeBlocked.value = true
|
||||||
} else {
|
} else {
|
||||||
@ -404,6 +419,7 @@ onBeforeUnmount(() => {
|
|||||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('message', onMessage)
|
window.removeEventListener('message', onMessage)
|
||||||
|
window.removeEventListener('resize', updateIsMobile)
|
||||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
screensaverStore.resume(screensaverReason.value)
|
screensaverStore.resume(screensaverReason.value)
|
||||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import AppSession from '../AppSession.vue'
|
import AppSession from '../AppSession.vue'
|
||||||
|
|
||||||
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||||
mockReplace: vi.fn(),
|
mockReplace: vi.fn(() => Promise.resolve()),
|
||||||
mockPush: vi.fn(),
|
mockPush: vi.fn(() => Promise.resolve()),
|
||||||
mockWindowOpen: vi.fn(),
|
mockWindowOpen: vi.fn(),
|
||||||
mockSuppress: vi.fn(),
|
mockSuppress: vi.fn(),
|
||||||
mockResume: vi.fn(),
|
mockResume: vi.fn(),
|
||||||
@ -62,7 +62,7 @@ describe('AppSession mobile new-tab apps', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
|
it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
|
||||||
const wrapper = mount(AppSession, {
|
const wrapper = mount(AppSession, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
@ -75,9 +75,11 @@ describe('AppSession mobile new-tab apps', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
|
||||||
expect(mockReplace).not.toHaveBeenCalled()
|
// (no native bridge in the test) and dismiss the empty session — no
|
||||||
expect(wrapper.text()).toContain('This app opens in a new tab')
|
// "this app opens in a tab" interstitial.
|
||||||
expect(wrapper.text()).toContain('Open in new tab')
|
expect(mockWindowOpen).toHaveBeenCalled()
|
||||||
|
expect(mockReplace).toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).not.toContain('This app opens in a new tab')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
|
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
<AppLoadingScreen v-if="loading" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||||
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
|
||||||
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||||
@ -116,13 +111,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||||
|
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
appUrl: string
|
appUrl: string
|
||||||
appId: string
|
appId: string
|
||||||
appTitle: string
|
appTitle: string
|
||||||
|
appIcon: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
iframeBlocked: boolean
|
iframeBlocked: boolean
|
||||||
mustOpenNewTab: boolean
|
mustOpenNewTab: boolean
|
||||||
@ -144,6 +141,40 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||||
|
|
||||||
|
// Faux load progress for the loading screen. Cross-origin iframes give no real
|
||||||
|
// progress events, so ease toward ~92% while loading and snap to 100% on load —
|
||||||
|
// far better UX than a black screen with a bare spinner.
|
||||||
|
const loadProgress = ref(0)
|
||||||
|
let progressTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function stopProgress() {
|
||||||
|
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProgress() {
|
||||||
|
stopProgress()
|
||||||
|
loadProgress.value = 8
|
||||||
|
progressTimer = setInterval(() => {
|
||||||
|
// Decelerate as it approaches the cap so it never visually "finishes" early.
|
||||||
|
const remaining = 92 - loadProgress.value
|
||||||
|
loadProgress.value += Math.max(0.4, remaining * 0.08)
|
||||||
|
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.loading, (isLoading) => {
|
||||||
|
if (isLoading) {
|
||||||
|
startProgress()
|
||||||
|
} else {
|
||||||
|
stopProgress()
|
||||||
|
loadProgress.value = 100
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
|
||||||
|
|
||||||
|
onBeforeUnmount(stopProgress)
|
||||||
|
|
||||||
function focusIframe() {
|
function focusIframe() {
|
||||||
iframeRef.value?.focus({ preventScroll: true })
|
iframeRef.value?.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,6 +239,16 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
|||||||
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||||
'archy-lnd-ui': '/assets/img/app-icons/lnd.svg',
|
'archy-lnd-ui': '/assets/img/app-icons/lnd.svg',
|
||||||
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||||
|
// ElectrumX ships under a few historical ids (the backend was renamed
|
||||||
|
// electrs → electrumx). Without an explicit map, an `electrs`-keyed install
|
||||||
|
// falls through to the default `/assets/img/app-icons/electrs.png`, which
|
||||||
|
// doesn't exist → handleImageError swaps .png→.svg and lands on electrs.svg
|
||||||
|
// (the "Electrs in Rust" logo) instead of the real ElectrumX icon. Pin the
|
||||||
|
// whole family to the ElectrumX icon so My Apps shows the right logo no
|
||||||
|
// matter which id the node has it installed under.
|
||||||
|
'electrs': '/assets/img/app-icons/electrumx.png',
|
||||||
|
'electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||||
|
'electrumx': '/assets/img/app-icons/electrumx.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent-app icon by prefix, for stack members not listed explicitly above
|
// Parent-app icon by prefix, for stack members not listed explicitly above
|
||||||
|
|||||||
@ -143,9 +143,10 @@ const mobileTabBar = ref<HTMLElement | null>(null)
|
|||||||
const MOBILE_LAYOUT_MAX_WIDTH = 920
|
const MOBILE_LAYOUT_MAX_WIDTH = 920
|
||||||
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
|
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
|
||||||
|
|
||||||
// App sessions own their mobile controls. Normal mobile launches use the route
|
// App sessions own their mobile controls, so the nav hides while one is open.
|
||||||
// session; keeping this guard also protects any desktop-panel state on resize.
|
// Mobile launches now use the store-driven panel (no route change) to keep the
|
||||||
const isAppSessionActive = computed(() => route.name === 'app-session')
|
// background tab intact, so treat an active panel the same as a routed session.
|
||||||
|
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||||
|
|
||||||
// Show persistent tabs for Apps/Marketplace on mobile
|
// Show persistent tabs for Apps/Marketplace on mobile
|
||||||
const showAppsTabs = computed(() => {
|
const showAppsTabs = computed(() => {
|
||||||
|
|||||||
@ -14,16 +14,16 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/generate-app-catalog.sh [output-path]
|
# scripts/generate-app-catalog.sh [output-path]
|
||||||
# EMBED_MANIFESTS=1 scripts/generate-app-catalog.sh # also embed full manifests
|
# EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy)
|
||||||
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
||||||
#
|
#
|
||||||
# EMBED_MANIFESTS (opt-in, default off): also embed each app's full
|
# EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full
|
||||||
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes can
|
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes
|
||||||
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
|
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
|
||||||
# by container::app_catalog + the orchestrator's load_manifests overlay
|
# by container::app_catalog + the orchestrator's load_manifests overlay
|
||||||
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. Kept
|
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The
|
||||||
# opt-in during the migration window so a routine catalog regen never changes
|
# migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only
|
||||||
# what phase-1 nodes install until we deliberately turn it on.
|
# to reproduce the old version/image-only catalog.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@ -36,7 +36,7 @@ source "$ROOT/scripts/image-versions.sh"
|
|||||||
set +a
|
set +a
|
||||||
|
|
||||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
||||||
EMBED_MANIFESTS="${EMBED_MANIFESTS:-}" python3 - <<'PY'
|
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
||||||
import glob
|
import glob
|
||||||
import json, os
|
import json, os
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user