Compare commits

...

2 Commits

Author SHA1 Message Date
archipelago
2a249b8a48 feat(android): companion in-app WebView footer controls + loader; shared debug key; v0.4.7
- InAppBrowser now has a bottom control bar (back/forward/reload/open-in-browser/
  close) mirroring the web mobile footer, plus a centered loading screen
  (app favicon + progress bar) instead of a bare top bar over black.
- Commit a repo-dedicated debug keystore and pin signingConfigs.debug to it so
  every machine — and the published companion download — signs debug builds with
  the SAME key (fixes "App not installed" signature-mismatch on update). Force v1+v2.
- Bump versionCode 10→11, versionName 0.4.6→0.4.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:48:58 -04:00
archipelago
a7c7c44843 feat(neode-ui): mobile app-launch UX — store-driven panel, loader, ElectrumX icon
- Mobile launches use the store-driven panel (no route push) so the background
  tab no longer changes and closing returns to where you launched from.
- Tab-only apps open directly (in-app WebView on companion / new tab on PWA) —
  no "this app opens in a tab" interstitial.
- Shared AppLoadingScreen (app icon + progress bar) on the app session and the
  legacy iframe overlay instead of a black screen.
- Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding
  under the bottom tab bar in mobile browsers (no-op in the companion WebView).
- ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps.
- isMobile made reactive so overlay/footer/teleport decisions track the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:48:57 -04:00
15 changed files with 572 additions and 139 deletions

5
Android/.gitignore vendored
View File

@ -14,3 +14,8 @@ local.properties
*.aab *.aab
*.jks *.jks
*.keystore *.keystore
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
# machine (and the published companion download) signs debug builds identically —
# updates then install over the top without an uninstall. Debug keys are not
# secret (well-known password "android"); never commit a real release keystore.
!/app/debug.keystore

View File

