feat(ui): auto-tab fallback — embed-refusing apps become tab apps
Demo images / Build & push demo images (push) Successful in 3m49s
Demo images / Build & push demo images (push) Successful in 3m49s
An app whose frame never loads while its backend reports Running (the embed-refusal signature: frame-busting JS, top-level-origin apps, SameSite=Strict logins — everything the gate's header stripping cannot fix) is remembered in localStorage; every later launch opens a tab straight from the click (user gesture, so no popup blocker), and opensInTab() gives it the tab-launch icon. A successful iframe load clears the memory and entries expire after 7 days, so nodes that gain embedding (gate improvements) get re-probed instead of being remembered broken forever. Dev guide updated; v1.8.2 changelog + What's New curated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63cb9dd22c
commit
2399eeac66
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Auto-tab memory — apps observed to refuse iframe embedding.
|
||||
*
|
||||
* The gate strips frame-blocking headers for gated apps (1.8.1+), but three
|
||||
* failure modes survive any header fix: JS frame-busting, apps that must be
|
||||
* the top-level origin (OAuth/WebAuthn), and SameSite=Strict session cookies.
|
||||
* Cross-origin embed failure is not reliably detectable up front from the
|
||||
* browser, so the app session's load-timeout is the detector — and this
|
||||
* module is the memory: once an app is seen blocked while RUNNING, it is
|
||||
* remembered here and every later launch opens it as a tab app directly from
|
||||
* the click (a user gesture, so never popup-blocked). The dead grey pane
|
||||
* happens at most once per app.
|
||||
*
|
||||
* Entries expire after 7 days so a node update that fixes embedding (for
|
||||
* example a gate improvement) gets re-probed instead of being remembered as
|
||||
* broken forever; a successful iframe load also clears the entry immediately.
|
||||
*/
|
||||
|
||||
const KEY = 'archipelago_auto_new_tab_apps'
|
||||
const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function read(): Record<string, number> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(KEY) || '{}')
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
|
||||
} catch { /* corrupt/unavailable storage reads as empty */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function write(entries: Record<string, number>) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(entries)) } catch { /* full/denied */ }
|
||||
}
|
||||
|
||||
export function isAutoTabApp(id: string): boolean {
|
||||
const at = read()[id]
|
||||
return typeof at === 'number' && Date.now() - at < EXPIRY_MS
|
||||
}
|
||||
|
||||
export function rememberAutoTabApp(id: string): void {
|
||||
const entries = read()
|
||||
entries[id] = Date.now()
|
||||
write(entries)
|
||||
}
|
||||
|
||||
export function forgetAutoTabApp(id: string): void {
|
||||
const entries = read()
|
||||
if (id in entries) {
|
||||
delete entries[id]
|
||||
write(entries)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||
@@ -199,9 +200,27 @@ function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||||
// Remembered embed-refusers (frame-busting, top-level-origin apps): the
|
||||
// first blocked encounter records them, every later launch is a tab app.
|
||||
isAutoTabApp(appId.value) ||
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// The auto-tab detector: the load-timeout marked the frame blocked, the
|
||||
// warming-up retry loop has given up, and the backend says the app is
|
||||
// actually RUNNING — that combination is the embed-refusal signature (a
|
||||
// down app is "warming up" or shows a blocked reason instead). Remember it
|
||||
// so this app never shows the dead pane again; a later successful iframe
|
||||
// load (onLoad) clears the memory, and entries expire on their own.
|
||||
watch([iframeBlocked, warmingUp], ([blocked, warming]) => {
|
||||
if (
|
||||
blocked && !warming && !blockedReason.value &&
|
||||
(packageEntry.value?.state === PackageState.Running)
|
||||
) {
|
||||
rememberAutoTabApp(appId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
// the Electrum app; pass the status to the frame only while still syncing.
|
||||
@@ -344,6 +363,10 @@ function onLoad() {
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
autoRetryCount.value = 0
|
||||
// A frame that loads embeds fine — heal any stale auto-tab memory (for
|
||||
// example an app remembered as blocked before the gate learned to strip
|
||||
// frame headers).
|
||||
forgetAutoTabApp(appId.value)
|
||||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
@@ -173,7 +174,10 @@ export const TAB_LAUNCH_APPS = new Set([
|
||||
])
|
||||
|
||||
export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
// The launch icon and launch behavior follow the dynamic verdict too:
|
||||
// apps observed refusing the iframe (remembered by the app session) show
|
||||
// the same tab-launch affordance as the statically-known tab apps.
|
||||
return TAB_LAUNCH_APPS.has(id) || isAutoTabApp(id)
|
||||
}
|
||||
|
||||
// Backend services that ship no icon of their own reuse their PARENT app's icon
|
||||
|
||||
@@ -362,6 +362,17 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.2-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.2-alpha</span>
|
||||
<span class="text-xs text-white/40">August 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.</p>
|
||||
<p>**The screensaver and login emblem are back to their clean black finish.** A glossy paint experiment on the circle behind the logo shipped by mistake in the previous update; it's removed everywhere.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.1-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
Reference in New Issue
Block a user