fix(apps): restore mobile and website launching
This commit is contained in:
parent
daad50325b
commit
413d50116e
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.).
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user