Files
archy/neode-ui/src/views/NostrTabSigner.vue
T

222 lines
7.3 KiB
Vue

<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>