feat(release): stage GitWorkshop and next node updates
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 && sudo update-ca-certificates</code></pre>
|
||||
<p><strong class="text-white/70">Firefox:</strong> Settings → Privacy & 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/ && 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user