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
*.jks
*.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"
minSdk = 26
targetSdk = 35
versionCode = 12
versionName = "0.4.8"
versionCode = 13
versionName = "0.4.9"
vectorDrawables {
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 {
debug {
// Separate app ID so a debug/test build installs alongside the
// release app instead of colliding on signature.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
release {
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
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.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
import android.graphics.BitmapFactory
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.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.Public
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.draw.clip
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.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -71,6 +54,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.res.stringResource
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.TextMuted
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). */
private fun openExternalUrl(context: android.content.Context, url: String) {
@ -143,159 +130,6 @@ private fun WebView.applyArchipelagoSettings() {
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")
@Composable
fun WebViewScreen(
@ -308,37 +142,6 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) }
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.
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
// while this is shown, so closing it returns instantly with no reload.
@ -427,7 +230,6 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
enableFileDownloads(context)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
@ -539,13 +341,6 @@ fun WebViewScreen(
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"
// for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves.
@ -640,33 +435,40 @@ fun WebViewScreen(
url = target,
serverUrl = serverUrl,
onClose = { inAppUrl = null },
onShowFileChooser = showFileChooser,
)
}
}
}
}
/** One control in the in-app browser's bottom bar sized and styled to match
* the web app-session mobile bar (52dp tap target, 24dp glyph, muted white). */
@Composable
private fun FooterButton(iconRes: Int, descRes: Int, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(52.dp)) {
Icon(
painter = painterResource(iconRes),
contentDescription = stringResource(descRes),
tint = Color.White.copy(alpha = 0.72f),
modifier = Modifier.size(24.dp),
)
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
* can be shown on the loading screen 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 = android.net.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
}
}
/**
* 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
* (app icon + progress) and a bottom control bar matching the web app-session
* mobile bar. Same-host navigation stays here; any genuinely external link
* escapes to the phone's browser.
* shown in an iframe. Loads the app in a local WebView with a centered loading
* screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
* web mobile-iframe footer (back / forward / reload / open-in-browser / close).
* Same-host navigation stays here; any genuinely external link escapes to the
* phone's browser.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
@ -674,21 +476,23 @@ private fun InAppBrowser(
url: String,
serverUrl: String,
onClose: () -> Unit,
onShowFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean,
) {
val context = LocalContext.current
var browser by remember { mutableStateOf<WebView?>(null) }
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 loading by remember { mutableStateOf(true) }
// The launched app's icon, shown on the loading splash. Seeded by a
// best-effort favicon fetch, then upgraded to the real icon once the
// WebView reports onReceivedIcon.
var appIcon by remember { mutableStateOf<Bitmap?>(null) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// 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) {
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.
@ -700,16 +504,11 @@ private fun InAppBrowser(
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// 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),
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
@ -723,7 +522,6 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
enableFileDownloads(ctx)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
@ -735,24 +533,24 @@ private fun InAppBrowser(
}
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() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
if (favicon != null) appIcon = favicon
}
override fun onPageFinished(view: WebView?, u: String?) {
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(
@ -774,91 +572,93 @@ private fun InAppBrowser(
},
)
// Loading splash — app icon + progress bar, covering the white
// first-paint flash until the app's own UI is ready.
// Centered loading screen — app favicon (or spinner) + title + bar.
if (loading) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.padding(32.dp),
.background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val icon = appIcon
if (icon != null) {
Box(
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) {
val fav = favicon
if (fav != null) {
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(14.dp)),
bitmap = fav.asImageBitmap(),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
imageVector = Icons.Default.Public,
contentDescription = null,
tint = TextMuted,
modifier = Modifier.size(64.dp),
)
CircularProgressIndicator(color = BitcoinOrange)
}
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodyLarge,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(0.55f),
modifier = Modifier.width(220.dp),
color = BitcoinOrange,
trackColor = SurfaceBlack,
trackColor = TextMuted.copy(alpha = 0.2f),
)
}
}
}
// Mobile footer controls — matched to the web app-session bar: five
// 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)),
)
// Bottom control bar — mirrors the web mobile-iframe footer.
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.heightIn(min = 64.dp)
.padding(vertical = 6.dp, horizontal = 8.dp),
.height(56.dp)
.background(SurfaceBlack)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
FooterButton(R.drawable.ic_nav_back, R.string.back) {
browser?.let { if (it.canGoBack()) it.goBack() }
IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
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) {
browser?.let { if (it.canGoForward()) it.goForward() }
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
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) {
browser?.reload()
IconButton(onClick = { browser?.reload() }) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Reload",
tint = TextPrimary,
)
}
FooterButton(R.drawable.ic_nav_newtab, R.string.open_in_browser) {
openExternalUrl(context, browser?.url ?: url)
}
FooterButton(R.drawable.ic_nav_close, R.string.close) {
onClose()
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
Icon(
imageVector = Icons.Default.OpenInBrowser,
contentDescription = stringResource(R.string.open_in_browser),
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">
<!-- Loading indicator -->
<Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<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">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<AppLoadingScreen
v-if="iframeLoading"
:icon="overlayIcon"
:title="store.title || 'App'"
:progress="loadProgress"
/>
</Transition>
<iframe
ref="iframeRef"
@ -184,10 +184,12 @@
</template>
<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 NostrSignConsent from '@/components/NostrSignConsent.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'
interface PaymentRequest {
@ -207,6 +209,39 @@ const isRefreshing = ref(false)
const iframeLoading = ref(true)
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
const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
@ -573,6 +608,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true)
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()
})
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', {
value: 390,
writable: true,
@ -65,8 +65,10 @@ describe('useAppLauncherStore', () => {
store.openSession('indeedhub')
expect(store.panelAppId).toBe(null)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
// Mobile now uses the store-driven panel like desktop panel mode so the
// 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', () => {
@ -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', {
value: 390,
writable: true,
@ -127,10 +129,17 @@ describe('useAppLauncherStore', () => {
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.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
expect(mockPush).not.toHaveBeenCalled()
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', () => {
@ -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', {
value: 390,
writable: true,
@ -274,9 +283,12 @@ describe('useAppLauncherStore', () => {
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.panelAppId).toBe('arch-presentation')
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', () => {

View File

@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
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
@ -222,14 +223,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function openSession(appId: string) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) {
openExternal(launchUrl)
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
// 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
}
}
// 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'
if (mode === 'panel' && !mobile) {
if (mobile || mode === 'panel') {
panelAppId.value = appId
} else {
panelAppId.value = null

View File

@ -164,6 +164,20 @@ select:focus-visible {
/* Mobile: override with tab bar clearance */
@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 {
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 {
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 {
if (!url) return
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge })
.ArchipelagoNative
const native = nativeBridge()
if (native && typeof native.openExternal === 'function') {
native.openExternal(url)
return
}
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>
<div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel">
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
<div
:class="backdropClasses"
@click.self="handleBackdropClick"
@ -27,6 +27,7 @@
:app-url="appUrl"
:app-id="appId"
:app-title="appTitle"
:app-icon="appIcon"
:loading="loading"
:iframe-blocked="iframeBlocked"
:must-open-new-tab="mustOpenNewTab"
@ -104,10 +105,10 @@ import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { launchBlockedReason } from './apps/appsConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl } from '@/utils/openExternal'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
const props = defineProps<{
@ -154,9 +155,17 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
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 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))
// 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(() => {
if (isInlinePanel.value) return 'app-session-backdrop-inline'
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (isInlinePanel.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
})
@ -370,10 +381,13 @@ watch(displayMode, (mode) => {
})
onMounted(() => {
// Apps that block iframes open externally on desktop. On mobile, keep the
// session surface visible so launcher taps do not bounce straight out.
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
// Open them directly instead of showing a "this app opens in a tab"
// interstitial: desktop new browser tab; mobile in-app WebView (companion)
// 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')
else closeRouteSession()
return
@ -381,8 +395,9 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
window.addEventListener('resize', updateIsMobile)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
@ -404,6 +419,7 @@ onBeforeUnmount(() => {
if (iframeCheckId) clearTimeout(iframeCheckId)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
window.removeEventListener('resize', updateIsMobile)
document.removeEventListener('fullscreenchange', onFullscreenChange)
screensaverStore.resume(screensaverReason.value)
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'
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
mockReplace: vi.fn(),
mockPush: vi.fn(),
mockReplace: vi.fn(() => Promise.resolve()),
mockPush: vi.fn(() => Promise.resolve()),
mockWindowOpen: vi.fn(),
mockSuppress: 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, {
global: {
stubs: {
@ -75,9 +75,11 @@ describe('AppSession mobile new-tab apps', () => {
})
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('This app opens in a new tab')
expect(wrapper.text()).toContain('Open in new tab')
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
// (no native bridge in the test) and dismiss the empty session — no
// "this app opens in a tab" interstitial.
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>
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
<Transition name="content-fade">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<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>
<AppLoadingScreen v-if="loading" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition>
<!-- ElectrumX sync screen shown before the real UI while the on-chain
@ -116,13 +111,15 @@
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
const props = defineProps<{
appUrl: string
appId: string
appTitle: string
appIcon: string
loading: boolean
iframeBlocked: boolean
mustOpenNewTab: boolean
@ -144,6 +141,40 @@ const emit = defineEmits<{
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() {
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-lnd-ui': '/assets/img/app-icons/lnd.svg',
'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

View File

@ -143,9 +143,10 @@ const mobileTabBar = ref<HTMLElement | null>(null)
const MOBILE_LAYOUT_MAX_WIDTH = 920
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
// App sessions own their mobile controls. Normal mobile launches use the route
// session; keeping this guard also protects any desktop-panel state on resize.
const isAppSessionActive = computed(() => route.name === 'app-session')
// App sessions own their mobile controls, so the nav hides while one is open.
// Mobile launches now use the store-driven panel (no route change) to keep the
// 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
const showAppsTabs = computed(() => {