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:
commit
44f7af2017
5
Android/.gitignore
vendored
5
Android/.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
BIN
Android/app/debug.keystore
Normal file
Binary file not shown.
@ -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),
|
||||
.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(
|
||||
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) {
|
||||
Image(
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(14.dp)),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Public,
|
||||
contentDescription = null,
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val fav = favicon
|
||||
if (fav != null) {
|
||||
Image(
|
||||
bitmap = fav.asImageBitmap(),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
// Bottom control bar — mirrors the web mobile-iframe footer.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xF2141414)),
|
||||
.height(56.dp)
|
||||
.background(SurfaceBlack)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color(0x14FFFFFF)),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.heightIn(min = 64.dp)
|
||||
.padding(vertical = 6.dp, 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() }
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_forward, R.string.forward) {
|
||||
browser?.let { if (it.canGoForward()) it.goForward() }
|
||||
}
|
||||
FooterButton(R.drawable.ic_nav_refresh, R.string.refresh) {
|
||||
browser?.reload()
|
||||
}
|
||||
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 = { browser?.goBack() }, enabled = canGoBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Forward",
|
||||
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { browser?.reload() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reload",
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
81
neode-ui/src/components/AppLoadingScreen.vue
Normal file
81
neode-ui/src/components/AppLoadingScreen.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="app-loading-screen absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="app-loading-icon">
|
||||
<img :src="icon" :alt="title" @error="handleImageError" />
|
||||
</div>
|
||||
<p class="app-loading-title">{{ title }}</p>
|
||||
<div class="app-loading-bar">
|
||||
<div class="app-loading-fill" :style="{ width: `${clampedProgress}%` }"></div>
|
||||
</div>
|
||||
<p class="app-loading-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
progress: number
|
||||
hint?: string
|
||||
}>(), {
|
||||
hint: 'Loading…',
|
||||
})
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.progress)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-loading-screen {
|
||||
gap: 18px;
|
||||
background: #0b0d12;
|
||||
}
|
||||
.app-loading-icon {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: app-loading-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.app-loading-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.app-loading-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.app-loading-bar {
|
||||
width: min(240px, 60vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-loading-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.app-loading-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
@keyframes app-loading-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.85; }
|
||||
}
|
||||
</style>
|
||||
@ -55,7 +55,7 @@ describe('useAppLauncherStore', () => {
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
||||
@ -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)
|
||||
return
|
||||
|
||||
// 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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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(() => {})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user