fix(apps): restore mobile and website launching

This commit is contained in:
archipelago 2026-05-17 19:22:18 -04:00
parent daad50325b
commit 413d50116e
12 changed files with 402 additions and 39 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## v1.7.59-alpha (2026-05-17)
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
## v1.7.58-alpha (2026-05-17)
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.

View File

@ -376,7 +376,8 @@ impl RpcHandler {
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
{
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e

View File

@ -557,10 +557,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
tier: "",
},
};
apply_dynamic_metadata(app_id, &mut meta);
meta.tier = get_app_tier(app_id);
meta
}
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
let Ok(data) = std::fs::read_to_string(config_path) else {
return;
};
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
return;
};
if let Some(title) = cfg
.get("title")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty() && s.len() <= 80)
{
meta.title = title.to_string();
}
if let Some(description) = cfg
.get("description")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty() && s.len() <= 240)
{
meta.description = description.to_string();
}
}
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).

View File

@ -48,7 +48,9 @@ pub struct MeshtasticDevice {
impl MeshtasticDevice {
pub async fn open(path: &str) -> Result<Self> {
match tokio::fs::metadata(path).await {
Ok(meta) => debug!(path = %path, permissions = ?meta.permissions(), "Device node exists"),
Ok(meta) => {
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
}
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
}
@ -82,7 +84,12 @@ impl MeshtasticDevice {
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining.min(Duration::from_millis(250)), self.read_from_radio()).await {
match tokio::time::timeout(
remaining.min(Duration::from_millis(250)),
self.read_from_radio(),
)
.await
{
Ok(Ok(Some(frame))) => {
saw_meshtastic_frame = true;
self.handle_from_radio(&frame);
@ -210,7 +217,11 @@ impl MeshtasticDevice {
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
other => {
debug!(field = other, len = value.len(), "Unhandled Meshtastic FromRadio field");
debug!(
field = other,
len = value.len(),
"Unhandled Meshtastic FromRadio field"
);
None
}
}
@ -336,7 +347,10 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
if end > buf.len() {
return None;
}
if matches!(field, FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO) {
if matches!(
field,
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
) {
return Some((field, &buf[idx..end]));
}
idx = end;
@ -500,12 +514,8 @@ fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
if end > buf.len() {
None
} else {
let value = u32::from_le_bytes([
buf[pos],
buf[pos + 1],
buf[pos + 2],
buf[pos + 3],
]);
let value =
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
Some((field, FieldValue::Fixed32(value), end))
}
}

View File

@ -112,6 +112,22 @@ describe('useAppLauncherStore', () => {
)
})
it('routes desktop new-tab apps into app session on mobile', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
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' } })
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
@ -219,6 +235,35 @@ describe('useAppLauncherStore', () => {
)
})
it('opens known prepackaged websites in new tab on desktop when requested', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://nwnn.l484.com',
'_blank',
'noopener,noreferrer',
)
})
it('routes prepackaged websites into app session on mobile', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
})
it('routes HTTPS same-host apps via session view', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },

View File

@ -31,6 +31,10 @@ function mustOpenInNewTab(url: string): boolean {
}
}
function isMobileViewport(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 768
}
function inferAppIdFromTitle(title?: string): string | null {
const t = (title || '').toLowerCase()
if (!t) return null
@ -149,8 +153,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (mode === 'panel' && !isMobile) {
if (mode === 'panel' && !isMobileViewport()) {
panelAppId.value = appId
} else {
panelAppId.value = null
@ -168,8 +171,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Force selected apps to open directly in new tab
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
if (!isMobileViewport() && payload.openInNewTab) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
// Force selected apps to open directly in new tab on desktop only. On
// phones, route through the app session/webview so app icons behave like
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
@ -181,7 +191,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
// Unknown apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}

View File

@ -288,16 +288,19 @@ function goBack() {
function launchApp() {
if (!pkg.value) return
const id = appId.value
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
return
}
if (isWebsitePackage(id, pkg.value)) {
const url = resolveRuntimeLaunchUrl(pkg.value)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
if (url) {
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
}
return
}

View File

@ -146,7 +146,7 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const mustOpenNewTab = computed(() => !isMobile && NEW_TAB_APPS.has(appId.value))
const screensaverReason = computed(() => `app-session:${appId.value}`)
const screensaverSuppressedApps = new Set([
'indeedhub',
@ -530,6 +530,8 @@ onBeforeUnmount(() => {
.app-session-fullscreen {
height: 100vh;
height: 100dvh;
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
background: black;
}
.app-session-panel.glass-card {
border: none !important;
@ -543,8 +545,8 @@ onBeforeUnmount(() => {
}
.app-session-frame-safe {
flex: none !important;
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
padding-bottom: 0;
}
}

View File

@ -18,24 +18,48 @@
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<div class="flex-1 flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden">
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
<div class="md:hidden flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
<button
type="button"
class="sideload-icon-btn sideload-icon-btn-mobile"
aria-label="Sideload app"
title="Sideload app"
@click="showSideload = true"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</button>
</div>
</div>
@ -125,6 +149,63 @@
@confirm="onConfirmUninstall"
/>
<Transition name="fade">
<div
v-if="showSideload"
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
@click.self="closeSideload"
>
<form class="sideload-modal" @submit.prevent="submitSideload">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">&times;</button>
</div>
<div class="space-y-4">
<label class="block">
<span class="sideload-label">App ID</span>
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
</label>
<label class="block">
<span class="sideload-label">Docker image</span>
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="block">
<span class="sideload-label">Title</span>
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
</label>
<label class="block">
<span class="sideload-label">Port mapping</span>
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
</label>
</div>
<label class="block">
<span class="sideload-label">Description</span>
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
</label>
</div>
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
<p class="font-medium text-white/80 mb-2">Easy sources</p>
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
</div>
<div class="mt-5 flex gap-3">
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
{{ sideloading ? 'Installing...' : 'Install' }}
</button>
</div>
</form>
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
@ -149,6 +230,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { rpcClient } from '@/api/rpc-client'
import { type PackageDataEntry, type PackageState } from '@/types/api'
import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
@ -156,7 +238,7 @@ import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import {
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
const { t } = useI18n()
@ -165,6 +247,16 @@ const route = useRoute()
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
const showSideload = ref(false)
const sideloading = ref(false)
const sideloadError = ref('')
const sideloadForm = ref({
id: '',
image: '',
title: '',
port: '',
description: '',
})
// Only stagger-animate on first mount
const showStagger = !appsAnimationDone
@ -286,12 +378,20 @@ function goToApp(id: string) {
function launchApp(id: string) {
const pkg = packages.value[id]
if (pkg && isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (pkg && webOnlyUrl) {
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
return
}
if (pkg && opensInTab(id)) {
if (pkg && isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
}
return
}
if (!isMobile && pkg && opensInTab(id)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
@ -308,4 +408,147 @@ async function updateApp(id: string) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
function closeSideload() {
if (sideloading.value) return
showSideload.value = false
sideloadError.value = ''
}
function inferPortMapping(image: string): string {
const lower = image.toLowerCase()
if (lower.includes('excalidraw')) return '3009:80'
if (lower.includes('stirling')) return '3011:8080'
if (lower.includes('freshrss')) return '3012:80'
if (lower.includes('wallabag')) return '3013:80'
if (lower.includes('hedgedoc')) return '3014:3000'
if (lower.includes('cyberchef')) return '3015:80'
if (lower.includes('mealie')) return '3016:9000'
if (lower.includes('pairdrop')) return '3017:3000'
return ''
}
async function submitSideload() {
const id = sideloadForm.value.id.trim().toLowerCase()
const image = sideloadForm.value.image.trim()
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
return
}
if (!image || !image.includes('/')) {
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
return
}
sideloading.value = true
sideloadError.value = ''
const containerConfig: Record<string, unknown> = {}
containerConfig.title = title
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
if (port) containerConfig.ports = [port]
try {
serverStore.setInstallProgress(id, {
id,
title,
status: 'downloading',
progress: 2,
message: 'Sideload queued...',
attempt: 0,
})
await rpcClient.call({
method: 'package.install',
params: {
id,
dockerImage: image,
version: 'sideload',
containerConfig,
},
timeout: 15000,
})
closeSideload()
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
} catch (err) {
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
serverStore.setInstallProgress(id, {
id,
title,
status: 'error',
progress: 0,
message: sideloadError.value,
attempt: 0,
})
} finally {
sideloading.value = false
}
}
</script>
<style scoped>
.sideload-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.sideload-icon-btn:hover,
.sideload-icon-btn:focus-visible {
border-color: rgba(255, 255, 255, 0.38);
background: rgba(255, 255, 255, 0.15);
color: white;
}
.sideload-icon-btn-mobile {
width: 48px;
height: 48px;
}
.sideload-modal {
width: 100%;
max-width: 34rem;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow-y: auto;
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;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
.sideload-label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.62);
}
.sideload-input {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
padding: 0.75rem 0.9rem;
color: white;
outline: none;
}
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
}
</style>

View File

@ -62,6 +62,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',

View File

@ -79,7 +79,7 @@ import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl } from './appsConfig'
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
@ -120,14 +120,20 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
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) {
window.open(url, '_blank', 'noopener,noreferrer')
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
return
}
}
if (opensInTab(id)) {
if (!isMobile && opensInTab(id)) {
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')

View File

@ -107,6 +107,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
@ -132,6 +133,11 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
},
'arch-presentation': {
state: 'running' as PackageState,
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
},
'syntropy-institute': {
state: 'running' as PackageState,
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },