feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
+17 -6
View File
@@ -262,12 +262,23 @@ const canLaunch = computed(() => {
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
])
const features = computed(() => {
if (packageKey.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default',
]
})
function goBack() {
if (route.query.from === 'discover') {
+36 -10
View File
@@ -85,14 +85,28 @@
</svg>
</button>
</div>
</div>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
<!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. -->
<NostrSignConsent
:show="nostrBridge.showConsent.value"
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
:method="nostrBridge.consentRequest.value?.method ?? ''"
:identity-label="nostrBridge.consentRequest.value?.identityLabel"
:event-kind="nostrBridge.consentRequest.value?.eventKind"
:content="nostrBridge.consentRequest.value?.content"
:phase="nostrBridge.consentPhase.value"
:error="nostrBridge.consentError.value"
@approve="nostrBridge.approveConsent"
@deny="nostrBridge.denyConsent"
/>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="identity.cancelIdentitySelection"
/>
</div>
</div>
</Teleport>
</div>
@@ -105,6 +119,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -265,7 +280,12 @@ function closeRouteSession() {
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
appId: () => appId.value,
appName: () => appTitle.value,
appUrl: () => appUrl.value,
frameWindow: () => iframeRef.value?.contentWindow ?? null,
})
// --- Display mode ---
@@ -349,7 +369,7 @@ const backdropClasses = computed(() => {
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
const base = 'app-session-panel glass-card relative overflow-hidden'
if (inlinePanelMode.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
@@ -443,6 +463,7 @@ function handleBackdropClick() {
}
function closeSession() {
if (nostrBridge.showConsent.value) nostrBridge.denyConsent()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@@ -450,6 +471,11 @@ function closeSession() {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent()
e.preventDefault()
return
}
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
@@ -465,7 +491,7 @@ function onFullscreenChange() {
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
}
+4 -4
View File
@@ -637,10 +637,10 @@ function goToApp(id: string) {
async function launchApp(id: string) {
const shown = await maybeShowCredentialsBeforeLaunch(id)
if (shown) return
launchAppNow(id)
launchAppNow(id, true)
}
function launchAppNow(id: string) {
function launchAppNow(id: string, credentialsChecked = false) {
const pkg = packages.value[id]
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -664,7 +664,7 @@ function launchAppNow(id: string) {
return
}
}
useAppLauncherStore().openSession(id)
useAppLauncherStore().openSession(id, { skipCredentialPrompt: credentialsChecked })
}
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
@@ -721,7 +721,7 @@ function closeCredentialModal() {
function continueCredentialLaunch() {
const id = credentialModal.value.appId
closeCredentialModal()
if (id) launchAppNow(id)
if (id) launchAppNow(id, true)
}
async function copyModalCredential(label: string, value: string) {
+168 -22
View File
@@ -93,29 +93,18 @@
</div>
</div>
<!-- Hero + Featured + Banner (only when no search) -->
<!-- Hero + registry-owned storefront (only when no search) -->
<template v-if="!searchQuery">
<DiscoverHero
:total-apps="allApps.length"
:installed-count="installedCount"
/>
<FeaturedApps
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<!-- A registry storefront extends the featured story; it does not
replace it. Keep the primary banner above Popular, then place
registry promotions after the popular rows. -->
<div
v-if="featuredBanner"
v-if="catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
@@ -152,15 +141,136 @@
</div>
</div>
<!-- Mobile companion app banner — opens the download/pairing modal -->
<CompanionBanner />
<!-- New catalogs own the store composition. Six popular cards make two
rows on desktop; registry operators can reorder them without an OS
release. Older catalogs retain the legacy featured fallback. -->
<div v-if="catalogStorefront" class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">popular</span>
<h2 class="text-xl font-bold text-white">Popular Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ popularApps.length }} apps</span>
</div>
<AppGrid
v-if="catalogStorefront"
:filtered-apps="popularApps"
:show-stagger="showStagger"
:stagger-offset="0"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-installed-state="getInstalledState"
:get-app-tier="getAppTier"
:is-loading="loadingCommunity"
loading-message="Loading..."
:nostr-error="''"
:is-nostr-category="false"
:search-query="''"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<FeaturedApps
v-else
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<div
v-if="!catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
<img
:src="featuredBanner.banner"
:alt="featuredBanner.headline"
class="featured-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">featured</span>
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
<div class="flex items-center gap-3">
<button
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
@click.stop="launchInstalledApp(featuredBannerApp)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>Launch</button>
<button
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
@click.stop="handleInstall(featuredBannerApp)"
:disabled="installingApps.has(featuredBannerApp.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
<span v-else>Install</span>
</button>
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
</div>
</div>
</div>
<!-- Promotions are ordered and written by the registry catalog. -->
<div
v-for="promotion in storefrontPromotions"
:key="promotion.id"
class="featured-banner source-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="viewAppDetails(promotion.app)"
>
<img
:src="promotion.banner"
alt=""
class="featured-banner-img source-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay source-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
<span class="text-white/50 text-sm font-mono">{{ promotion.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ promotion.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ promotion.description }}</p>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="isInstalled(promotion.app.id) && !isStartingUp(promotion.app.id)"
@click.stop="launchInstalledApp(promotion.app)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>{{ promotion.launchLabel || 'Launch' }}</button>
<button
v-else-if="!isInstalled(promotion.app.id) && promotion.app.dockerImage"
@click.stop="handleInstall(promotion.app)"
:disabled="installingApps.has(promotion.app.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>{{ installingApps.has(promotion.app.id) ? 'Installing…' : (promotion.installLabel || 'Install') }}</button>
<button
@click.stop="viewAppDetails(promotion.app)"
class="rounded-lg px-4 py-2.5 text-sm font-medium text-white/70 hover:text-white"
>{{ promotion.detailsLabel || 'View details →' }}</button>
</div>
</div>
</div>
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
<h2 class="text-xl font-bold text-white">Available to Install</h2>
<h2 class="text-xl font-bold text-white">All Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
<span class="text-white/30 text-sm">{{ remainingApps.length }} apps</span>
</div>
</template>
@@ -179,7 +289,7 @@
</div>
<AppGrid
:filtered-apps="filteredApps"
:filtered-apps="gridApps"
:show-stagger="showStagger"
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
:containers-scanned="containersScanned"
@@ -199,6 +309,10 @@
@retry-nostr="retryNostr"
/>
<!-- The node-owned companion download is operational UI, not registry
merchandising, so it follows the complete app listing. -->
<CompanionBanner v-if="!searchQuery" />
<!-- Manifesto Footer (only when no search) -->
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
<div class="flex items-center gap-3 mb-4">
@@ -250,7 +364,7 @@ import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
const router = useRouter()
const store = useAppStore()
@@ -299,6 +413,12 @@ const catalogFeatured = useCachedResource<CatalogFeatured | null>({
ttlMs: 300_000,
persist: true,
}).data
const catalogStorefront = useCachedResource<CatalogStorefront | null>({
key: 'app-catalog:storefront',
fetcher: async () => (await fetchAppCatalog())?.storefront ?? null,
ttlMs: 300_000,
persist: true,
}).data
const communityApps = computed(() => catalogResource.data.value ?? [])
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
@@ -452,6 +572,32 @@ const filteredApps = computed(() => {
return apps
})
const popularApps = computed(() => {
const byId = new Map(allApps.value.map(app => [app.id, app]))
const popular: MarketplaceApp[] = []
for (const id of catalogStorefront.value?.popular ?? []) {
const app = byId.get(id)
if (app?.dockerImage) popular.push(app)
}
return popular
})
const popularIds = computed(() => new Set(popularApps.value.map(app => app.id)))
const remainingApps = computed(() =>
catalogStorefront.value
? filteredApps.value.filter(app => !popularIds.value.has(app.id))
: filteredApps.value
)
const gridApps = computed(() => searchQuery.value ? filteredApps.value : remainingApps.value)
const storefrontPromotions = computed(() => {
if (!catalogStorefront.value) return []
return catalogStorefront.value.promotions.flatMap(promotion => {
const app = allApps.value.find(candidate => candidate.id === promotion.id)
return app ? [{ ...promotion, app }] : []
})
})
const installedCount = computed(() => {
return allApps.value.filter(app => isInstalled(app.id)).length
})
@@ -497,6 +497,15 @@ function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefin
// Placeholder features
const features = computed(() => {
if (appId.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
+221
View File
@@ -0,0 +1,221 @@
<template>
<main class="relative h-screen w-screen overflow-hidden bg-transparent" aria-label="Archipelago Nostr signer">
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appName"
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
<NostrSignConsent
:show="bridge.showConsent.value"
:app-name="bridge.consentRequest.value?.appName ?? appName"
:method="bridge.consentRequest.value?.method ?? ''"
:identity-label="bridge.consentRequest.value?.identityLabel"
:event-kind="bridge.consentRequest.value?.eventKind"
:content="bridge.consentRequest.value?.content"
:phase="bridge.consentPhase.value"
:error="bridge.consentError.value"
@approve="bridge.approveConsent"
@deny="bridge.denyConsent"
/>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
const appId = ref('')
const appName = ref('App')
const appOrigin = ref('')
const showIdentityPicker = ref(false)
const queuedRequests: MessageEvent[] = []
let hideTimer: ReturnType<typeof setTimeout> | null = null
function getStoredIdentity(): SelectedIdentity | null {
if (!appId.value) return null
try {
const raw = localStorage.getItem(`archipelago_app_identity_${appId.value}`)
return raw ? JSON.parse(raw) as SelectedIdentity : null
} catch {
return null
}
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(`archipelago_app_identity_${appId.value}`, JSON.stringify(identity)) } catch {}
}
function parentPost(message: Record<string, unknown>) {
window.parent.postMessage(message, appOrigin.value || '*')
}
function showSigner() {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
parentPost({ type: 'archipelago:signer-show' })
}
function hideSigner(delay = 0) {
if (hideTimer !== null) clearTimeout(hideTimer)
const hide = () => {
hideTimer = null
if (!showIdentityPicker.value && !bridge.showConsent.value) {
parentPost({ type: 'archipelago:signer-hide' })
}
}
if (delay > 0) hideTimer = setTimeout(hide, delay)
else hide()
}
function sendIdentity(identity: SelectedIdentity) {
parentPost({ type: 'archipelago:signer-identity', identity })
}
const bridge = useNostrBridge(getStoredIdentity, {
appId: () => appId.value,
appName: () => appName.value,
appUrl: () => appOrigin.value,
frameWindow: () => window.parent,
})
// Keep the app-side broker frame hidden for silent/remembered requests. It is
// a full-viewport iframe in tabs and Companion WebViews, so showing it for
// every background getPublicKey/getRelays call produces a visible flash even
// when no consent card opens. Reveal it only when there is actually something
// for the user to review, and keep it visible through signing/result.
watch(bridge.showConsent, (show) => {
if (show) showSigner()
else if (!showIdentityPicker.value) hideSigner()
})
function isSameNodeAppOrigin(origin: string): boolean {
try {
const candidate = new URL(origin)
return (candidate.protocol === 'http:' || candidate.protocol === 'https:')
&& candidate.hostname === window.location.hostname
} catch {
return false
}
}
async function onIdentitySelected(identity: SelectedIdentity) {
storeIdentity(identity)
showIdentityPicker.value = false
sendIdentity(identity)
const requests = queuedRequests.splice(0)
if (requests.length) {
await nextTick()
for (const request of requests) await handleRequest(request)
} else {
// The host app normally follows the selected identity with getPublicKey
// and signEvent. Keep one continuous signer surface through that chain so
// the picker does not disappear and immediately flash back as consent.
hideSigner(400)
}
}
async function handleRequest(event: MessageEvent) {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
await bridge.handleNostrRequest(event)
// getRelays and remembered approvals complete without opening the consent
// card. Do not leave the otherwise-transparent broker intercepting the app.
if (!bridge.showConsent.value && !showIdentityPicker.value) {
hideSigner()
}
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
const requests = queuedRequests.splice(0)
for (const request of requests) {
const id = (request.data as { id?: unknown } | null)?.id
window.parent.postMessage({ type: 'nostr-response', id, error: 'Identity selection cancelled' }, request.origin)
}
parentPost({ type: 'archipelago:signer-identity-cancelled' })
hideSigner()
}
function onMessage(event: MessageEvent) {
if (event.source !== window.parent) return
const data = event.data as Record<string, unknown> | null
if (!data) return
if (data.type === 'archipelago:signer-init') {
const id = typeof data.appId === 'string' ? data.appId : ''
const name = typeof data.appName === 'string' ? data.appName : 'App'
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id) || !isSameNodeAppOrigin(event.origin)) return
appId.value = id
appName.value = name.slice(0, 120)
appOrigin.value = event.origin
const stored = getStoredIdentity()
if (stored) {
sendIdentity(stored)
hideSigner(400)
} else {
showIdentityPicker.value = true
showSigner()
}
return
}
if (data.type === 'archipelago:signer-select-identity' && appId.value && event.origin === appOrigin.value) {
showIdentityPicker.value = true
showSigner()
return
}
if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return
if (!getStoredIdentity()) {
queuedRequests.push(event)
showIdentityPicker.value = true
showSigner()
return
}
void handleRequest(event)
}
onMounted(() => {
// This route is rendered inside a full-viewport broker iframe. The global
// dashboard stylesheet gives body a solid black canvas and animated
// compositor layers; Android WebView can retain that last iframe surface
// for a frame (or indefinitely) after the picker closes. Keep the broker's
// document genuinely transparent so even a stale surface cannot cover the
// app beneath it.
document.documentElement.classList.add('nostr-signer-route')
document.body.classList.add('nostr-signer-route')
window.addEventListener('message', onMessage)
window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
})
onBeforeUnmount(() => {
if (hideTimer !== null) clearTimeout(hideTimer)
window.removeEventListener('message', onMessage)
document.documentElement.classList.remove('nostr-signer-route')
document.body.classList.remove('nostr-signer-route')
})
</script>
<style>
html.nostr-signer-route,
html.nostr-signer-route body,
html.nostr-signer-route #app {
background: transparent !important;
}
html.nostr-signer-route body::before,
html.nostr-signer-route body::after,
html.nostr-signer-route::before {
content: none !important;
animation: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
</style>
+8 -3
View File
@@ -914,9 +914,10 @@ async function loadStatus() {
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
}
// Mirror the backend in both directions. The old one-way assignment could
// set this after a completed download but never clear it after cancellation,
// leaving the Install button visible until the component remounted.
downloaded.value = res.update_in_progress
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
}
@@ -1043,6 +1044,10 @@ async function cancelDownload() {
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
// `update_in_progress` is the backend's staged/installable flag. Leaving
// this true made the card render Install until the next page refresh even
// though cancellation had already removed the partial staging files.
updateInProgress.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
@@ -41,7 +41,15 @@ vi.mock('../appSession/useAppIdentity', () => ({
}))
vi.mock('../appSession/useNostrBridge', () => ({
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
useNostrBridge: () => ({
handleNostrRequest: vi.fn(),
showConsent: { value: false },
consentRequest: { value: null },
consentPhase: { value: 'review' },
consentError: { value: '' },
approveConsent: vi.fn(),
denyConsent: vi.fn(),
}),
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -0,0 +1,49 @@
import { shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
describe('NostrTabSigner visibility', () => {
beforeEach(() => {
localStorage.clear()
window.history.replaceState({}, '', '/nostr-signer')
})
afterEach(() => vi.restoreAllMocks())
function parentMessage(data: Record<string, unknown>) {
const event = new MessageEvent('message', { data, origin: window.location.origin })
Object.defineProperty(event, 'source', { value: window.parent })
window.dispatchEvent(event)
}
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
name: 'Alice',
nostr_pubkey: 'abc123',
}))
const postMessage = vi.spyOn(window.parent, 'postMessage')
const wrapper = shallowMount(NostrTabSigner)
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(true)
expect(document.body.classList.contains('nostr-signer-route')).toBe(true)
parentMessage({
type: 'archipelago:signer-init',
appId: 'archipelago-source',
appName: 'GitWorkshop',
})
postMessage.mockClear()
parentMessage({ type: 'nostr-request', id: 1, method: 'getRelays', params: {} })
await Promise.resolve()
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-show' }),
expect.anything(),
)
wrapper.unmount()
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
@@ -45,6 +45,11 @@ describe('appSessionConfig', () => {
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('does not force GitWorkshop into a dashboard iframe in Companion', () => {
expect(GENERATED_HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
expect(HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
@@ -147,4 +152,17 @@ describe('appSessionConfig', () => {
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
})
it('keeps the pre-catalog Source app on the dashboard origin', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
// Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search')
})
})
@@ -0,0 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { consentKey, hasRememberedConsent, rememberConsent } from '../nostrConsent'
describe('NIP-07 consent storage', () => {
beforeEach(() => localStorage.clear())
it('binds remembered approval to origin, app, identity and method', () => {
const key = consentKey('https://node.example', 'archipelago-source', 'identity-a', 'signEvent')
rememberConsent(key)
expect(hasRememberedConsent(key)).toBe(true)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-b', 'signEvent'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-a', 'nip44.decrypt'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://other-node.example', 'archipelago-source', 'identity-a', 'signEvent'))).toBe(false)
})
})
@@ -0,0 +1,314 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
type ProviderWindow = Window & {
__archipelagoNostr?: boolean
ArchipelagoSurface?: {
expectPageTransition: () => void
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
callback: (identity: { nostr_pubkey: string }) => void,
) => () => void
}
}
describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow
beforeEach(() => {
providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
document.documentElement.innerHTML = '<head><title>IndeedHub</title></head><body></body>'
window.history.replaceState({}, '', '/app/indeedhub/')
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
Reflect.deleteProperty(document, 'readyState')
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
})
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: userActivated },
})
window.eval(providerSource)
window.dispatchEvent(new Event('load'))
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
postMessage.mockClear()
return { frame, postMessage, signerOrigin }
}
it('reopens the chooser for a user-triggered NIP-07 login', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const publicKey = providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }),
signerOrigin,
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('uses the remembered identity for background account restoration', () => {
const { postMessage, signerOrigin } = loadProvider(false)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }),
expect.anything(),
)
})
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = {
expectPageTransition: vi.fn(),
}
providerWindow.ArchipelagoSurface = surface
const { frame, postMessage, signerOrigin } = loadProvider(false)
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
expect(frame.style.display).toBe('block')
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
expect(frame.style.display).toBe('block')
expect(frame.style.width).toBe('1px')
expect(frame.style.height).toBe('1px')
expect(frame.style.opacity).toBe('0')
expect(frame.style.pointerEvents).toBe('none')
expect(frame.style.transform).toContain('-10000px')
expect(document.querySelector('#archipelago-nostr-signer')).toBe(frame)
const publicKey = providerWindow.nostr!.getPublicKey()
const replacement = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
expect(replacement).toBe(frame)
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: replacement.contentWindow })
window.dispatchEvent(ready)
const request = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
const listener = vi.fn()
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
window.dispatchEvent(changed)
expect(listener).toHaveBeenCalledOnce()
})
it('turns an automatic IndeeHub identity into a NIP-98 signing request', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/nostr-auth/health`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
}),
signerOrigin,
)
})
it('waits for the signer success surface to hide before reloading after NIP-98', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
const signRequest = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request' && message.id != null)!
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
await Promise.resolve()
expect(raf).toHaveBeenCalledOnce()
})
it('queues a request until signer-init when the signer iframe wins the load race', () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: false },
})
Object.defineProperty(document, 'readyState', {
configurable: true,
value: 'loading',
})
window.eval(providerSource)
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalled()
window.dispatchEvent(new Event('load'))
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
})
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useAppIdentity, type SelectedIdentity } from '../useAppIdentity'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const alice: SelectedIdentity = {
id: 'alice-id',
name: 'Alice',
did: 'did:key:alice',
pubkey: 'identity-key',
nostr_pubkey: 'nostr-key',
}
describe('useAppIdentity explicit identity selection', () => {
beforeEach(() => {
localStorage.clear()
vi.mocked(rpcClient.call).mockResolvedValue({ signature: 'proof' })
})
it('reuses a stored identity normally but reopens the picker for login', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify(alice))
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(false)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.handleIdentityRequest()
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:identity', nostr_pubkey: 'nostr-key' }),
'*',
))
postMessage.mockClear()
identity.handleIdentityRequest(true)
expect(showPicker.value).toBe(true)
expect(postMessage).not.toHaveBeenCalled()
})
it('notifies the requesting app when the chooser is cancelled', () => {
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(true)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.cancelIdentitySelection()
expect(showPicker.value).toBe(false)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:identity-cancelled' }, '*')
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useNostrBridge } from '../useNostrBridge'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('useNostrBridge consent presentation', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'signed-event' })
})
afterEach(() => vi.useRealTimers())
it('keeps the contained identity loader visible through signing and completion', async () => {
const source = { postMessage: vi.fn() } as unknown as Window
const bridge = useNostrBridge(
() => ({ id: 'identity-a', name: 'Alice', nostr_pubkey: 'pubkey-a' } as never),
{
appId: () => 'archipelago-source', appName: () => 'GitWorkshop',
appUrl: () => 'https://node.test/app/archipelago-source/', frameWindow: () => source,
},
)
const event = {
data: { type: 'nostr-request', id: 'request-1', method: 'signEvent', params: { event: { kind: 1621, content: 'Fix it' } } },
source, origin: 'https://node.test',
} as MessageEvent
const handling = bridge.handleNostrRequest(event)
await Promise.resolve()
expect(bridge.showConsent.value).toBe(true)
expect(bridge.consentPhase.value).toBe('review')
bridge.approveConsent(false)
expect(bridge.consentPhase.value).toBe('signing')
expect(bridge.showConsent.value).toBe(true)
await handling
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'nostr-response', id: 'request-1' }), 'https://node.test')
await vi.advanceTimersByTimeAsync(350)
expect(bridge.consentPhase.value).toBe('success')
await vi.advanceTimersByTimeAsync(325)
expect(bridge.showConsent.value).toBe(false)
})
})
@@ -1,7 +1,12 @@
/** Static configuration maps for app session routing and display */
import { portIsGateFronted } from '../discover/curatedApps'
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import {
GENERATED_APP_PORTS,
GENERATED_APP_TITLES,
GENERATED_HOST_FRAME_APPS,
GENERATED_NEW_TAB_APPS,
} from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
@@ -50,6 +55,7 @@ export const APP_PORTS: Record<string, number> = {
/** Apps that need nginx proxy for iframe embedding.
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'archipelago-source': '/app/archipelago-source/',
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
@@ -59,6 +65,21 @@ export const PROXY_APPS: Record<string, string> = {
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
/**
* First-party apps that are deliberately being node-tested before their
* manifest reaches the release-signed catalog. Keep this list narrow: it is
* only a scheme-routing fallback, and does not make an app installable or
* trusted. Once the signed catalog carries the app, portIsGateFronted is the
* normal source of truth.
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
return portIsGateFronted(appId, port) || PRE_CATALOG_GATED_PORTS[appId] === Number(port)
}
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nostrudel': 'https://nostrudel.ninja',
@@ -81,6 +102,13 @@ export const NEW_TAB_APPS = new Set([
'tailscale',
])
/** Apps that consume an integration supplied by the dashboard parent frame.
* The Android companion normally promotes sessions into a top-level native
* WebView; doing that to one of these apps would sever its postMessage bridge. */
export const HOST_FRAME_APPS = new Set([
...GENERATED_HOST_FRAME_APPS,
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
@@ -103,6 +131,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// GitWorkshop is deliberately mounted below the dashboard origin. This is
// the only launch shape that survives every supported ingress (LAN,
// Tailscale, FIPS, Tor and reverse proxies) without assuming that a second
// high port is reachable through the same address.
if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
}
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
@@ -120,7 +158,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// would fail to connect over https at all.
try {
const port = new URL(base).port
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
if (appPortIsGateFronted(id, port)) base = matchPageScheme(base)
} catch { /* keep as-is */ }
if (routeQueryPath) base += routeQueryPath
return base
@@ -152,7 +190,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
*/
export function appOrigin(port: number, appId?: string): string {
const https = appId
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
? HTTPS_APP_IDS.has(appId) || (appPortIsGateFronted(appId, port) && pageScheme() === 'https:')
: pageScheme() === 'https:'
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
}
@@ -4,6 +4,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"adguardhome": 3030,
"aiui": 5180,
"alby-hub": 8187,
"archipelago-source": 8337,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -42,6 +43,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"adguardhome": "AdGuard Home",
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archipelago-source": "GitWorkshop",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -114,3 +116,6 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"uptime-kuma",
"vaultwarden",
])
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
])
@@ -0,0 +1,30 @@
const CONSENT_KEY = 'archipelago_nostr_consent_v2'
function readRemembered(): Set<string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(CONSENT_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [])
} catch {
return new Set()
}
}
/** Remembered NIP-07 access is scoped to the exact app, identity and method. */
export function consentKey(
origin: string,
appId: string,
identityId: string,
method: string,
): string {
return JSON.stringify(['v2', origin, appId, identityId, method])
}
export function hasRememberedConsent(key: string): boolean {
return readRemembered().has(key)
}
export function rememberConsent(key: string): void {
const remembered = readRemembered()
remembered.add(key)
try { localStorage.setItem(CONSENT_KEY, JSON.stringify([...remembered])) } catch { /* unavailable/full */ }
}
@@ -16,7 +16,7 @@ export interface SelectedIdentity {
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
return id === 'indeedhub' || id === 'nostrudel' || id === 'archipelago-source'
}
export function useAppIdentity(
@@ -68,18 +68,24 @@ export function useAppIdentity(
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
function handleIdentityRequest(force = false) {
if (IS_DEMO) return
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
if (stored && !force) sendIdentity(stored)
else showIdentityPicker.value = true
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
return {
getStoredIdentity,
sendIdentity,
onIdentitySelected,
onIframeLoadIdentity,
handleIdentityRequest,
cancelIdentitySelection,
}
}
+158 -32
View File
@@ -1,31 +1,147 @@
/** Composable for NIP-07 Nostr signing between parent and iframe apps.
*
* Replies always target event.origin — the frame's REAL origin. The app's
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
* dashboard); targeting it makes postMessage throw and the app never sees
* its response. */
/** Consent-gated NIP-07 bridge between the dashboard and an iframe app. */
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from './nostrConsent'
interface BridgeOptions {
appId: () => string
appName: () => string
appUrl: () => string
frameWindow: () => Window | null
}
export interface BridgeConsentRequest {
appName: string
method: string
identityLabel: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
const CONSENT_METHODS = new Set([
'getPublicKey', 'signEvent',
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
function senderMatches(expectedUrl: string, senderOrigin: string): boolean {
try {
const expected = new URL(expectedUrl, window.location.origin)
const sender = new URL(senderOrigin)
return expected.hostname === sender.hostname && expected.port === sender.port
} catch {
return false
}
}
export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null,
options: BridgeOptions,
) {
const consentRequest = ref<BridgeConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
function requestConsent(
method: string,
identityLabel: string,
eventKind?: number,
content?: string,
): Promise<boolean> {
return new Promise((resolve, reject) => {
consentGeneration += 1
consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
function approveConsent(remember: boolean) {
consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
consentRequest.value?.reject()
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
async function finishConsentSuccess() {
const generation = approvedGeneration
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
if (generation !== consentGeneration || !showConsent.value) return
consentPhase.value = 'success'
await new Promise(resolve => setTimeout(resolve, 325))
if (generation !== consentGeneration) return
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
}
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
}
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
if (
!source ||
source !== options.frameWindow() ||
!senderMatches(options.appUrl(), event.origin)
) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin
let prompted = false
try {
if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) {
prompted = true
const remember = await requestConsent(
method,
identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : undefined,
)
if (remember) rememberConsent(key)
}
}
let result: unknown
if (method === 'getPublicKey') {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
@@ -34,30 +150,40 @@ export function useNostrBridge(
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
if (import.meta.env.DEV) console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
// Reply to the sender's REAL origin, never to the stored app URL:
// a scheme-upgraded frame (HSTS, or any future upgrade) makes the
// stored http:// URL a stale targetOrigin — postMessage then throws
// and the app never receives its response. nostr sign-in on IndeeHub
// over HTTPS died exactly there (2026-09-01).
source.postMessage({ type: 'nostr-response', id, result }, event.origin || '*')
result = identityId
? await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
: await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip04.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else if (method === 'nip44.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip44.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, origin)
if (prompted) void finishConsentSuccess()
} catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, event.origin || '*')
source.postMessage({
type: 'nostr-response', id,
error: err instanceof Error ? err.message : 'Unknown error',
}, origin)
if (prompted && showConsent.value) finishConsentError(err)
}
}
return { handleNostrRequest }
return {
handleNostrRequest,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
}
+4 -4
View File
@@ -222,7 +222,7 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
if (shown) return
launchNow(id, pkg)
launchNow(id, pkg, true)
} else {
emit('goToApp', id)
}
@@ -248,7 +248,7 @@ function openAppOptions(id: string) {
emit('goToApp', id)
}
function launchNow(id: string, pkg: PackageDataEntry) {
function launchNow(id: string, pkg: PackageDataEntry, credentialsChecked = false) {
markLaunching(id)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -270,7 +270,7 @@ function launchNow(id: string, pkg: PackageDataEntry) {
return
}
}
appLauncher.openSession(id)
appLauncher.openSession(id, { skipCredentialPrompt: credentialsChecked })
}
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
@@ -308,7 +308,7 @@ function continueCredentialLaunch() {
const id = credentialModal.value.appId
const entry = props.apps.find(([appId]) => appId === id)
closeCredentialModal()
if (entry) launchNow(entry[0], entry[1])
if (entry) launchNow(entry[0], entry[1], true)
}
async function copyModalCredential(label: string, value: string) {
+31 -2
View File
@@ -12,10 +12,31 @@ export interface CatalogFeatured {
tag: string
}
/** Registry-owned App Store ordering and promotions. Keeping this alongside
* the app entries lets a catalog release change merchandising without an OS
* or dashboard release. */
export interface CatalogPromotion {
id: string
banner: string
eyebrow: string
headline: string
description: string
tag: string
launchLabel?: string
installLabel?: string
detailsLabel?: string
}
export interface CatalogStorefront {
popular: string[]
promotions: CatalogPromotion[]
}
export interface AppCatalog {
version: number
registry: string
featured: CatalogFeatured
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: MarketplaceApp[]
}
@@ -28,6 +49,8 @@ export interface AppCatalog {
export interface SignedAppCatalog {
schema?: number
updated?: string
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: Record<string, SignedAppEntry>
}
@@ -171,6 +194,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// dashboard release. The community catalog supplies the featured banner
// and curated copy for shared ids; signed-only ids join the listing as-is.
let signedApps: MarketplaceApp[] = []
let signedFeatured: CatalogFeatured | undefined
let signedStorefront: CatalogStorefront | undefined
let signedOk = false
try {
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
@@ -179,6 +204,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
if (data.apps && !Array.isArray(data.apps)) {
signedCatalogCache = data
signedApps = signedCatalogToApps(data)
signedFeatured = data.featured
signedStorefront = data.storefront
signedOk = signedApps.length > 0
}
}
@@ -214,7 +241,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()],
}
cachedCatalog = merged
@@ -269,6 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
{ id: 'archipelago-source', title: 'GitWorkshop', version: '0.4.0', category: 'development', description: "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.", icon: '/assets/img/app-icons/gitworkshop-dc36db6.svg', author: 'GitWorkshop contributors', maintainerNpub: 'npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg', dockerImage: 'localhost/archipelago-source:local', repoUrl: 'https://github.com/DanConwayDev/gitworkshop' },
]
}
+1
View File
@@ -18,6 +18,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
containerConfig?: ContainerConfig
requires?: string[]
tier?: string
maintainerNpub?: string
}
export type FeaturedApp = MarketplaceApp & {
@@ -438,5 +438,16 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://gitea.com',
},
{
id: 'archipelago-source',
title: 'GitWorkshop',
version: '0.4.0',
category: 'development',
description: "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
icon: '/assets/img/app-icons/gitworkshop-dc36db6.svg',
author: 'GitWorkshop contributors',
dockerImage: 'localhost/archipelago-source:local',
repoUrl: 'https://github.com/DanConwayDev/gitworkshop',
},
]
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
@@ -73,6 +74,15 @@ async function probe() {
}
}
function downloadCertificate(event: MouseEvent) {
// Android WebView does not implement HTML downloads by itself. Ask the
// companion to fetch this connected node's CA and open the system credential
// installer; normal browsers keep the Content-Disposition download.
if (installCertificateInCompanion()) {
event.preventDefault()
}
}
onMounted(probe)
</script>
@@ -109,6 +119,7 @@ onMounted(probe)
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
@click="downloadCertificate"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -132,29 +143,85 @@ onMounted(probe)
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> — open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set “When using this certificate” to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> — download it in Safari and
allow the profile, then Settings → General → VPN &amp; Device Management to
install it, and finally Settings → General → About → Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> — right-click → Install
Certificate → Local Machine → place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> — Settings → Security →
Encryption &amp; credentials → Install a certificate → CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> — copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store — add it under Settings → Privacy &amp; Security →
View Certificates → Authorities.</p>
<p class="text-white/50">
<div class="mt-2 space-y-5 text-sm text-white/60">
<p class="text-white/70">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key — remove the certificate from your devices if you retire it.
</p>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">macOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Double-click the file to add it to your <em>login keychain</em>.</li>
<li>Open Keychain Access and find it under Certificates.</li>
<li>Open it, expand Trust, set “When using this certificate” to <em>Always Trust</em>, then close the window and enter your password.</li>
</ol>
<p>Quit and reopen your browser after changing the trust setting.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">iOS / iPadOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Open the file in Safari and tap Allow to download the profile.</li>
<li>Settings → Profile Downloaded, or General → VPN &amp; Device Management → Install.</li>
<li>Settings → General → About → Certificate Trust Settings → switch the certificate on.</li>
</ol>
<p class="text-orange-200/80">The final Certificate Trust Settings step is required.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Windows</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Right-click the file and choose Install Certificate.</li>
<li>Select Local Machine.</li>
<li>Choose “Place all certificates in the following store” → Trusted Root Certification Authorities → Finish.</li>
</ol>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Android</h4>
<p>Settings → Security → Encryption &amp; credentials → Install a certificate → CA certificate, then choose the file.</p>
<p>Browsers using the system certificate store will trust it after restart. Apps that pin their own certificates may still refuse it.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Linux</h4>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo install -m644 /path/to/node-ca.crt /usr/local/share/ca-certificates/node-ca.crt &amp;&amp; sudo update-ca-certificates</code></pre>
<p><strong class="text-white/70">Firefox:</strong> Settings → Privacy &amp; Security → View Certificates → Authorities → Import, then enable “Trust this CA to identify websites”.</p>
<p><strong class="text-white/70">Arch / Manjaro:</strong></p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo cp node-ca.crt /etc/ca-certificates/trust-source/anchors/ &amp;&amp; sudo update-ca-trust extract</code></pre>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Restart the browser first</h4>
<p>Chrome, Brave, Firefox, and Safari cache certificate decisions. Fully quit and reopen the browser before troubleshooting a certificate that still appears untrusted.</p>
<p>For a one-visit sanity check on a machine you own, Chrome and Brave accept the keyboard shortcut <code class="px-1 py-0.5 bg-black/30 rounded text-xs">thisisunsafe</code> on the certificate error page. Use this only for testing.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">If the node name does not resolve</h4>
<p>Certificate trust and DNS are separate. If <code class="px-1 py-0.5 bg-black/30 rounded text-xs">node.local</code> does not resolve, prefer the node's Tailscale MagicDNS name when available.</p>
<p>To keep using a local name on Linux or macOS, add the node address to <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>:</p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>echo '192.168.x.y mynode.local' | sudo tee -a /etc/hosts</code></pre>
<p>On Linux, if that still fails, inspect <code class="px-1 py-0.5 bg-black/30 rounded text-xs">grep '^hosts:' /etc/nsswitch.conf</code>. Put <code class="px-1 py-0.5 bg-black/30 rounded text-xs">files</code> before <code class="px-1 py-0.5 bg-black/30 rounded text-xs">mdns_minimal [NOTFOUND=return]</code> so an mDNS miss cannot block <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Symptoms</h4>
<div class="overflow-x-auto rounded-lg border border-white/10">
<table class="w-full text-left text-xs">
<thead class="bg-white/5 text-white/75">
<tr><th class="p-2">What you see</th><th class="p-2">Likely cause</th></tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr><td class="p-2">Not trusted / ERR_CERT_AUTHORITY_INVALID</td><td class="p-2">The certificate is not installed, or the browser was not restarted.</td></tr>
<tr><td class="p-2">This site can't be reached / DNS error</td><td class="p-2">Name resolution, not TLS. Check the DNS guidance above.</td></tr>
<tr><td class="p-2">curl works, browser does not</td><td class="p-2">A separate browser certificate store or a stale browser process.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</details>
</div>
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NodeCertificateSection from '../NodeCertificateSection.vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
vi.mock('@/utils/openExternal', () => ({
installCertificateInCompanion: vi.fn(),
}))
const installCertificate = vi.mocked(installCertificateInCompanion)
describe('NodeCertificateSection', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => '-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----',
}))
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('uses the native installer and cancels WebView navigation in the companion', async () => {
installCertificate.mockReturnValue(true)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
expect(installCertificate).toHaveBeenCalledOnce()
expect(click.defaultPrevented).toBe(true)
})
it('preserves the ordinary browser download when no native installer exists', async () => {
installCertificate.mockReturnValue(false)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
const componentPreservedDownload = !click.defaultPrevented
expect(installCertificate).toHaveBeenCalledOnce()
expect(componentPreservedDownload).toBe(true)
})
it('includes the complete trust, browser restart, DNS, and troubleshooting guidance', async () => {
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('details').exists()).toBe(true))
const text = wrapper.text()
expect(text).toContain('Certificate Trust Settings')
expect(text).toContain('Trusted Root Certification Authorities')
expect(text).toContain('update-ca-trust extract')
expect(text).toContain('Restart the browser first')
expect(text).toContain('thisisunsafe')
expect(text).toContain('Tailscale MagicDNS')
expect(text).toContain("This site can't be reached / DNS error")
expect(text).toContain('curl works, browser does not')
})
})
+58 -30
View File
@@ -392,6 +392,17 @@
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<IdentitySuccessPane
v-if="profileSuccess"
:identity-name="profileSuccess.identityName"
:event-id="profileSuccess.eventId"
:accepted="profileSuccess.accepted"
:attempted="profileSuccess.attempted"
:relay-note="profileSuccess.relayNote"
@again="profileSuccess = null"
@done="closeProfileEditor"
/>
<template v-else>
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img
@@ -455,11 +466,11 @@
</div>
</div>
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -471,6 +482,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ManagedIdentity, IdentityProfile } from './types'
import IdentitySuccessPane from '@/components/IdentitySuccessPane.vue'
const { t } = useI18n()
@@ -618,7 +630,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
}
}
const profileError = ref('')
const profileSuccess = ref('')
interface ProfilePublishSuccess {
identityName: string
eventId: string
accepted: number
attempted: number
relayNote: string
}
const profileSuccess = ref<ProfilePublishSuccess | null>(null)
async function loadIdentities() {
const hadIdentities = managedIdentities.value.length > 0
@@ -739,58 +758,67 @@ function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
try {
const identity = profileEditorIdentity.value
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
params: { id: identity.id, ...profileForm.value },
})
await loadIdentities()
const n = res.accepted?.length ?? 0
const total = res.relays_attempted ?? 0
const tail = `(${res.event_id.slice(0, 12)}…)`
if (n === total) {
profileSuccess.value = `Published to all ${total} relays ${tail}`
} else if (n > 0) {
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
const first = res.rejected?.[0]
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
} else {
profileError.value = `Published to 0/${total} relays — check Manage Relays`
try {
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({ method: 'identity.publish-profile', params: { id: identity.id } })
const accepted = res.accepted?.length ?? 0
const attempted = res.relays_attempted ?? 0
const rejected = res.rejected?.[0]
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: res.event_id || '',
accepted,
attempted,
relayNote: accepted === attempted
? ''
: rejected
? `${rejected[0]} rejected the event: ${rejected[1]}`
: 'The profile is saved on this node. Check Manage Relays before retrying publication.',
}
} catch (publishError: unknown) {
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: '',
accepted: 0,
attempted: 0,
relayNote: `The profile is saved on this node, but relay publication failed: ${publishError instanceof Error ? publishError.message : 'unknown error'}`,
}
}
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
profileError.value = err instanceof Error ? err.message : 'Failed to save profile'
} finally {
profilePublishing.value = false
}
}
defineExpose({ loadIdentities, managedIdentities })
defineExpose({ loadIdentities, managedIdentities, openProfileEditor, publishProfile })
</script>
<style scoped>
@@ -5,6 +5,19 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<PaymentSuccessPane
v-if="sendSuccess"
:amount="sendSuccess.amount"
verb="SENT"
:method-label="sendSuccess.methodLabel"
:rows="sendSuccess.rows"
:note="sendSuccess.note"
again-label="Send another"
@again="sendAnother"
@done="closeUnifiedSendModal"
/>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -32,12 +45,6 @@
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
@@ -93,8 +100,6 @@
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
@@ -106,6 +111,7 @@
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -177,7 +183,6 @@
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
@@ -233,7 +238,12 @@ const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
const ecashSendToken = ref('')
const sendSuccess = ref<{
amount: number
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
// Receive state
const showUnifiedReceiveModal = ref(false)
@@ -246,7 +256,6 @@ const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
const ecashReceiveToken = ref('')
const ecashReceiveResult = ref('')
// Details of the last successful ecash receive, for the success screen.
// Null = nothing to celebrate yet, so the form shows.
const ecashSuccess = ref<{
@@ -265,12 +274,15 @@ const effectiveSendMethod = computed(() => {
return 'lightning'
})
function openSend() { showUnifiedSendModal.value = true }
function openSend() {
sendSuccess.value = null
showUnifiedSendModal.value = true
}
function openReceive() { showUnifiedReceiveModal.value = true }
function closeUnifiedSendModal() {
showUnifiedSendModal.value = false
ecashSendToken.value = ''
sendSuccess.value = null
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
@@ -284,16 +296,10 @@ function closeUnifiedReceiveModal() {
receiveInvoiceResult.value = ''
receiveOnchainAddress.value = ''
ecashReceiveToken.value = ''
ecashReceiveResult.value = ''
ecashSuccess.value = null
unifiedReceiveError.value = ''
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
emit('toast', t('web5.ecashTokenCopied'))
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
emit('toast', msg)
@@ -303,7 +309,7 @@ async function unifiedSend() {
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
ecashSendToken.value = ''
sendSuccess.value = null
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
@@ -316,7 +322,16 @@ async function unifiedSend() {
method: 'wallet.ecash-send',
params: { amount_sats: unifiedSendAmount.value },
})
ecashSendToken.value = res.token
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent as Cashu',
rows: [{
label: 'Token to share',
value: res.token,
hint: 'The recipient needs this token to claim the sats. Keep it until they confirm receipt.',
truncate: true,
}],
}
} else if (method === 'lightning') {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.pasteInvoice')
@@ -327,6 +342,14 @@ async function unifiedSend() {
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
sendSuccess.value = {
amount: res.amount_sats || unifiedSendAmount.value,
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
rows: res.payment_hash ? [{ label: 'Payment hash', value: res.payment_hash }] : [],
...(res.status === 'pending'
? { note: 'This payment is taking longer than usual to settle. Check transactions before retrying.' }
: {}),
}
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
@@ -354,6 +377,12 @@ async function unifiedSend() {
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain',
rows: [{ label: 'Transaction ID', value: res.txid }],
note: 'The transaction has been broadcast and will confirm over the next blocks.',
}
} catch (sendErr: unknown) {
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
@@ -416,6 +445,12 @@ function startMeshRelayPolling(_requestId: number) {
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain over mesh',
rows: [{ label: 'Transaction ID', value: match[1] }],
note: 'Broadcast confirmed. The transaction is waiting for block confirmations.',
}
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
}
}
@@ -454,6 +489,12 @@ async function finalizePsbt() {
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain with hardware wallet',
rows: [],
note: t('web5.broadcastViaHwWallet'),
}
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
@@ -462,6 +503,19 @@ async function finalizePsbt() {
}
}
function sendAnother() {
sendSuccess.value = null
unifiedSendAmount.value = 0
unifiedSendDest.value = ''
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
useHardwareWallet.value = false
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
@@ -526,7 +580,6 @@ async function unifiedReceive() {
params: { token: ecashReceiveToken.value.trim() },
})
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
// Ecash leaves no public ledger entry behind, so the issuer and the
// redeemed token are the only things a person can quote later if the
// payment is ever questioned. Capture them before clearing the box.
@@ -73,4 +73,36 @@ describe('Web5Identities', () => {
expect(wrapper.text()).toContain('Personal')
expect(wrapper.text()).not.toContain('Refreshing identities...')
})
it('turns a saved profile into the identity success screen with honest relay coverage', async () => {
const identity = makeIdentity('Personal')
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.update-profile') return Promise.resolve({})
if (request.method === 'identity.list') return Promise.resolve({ identities: [identity] })
if (request.method === 'identity.publish-profile') {
return Promise.resolve({
event_id: 'event-123', accepted: ['wss://one'],
rejected: [['wss://two', 'write denied']], relays_attempted: 2, published: true,
})
}
return Promise.resolve({})
})
const wrapper = mount(Web5Identities, {
props: { showStagger: false },
global: { stubs: { Teleport: true } },
})
const vm = wrapper.vm as unknown as {
openProfileEditor: (identity: ManagedIdentity) => void
publishProfile: () => Promise<void>
}
vm.openProfileEditor(identity)
await wrapper.vm.$nextTick()
await vm.publishProfile()
await flushPromises()
expect(wrapper.text()).toContain('IDENTITY UPDATED')
expect(wrapper.text()).toContain('Published to 1/2 configured relays.')
expect(wrapper.text()).toContain('wss://two rejected the event: write denied')
expect(wrapper.text()).toContain('event-123')
})
})