- LND wallet: request correct address type so receive-address generation no longer 400s - AIUI/app session: on-screen pointer can click + type into app content (incl. app store search); "open in new tab" opens the phone browser; mobile credential modal centered instead of full-height (remote-relay.ts, AppSession.vue, AppSessionFrame.vue, AppIconGrid.vue, openExternal.ts, WebViewScreen.kt) + remote-relay tests - health_monitor: electrs auto-recovers from a corrupt index and shows a percent/block-height progress screen while reindexing (useElectrsSync.ts) - update.rs: drop retired tx1138 secondary mirror (one-time migration); longer download timeout for slow connections - CHANGELOG: v1.7.90-alpha notes - tests/release/run.sh: harness tweaks Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
360 lines
12 KiB
Vue
360 lines
12 KiB
Vue
<template>
|
|
<div class="app-icon-grid-wrap">
|
|
<!-- Swipeable pages -->
|
|
<div
|
|
ref="scrollContainer"
|
|
class="app-icon-pages"
|
|
@scroll="onScroll"
|
|
>
|
|
<div
|
|
v-for="(page, pageIndex) in pages"
|
|
:key="pageIndex"
|
|
class="app-icon-page"
|
|
>
|
|
<div
|
|
v-for="([id, pkg]) in page"
|
|
:key="id"
|
|
class="app-icon-item"
|
|
role="button"
|
|
:tabindex="0"
|
|
:aria-label="getTitle(id, pkg)"
|
|
@pointerdown="startLongPress(id)"
|
|
@pointerup="clearLongPress"
|
|
@pointercancel="clearLongPress"
|
|
@pointerleave="clearLongPress"
|
|
@contextmenu.prevent="openAppOptions(id)"
|
|
@click="handleTap(id, pkg)"
|
|
@keydown.enter="handleTap(id, pkg)"
|
|
@keydown.space.prevent="openAppOptions(id)"
|
|
>
|
|
<!-- Icon with status indicator -->
|
|
<div class="app-icon-frame">
|
|
<img
|
|
:src="getIcon(id, pkg)"
|
|
:alt="getTitle(id, pkg)"
|
|
class="app-icon-img archy-app-icon"
|
|
@error="handleImageError"
|
|
/>
|
|
<!-- Status dot -->
|
|
<span
|
|
v-if="pkg.state === 'running'"
|
|
class="app-icon-status app-icon-status-running"
|
|
></span>
|
|
<span
|
|
v-else-if="pkg.state === 'exited'"
|
|
class="app-icon-status app-icon-status-error"
|
|
></span>
|
|
<span
|
|
v-else-if="pkg.state === 'starting' || pkg.state === 'stopping' || pkg.state === 'installing'"
|
|
class="app-icon-status app-icon-status-transition"
|
|
></span>
|
|
<!-- Installing overlay -->
|
|
<div
|
|
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
|
class="app-icon-installing"
|
|
>
|
|
<svg class="animate-spin h-5 w-5 text-white" 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>
|
|
</div>
|
|
<!-- Label -->
|
|
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
|
<span
|
|
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
|
class="app-icon-progress-label"
|
|
:title="progressLabel(id, pkg)"
|
|
>
|
|
{{ progressLabel(id, pkg) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Page dots -->
|
|
<div v-if="pages.length > 1" class="app-icon-dots">
|
|
<button
|
|
v-for="(_, i) in pages"
|
|
:key="i"
|
|
class="app-icon-dot"
|
|
:class="{ 'app-icon-dot-active': i === activePage }"
|
|
:aria-label="`Page ${i + 1}`"
|
|
@click="scrollToPage(i)"
|
|
></button>
|
|
</div>
|
|
|
|
<Transition name="fade">
|
|
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
|
<div class="credential-modal-panel">
|
|
<div class="flex items-start justify-between gap-4 mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
|
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
|
</div>
|
|
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">×</button>
|
|
</div>
|
|
<div class="credential-modal-body space-y-3">
|
|
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
|
<div class="flex items-center justify-between gap-3 mb-1">
|
|
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
|
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
|
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useServerStore } from '@/stores/server'
|
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
|
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
|
import { resolveAppCredentials } from './appCredentials'
|
|
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
|
import { getCuratedAppList } from '../discover/curatedApps'
|
|
|
|
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
|
|
|
const serverStore = useServerStore()
|
|
const appLauncher = useAppLauncherStore()
|
|
|
|
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
|
const credentialModal = ref({
|
|
show: false,
|
|
appId: '',
|
|
title: '',
|
|
description: '',
|
|
credentials: [] as AppCredential[],
|
|
copied: '',
|
|
})
|
|
|
|
const props = defineProps<{
|
|
apps: [string, PackageDataEntry][]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
goToApp: [id: string]
|
|
}>()
|
|
|
|
const scrollContainer = ref<HTMLElement | null>(null)
|
|
const activePage = ref(0)
|
|
const longPressTriggered = ref(false)
|
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const pages = computed(() => {
|
|
const result: [string, PackageDataEntry][][] = []
|
|
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
|
|
result.push(props.apps.slice(i, i + ITEMS_PER_PAGE))
|
|
}
|
|
return result.length ? result : [[]]
|
|
})
|
|
|
|
function getTitle(id: string, pkg: PackageDataEntry): string {
|
|
const t = pkg.manifest?.title
|
|
if (t && t !== id) return t
|
|
return curatedMap.get(id)?.title || t || id
|
|
}
|
|
|
|
function getIcon(id: string, pkg: PackageDataEntry): string {
|
|
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
|
}
|
|
|
|
function progressLabel(id: string, pkg: PackageDataEntry): string {
|
|
const install = serverStore.installingApps.get(id)
|
|
if (install) {
|
|
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
|
|
}
|
|
if (serverStore.uninstallingApps.has(id)) {
|
|
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
async function handleTap(id: string, pkg: PackageDataEntry) {
|
|
if (longPressTriggered.value) {
|
|
longPressTriggered.value = false
|
|
return
|
|
}
|
|
if (canLaunch(pkg)) {
|
|
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
|
if (shown) return
|
|
launchNow(id, pkg)
|
|
} else {
|
|
emit('goToApp', id)
|
|
}
|
|
}
|
|
|
|
function startLongPress(id: string) {
|
|
clearLongPress()
|
|
longPressTriggered.value = false
|
|
longPressTimer = setTimeout(() => {
|
|
longPressTriggered.value = true
|
|
openAppOptions(id)
|
|
}, 550)
|
|
}
|
|
|
|
function clearLongPress() {
|
|
if (!longPressTimer) return
|
|
clearTimeout(longPressTimer)
|
|
longPressTimer = null
|
|
}
|
|
|
|
function openAppOptions(id: string) {
|
|
clearLongPress()
|
|
emit('goToApp', id)
|
|
}
|
|
|
|
function launchNow(id: string, pkg: PackageDataEntry) {
|
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
|
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
|
if (webOnlyUrl) {
|
|
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
|
return
|
|
}
|
|
if (isWebsitePackage(id, pkg)) {
|
|
const url = resolveRuntimeLaunchUrl(pkg)
|
|
if (url) {
|
|
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
|
return
|
|
}
|
|
}
|
|
if (!isMobile && opensInTab(id)) {
|
|
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
|
if (appUrl) {
|
|
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
|
return
|
|
}
|
|
}
|
|
appLauncher.openSession(id)
|
|
}
|
|
|
|
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
|
try {
|
|
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
|
const credentials = resolveAppCredentials(id, result)
|
|
if (!credentials) return false
|
|
credentialModal.value = {
|
|
show: true,
|
|
appId: id,
|
|
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
|
credentials: credentials.credentials,
|
|
copied: '',
|
|
}
|
|
return true
|
|
} catch {
|
|
const credentials = resolveAppCredentials(id, null)
|
|
if (!credentials) return false
|
|
credentialModal.value = {
|
|
show: true,
|
|
appId: id,
|
|
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
|
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
|
credentials: credentials.credentials,
|
|
copied: '',
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
function closeCredentialModal() { credentialModal.value.show = false }
|
|
|
|
function continueCredentialLaunch() {
|
|
const id = credentialModal.value.appId
|
|
const entry = props.apps.find(([appId]) => appId === id)
|
|
closeCredentialModal()
|
|
if (entry) launchNow(entry[0], entry[1])
|
|
}
|
|
|
|
async function copyModalCredential(label: string, value: string) {
|
|
try { await navigator.clipboard.writeText(value) } catch {
|
|
const textarea = document.createElement('textarea')
|
|
textarea.value = value
|
|
document.body.appendChild(textarea)
|
|
textarea.select()
|
|
document.execCommand('copy')
|
|
document.body.removeChild(textarea)
|
|
}
|
|
credentialModal.value.copied = label
|
|
}
|
|
|
|
function onScroll() {
|
|
const el = scrollContainer.value
|
|
if (!el) return
|
|
const pageWidth = el.clientWidth
|
|
if (pageWidth === 0) return
|
|
activePage.value = Math.round(el.scrollLeft / pageWidth)
|
|
}
|
|
|
|
function scrollToPage(index: number) {
|
|
const el = scrollContainer.value
|
|
if (!el) return
|
|
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sideload-modal {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
max-width: 34rem;
|
|
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 1.5rem 1.5rem 0 0;
|
|
background: rgba(8, 10, 18, 0.94);
|
|
padding: 1.25rem;
|
|
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
|
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
|
}
|
|
.sideload-close-btn {
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
flex-shrink: 0;
|
|
border-radius: 0.75rem;
|
|
color: rgba(255, 255, 255, 0.55);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
.credential-modal-body {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
.credential-modal-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
max-width: 34rem;
|
|
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
|
matching the wallet receive modal. The body scrolls if content overflows
|
|
rather than the panel stretching edge-to-edge. */
|
|
max-height: calc(
|
|
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
|
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
|
);
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 1.5rem;
|
|
background: rgba(8, 10, 18, 0.98);
|
|
padding: 1.25rem;
|
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
|
}
|
|
.credential-modal-actions {
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|