diff --git a/CHANGELOG.md b/CHANGELOG.md index cb91ef82..7efdfa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index fc2d2880..6dece47e 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -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 diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 3f7c6618..8bba3618 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -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::(&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.). diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 9f594cf4..13d16ee6 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -48,7 +48,9 @@ pub struct MeshtasticDevice { impl MeshtasticDevice { pub async fn open(path: &str) -> Result { 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)) } } diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index baf36914..69e4b7be 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -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' }, diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index e476ff34..70f0fe8e 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 } diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index dfa9c81d..872bcfa0 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -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 } diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 893ef532..ca9071e3 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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; } } diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index d13599be..51ee2f6f 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -18,24 +18,48 @@ :class="{ 'mode-switcher-btn-active': selectedCategory === category.id }" >{{ category.name }} - +
+ + +
- -
+ +
+
@@ -125,6 +149,63 @@ @confirm="onConfirmUninstall" /> + +
+
+
+
+

Sideload app

+

Install a trusted Docker image with a simple web UI.

+
+ +
+ +
+ + +
+ + +
+ +
+ +
{{ sideloadError }}
+ +
+

Easy sources

+

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.

+
+ +
+ + +
+
+
+
+