@ -11,20 +11,40 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 11
versionName = "0.4.6" versionName = "0.4.7"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
} }
signingConfigs {
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
// machine — and the published companion download — signs debug builds with
// the SAME key. Without this, Gradle falls back to each machine's
// ~/.android/debug.keystore, so a build from a different machine has a
// different signature and the phone rejects the update ("App not installed").
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
// a v2-only sideload with "App not installed" — keep v1 for max compat.
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes { buildTypes {
debug { debug {
// Separate app ID so a debug/test build installs alongside the // Separate app ID so a debug/test build installs alongside the
// release app instead of colliding on signature. // release app instead of colliding on signature.
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-debug" versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
} }
release { release {
isMinifyEnabled = true isMinifyEnabled = true

BIN
Android/app/debug.keystore Normal file

Binary file not shown.

View File

@ -14,6 +14,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -27,11 +28,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.OpenInBrowser 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@ -45,6 +52,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -430,9 +439,11 @@ fun WebViewScreen(
/** /**
* Lightweight in-app browser used when the kiosk hands off an app that can't be * Lightweight in-app browser used when the kiosk hands off an app that can't be
* shown in an iframe. Loads the app in a local WebView with a minimal top bar * shown in an iframe. Loads the app in a local WebView with a centered loading
* (close + title + escalate-to-real-browser). Same-host navigation stays here; * screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
* any genuinely external link escapes to the phone's browser. * 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") @SuppressLint("SetJavaScriptEnabled")
@Composable @Composable
@ -444,8 +455,11 @@ private fun InAppBrowser(
val context = LocalContext.current val context = LocalContext.current
var browser by remember { mutableStateOf<WebView?>(null) } var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var favicon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) } var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Back: walk the in-app history first, then close the overlay. // Back: walk the in-app history first, then close the overlay.
BackHandler { BackHandler {
@ -459,13 +473,152 @@ private fun InAppBrowser(
.background(SurfaceBlack) .background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing), .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 ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
override fun onReceivedTitle(view: WebView?, t: String?) {
if (!t.isNullOrBlank()) title = t
}
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
}
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(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
openExternalUrl(ctx, u)
return true
}
}
browser = this
loadUrl(url)
}
},
)
// Centered loading screen — app favicon (or spinner) + title + bar.
if (loading) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
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(18.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.width(220.dp),
color = BitcoinOrange,
trackColor = TextMuted.copy(alpha = 0.2f),
)
}
}
}
// Bottom control bar — mirrors the web mobile-iframe footer.
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(56.dp)
.padding(horizontal = 4.dp), .background(SurfaceBlack)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
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) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@ -473,82 +626,6 @@ private fun InAppBrowser(
tint = TextPrimary, tint = TextPrimary,
) )
} }
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
Icon(
imageVector = Icons.Default.OpenInBrowser,
contentDescription = stringResource(R.string.open_in_browser),
tint = TextMuted,
)
}
} }
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
color = BitcoinOrange,
trackColor = SurfaceBlack,
)
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
override fun onReceivedTitle(view: WebView?, t: String?) {
if (!t.isNullOrBlank()) title = t
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
openExternalUrl(ctx, u)
return true
}
}
browser = this
loadUrl(url)
}
},
)
} }
} }

View File

@ -69,12 +69,12 @@
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden"> <div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<!-- Loading indicator --> <!-- Loading indicator -->
<Transition name="content-fade"> <Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40"> <AppLoadingScreen
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> v-if="iframeLoading"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> :icon="overlayIcon"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> :title="store.title || 'App'"
</svg> :progress="loadProgress"
</div> />
</Transition> </Transition>
<iframe <iframe
ref="iframeRef" ref="iframeRef"
@ -184,10 +184,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue' import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue' import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest { interface PaymentRequest {
@ -207,6 +209,39 @@ const isRefreshing = ref(false)
const iframeLoading = ref(true) const iframeLoading = ref(true)
const iframeBlocked = ref(false) const iframeBlocked = ref(false)
// Best-guess icon for the loading screen resolved from the /app/{id}/ path
// when present; AppLoadingScreen's <img> falls back to the default icon if the
// guessed asset 404s.
const overlayIcon = computed(() => {
const url = store.url
if (!url) return DEFAULT_APP_ICON
try {
const m = new URL(url, window.location.origin).pathname.match(/^\/app\/([a-z0-9._-]+)/i)
if (m?.[1]) return `/assets/img/app-icons/${m[1].toLowerCase()}.png`
} catch { /* not a parseable URL */ }
return DEFAULT_APP_ICON
})
// Faux load progress (cross-origin iframes give no real progress events): ease
// toward ~92% while loading, snap to 100% on load.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
loadProgress.value += Math.max(0.4, (92 - loadProgress.value) * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(iframeLoading, (loading) => {
if (loading) startProgress()
else { stopProgress(); loadProgress.value = 100 }
}, { immediate: true })
// Nostr identity picker state // Nostr identity picker state
const showIdentityPicker = ref(false) const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_' const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
@ -573,6 +608,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimers() clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
}) })

View File

