merge: companion-mobile-ux UX (loader/store-driven launch/icons + android webview) into main

# Conflicts:
#	Android/app/build.gradle.kts
#	Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
#	neode-ui/src/views/apps/appsConfig.ts
This commit is contained in:
archipelago 2026-06-23 14:07:44 -04:00
commit 44f7af2017
15 changed files with 435 additions and 373 deletions

5
Android/.gitignore vendored
View File

@ -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

View File

@ -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

Binary file not shown.

View File

@ -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
// to the top edge while the page loads.
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars),
) { ) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
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)),
contentAlignment = Alignment.Center,
) {
val fav = favicon
if (fav != null) {
Image( Image(
bitmap = icon.asImageBitmap(), bitmap = fav.asImageBitmap(),
contentDescription = null, contentDescription = title,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.size(64.dp)
.clip(RoundedCornerShape(14.dp)),
) )
} else { } else {
Icon( CircularProgressIndicator(color = BitcoinOrange)
imageVector = Icons.Default.Public,
contentDescription = null,
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
// border, sitting above the system navigation bar.
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xF2141414)),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color(0x14FFFFFF)),
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars) .height(56.dp)
.heightIn(min = 64.dp) .background(SurfaceBlack)
.padding(vertical = 6.dp, horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
FooterButton(R.drawable.ic_nav_back, R.string.back) { IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
browser?.let { if (it.canGoBack()) it.goBack() } Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
)
} }
FooterButton(R.drawable.ic_nav_forward, R.string.forward) { IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
browser?.let { if (it.canGoForward()) it.goForward() } Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Forward",
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
)
} }
FooterButton(R.drawable.ic_nav_refresh, R.string.refresh) { IconButton(onClick = { browser?.reload() }) {
browser?.reload() Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Reload",
tint = TextPrimary,
)
} }
FooterButton(R.drawable.ic_nav_newtab, R.string.open_in_browser) { IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
openExternalUrl(context, browser?.url ?: url) Icon(
} imageVector = Icons.Default.OpenInBrowser,
FooterButton(R.drawable.ic_nav_close, R.string.close) { contentDescription = stringResource(R.string.open_in_browser),
onClose() tint = TextPrimary,
)
} }
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close),
tint = TextPrimary,
)
} }
} }
} }

View File

@ -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)
}) })

View 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>

View File

@ -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', () => {

View File

@ -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
// 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 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

View File

@ -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);
} }

View File

@ -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')
}

View File

@ -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(() => {})

View File

@ -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')
}) })
}) })

View File

@ -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 })
} }

View File

@ -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

View File

@ -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(() => {