Compare commits

...
2 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 2399eeac66 feat(ui): auto-tab fallback — embed-refusing apps become tab apps
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>
2026-08-13 20:23:04 -04:00
archipelago 63cb9dd22c Revert "feat(ui): Kammergut gloss test on the logo badge inner circle"
Demo images / Build & push demo images (push) Successful in 3m51s
This reverts commit 6672d978f7.
2026-08-13 15:02:36 -04:00
8 changed files with 103 additions and 45 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## v1.8.2-alpha (2026-08-13)
- **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.
- **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.
## v1.8.1-alpha (2026-08-13)
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
+8
View File
@@ -159,6 +159,14 @@ the top-level origin (OAuth redirect flows, WebAuthn), or sets
in the real embedded app session, **not** a plain browser tab: tabs don't
enforce framing headers, so a tab proves nothing about the iframe.
**The dashboard also self-heals**: if a running app's frame fails to load,
the session offers "open in tab" and *remembers the app as a tab app* — every
later launch opens a tab directly, with the tab-launch icon on its button.
The memory clears itself when the app later embeds successfully, and expires
weekly so fixes get re-probed. This safety net is not a licence to skip the
manifest flag: declaring `open_in_new_tab: true` up front spares your users
the one dead-pane encounter the detector needs.
(Historical note: before the gate handled this, embeddable-but-blocking apps
each carried a hand-built nginx strip proxy — gitea's port-3000 proxy is the
surviving example. Do not copy that pattern for new apps.)
-34
View File
@@ -1,39 +1,5 @@
<template>
<div id="app">
<!-- KAMMERGUT GLOSS TEST (2026-08-12): SVG paint filter lifted verbatim
from plan-b's Kammergut wordmark (#paintGloss). Consumed by the
.logo-gradient-border::after override in style.css. Revert by
deleting this svg block + that css block (one commit). -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<defs>
<filter id="paintGloss" x="-12%" y="-30%" width="124%" height="160%" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="2.5" result="dilated"/>
<feGaussianBlur in="dilated" stdDeviation="6" result="puddle"/>
<feFlood flood-color="#fff4dc" flood-opacity="0.85" result="puddleColor"/>
<feComposite in="puddleColor" in2="puddle" operator="in" result="puddleHi"/>
<feFlood flood-color="#1A1A18" flood-opacity="0.18" result="puddleShadowColor"/>
<feComposite in="puddleShadowColor" in2="puddle" operator="in" result="puddleShadow"/>
<feOffset in="puddleShadow" dx="0" dy="3" result="puddleShadowOff"/>
<feGaussianBlur in="SourceAlpha" stdDeviation="1.4" result="bump"/>
<feSpecularLighting in="bump" surfaceScale="4" specularConstant="0.9"
specularExponent="40" lighting-color="#fff2d4" result="spec2">
<fePointLight x="600" y="-200" z="380"/>
</feSpecularLighting>
<feComposite in="spec2" in2="SourceAlpha" operator="in" result="specClip2"/>
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="dsBlur"/>
<feOffset in="dsBlur" dx="0" dy="6" result="dsOff"/>
<feComponentTransfer in="dsOff" result="dsFinal">
<feFuncA type="linear" slope="0.45"/>
</feComponentTransfer>
<feMerge>
<feMergeNode in="dsFinal"/>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="specClip2"/>
</feMerge>
</filter>
</defs>
</svg>
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
-10
View File
@@ -1128,16 +1128,6 @@ html.controller-nav [data-controller-container]:focus {
z-index: 0;
}
/* KAMMERGUT GLOSS TEST (2026-08-12) black gloss paint on the badge's
inner circle, lifted from plan-b's Kammergut wordmark (.paint-3d
gradient + #paintGloss filter, defs injected in App.vue). Revert:
delete this block + the svg defs block in App.vue (one commit). */
.logo-gradient-border::after {
background: linear-gradient(180deg, #2a2a26 0%, #1a1a18 45%, #060604 100%);
filter: url(#paintGloss);
-webkit-filter: url(#paintGloss);
}
.logo-gradient-border img,
.logo-gradient-border svg {
border-radius: 9999px;
+51
View File
@@ -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)
}
}
+23
View File
@@ -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 */ }
+5 -1
View File
@@ -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">