@ -0,0 +1,81 @@
<template>
<div class="app-loading-screen absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="app-loading-icon">
<img :src="icon" :alt="title" @error="handleImageError" />
</div>
<p class="app-loading-title">{{ title }}</p>
<div class="app-loading-bar">
<div class="app-loading-fill" :style="{ width: `${clampedProgress}%` }"></div>
</div>
<p class="app-loading-hint">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = withDefaults(defineProps<{
icon: string
title: string
progress: number
hint?: string
}>(), {
hint: 'Loading…',
})
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.progress)))
</script>
<style scoped>
.app-loading-screen {
gap: 18px;
background: #0b0d12;
}
.app-loading-icon {
width: 84px;
height: 84px;
border-radius: 20px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
animation: app-loading-pulse 1.8s ease-in-out infinite;
}
.app-loading-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-loading-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.app-loading-bar {
width: min(240px, 60vw);
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.app-loading-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #fb923c, #f59e0b);
transition: width 0.3s ease;
}
.app-loading-hint {
margin: 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
@keyframes app-loading-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
</style>

View File

@ -55,7 +55,7 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled() expect(mockWindowOpen).not.toHaveBeenCalled()
}) })
it('uses route-based app sessions on mobile instead of panel mode', () => { it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
Object.defineProperty(window, 'innerWidth', { Object.defineProperty(window, 'innerWidth', {
value: 390, value: 390,
writable: true, writable: true,
@ -65,8 +65,10 @@ describe('useAppLauncherStore', () => {
store.openSession('indeedhub') store.openSession('indeedhub')
expect(store.panelAppId).toBe(null) // Mobile now uses the store-driven panel like desktop panel mode so the
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } }) // underlying page/tab never changes and closing returns to the origin.
expect(store.panelAppId).toBe('indeedhub')
expect(mockPush).not.toHaveBeenCalled()
}) })
it('normalizes localhost launch URLs to current host before resolving', () => { it('normalizes localhost launch URLs to current host before resolving', () => {
@ -117,7 +119,7 @@ describe('useAppLauncherStore', () => {
) )
}) })
it('routes desktop new-tab apps into app session on mobile', () => { it('opens tab-only apps directly on mobile (new tab in PWA, no interstitial)', () => {
Object.defineProperty(window, 'innerWidth', { Object.defineProperty(window, 'innerWidth', {
value: 390, value: 390,
writable: true, writable: true,
@ -127,10 +129,17 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' }) store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
// Tab-only app on mobile-web: open directly in a new browser tab (the
// companion would use the in-app WebView). No session, no route push, no
// "this app opens in a tab" interstitial.
expect(store.isOpen).toBe(false) expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null) expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled() expect(mockPush).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } }) expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)
}) })
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => { it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
@ -264,7 +273,7 @@ describe('useAppLauncherStore', () => {
) )
}) })
it('routes prepackaged websites into app session on mobile', () => { it('opens prepackaged websites in the store-driven panel on mobile', () => {
Object.defineProperty(window, 'innerWidth', { Object.defineProperty(window, 'innerWidth', {
value: 390, value: 390,
writable: true, writable: true,
@ -274,9 +283,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true }) store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
// Iframeable prepackaged sites stay in-app via the store panel (no route
// change, no background swap) just like every other mobile launch.
expect(store.isOpen).toBe(false) expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('arch-presentation')
expect(mockWindowOpen).not.toHaveBeenCalled() expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } }) expect(mockPush).not.toHaveBeenCalled()
}) })
it('routes HTTPS same-host apps via session view', () => { it('routes HTTPS same-host apps via session view', () => {

View File

@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
import router from '@/router' import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage' import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay' import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab } from '@/utils/openExternal'
/** /**
* Open a URL in a new browser tab but if a companion (phone) is currently * Open a URL in a new browser tab but if a companion (phone) is currently
@ -222,14 +223,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function openSession(appId: string) { function openSession(appId: string) {
recordAppLaunch(appId) recordAppLaunch(appId)
const mobile = isMobileViewport() const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) { // Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
openExternal(launchUrl) // desktop opens a new browser tab; mobile opens the in-app WebView (Android
return // companion) or a new browser tab (PWA) — see openInAppOrNewTab.
if (NEW_TAB_APP_IDS.has(appId)) {
const launchUrl = directAppUrl(appId)
if (launchUrl) {
if (mobile) openInAppOrNewTab(launchUrl)
else openExternal(launchUrl)
return
}
} }
// Iframeable apps. Mobile and desktop-panel mode both use the store-driven
// panel so the underlying page/tab never changes (no background swap) and
// closing returns the user to wherever they launched from. Only desktop
// overlay/fullscreen modes use a routed session.
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel' const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel' && !mobile) { if (mobile || mode === 'panel') {
panelAppId.value = appId panelAppId.value = appId
} else { } else {
panelAppId.value = null panelAppId.value = null

View File

@ -164,6 +164,20 @@ select:focus-visible {
/* Mobile: override with tab bar clearance */ /* Mobile: override with tab bar clearance */
@media (max-width: 767px) { @media (max-width: 767px) {
/* Mobile web browsers report 100vh taller than the visible area (the dynamic
URL/toolbar chrome). The dashboard is the containing block for the fixed,
container-relative panes (the mesh chat/tools panes), so a 100vh-tall
container pushes their `bottom` offset below the visible viewport they
slide under the bottom tab bar (which is body-teleported and viewport-fixed,
so it stays put). Pin the dashboard to the *dynamic* viewport so the two
reference frames line up. No-op in the companion WebView (no browser chrome
dvh == vh), so its layout is unchanged. Doubled class beats Tailwind's
`.min-h-screen` (100vh) utility on specificity. */
.dashboard-view.dashboard-view {
height: 100dvh;
min-height: 100dvh;
}
.mobile-scroll-pad { .mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px); padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
} }

View File

@ -11,15 +11,37 @@
*/ */
interface ArchipelagoNativeBridge { interface ArchipelagoNativeBridge {
openExternal?: (url: string) => void openExternal?: (url: string) => void
openInApp?: (url: string) => void
}
function nativeBridge(): ArchipelagoNativeBridge | undefined {
return (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }).ArchipelagoNative
} }
export function openExternalUrl(url: string): void { export function openExternalUrl(url: string): void {
if (!url) return if (!url) return
const native = (window as unknown as { ArchipelagoNative?: ArchipelagoNativeBridge }) const native = nativeBridge()
.ArchipelagoNative
if (native && typeof native.openExternal === 'function') { if (native && typeof native.openExternal === 'function') {
native.openExternal(url) native.openExternal(url)
return return
} }
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} }
/**
* Launch an app that can't be embedded in an iframe (X-Frame-Options) from a
* mobile surface with NO "this app opens in a tab" interstitial.
*
* - Android companion: hand it to the in-app WebView (`openInApp`) so it stays
* inside Archipelago with the native back/forward/reload/close controls.
* - Plain mobile browser (PWA): open directly in a new browser tab.
*/
export function openInAppOrNewTab(url: string): void {
if (!url) return
const native = nativeBridge()
if (native && typeof native.openInApp === 'function') {
native.openInApp(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="app-session-root"> <div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel"> <Teleport to="body" :disabled="isInlinePanel && !isMobile">
<div <div
:class="backdropClasses" :class="backdropClasses"
@click.self="handleBackdropClick" @click.self="handleBackdropClick"
@ -27,6 +27,7 @@
:app-url="appUrl" :app-url="appUrl"
:app-id="appId" :app-id="appId"
:app-title="appTitle" :app-title="appTitle"
:app-icon="appIcon"
:loading="loading" :loading="loading"
:iframe-blocked="iframeBlocked" :iframe-blocked="iframeBlocked"
:must-open-new-tab="mustOpenNewTab" :must-open-new-tab="mustOpenNewTab"
@ -104,10 +105,10 @@ import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS, type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle, resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig' } from './appSession/appSessionConfig'
import { launchBlockedReason } from './apps/appsConfig' import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity' import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge' import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl } from '@/utils/openExternal' import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync' import { useElectrsSync } from '@/composables/useElectrsSync'
const props = defineProps<{ const props = defineProps<{
@ -154,9 +155,17 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value)) const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null) const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appIcon = computed(() =>
packageEntry.value
? resolveAppIcon(appId.value, packageEntry.value)
: `/assets/img/app-icons/${appId.value}.png`
)
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value)) const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready') const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 // Reactive so the overlay/teleport/footer/animation decisions track the live
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value)) const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
// ElectrumX shows a sync screen before its real UI (the Electrum server only // ElectrumX shows a sync screen before its real UI (the Electrum server only
@ -241,16 +250,18 @@ function setMode(mode: DisplayMode) {
} }
} }
// Reactive classes based on display mode // Reactive classes based on display mode. On mobile the store-driven panel
// renders as a full-screen overlay (teleported to body) so it covers the nav
// and the underlying page never changes desktop keeps the inline panel.
const backdropClasses = computed(() => { const backdropClasses = computed(() => {
if (isInlinePanel.value) return 'app-session-backdrop-inline' if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay' return 'app-session-backdrop-overlay'
}) })
const panelClasses = computed(() => { const panelClasses = computed(() => {
const base = 'app-session-panel glass-card' const base = 'app-session-panel glass-card'
if (isInlinePanel.value) return `${base} app-session-inline` if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen` if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay` return `${base} app-session-overlay`
}) })
@ -370,10 +381,13 @@ watch(displayMode, (mode) => {
}) })
onMounted(() => { onMounted(() => {
// Apps that block iframes open externally on desktop. On mobile, keep the // Apps that block iframes (X-Frame-Options) can't be shown in the session.
// session surface visible so launcher taps do not bounce straight out. // Open them directly instead of showing a "this app opens in a tab"
if (mustOpenNewTab.value && appUrl.value && !isMobile) { // interstitial: desktop new browser tab; mobile in-app WebView (companion)
window.open(appUrl.value, '_blank', 'noopener,noreferrer') // or new tab (PWA). Then dismiss the (empty) session surface.
if (mustOpenNewTab.value && appUrl.value) {
if (isMobile.value) openInAppOrNewTab(appUrl.value)
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close') if (isInlinePanel.value) emit('close')
else closeRouteSession() else closeRouteSession()
return return
@ -381,8 +395,9 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true) window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
window.addEventListener('resize', updateIsMobile)
document.addEventListener('fullscreenchange', onFullscreenChange) document.addEventListener('fullscreenchange', onFullscreenChange)
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) { if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false loading.value = false
iframeBlocked.value = true iframeBlocked.value = true
} else { } else {
@ -404,6 +419,7 @@ onBeforeUnmount(() => {
if (iframeCheckId) clearTimeout(iframeCheckId) if (iframeCheckId) clearTimeout(iframeCheckId)
window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage) window.removeEventListener('message', onMessage)
window.removeEventListener('resize', updateIsMobile)
document.removeEventListener('fullscreenchange', onFullscreenChange) document.removeEventListener('fullscreenchange', onFullscreenChange)
screensaverStore.resume(screensaverReason.value) screensaverStore.resume(screensaverReason.value)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})

View File

@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppSession from '../AppSession.vue' import AppSession from '../AppSession.vue'
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({ const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
mockReplace: vi.fn(), mockReplace: vi.fn(() => Promise.resolve()),
mockPush: vi.fn(), mockPush: vi.fn(() => Promise.resolve()),
mockWindowOpen: vi.fn(), mockWindowOpen: vi.fn(),
mockSuppress: vi.fn(), mockSuppress: vi.fn(),
mockResume: vi.fn(), mockResume: vi.fn(),
@ -62,7 +62,7 @@ describe('AppSession mobile new-tab apps', () => {
}) })
}) })
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => { it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
const wrapper = mount(AppSession, { const wrapper = mount(AppSession, {
global: { global: {
stubs: { stubs: {
@ -75,9 +75,11 @@ describe('AppSession mobile new-tab apps', () => {
}) })
await flushPromises() await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled() // Tab-only app (gitea) on mobile-web: open directly in a new browser tab
expect(mockReplace).not.toHaveBeenCalled() // (no native bridge in the test) and dismiss the empty session — no
expect(wrapper.text()).toContain('This app opens in a new tab') // "this app opens in a tab" interstitial.
expect(wrapper.text()).toContain('Open in new tab') expect(mockWindowOpen).toHaveBeenCalled()
expect(mockReplace).toHaveBeenCalled()
expect(wrapper.text()).not.toContain('This app opens in a new tab')
}) })
}) })

View File

@ -1,12 +1,7 @@
<template> <template>
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe"> <div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
<Transition name="content-fade"> <Transition name="content-fade">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40"> <AppLoadingScreen v-if="loading" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
</Transition> </Transition>
<!-- ElectrumX sync screen shown before the real UI while the on-chain <!-- ElectrumX sync screen shown before the real UI while the on-chain
@ -116,13 +111,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync' import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
const props = defineProps<{ const props = defineProps<{
appUrl: string appUrl: string
appId: string appId: string
appTitle: string appTitle: string
appIcon: string
loading: boolean loading: boolean
iframeBlocked: boolean iframeBlocked: boolean
mustOpenNewTab: boolean mustOpenNewTab: boolean
@ -144,6 +141,40 @@ const emit = defineEmits<{
const iframeRef = ref<HTMLIFrameElement | null>(null) const iframeRef = ref<HTMLIFrameElement | null>(null)
// Faux load progress for the loading screen. Cross-origin iframes give no real
// progress events, so ease toward ~92% while loading and snap to 100% on load
// far better UX than a black screen with a bare spinner.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
// Decelerate as it approaches the cap so it never visually "finishes" early.
const remaining = 92 - loadProgress.value
loadProgress.value += Math.max(0.4, remaining * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(() => props.loading, (isLoading) => {
if (isLoading) {
startProgress()
} else {
stopProgress()
loadProgress.value = 100
}
}, { immediate: true })
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
onBeforeUnmount(stopProgress)
function focusIframe() { function focusIframe() {
iframeRef.value?.focus({ preventScroll: true }) iframeRef.value?.focus({ preventScroll: true })
} }

View File

@ -10,7 +10,14 @@ export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing) // Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set([ export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor', 'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
// built-in Mesh tab) belong in Services, not My Apps.
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
'immich_postgres', 'immich_redis', 'immich_postgres', 'immich_redis',
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
// the launcher app; postgres/redis are backends → Services.
'immich-postgres', 'immich-redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web', 'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui', 'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui', 'bitcoin-ui', 'lnd-ui', 'electrs-ui',
@ -37,7 +44,10 @@ export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true if (id.startsWith('indeedhub-build_')) return true
if (id.startsWith('archy-')) return true if (id.startsWith('archy-')) return true
if (id.endsWith('_db') || id.endsWith('-db')) return true // Backend naming patterns that never carry a user-facing UI: databases and
// caches. Safe to classify by suffix (a database is never a launcher).
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
if (id.endsWith('_db')) return true
return false return false
} }
@ -96,8 +106,11 @@ export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
// Curated known apps stay in My Apps even if their manifest predates the UI // Curated known apps stay in My Apps even if their manifest predates the UI
// interface field. // interface field.
if (isKnownApp(id, pkg)) return false if (isKnownApp(id, pkg)) return false
// Fallback: reachable on the LAN but declares no UI → treat as a website. // Anything still here has no declared UI and isn't a known launcher app:
return !!pkg && !!runtimeLanAddress(pkg) // databases, APIs, backends, workers. They belong in Services (not My Apps),
// whether or not they expose a LAN address. (#10 — "anything that isn't the
// frontend UI launcher".)
return !!pkg
} }
export function filterEntriesForTab( export function filterEntriesForTab(
@ -113,10 +126,33 @@ export function filterEntriesForTab(
if (activeTab === 'apps' && selectedCategory !== 'all') { if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory return getAppCategory(id, pkg) === selectedCategory
} }
if (activeTab === 'services' && selectedCategory !== 'all') {
return getServiceCategory(id, pkg) === selectedCategory
}
return true return true
}) })
} }
// Group a (non-launcher) service container by type for the Services tab sub-nav
// (#12). Heuristic over the container id + manifest id.
export function getServiceCategory(id: string, pkg?: PackageDataEntry): string {
const s = `${id} ${pkg?.manifest?.id || ''}`.toLowerCase()
if (/postgres|mariadb|mysql|(^|[-_])db([-_]|$)/.test(s)) return 'database'
if (/redis|valkey|(^|[-_])cache([-_]|$)/.test(s)) return 'cache'
if (/(^|[-_])api([-_]|$)/.test(s)) return 'api'
return 'backend'
}
export function buildServiceCategories(t: (key: string) => string): Array<{ id: string; name: string }> {
return [
{ id: 'all', name: t('marketplace.all') },
{ id: 'database', name: 'Databases' },
{ id: 'cache', name: 'Caches' },
{ id: 'api', name: 'APIs' },
{ id: 'backend', name: 'Backends' },
]
}
// Web-only app IDs and their URLs // Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS: Record<string, string> = { export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com', 'nwnn': 'https://nwnn.l484.com',
@ -178,8 +214,56 @@ export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id) return TAB_LAUNCH_APPS.has(id)
} }
// Backend services that ship no icon of their own reuse their PARENT app's icon
// (#14) so they render the app's logo instead of a 404 → 📦 placeholder. Paths
// are explicit because icon extensions vary (.png / .webp / .svg).
const APP_ICON_FALLBACKS: Record<string, string> = { const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg', gitea: '/assets/img/app-icons/gitea.svg',
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
// immich stack
'immich-postgres': '/assets/img/app-icons/immich.png',
'immich-redis': '/assets/img/app-icons/immich.png',
'immich-server': '/assets/img/app-icons/immich.png',
'immich_postgres': '/assets/img/app-icons/immich.png',
'immich_redis': '/assets/img/app-icons/immich.png',
// btcpay stack
'archy-btcpay-db': '/assets/img/app-icons/btcpay-server.png',
'archy-nbxplorer': '/assets/img/app-icons/btcpay-server.png',
// mempool stack
'archy-mempool-db': '/assets/img/app-icons/mempool.webp',
'mempool-api': '/assets/img/app-icons/mempool.webp',
'archy-mempool-web': '/assets/img/app-icons/mempool.webp',
'mysql-mempool': '/assets/img/app-icons/mempool.webp',
// bitcoin / lightning companion UIs
'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
// (e.g. every indeedhub-* sub-container → indeedhub).
const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
['immich-', '/assets/img/app-icons/immich.png'],
['immich_', '/assets/img/app-icons/immich.png'],
]
function serviceParentIcon(id: string): string | undefined {
for (const [prefix, icon] of SERVICE_ICON_PREFIXES) {
if (id.startsWith(prefix)) return icon
}
return undefined
} }
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg' export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
@ -195,7 +279,12 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
) { ) {
return icon return icon
} }
return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png` return (
curatedIcon ||
APP_ICON_FALLBACKS[id] ||
serviceParentIcon(id) ||
`/assets/img/app-icons/${id}.png`
)
} }
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
@ -302,6 +391,21 @@ export function useCategoriesWithApps(
}) })
} }
// Services-tab equivalent of useCategoriesWithApps: only show a service category
// when at least one installed service belongs to it (#12).
export function useServiceCategories(
packages: Ref<Record<string, PackageDataEntry>>,
serviceCategories: Ref<Array<{ id: string; name: string }>>,
) {
return computed(() => {
const entries = Object.entries(packages.value).filter(([id, pkg]) => isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
return serviceCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getServiceCategory(id, pkg) === cat.id)
})
})
}
export function handleImageError(e: Event) { export function handleImageError(e: Event) {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement
const currentSrc = target.src const currentSrc = target.src

View File

@ -143,9 +143,10 @@ const mobileTabBar = ref<HTMLElement | null>(null)
const MOBILE_LAYOUT_MAX_WIDTH = 920 const MOBILE_LAYOUT_MAX_WIDTH = 920
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth) const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
// App sessions own their mobile controls. Normal mobile launches use the route // App sessions own their mobile controls, so the nav hides while one is open.
// session; keeping this guard also protects any desktop-panel state on resize. // Mobile launches now use the store-driven panel (no route change) to keep the
const isAppSessionActive = computed(() => route.name === 'app-session') // background tab intact, so treat an active panel the same as a routed session.
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
// Show persistent tabs for Apps/Marketplace on mobile // Show persistent tabs for Apps/Marketplace on mobile
const showAppsTabs = computed(() => { const showAppsTabs = computed(() => {