feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { expect, test } from '@playwright/test'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
test('mobile Chromium returns to the live app after the signer is hidden', async ({ context, page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await context.route('**/*', async (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/nostr-provider.js') {
await route.fulfill({ contentType: 'application/javascript', body: providerSource })
return
}
if (url.port === '' && url.pathname === '/nostr-signer') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><body style="margin:0;background:#111827">
<script>
addEventListener('message', (event) => {
if (event.data && event.data.type === 'archipelago:signer-init') {
parent.postMessage({ type: 'archipelago:signer-show' }, event.origin)
setTimeout(() => {
parent.postMessage({
type: 'archipelago:signer-identity',
identity: { nostr_pubkey: 'browser-handoff-key' },
}, event.origin)
parent.postMessage({ type: 'archipelago:signer-hide' }, event.origin)
}, 50)
}
})
parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
</script>
</body></html>`,
})
return
}
if (url.port === '7778') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><head><title>IndeedHub</title>
<script src="/nostr-provider.js" data-no-nip98></script>
<style>html,body{margin:0;background:#166534}#app{height:100vh;color:white;display:grid;place-items:center}</style>
</head><body><main id="app">app-ready</main><script>
addEventListener('load', () => {
archipelagoNostr.onIdentitySelected((identity) => {
document.querySelector('#app').textContent = 'signed-in:' + identity.nostr_pubkey
})
})
</script></body></html>`,
})
return
}
await route.abort()
})
await page.goto('http://app.test:7778/')
await expect(page.locator('#app')).toHaveText('signed-in:browser-handoff-key')
const broker = page.locator('#archipelago-nostr-signer')
await expect(broker).toHaveCount(1)
await expect(broker).toHaveCSS('width', '1px')
await expect(broker).toHaveCSS('height', '1px')
await expect(broker).toHaveCSS('opacity', '0')
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(22, 101, 52)')
expect(await page.evaluate(() => document.elementFromPoint(195, 422)?.id)).toBe('app')
})
+3
View File
@@ -13,6 +13,9 @@ export default defineConfig({
screenshot: 'only-on-failure',
trace: 'off',
ignoreHTTPSErrors: true,
launchOptions: process.env.ARCHY_CHROMIUM_EXECUTABLE
? { executablePath: process.env.ARCHY_CHROMIUM_EXECUTABLE }
: undefined,
},
projects: [
{
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title id="title">GitWorkshop</title>
<!-- Official GitWorkshop mark from pinned upstream commit dc36db6, inset to
match Archipelago's app-icon safe area. The versioned filename also
prevents an older Source prototype icon surviving in browser caches. -->
<g transform="translate(13 13) scale(1.59375)">
<defs>
<clipPath id="gitworkshop-clip-dc36db6">
<rect width="64" height="64" rx="16" fill="#fff"/>
</clipPath>
</defs>
<g clip-path="url(#gitworkshop-clip-dc36db6)">
<rect width="64" height="64" rx="16" fill="#9333EA"/>
<path fill="#fff" fill-rule="evenodd" d="M14.999 34.961v-17.96h4v17.96c0 3.172 1.492 6.152 4.015 8.036L28.377 47a14.026 14.026 0 0 1 5.622 11.24V64h-4v-5.76c0-3.17-1.492-6.15-4.015-8.035l-5.363-4.004a14.027 14.027 0 0 1-5.622-11.24Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M48.999 34.961v-17.96h-4v17.96c0 3.172-1.492 6.152-4.015 8.036L35.62 47a14.027 14.027 0 0 0-5.622 11.24V64h4v-5.76c0-3.17 1.492-6.15 4.015-8.035l5.363-4.004A14.027 14.027 0 0 0 49 34.962Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M29.799 64.001V27.887h4.4V64h-4.4Z" clip-rule="evenodd"/>
<path fill="#fff" d="M40.999 18.001a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm-29.985 0a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm15.985 12a5 5 0 1 1 10 0 5 5 0 0 1-10 0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+36
View File
@@ -9,6 +9,29 @@
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "adguardhome",
@@ -247,6 +270,19 @@
},
"tier": "optional"
},
{
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"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",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{
"id": "grafana",
"title": "Grafana",
+351 -111
View File
@@ -1,160 +1,400 @@
/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* Provides window.nostr (NIP-07) for iframe apps.
* Auto sign-in: does NIP-98 auth directly then reloads so the app
* picks up the valid session. Shows a loading overlay during auth.
* In an Archipelago iframe, requests go directly to the parent dashboard.
* In a browser tab or companion WebView, a dashboard-origin signer frame
* supplies the same identity picker and consent UI. No opener is required,
* and private keys never leave the node backend.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
var pending = {}, nextId = 1;
var providerScript = document.currentScript;
var autoNip98 = !(providerScript && providerScript.hasAttribute('data-no-nip98'));
var embedded = window !== window.top;
var pending = {}, nextId = 1, queuedMessages = [];
var identitySelection = null;
var selectedIdentity = null, identitySubscribers = [];
var selectedPublicKey = null, selectedPublicKeyTimer = null;
var signerFrame = null, signerReady = embedded, signerInitialised = embedded;
var signerVisible = false, signerHideWaiters = [];
var appReady = embedded || document.readyState === 'complete';
function dashboardOrigin() {
var url = new URL(window.location.href);
url.port = '';
return url.origin;
}
function inferAppId() {
var configured = providerScript && providerScript.getAttribute('data-app-id');
if (configured) return configured;
var route = window.location.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i);
if (route) return route[1].toLowerCase();
var ports = { '7778': 'indeedhub', '8337': 'archipelago-source' };
return ports[window.location.port] || ('app-' + (window.location.port || 'dashboard'));
}
function sendToSignerFrame(message) {
if (!signerFrame || !signerFrame.contentWindow) return;
signerFrame.contentWindow.postMessage(message, dashboardOrigin());
}
function postToSigner(message) {
if (embedded) {
window.parent.postMessage(message, '*');
return;
}
if (!signerFrame) createSignerFrame();
// A loaded iframe is not yet an initialised signer. Requests that arrive
// while the host app is still booting must follow signer-init, otherwise
// the signer correctly rejects them because it has no app id/origin yet.
if (!signerReady || !signerInitialised || !signerFrame || !signerFrame.contentWindow) {
queuedMessages.push(message);
return;
}
sendToSignerFrame(message);
}
function setSignerVisible(visible) {
if (!signerFrame) return;
signerVisible = visible;
signerFrame.style.display = 'block';
signerFrame.style.visibility = 'visible';
signerFrame.style.pointerEvents = visible ? 'auto' : 'none';
signerFrame.style.opacity = visible ? '1' : '0';
signerFrame.style.top = '0';
signerFrame.style.left = '0';
signerFrame.style.width = visible ? '100vw' : '1px';
signerFrame.style.height = visible ? '100vh' : '1px';
signerFrame.style.transform = visible ? 'none' : 'translate(-10000px, -10000px)';
signerFrame.setAttribute('aria-hidden', visible ? 'false' : 'true');
if (!visible && signerHideWaiters.length) {
var waiters = signerHideWaiters.splice(0);
waiters.forEach(function (resolve) { resolve(); });
}
}
// NIP-98 returns before the signer's short success animation has closed.
// Reloading an Android WebView while that topmost cross-origin frame is
// still visible can leave a blank compositor surface until the user reloads
// again. Let the broker finish and hide first, with a bounded fallback so a
// lost UI message can never prevent authentication from completing.
function waitForSignerToHide() {
if (embedded || !signerVisible) return Promise.resolve();
return new Promise(function (resolve) {
var settled = false;
function finish() {
if (settled) return;
settled = true;
resolve();
}
signerHideWaiters.push(finish);
setTimeout(finish, 1500);
});
}
function createSignerFrame() {
if (embedded || signerFrame) return;
signerFrame = document.createElement('iframe');
signerFrame.id = 'archipelago-nostr-signer';
signerFrame.title = 'Archipelago Nostr signer';
signerFrame.src = dashboardOrigin() + '/nostr-signer';
// Keep the broker document alive between requests, but park its compositor
// surface physically off-screen. Removing or display-hiding a full-screen
// cross-origin iframe can leave Android WebView (and some mobile Chromium
// builds) showing that stale black/grey surface until a manual refresh.
// A 1px off-screen frame cannot obscure the app and also avoids reloading
// the signer between getPublicKey/signEvent calls.
signerFrame.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;transform:translate(-10000px,-10000px);border:0;z-index:2147483647;background:transparent;display:block;visibility:visible;opacity:0;pointer-events:none;';
signerFrame.setAttribute('aria-hidden', 'true');
document.documentElement.appendChild(signerFrame);
}
function initialiseSignerWhenReady() {
if (embedded || signerInitialised || !signerReady || !appReady) return;
sendToSignerFrame({
type: 'archipelago:signer-init',
appId: inferAppId(),
appName: (document.title || 'App').replace(/\s*[|—-]\s*Archipelago\s*$/i, ''),
});
signerInitialised = true;
while (queuedMessages.length) sendToSignerFrame(queuedMessages.shift());
}
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
postToSigner({ type: 'nostr-request', id: id, method: method, params: params || {} });
setTimeout(function () {
if (pending[id]) {
pending[id].reject(new Error('NIP-07 timeout'));
delete pending[id];
}
}, 30000);
});
}
// Archipelago-aware apps can call this immediately before an explicit login
// action. Standard NIP-07 intentionally has no "choose account" method, so
// getPublicKey() alone cannot distinguish a fresh login from a routine signer
// call. Keeping this as an optional companion API preserves NIP-07 compatibility
// while allowing users to change their node identity when they log in again.
function selectIdentity() {
if (identitySelection) {
identitySelection.reject(new Error('A node identity choice is already open'));
clearTimeout(identitySelection.timer);
}
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
if (!identitySelection) return;
identitySelection = null;
reject(new Error('Identity selection timed out'));
}, 30000);
identitySelection = { resolve: resolve, reject: reject, timer: timer };
postToSigner({
type: embedded
? 'archipelago:identity:request'
: 'archipelago:signer-select-identity',
force: true,
});
});
}
function finishIdentitySelection(identity) {
// The identity picker is itself an explicit choice to disclose this key.
// Keep it briefly so the login library's immediately-following
// getPublicKey() does not depend on another cross-origin WebView round trip.
// This is deliberately one-shot and short-lived.
if (identity && typeof identity.nostr_pubkey === 'string' && identity.nostr_pubkey) {
selectedIdentity = { nostr_pubkey: identity.nostr_pubkey };
selectedPublicKey = identity.nostr_pubkey;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = setTimeout(function () {
selectedPublicKey = null;
selectedPublicKeyTimer = null;
}, 15000);
identitySubscribers.slice().forEach(function (subscriber) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
});
}
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.resolve(identity);
}
function cancelIdentitySelection() {
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.reject(new Error('Identity selection cancelled'));
}
function getPublicKey() {
// Most NIP-07 apps call getPublicKey directly from their login button. A
// live user activation lets the node offer account switching to those apps
// without making background account restoration reopen the picker. Apps
// with an async login flow should call archipelagoNostr.selectIdentity()
// explicitly; its result is consumed here so the picker is not shown twice.
if (selectedPublicKey) {
var publicKey = selectedPublicKey;
selectedPublicKey = null;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = null;
return Promise.resolve(publicKey);
}
if (navigator.userActivation && navigator.userActivation.isActive) {
return selectIdentity().then(function () {
return getPublicKey();
});
}
return request('getPublicKey');
}
// Framework components often mount just after the provider receives the
// eager first-launch identity. A sticky subscription prevents that choice
// from being lost between window.load and React/Vue effect registration.
function onIdentitySelected(subscriber) {
if (typeof subscriber !== 'function') {
throw new TypeError('Identity subscriber must be a function');
}
identitySubscribers.push(subscriber);
if (selectedIdentity) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
}
return function () {
identitySubscribers = identitySubscribers.filter(function (entry) {
return entry !== subscriber;
});
};
}
function getSelectedIdentity() {
return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey };
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
var validSource = embedded
? e.source === window.parent
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
if (!validSource || !e.data) return;
if (!embedded && e.data.type === 'archipelago:signer-ready') {
signerReady = true;
initialiseSignerWhenReady();
return;
}
if (!embedded && e.data.type === 'archipelago:signer-show') {
setSignerVisible(true);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-hide') {
setSignerVisible(false);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-identity') {
finishIdentitySelection(e.data.identity);
window.postMessage({
type: 'archipelago:identity',
nostr_pubkey: e.data.identity && e.data.identity.nostr_pubkey,
}, window.location.origin);
return;
}
if (embedded && e.data.type === 'archipelago:identity') {
finishIdentitySelection(e.data);
return;
}
if (e.data.type === 'archipelago:identity-cancelled' ||
e.data.type === 'archipelago:signer-identity-cancelled') {
cancelIdentitySelection();
return;
}
if (e.data.type !== 'nostr-response') return;
var handler = pending[e.data.id];
if (!handler) return;
delete pending[e.data.id];
e.data.error ? handler.reject(new Error(e.data.error)) : handler.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getPublicKey: getPublicKey,
signEvent: function (event) { return request('signEvent', { event: event }); },
sign: function (event) { return request('signEvent', { event: event }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
encrypt: function (pubkey, plaintext) { return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
nip44: {
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
encrypt: function (pubkey, plaintext) { return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
};
// --- Loading Overlay ---
var overlay = null;
window.archipelagoNostr = {
selectIdentity: selectIdentity,
onIdentitySelected: onIdentitySelected,
getSelectedIdentity: getSelectedIdentity,
};
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Direct NIP-98 Auth ---
// Optional direct NIP-98 session bootstrap for apps that use it. Signing
// itself is shown by the shared broker, so this deliberately adds no second
// full-screen loader inside the app.
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
var healthUrl = window.location.origin + '/api/nostr-auth/health';
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
var healthController = new AbortController();
var healthTimeout = setTimeout(function () { healthController.abort(); }, 3000);
var apiBase = '/api';
var healthUrl = window.location.origin + apiBase + '/nostr-auth/health';
var sessionUrl = window.location.origin + apiBase + '/auth/nostr/session';
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
clearTimeout(healthTimeout);
if (!response.ok) throw new Error('Health ' + response.status);
return window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']],
});
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
var controller = new AbortController();
setTimeout(function () { controller.abort(); }, 10000);
return fetch(sessionUrl, {
method: 'POST',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
signal: controller.signal,
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
return res.json();
}).then(function (response) {
if (!response.ok) throw new Error('Auth failed: ' + response.status);
return response.json();
}).then(function (data) {
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
if (!data.accessToken) throw new Error('Authentication returned no access token');
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
return waitForSignerToHide().then(function () {
// Give WebView one paint after the iframe is hidden before replacing
// the document. The stored session is already durable at this point.
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
}).then(function () {
if (window.ArchipelagoSurface &&
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
window.ArchipelagoSurface.expectPageTransition();
}
window.location.reload();
});
}).catch(function (error) {
authDone = false;
var message = error && error.message ? error.message : String(error);
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', message);
});
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return;
if (e.source !== window && e.source !== window.parent) return;
var pubkey = e.data.nostr_pubkey;
if (!pubkey) return;
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
if (token && token.indexOf('mock-') === -1) return;
} catch (_) {}
setTimeout(function () { doNip98Auth(pubkey); }, 1500);
});
// Only identity-aware apps open the chooser eagerly. The provider is also
// injected into several ordinary app proxies; those stay untouched unless
// they actually invoke a NIP-07 method, which lazily creates the broker.
if (!embedded && ['indeedhub', 'nostrudel', 'archipelago-source'].indexOf(inferAppId()) !== -1) {
createSignerFrame();
}
// The provider is injected in <head>, before framework startup. Waiting for
// load makes the first-launch picker meaningful: React/Vue login listeners
// and account stores exist before a fast identity choice can be emitted.
if (!embedded && !appReady) {
window.addEventListener('load', function () {
appReady = true;
initialiseSignerWhenReady();
}, { once: true });
}
})();
+7 -1
View File
@@ -1,5 +1,7 @@
<template>
<div id="app">
<RouterView v-if="isSignerBroker && isReady" />
<template v-else>
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
@@ -15,6 +17,7 @@
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<AppCredentialInterstitial />
<!-- Global toast notifications -->
<ToastStack />
@@ -88,11 +91,12 @@
</div>
</Transition>
</Teleport>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
@@ -100,6 +104,7 @@ import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import AppCredentialInterstitial from './components/AppCredentialInterstitial.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
@@ -240,6 +245,7 @@ function onKeyDown(e: KeyboardEvent) {
}
const route = useRoute()
const isSignerBroker = computed(() => route.meta.signerBroker === true)
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
@@ -0,0 +1,43 @@
<template>
<BaseModal
:show="store.credentialPrompt.show"
:title="store.credentialPrompt.title"
max-width="max-w-lg"
z-index="z-[4100]"
@close="store.cancelCredentialLaunch()"
>
<div v-if="store.credentialPrompt.loading" class="flex min-h-40 flex-col items-center justify-center text-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-orange-300" aria-hidden="true"></div>
<p class="mt-4 text-sm text-white/55">{{ store.credentialPrompt.description }}</p>
</div>
<template v-else>
<p v-if="store.credentialPrompt.description" class="-mt-1 mb-4 text-sm text-white/55">
{{ store.credentialPrompt.description }}
</p>
<div class="space-y-3">
<div v-for="credential in store.credentialPrompt.credentials" :key="credential.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="mb-1 flex items-center justify-between gap-3">
<span class="text-xs uppercase tracking-wide text-white/60">{{ credential.label }}</span>
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="store.copyCredential(credential.label, credential.value)">
{{ store.credentialPrompt.copied === credential.label ? 'Copied' : 'Copy' }}
</button>
</div>
<p class="break-all font-mono text-sm text-white">{{ credential.value }}</p>
</div>
</div>
</template>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row">
<button type="button" class="w-full rounded-lg px-4 py-3 sm:flex-1 glass-button" @click="store.cancelCredentialLaunch()">Cancel</button>
<button v-if="!store.credentialPrompt.loading" type="button" class="w-full rounded-lg px-4 py-3 font-semibold sm:flex-1 glass-button" @click="store.continueCredentialLaunch()">Continue to app</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
const store = useAppLauncherStore()
</script>
+80 -20
View File
@@ -118,7 +118,22 @@
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div v-if="completedPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl max-h-[90%] overflow-y-auto">
<PaymentSuccessPane
:amount="completedPayment.amount"
:verb="completedPayment.verb"
:method-label="completedPayment.methodLabel"
:rows="completedPayment.rows"
:note="completedPayment.note"
@done="completedPayment = null"
/>
</div>
</div>
</Transition>
<Transition name="content-fade">
<div v-if="pendingPayment && !completedPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-black/80 border border-white/15 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
@@ -162,30 +177,31 @@
</div>
</div>
</Transition>
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:identity-label="store.consentRequest?.identityLabel"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
:phase="store.consentPhase"
:error="store.consentError"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Nostr signing consent modal -->
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
:method="store.consentRequest?.method ?? ''"
:event-kind="store.consentRequest?.eventKind"
:content="store.consentRequest?.content"
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</template>
<script setup lang="ts">
@@ -194,6 +210,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -292,6 +309,11 @@ function onIdentitySelected(identity: SelectedIdentity) {
sendSelectedIdentity(identity)
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
/** Send a specific identity to the iframe */
async function sendSelectedIdentity(identity: SelectedIdentity) {
try {
@@ -330,6 +352,13 @@ function clearTimers() {
const pendingPayment = ref<PaymentRequest | null>(null)
const paymentProcessing = ref(false)
const paymentError = ref('')
const completedPayment = ref<{
amount: number
verb: string
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
const paymentOrigin = ref('')
function refreshIframe() {
@@ -471,7 +500,8 @@ function onMessage(e: MessageEvent) {
}
// Iframe app requests identity on demand
if (e.data?.type === 'archipelago:identity:request' && store.isOpen) {
sendIdentityIfSupported()
if (e.data?.force === true) showIdentityPicker.value = true
else sendIdentityIfSupported()
}
// Wallet connect — app requests a payment
if (e.data?.type === 'archipelago:payment-request' && store.isOpen) {
@@ -495,6 +525,7 @@ function handlePaymentRequest(e: MessageEvent) {
address: data.address,
}
paymentOrigin.value = e.origin
completedPayment.value = null
paymentError.value = ''
paymentProcessing.value = false
}
@@ -561,6 +592,34 @@ async function approvePayment() {
}
sendPaymentResponse(paymentOrigin.value, pay.request_id, true, undefined, receipt)
const rows: SuccessRow[] = []
if (typeof receipt.payment_hash === 'string' && receipt.payment_hash) {
rows.push({ label: 'Payment hash', value: receipt.payment_hash })
}
if (typeof receipt.txid === 'string' && receipt.txid) {
rows.push({ label: 'Transaction ID', value: receipt.txid })
}
if (typeof receipt.token === 'string' && receipt.token) {
rows.push({
label: 'Token sent to app',
value: receipt.token,
hint: 'The app received this token. Keep a copy until it confirms the payment.',
truncate: true,
})
}
if (typeof receipt.payment_request === 'string' && receipt.payment_request) {
rows.push({ label: 'Invoice sent to app', value: receipt.payment_request, truncate: true })
}
const createdInvoice = method === 'lightning' && !pay.invoice
completedPayment.value = {
amount: Number(receipt.amount_sats) || pay.amount_sats,
verb: createdInvoice ? 'READY' : 'SENT',
methodLabel: createdInvoice
? 'Lightning invoice created'
: method === 'lightning' ? 'Paid over Lightning' : method === 'ecash' ? 'Sent as Cashu' : 'Sent on-chain',
rows,
note: createdInvoice ? 'The invoice was returned to the requesting app.' : `Payment approved for ${store.title || 'the app'}.`,
}
pendingPayment.value = null
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Payment failed'
@@ -612,6 +671,7 @@ watch(
if (pendingPayment.value) {
rejectPayment()
}
completedPayment.value = null
}
}
)
@@ -0,0 +1,50 @@
<template>
<div class="py-3 text-center">
<NostrIdentityOrb class="mx-auto" size="large" state="success" aria-label="Nostr identity updated" />
<p class="mt-3 text-[10px] uppercase tracking-[0.24em] text-green-300/70">Identity saved</p>
<h2 id="profile-editor-title" class="mt-1 text-2xl font-bold tracking-wide text-white">IDENTITY UPDATED</h2>
<p class="mt-2 text-sm text-white/55">{{ identityName }} is updated on this node.</p>
<div class="mt-6 space-y-3 rounded-xl bg-white/5 p-4 text-left">
<div>
<p class="mb-1 text-xs text-white/45">Relay publication</p>
<p class="text-sm text-white/80">{{ relaySummary }}</p>
</div>
<div v-if="eventId">
<p class="mb-1 text-xs text-white/45">Nostr event ID</p>
<div class="flex items-center gap-2">
<p class="min-w-0 flex-1 break-all font-mono text-xs text-white/75">{{ eventId }}</p>
<CopyButton class="shrink-0" :value="eventId" />
</div>
</div>
<p v-if="relayNote" class="text-xs leading-relaxed text-amber-200/70">{{ relayNote }}</p>
</div>
<div class="mt-6 flex gap-3">
<button type="button" class="glass-button flex-1 rounded-xl px-4 py-3 text-sm font-medium" @click="$emit('again')">Keep editing</button>
<button type="button" class="glass-button glass-button-warning flex-1 rounded-xl px-4 py-3 text-sm font-semibold" @click="$emit('done')">Done</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CopyButton from '@/components/CopyButton.vue'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
const props = withDefaults(defineProps<{
identityName: string
eventId?: string
accepted?: number
attempted?: number
relayNote?: string
}>(), { eventId: '', accepted: 0, attempted: 0, relayNote: '' })
defineEmits<{ again: []; done: [] }>()
const relaySummary = computed(() => {
if (!props.attempted) return 'Saved locally; no relay publication was attempted.'
if (props.accepted === props.attempted) return `Published to all ${props.attempted} configured relays.`
return `Published to ${props.accepted}/${props.attempted} configured relays.`
})
</script>
@@ -0,0 +1,96 @@
<template>
<div
class="nostr-orb"
:class="[`nostr-orb-${size}`, `nostr-orb-${state}`]"
role="img"
:aria-label="ariaLabel"
>
<div class="nostr-orb-ring" aria-hidden="true">
<span
v-for="(_, i) in 48"
:key="i"
class="nostr-orb-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<div class="nostr-orb-border" aria-hidden="true">
<div class="nostr-orb-inner">
<svg viewBox="0 0 122.88 88.39" xmlns="http://www.w3.org/2000/svg" class="nostr-orb-mark">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
<div v-if="state === 'success'" class="nostr-orb-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="m5 12 4 4L19 6"/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
size?: 'small' | 'medium' | 'large'
state?: 'idle' | 'loading' | 'success'
ariaLabel?: string
}>(), {
size: 'large',
state: 'idle',
ariaLabel: 'Nostr identity',
})
</script>
<style scoped>
.nostr-orb {
--orb-size: 148px;
--disc-size: 104px;
--mark-width: 42px;
--ring-radius: 60px;
--bar-height: 14px;
position: relative;
width: var(--orb-size);
height: var(--orb-size);
flex: 0 0 auto;
}
.nostr-orb-medium { --orb-size: 112px; --disc-size: 78px; --mark-width: 32px; --ring-radius: 45px; --bar-height: 11px; }
.nostr-orb-small { --orb-size: 80px; --disc-size: 56px; --mark-width: 23px; --ring-radius: 32px; --bar-height: 8px; }
.nostr-orb-ring { position: absolute; inset: 0; pointer-events: none; }
.nostr-orb-segment {
position: absolute;
left: 50%; top: 50%; width: 2.5px; height: var(--bar-height);
margin-left: -1.25px; margin-top: calc(var(--bar-height) / -2);
border-radius: 2px;
background: linear-gradient(to bottom, rgba(250,250,250,.45), rgba(250,250,250,.06));
transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1));
animation: nostr-orb-pulse 4s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 20ms);
}
.nostr-orb-loading .nostr-orb-segment { animation-duration: 1.35s; }
.nostr-orb-success .nostr-orb-segment { background: linear-gradient(to bottom, rgba(74,222,128,.75), rgba(74,222,128,.08)); animation-duration: 2.4s; }
.nostr-orb-border {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: var(--disc-size); height: var(--disc-size); border-radius: 9999px; padding: 3px;
background: linear-gradient(135deg, rgba(255,255,255,.6), rgba(0,0,0,.8));
box-shadow: 0 8px 24px rgba(0,0,0,.5); filter: drop-shadow(0 0 24px rgba(255,255,255,.08));
}
.nostr-orb-inner { width: 100%; height: 100%; border-radius: inherit; background: #000; display: flex; align-items: center; justify-content: center; }
.nostr-orb-mark { width: var(--mark-width); height: auto; filter: drop-shadow(0 0 12px rgba(250,250,250,.12)); animation: nostr-orb-breathe 4s ease-in-out infinite; }
.nostr-orb-loading .nostr-orb-mark { animation-duration: 1.5s; }
.nostr-orb-check {
position: absolute; right: 8%; bottom: 8%; width: 27%; height: 27%; border-radius: 9999px;
display: flex; align-items: center; justify-content: center; color: #dcfce7;
background: #16a34a; border: 2px solid rgba(255,255,255,.8); box-shadow: 0 4px 18px rgba(22,163,74,.45);
}
.nostr-orb-check svg { width: 65%; height: 65%; }
@keyframes nostr-orb-pulse {
0%, 100% { opacity: .15; transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1)) scaleY(.45); }
45%, 60% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(calc(var(--ring-radius) * -1)) scaleY(1.15); }
}
@keyframes nostr-orb-breathe {
0%, 100% { opacity: .72; transform: scale(1); }
50% { opacity: 1; transform: scale(1.08); filter: drop-shadow(0 0 20px rgba(250,250,250,.22)); }
}
@media (prefers-reduced-motion: reduce) {
.nostr-orb-segment, .nostr-orb-mark { animation: none; }
}
</style>
+40 -141
View File
@@ -1,13 +1,10 @@
<template>
<Teleport to="body">
<Transition name="identity-picker">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop — frosted blur -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-2xl"></div>
<Transition name="identity-picker">
<div
v-if="show"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6"
@click.self="$emit('cancel')"
>
<!-- Main panel -->
<div
@@ -16,37 +13,22 @@
role="dialog"
aria-modal="true"
:aria-label="`Select identity for ${appName}`"
class="relative z-10 w-full max-w-lg"
class="identity-picker-card glass-card relative z-10 w-full max-w-md overflow-y-auto p-5 sm:p-6"
>
<!-- Header: screensaver-style glass disc + radial viz ring -->
<div class="relative mb-6 flex flex-col items-center">
<div class="nostr-hero">
<!-- Radial viz segments — exact screensaver pattern, 48 bars, #FAFAFA -->
<div class="nostr-viz-ring">
<div
v-for="(_, i) in 48"
:key="i"
class="nostr-viz-segment"
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
/>
</div>
<!-- Glass disc — exact logo-gradient-border from screensaver -->
<div class="nostr-glass-border">
<div class="nostr-glass-inner">
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
</svg>
</div>
</div>
<div class="mb-5 flex items-center gap-3">
<NostrIdentityOrb size="small" />
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-[0.2em] text-white/35">Nostr identity</p>
<h2 class="mt-1 text-xl font-semibold text-white">Choose an identity</h2>
<p class="mt-1 truncate text-xs text-white/45">{{ appName }}</p>
</div>
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
<button type="button" class="rounded-lg p-2 text-white/55 transition-colors hover:bg-white/10 hover:text-white" aria-label="Close" @click="$emit('cancel')">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<!-- Identity list -->
<div class="glass-card p-4 space-y-2 max-h-[50vh] overflow-y-auto" role="radiogroup" aria-label="Available identities">
<div class="space-y-2" role="radiogroup" aria-label="Available identities">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-white/40" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
@@ -55,6 +37,14 @@
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
</div>
<div v-else-if="loadError" class="text-center py-8">
<p class="text-red-300/80 text-sm">Couldn&rsquo;t load identities.</p>
<p class="text-white/35 text-xs mt-1">{{ loadError }}</p>
<button type="button" class="glass-button mt-4 rounded-lg px-4 py-2 text-xs text-white/70" @click="loadIdentities">
Try again
</button>
</div>
<div v-else-if="identities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
@@ -117,19 +107,19 @@
</button>
</div>
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
<p class="mt-3 text-center text-[10px] tracking-widest text-white/20">
NIP-07 &middot; SECP256K1 &middot; Signed locally
</p>
</div>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { rpcClient } from '@/api/rpc-client'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
interface Identity {
id: string
@@ -156,6 +146,7 @@ const modalRef = ref<HTMLElement | null>(null)
const identities = ref<Identity[]>([])
const selectedId = ref<string | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
@@ -174,14 +165,18 @@ onMounted(() => {
async function loadIdentities() {
loading.value = true
loadError.value = null
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
if (defaultId) selectedId.value = defaultId.id
} catch {
} catch (error) {
identities.value = []
loadError.value = error instanceof Error
? error.message
: 'Check the node connection and try again.'
} finally {
loading.value = false
}
@@ -207,116 +202,20 @@ function avatarClasses(purpose: string): string {
</script>
<style scoped>
/* ── Hero container ── */
.nostr-hero {
position: relative;
width: 148px;
height: 148px;
}
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
.nostr-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.nostr-viz-segment {
position: absolute;
left: 50%;
top: 50%;
width: 2.5px;
height: 14px;
margin-left: -1.25px;
margin-top: -7px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
border-radius: 1.5px;
transform-origin: center center;
transform: rotate(var(--seg-deg)) translateY(-60px);
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--seg-i) * 0.02s);
}
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
@keyframes seg-pulse {
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
}
/* ── Glass disc — exact screensaver logo-gradient-border ── */
.nostr-glass-border {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 104px;
height: 104px;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
}
.nostr-glass-inner {
width: 100%;
height: 100%;
border-radius: 9999px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Cinema icon — breathing glow ── */
.nostr-cinema-svg {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
animation: cinema-breathe 4s ease-in-out infinite;
}
@keyframes cinema-breathe {
0%, 100% {
opacity: 0.7;
transform: scale(1);
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
}
50% {
opacity: 1;
transform: scale(1.08);
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
}
}
.identity-picker-card { max-height: min(90%, 680px); }
/* ── Modal transitions ── */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.4s ease;
}
.identity-picker-enter-active > .relative {
.identity-picker-enter-active .identity-picker-card {
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
}
.identity-picker-leave-active > .relative {
.identity-picker-leave-active .identity-picker-card {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from { opacity: 0; }
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
.identity-picker-enter-from .identity-picker-card { transform: translateY(10px) scale(0.98); opacity: 0; }
.identity-picker-leave-to { opacity: 0; }
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
.identity-picker-leave-to .identity-picker-card { transform: translateY(10px) scale(0.98); opacity: 0; }
</style>
+69 -128
View File
@@ -1,152 +1,93 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="deny"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Nostr Signing Request</h3>
<button
@click="deny"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<Transition name="consent">
<div v-if="show" class="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6" @click.self="phase === 'review' && deny()">
<div ref="modalRef" class="nostr-consent-card glass-card w-full max-w-md overflow-y-auto p-5 sm:p-6" role="dialog" aria-modal="true" :aria-labelledby="`${dialogId}-title`" :aria-busy="phase === 'signing'">
<template v-if="phase === 'signing' || phase === 'success'">
<div class="flex min-h-[300px] flex-col items-center justify-center text-center">
<NostrIdentityOrb size="medium" :state="phase === 'success' ? 'success' : 'loading'" :aria-label="phase === 'success' ? 'Nostr request approved' : 'Signing with Nostr identity'" />
<h3 :id="`${dialogId}-title`" class="mt-4 text-xl font-semibold text-white">{{ phase === 'success' ? successTitle : progressTitle }}</h3>
<p class="mt-2 text-sm text-white/55">{{ phase === 'success' ? 'The app received the approved result.' : 'Your key stays on this node.' }}</p>
<p class="mt-4 text-[10px] uppercase tracking-[0.22em] text-white/25">NIP-07 · signed locally</p>
</div>
</template>
<template v-else>
<div class="mb-5 flex items-center gap-3">
<NostrIdentityOrb size="small" :state="phase === 'error' ? 'idle' : 'loading'" />
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-[0.2em] text-white/35">Nostr identity</p>
<h3 :id="`${dialogId}-title`" class="mt-1 text-xl font-semibold text-white">{{ phase === 'error' ? 'Request failed' : requestTitle }}</h3>
<p class="mt-1 truncate text-xs text-white/45">{{ appName }}</p>
</div>
<button type="button" class="rounded-lg p-2 text-white/55 transition-colors hover:bg-white/10 hover:text-white" aria-label="Close" @click="deny">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="space-y-3 mb-6">
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">App</p>
<p class="text-white text-sm font-medium">{{ appName }}</p>
</div>
<div class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Method</p>
<p class="text-white text-sm font-medium">{{ method }}</p>
</div>
<div v-if="contentPreview" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Content</p>
<p class="text-white/80 text-sm font-mono break-all">{{ contentPreview }}</p>
</div>
<div v-if="eventKind !== undefined" class="bg-black/20 rounded-xl border border-white/10 p-3">
<p class="text-white/50 text-xs uppercase tracking-wider mb-1">Event Kind</p>
<p class="text-white text-sm font-medium">{{ eventKind }} <span class="text-white/50">({{ eventKindLabel }})</span></p>
</div>
<div v-if="phase === 'error'" class="alert-error mb-5 text-sm">{{ error || 'The node could not complete this request.' }}</div>
<div v-else class="mb-5 space-y-2">
<div class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Request</p><p class="text-sm font-medium text-white">{{ methodLabel }}</p></div>
<div v-if="identityLabel" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Identity</p><p class="text-sm font-medium text-white">{{ identityLabel }}</p></div>
<div v-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="eventKind !== undefined" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Event kind</p><p class="text-sm font-medium text-white">{{ eventKind }} <span class="text-white/45">({{ eventKindLabel }})</span></p></div>
</div>
<label class="flex items-center gap-2 mb-4 cursor-pointer">
<input
v-model="rememberChoice"
type="checkbox"
class="w-4 h-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50"
/>
<span class="text-white/70 text-sm">Remember for this app</span>
<label v-if="phase === 'review'" class="mb-5 flex cursor-pointer items-start gap-2">
<input v-model="rememberChoice" type="checkbox" class="mt-0.5 h-4 w-4 rounded border-white/30 bg-white/10 text-orange-400 focus:ring-orange-400/50" />
<span class="text-sm leading-snug text-white/65">Remember for this app, identity, and request type</span>
</label>
<div class="flex gap-3">
<button @click="deny" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium">
Deny
</button>
<button @click="approve" class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30">
Approve
</button>
<button type="button" class="glass-button flex-1 rounded-lg py-2.5 text-sm font-medium" @click="deny">{{ phase === 'error' ? 'Close' : 'Deny' }}</button>
<button v-if="phase === 'review'" type="button" class="glass-button flex-1 rounded-lg border-orange-400/30 py-2.5 text-sm font-medium text-orange-300" @click="approve">Approve</button>
</div>
</div>
</template>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import NostrIdentityOrb from '@/components/NostrIdentityOrb.vue'
const EVENT_KIND_LABELS: Record<number, string> = {
0: 'Metadata',
1: 'Short Text Note',
2: 'Recommend Relay',
3: 'Contacts',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
9734: 'Zap Request',
9735: 'Zap Receipt',
10002: 'Relay List',
30023: 'Long-form Content',
0: 'Metadata', 1: 'Short text note', 2: 'Recommend relay', 3: 'Contacts', 4: 'Encrypted DM',
5: 'Event deletion', 6: 'Repost', 7: 'Reaction', 1618: 'Git pull request', 1619: 'Git pull request update',
1621: 'Git issue', 9734: 'Zap request', 9735: 'Zap receipt', 10002: 'Relay list',
30023: 'Long-form content', 30617: 'Git repository announcement',
}
const props = defineProps<{
show: boolean
appName: string
method: string
eventKind?: number
content?: string
}>()
const emit = defineEmits<{
approve: [remember: boolean]
deny: []
}>()
const METHOD_LABELS: Record<string, string> = {
getPublicKey: 'Share public identity', signEvent: 'Sign Nostr event',
'nip04.encrypt': 'Encrypt a private message', 'nip04.decrypt': 'Decrypt a private message',
'nip44.encrypt': 'Encrypt protected content', 'nip44.decrypt': 'Decrypt protected content',
}
const props = withDefaults(defineProps<{
show: boolean; appName: string; method: string; identityLabel?: string; eventKind?: number; content?: string
phase?: 'review' | 'signing' | 'success' | 'error'; error?: string
}>(), { phase: 'review', error: '' })
const emit = defineEmits<{ approve: [remember: boolean]; deny: [] }>()
const modalRef = ref<HTMLElement | null>(null)
const rememberChoice = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('deny'))
const contentPreview = computed(() => {
if (!props.content) return ''
return props.content.length > 200 ? props.content.slice(0, 200) + '...' : props.content
const dialogId = `nostr-consent-${Math.random().toString(36).slice(2)}`
watch(() => props.show, show => { if (show) rememberChoice.value = false })
useModalKeyboard(modalRef, computed(() => props.show), () => {
if (props.phase === 'review' || props.phase === 'error') emit('deny')
})
const eventKindLabel = computed(() => {
if (props.eventKind === undefined) return ''
return EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown'
})
function approve() {
emit('approve', rememberChoice.value)
}
function deny() {
emit('deny')
}
const methodLabel = computed(() => METHOD_LABELS[props.method] ?? props.method)
const requestTitle = computed(() => props.method === 'getPublicKey' ? 'Share this identity?' : 'Approve this request?')
const progressTitle = computed(() => props.method === 'getPublicKey' ? 'Sharing identity…' : 'Signing locally…')
const successTitle = computed(() => props.method === 'getPublicKey' ? 'Identity shared' : 'Request signed')
const contentPreview = computed(() => !props.content ? '' : props.content.length > 200 ? `${props.content.slice(0, 200)}…` : props.content)
const eventKindLabel = computed(() => props.eventKind === undefined ? '' : EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown')
function approve() { emit('approve', rememberChoice.value) }
function deny() { emit('deny') }
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease;
}
.modal-enter-from .glass-card {
transform: scale(0.95);
}
.modal-leave-to .glass-card {
transform: scale(0.95);
}
.nostr-consent-card { max-height: min(90%, 680px); }
.consent-enter-active, .consent-leave-active { transition: opacity .22s ease; }
.consent-enter-active .nostr-consent-card, .consent-leave-active .nostr-consent-card { transition: transform .24s ease, opacity .2s ease; }
.consent-enter-from, .consent-leave-to { opacity: 0; }
.consent-enter-from .nostr-consent-card, .consent-leave-to .nostr-consent-card { opacity: 0; transform: translateY(10px) scale(.98); }
</style>
+153 -89
View File
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<BaseModal :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -21,22 +21,7 @@
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<!-- Paid: the invoice did its job — straight to the green check
(no broadcast step: Lightning settlement is final) -->
<div v-if="invoicePaid" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full flex items-center justify-center bg-green-500/15">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">{{ t('receiveBitcoin.paymentConfirmed') }}</p>
<p v-if="invoicePaid.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ invoicePaid.amountSats.toLocaleString() }} sats
</p>
</div>
<div v-else-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<CopyButton :value="invoiceResult" :label="t('common.copy')" />
@@ -45,50 +30,19 @@
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<!-- Payment detected: the QR did its job — show the outcome -->
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div
class="w-16 h-16 rounded-full flex items-center justify-center"
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'"
>
<!-- Check once confirmed, clock while in the mempool -->
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
</p>
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ paymentSeen.amountSats.toLocaleString() }} sats
</p>
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
{{ t('receiveBitcoin.paymentBroadcastHint') }}
</p>
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
<template v-else>
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</template>
</div>
<!-- Ark -->
@@ -116,9 +70,6 @@
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
<CopyButton :value="lnAddress" :label="t('common.copy')" />
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
</p>
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
</p>
@@ -134,16 +85,11 @@
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<!-- Once the payment is seen there is nothing left to do here -->
<div v-if="paymentSeen" class="flex">
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
</div>
<div v-else class="flex gap-3">
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -156,6 +102,28 @@
</button>
</div>
</BaseModal>
<!-- Completion is deliberately its own modal, matching the Lightning
payment moment. It is not an inline status inside the receive form. -->
<BaseModal
:show="show && !!receiveSuccess"
title="Payment received"
max-width="max-w-2xl"
content-class="max-h-[90vh] overflow-y-auto"
@close="close"
>
<PaymentSuccessPane
v-if="receiveSuccess"
:amount="receiveSuccess.amount"
:verb="receiveSuccess.verb"
:method-label="receiveSuccess.methodLabel"
:rows="receiveSuccess.rows"
:note="receiveSuccess.note"
again-label="Receive another"
@again="receiveAnother"
@done="close"
/>
</BaseModal>
</template>
<script setup lang="ts">
@@ -164,6 +132,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
import { useLightningRequired } from '@/composables/useLightningRequired'
@@ -182,6 +151,8 @@ const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (!open) {
stopWatchingPayment()
stopWatchingInvoice()
stopLnClaimPoll()
return
}
paymentSeen.value = null
@@ -198,13 +169,14 @@ watch(() => props.show, (open) => {
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
receiveSuccess.value = null
stopLnClaimPoll()
lnAddress.value = ''
lnAddressLoading.value = false
lnAddressError.value = false
lnClaimedSats.value = 0
lnPendingClaims.value = 0
lastLnReceiptId.value = 0
lnWatchStartedAt.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -219,7 +191,13 @@ const invoiceResult = ref('')
const onchainAddress = ref('')
const arkAddress = ref('')
const ecashToken = ref('')
const ecashResult = ref('')
const receiveSuccess = ref<{
amount: number
verb: string
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
@@ -236,11 +214,15 @@ const error = ref('')
const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
const lnClaimedSats = ref(0)
// A payment the backend fetched (and so already consumed at Minibits) but
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
// operator should see it rather than have it be a silent, unbounded wait.
const lnPendingClaims = ref(0)
const lastLnReceiptId = ref(0)
// A durable receipt is shared across browser contexts. Only surface one whose
// redemption happened after this receive watch began; reopening the modal must
// not replay the previous payment as though it were new.
const lnWatchStartedAt = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
let lnClaimInFlight = false
@@ -277,7 +259,10 @@ function stopLnClaimPoll() {
function startLnClaimPoll() {
stopLnClaimPoll()
lnClaimTimer = setInterval(() => void pollLnClaims(), 8000)
// Do not make a newly opened receive screen wait a full interval before it
// can discover a payment (or a receipt claimed by another active client).
void pollLnClaims()
lnClaimTimer = setInterval(() => void pollLnClaims(), 3000)
}
async function pollLnClaims() {
@@ -288,7 +273,13 @@ async function pollLnClaims() {
}
lnClaimInFlight = true
try {
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
const res = await rpcClient.call<{
received_sats?: number
failed_count?: number
receipt_id?: number
receipt_sats?: number
receipt_at?: number
}>({
method: 'wallet.ecash-lnaddress-claim',
// Relay collection alone has a ten-second window and redemption may
// then contact the mint. Keep the browser request alive long enough for
@@ -298,8 +289,28 @@ async function pollLnClaims() {
// The user may have closed the modal while the relay fetch was in flight.
// Do not resurrect its status or emit a stale received event afterward.
if (!props.show || !lnAddress.value) return
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
const receiptId = res?.receipt_id || 0
const isNewReceipt = receiptId > 0 && receiptId !== lastLnReceiptId.value
const receiptAt = res?.receipt_at || 0
const receiptAgeSeconds = Math.floor(Date.now() / 1000) - receiptAt
const happenedDuringThisWatch = receiptAt >= Math.max(0, lnWatchStartedAt.value - 1)
const recentSharedReceipt = isNewReceipt
&& happenedDuringThisWatch
&& receiptAgeSeconds >= 0
&& receiptAgeSeconds <= 180
const receivedSats = res?.received_sats && res.received_sats > 0
? res.received_sats
: recentSharedReceipt ? (res?.receipt_sats || 0) : 0
if (receiptId > 0) lastLnReceiptId.value = receiptId
if (receivedSats > 0) {
receiveSuccess.value = {
amount: receivedSats,
verb: 'RECEIVED',
methodLabel: 'Lightning address to Cashu',
rows: [{ label: 'Lightning address', value: lnAddress.value }],
note: 'The payment was claimed into this node\'s Cashu balance.',
}
stopLnClaimPoll()
emit('received')
}
lnPendingClaims.value = res?.failed_count || 0
@@ -314,7 +325,10 @@ onUnmounted(stopLnClaimPoll)
// Fetch the address the first time the operator opens the ecash tab.
watch(receiveMethod, (m) => {
if (m === 'ecash' && props.show) void loadLnAddress()
if (m === 'ecash' && props.show) {
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
void loadLnAddress()
}
})
// ── On-chain payment detection ────────────────────────────────────────────
@@ -326,10 +340,6 @@ watch(receiveMethod, (m) => {
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
let watchTimer: ReturnType<typeof setInterval> | null = null
function midTxid(txid: string): string {
return txid.length > 24 ? `${txid.slice(0, 10)}…${txid.slice(-10)}` : txid
}
function stopWatchingPayment() {
if (watchTimer) {
clearInterval(watchTimer)
@@ -367,6 +377,15 @@ async function checkForPayment() {
amountSats: hit.amount,
confirmations: hit.num_confirmations,
}
receiveSuccess.value = {
amount: hit.amount,
verb: 'RECEIVED',
methodLabel: hit.num_confirmations > 0 ? 'Confirmed on-chain' : 'Received on-chain',
rows: [{ label: t('receiveBitcoin.transactionId'), value: hit.tx_hash }],
note: hit.num_confirmations > 0
? 'The transaction has its first confirmation.'
: t('receiveBitcoin.paymentBroadcastHint'),
}
if (firstSighting) emit('received')
if (hit.num_confirmations > 0) stopWatchingPayment()
} catch {
@@ -407,6 +426,12 @@ async function checkInvoice() {
})
if (!res.settled) return
invoicePaid.value = { amountSats: res.amt_paid_sat || invoiceAmount.value }
receiveSuccess.value = {
amount: res.amt_paid_sat || invoiceAmount.value,
verb: 'RECEIVED',
methodLabel: 'Received over Lightning',
rows: invoiceRHash.value ? [{ label: 'Payment hash', value: invoiceRHash.value }] : [],
}
stopWatchingInvoice()
emit('received')
} catch {
@@ -430,20 +455,46 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
function close() {
stopWatchingPayment()
stopWatchingInvoice()
stopLnClaimPoll()
paymentSeen.value = null
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
ecashToken.value = ''
ecashResult.value = ''
receiveSuccess.value = null
lnAddress.value = ''
lnClaimedSats.value = 0
lnPendingClaims.value = 0
lastLnReceiptId.value = 0
lnWatchStartedAt.value = 0
error.value = ''
emit('close')
}
/** Return to a clean form after a receipt without carrying a spent token or address forward. */
function receiveAnother() {
receiveSuccess.value = null
error.value = ''
processing.value = false
if (receiveMethod.value === 'lightning') {
stopWatchingInvoice()
invoicePaid.value = null
invoiceRHash.value = ''
invoiceResult.value = ''
invoiceAmount.value = 0
invoiceMemo.value = ''
} else if (receiveMethod.value === 'onchain') {
stopWatchingPayment()
paymentSeen.value = null
onchainAddress.value = ''
} else if (receiveMethod.value === 'ecash') {
ecashToken.value = ''
if (lnAddress.value) startLnClaimPoll()
} else {
arkAddress.value = ''
}
}
async function receive() {
processing.value = true
error.value = ''
@@ -481,14 +532,27 @@ async function receive() {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
const token = ecashToken.value.trim()
const res = await rpcClient.call<{ received_sats?: number; kind?: string; mint_url?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
params: { token },
})
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
const rows: SuccessRow[] = []
if (res.mint_url) rows.push({ label: 'Mint', value: res.mint_url })
rows.push({
label: kind === 'Fedimint' ? 'Notes redeemed' : 'Token redeemed',
value: token,
hint: 'Keep this if you ever need to show what was redeemed.',
truncate: true,
})
receiveSuccess.value = {
amount: res.received_sats || 0,
verb: 'RECEIVED',
methodLabel: kind,
rows,
}
ecashToken.value = ''
emit('received')
}
} catch (err: unknown) {
+24 -31
View File
@@ -188,17 +188,6 @@
</div>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<CopyButton :value="ecashToken" :label="t('common.copy')" size="sm" class="mt-2" />
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
@@ -218,12 +207,11 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
const { t } = useI18n()
@@ -285,9 +273,9 @@ const successInfo = ref<{
methodLabel: string
hash?: string
txid?: string
rows?: SuccessRow[]
note?: string
} | null>(null)
const ecashToken = ref('')
// The identifiers worth keeping from a completed send, in the shape the
// shared success pane takes. Which ones exist depends on the rail: Lightning
@@ -298,6 +286,7 @@ const successRows = computed<SuccessRow[]>(() => {
const rows: SuccessRow[] = []
if (info.hash) rows.push({ label: 'Payment hash', value: info.hash })
if (info.txid) rows.push({ label: 'Transaction ID', value: info.txid })
if (info.rows) rows.push(...info.rows)
return rows
})
@@ -346,7 +335,6 @@ watch(() => props.show, (shown) => {
dest.value = ''
error.value = ''
successInfo.value = null
ecashToken.value = ''
sendAll.value = false
onchainBalance.value = null
feePreset.value = 'standard'
@@ -569,7 +557,6 @@ async function review() {
function close() {
error.value = ''
ecashToken.value = ''
confirming.value = false
successInfo.value = null
emit('close')
@@ -585,24 +572,12 @@ function sendAnother() {
error.value = ''
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() {
if (processing.value) return
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
processing.value = true
error.value = ''
ecashToken.value = ''
const method = effectiveMethod.value
const paidAmount = confirmAmount.value
@@ -621,14 +596,32 @@ async function send() {
method: 'wallet.ecash-send',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
successInfo.value = {
amount: paidAmount,
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 === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
ecashToken.value = res.token
successInfo.value = {
amount: paidAmount,
methodLabel: 'Sent as Fedimint ecash',
rows: [{
label: 'Notes to share',
value: res.token,
hint: 'The recipient needs these notes to claim the sats. Keep them until they confirm receipt.',
truncate: true,
}],
}
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
// Waits out slow multi-hop routing and only reports failure when LND
@@ -681,7 +674,7 @@ async function send() {
}
}
emit('sent')
// Success pane (or the token pane for ecash mints) takes over the modal.
// The shared success pane takes over the modal for every payment rail.
confirming.value = false
} catch (err: unknown) {
// Running node with nothing to pay with -> funding modal, not a raw string.
+35 -60
View File
@@ -234,35 +234,16 @@
</div>
<!-- ============ SUCCESS PANE ============ -->
<div v-else key="success" class="text-center py-2">
<div class="scan-success-badge mx-auto mb-5">
<ScreensaverRing size="badge" />
<div class="scan-success-core">
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successAmount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-2">{{ successVerb }}</div>
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
<span :class="railColor">{{ railLabel }}</span>
<span>balance now {{ balance.toLocaleString() }} sats</span>
</div>
<button
@click="close"
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Done
</button>
</div>
<PaymentSuccessPane
v-else
key="success"
:amount="successAmount"
:verb="successVerb"
:method-label="railLabel"
:rows="successRows"
:note="successNote"
@done="close"
/>
</Transition>
</div>
</div>
@@ -277,7 +258,7 @@ import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
@@ -706,6 +687,26 @@ const successAmount = ref(0)
const successVerb = ref('SENT')
const successDetail = ref('')
const successRef = ref('')
const successRows = computed<SuccessRow[]>(() => {
if (action.value === 'redeem-token' && dest.value) {
return [{
label: rail.value === 'fedimint' ? 'Notes redeemed' : 'Token redeemed',
value: dest.value,
hint: 'Keep this if you ever need to show what was redeemed.',
truncate: true,
}]
}
if (!successRef.value) return []
return [{
label: action.value === 'pay-invoice' ? 'Payment hash' : 'Transaction ID',
value: successRef.value,
}]
})
const successNote = computed(() => {
const parts = [successDetail.value]
if (balance.value !== null) parts.push(`${railLabel.value} balance now ${balance.value.toLocaleString()} sats.`)
return parts.filter(Boolean).join(' ')
})
async function confirmSend() {
if (processing.value) return
@@ -798,6 +799,10 @@ function resetAll() {
balance.value = null
processing.value = false
error.value = ''
successAmount.value = 0
successVerb.value = 'SENT'
successDetail.value = ''
successRef.value = ''
}
function close() {
@@ -868,34 +873,4 @@ onUnmounted(stopScanning)
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
/* Paid tick (FED-06) — same composition as SendBitcoinModal: the branded
ScreensaverRing badge with the emerald checkmark core centred over it. */
.scan-success-badge {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.scan-success-badge {
width: 192px;
height: 192px;
}
}
.scan-success-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6rem;
height: 6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
</style>
@@ -0,0 +1,27 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import IdentitySuccessPane from '../IdentitySuccessPane.vue'
describe('IdentitySuccessPane', () => {
it('shows the saved identity, relay coverage, event id, and identity animation', () => {
const wrapper = mount(IdentitySuccessPane, {
props: {
identityName: 'Alice', eventId: 'abc123', accepted: 2, attempted: 3,
relayNote: 'One relay rejected the event.',
},
})
expect(wrapper.text()).toContain('IDENTITY UPDATED')
expect(wrapper.text()).toContain('Alice is updated on this node.')
expect(wrapper.text()).toContain('Published to 2/3 configured relays.')
expect(wrapper.text()).toContain('abc123')
expect(wrapper.text()).toContain('One relay rejected')
expect(wrapper.find('.nostr-orb-success').exists()).toBe(true)
})
it('reports a local-only save without claiming relay publication', () => {
const wrapper = mount(IdentitySuccessPane, {
props: { identityName: 'Alice', accepted: 0, attempted: 0 },
})
expect(wrapper.text()).toContain('Saved locally; no relay publication was attempted.')
})
})
@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NostrSignConsent from '../NostrSignConsent.vue'
const baseProps = {
show: true,
appName: 'GitWorkshop',
method: 'signEvent',
identityLabel: 'Personal',
eventKind: 1621,
}
describe('NostrSignConsent', () => {
it('renders as a contained absolute overlay, not a body teleport', () => {
const wrapper = mount(NostrSignConsent, { props: baseProps })
expect(wrapper.findComponent({ name: 'Teleport' }).exists()).toBe(false)
expect(wrapper.find('.absolute.inset-0').exists()).toBe(true)
expect(wrapper.text()).toContain('Approve this request?')
expect(wrapper.text()).toContain('Git issue')
})
it('uses the shared identity animation while the node signs', () => {
const wrapper = mount(NostrSignConsent, { props: { ...baseProps, phase: 'signing' } })
expect(wrapper.text()).toContain('Signing locally…')
expect(wrapper.find('.nostr-orb-loading').exists()).toBe(true)
expect(wrapper.findAll('.nostr-orb-segment')).toHaveLength(48)
expect(wrapper.text()).not.toContain('Approve')
})
it('shows a short in-app completion state', () => {
const wrapper = mount(NostrSignConsent, { props: { ...baseProps, phase: 'success' } })
expect(wrapper.text()).toContain('Request signed')
expect(wrapper.find('.nostr-orb-success').exists()).toBe(true)
})
})
@@ -69,8 +69,8 @@ describe('paid tick renders the branded ring (FED-06)', () => {
const rings = wrapper.findAllComponents(ScreensaverRing)
expect(rings).toHaveLength(1)
expect(rings[0]?.props('size')).toBe('badge')
expect(document.querySelector('.scan-success-core')).not.toBeNull()
expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
expect(document.querySelector('.burst-core')).not.toBeNull()
expect(document.querySelector('.burst-check')).not.toBeNull()
// The plain fixed-size circle the ring replaced is gone.
expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
@@ -0,0 +1,88 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import i18n from '@/i18n'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
payLightningInvoice: vi.fn(),
},
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
handleLightningFailure: vi.fn().mockReturnValue(false),
}),
}))
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
describe('payment completion receipts', () => {
it('replaces the Cashu send form and keeps the token copyable', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ token: 'cashuB-test-token' } as never)
const wrapper = mount(SendBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
const vm = wrapper.vm as unknown as {
sendMethod: string
amountEntry: number
send: () => Promise<void>
}
vm.sendMethod = 'ecash'
vm.amountEntry = 2100
await vm.send()
await flushPromises()
expect(document.body.textContent).toContain('2,100')
expect(document.body.textContent).toContain('SENT')
expect(document.body.textContent).toContain('cashuB-test-token')
expect(document.body.textContent).toContain('Token to share')
expect(document.body.querySelector('textarea')).toBeNull()
expect(document.body.querySelector('input[type="number"]')).toBeNull()
wrapper.unmount()
})
it('replaces the ecash receive form and preserves the redeemed token and mint', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({
received_sats: 42,
kind: 'cashu',
mint_url: 'https://mint.example',
} as never)
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
const vm = wrapper.vm as unknown as {
receiveMethod: string
ecashToken: string
receive: () => Promise<void>
}
vm.receiveMethod = 'ecash'
vm.ecashToken = 'cashuB-redeemed-token'
await vm.receive()
await flushPromises()
expect(document.body.textContent).toContain('42')
expect(document.body.textContent).toContain('RECEIVED')
expect(document.body.textContent).toContain('https://mint.example')
expect(document.body.textContent).toContain('cashuB-redeemed-token')
expect(document.body.querySelector('textarea')).toBeNull()
wrapper.unmount()
})
})
@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -27,6 +27,12 @@ vi.mock('@/composables/useLightningRequired', () => ({
}),
}))
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
document.body.innerHTML = ''
})
// Guards an operator report (2026-09-08): clicking the Ecash tab appeared to
// close the whole Receive modal. Not reproduced here — the tab switch alone
// (success or failure of wallet.ecash-lnaddress) never emits `close` or
@@ -108,4 +114,71 @@ describe('ReceiveBitcoinModal — ecash tab click', () => {
wrapper.unmount()
vi.useRealTimers()
})
it('shows a recent receipt claimed by another active browser context', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') {
return {
received_sats: 0,
failed_count: 0,
receipt_id: 7,
receipt_sats: 1000,
receipt_at: Math.floor(Date.now() / 1000),
} as never
}
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(document.body.textContent).toContain('1,000')
expect(document.body.textContent).toContain('RECEIVED')
expect(document.body.querySelector('textarea')).toBeNull()
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.querySelector('[role="dialog"] h3')?.textContent).toBe('Payment received')
wrapper.unmount()
})
it('does not replay an older durable receipt when the receive screen is reopened', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') {
return {
received_sats: 0,
failed_count: 0,
receipt_id: 7,
receipt_sats: 1000,
receipt_at: Math.floor(Date.now() / 1000) - 30,
} as never
}
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
expect(document.body.querySelector('[role="dialog"] h3')?.textContent).not.toBe('Payment received')
expect(document.body.querySelector('textarea')).not.toBeNull()
wrapper.unmount()
})
})
+21 -3
View File
@@ -83,6 +83,15 @@ const router = createRouter({
component: () => import('../views/KioskRecovery.vue'),
meta: { public: true },
},
{
// Dashboard-origin broker embedded by top-level node apps. It reuses the
// exact contained identity/consent components used by app sessions; the
// route remains authenticated and accepts only same-node parent origins.
path: '/nostr-signer',
name: 'nostr-signer',
component: () => import('../views/NostrTabSigner.vue'),
meta: { signerBroker: true },
},
{
// The kiosk display no longer has its own launcher screen. It runs the
// normal app (onboarding → login → dashboard) like any other client.
@@ -268,10 +277,14 @@ const router = createRouter({
// Session check with timeout - avoids endless spinner on mobile/slow networks
const SESSION_CHECK_TIMEOUT_MS = 8000
async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): Promise<boolean> {
async function checkSessionWithTimeout(
store: ReturnType<typeof useAppStore>,
allowCookieWithoutLocalMarker = false,
bootstrapDashboard = true,
): Promise<boolean> {
try {
return await Promise.race([
store.checkSession(),
store.checkSession({ allowCookieWithoutLocalMarker, bootstrapDashboard }),
new Promise<boolean>((resolve) =>
setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS)
),
@@ -339,7 +352,12 @@ router.beforeEach(async (to, _from, next) => {
// Not authenticated at all (with timeout to avoid endless spinner on mobile)
if (!store.isAuthenticated) {
const hasSession = await checkSessionWithTimeout(store)
// A direct app tab can authenticate through the app gate before its
// dashboard-origin signer iframe has ever loaded. Ports share the node's
// host-only cookie, but not localStorage, so the broker must probe that
// cookie instead of mistaking the missing dashboard marker for logout.
const signerBroker = Boolean(to.meta.signerBroker)
const hasSession = await checkSessionWithTimeout(store, signerBroker, !signerBroker)
if (hasSession) {
next()
return
+32
View File
@@ -137,6 +137,38 @@ describe('useAppStore', () => {
const valid = await store.checkSession()
expect(valid).toBe(false)
expect(mockedRpc.call).not.toHaveBeenCalled()
})
it('checkSession can validate an app-gate cookie without a dashboard localStorage marker', async () => {
mockedRpc.call.mockResolvedValue('ping')
const store = useAppStore()
const valid = await store.checkSession({
allowCookieWithoutLocalMarker: true,
bootstrapDashboard: false,
})
expect(valid).toBe(true)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'system.get-hostname',
})
expect(mockedRpc.call).toHaveBeenCalledOnce()
expect(mockedWs.connect).not.toHaveBeenCalled()
expect(store.data).toBeNull()
expect(store.isAuthenticated).toBe(true)
expect(localStorage.getItem('neode-auth')).toBe('true')
})
it('checkSession rejects a missing app-gate cookie when explicitly probed', async () => {
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
const store = useAppStore()
const valid = await store.checkSession({ allowCookieWithoutLocalMarker: true })
expect(valid).toBe(false)
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('neode-auth')).toBeNull()
})
it('checkSession returns false and clears state on expired session', async () => {
@@ -14,9 +14,10 @@ const SIGNED = {
}
// vi.hoisted runs before vi.mock hoisting
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
const { mockPush, mockWindowOpen, mockRpcCall } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockRpcCall: vi.fn(),
}))
// Mock vue-router
@@ -26,6 +27,9 @@ vi.mock('vue-router', () => ({
vi.mock('@/router', () => ({
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: mockRpcCall },
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -35,6 +39,7 @@ describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRpcCall.mockResolvedValue({ credentials: [] })
__setSignedCatalogForTests(SIGNED as never)
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
@@ -66,9 +71,13 @@ describe('useAppLauncherStore', () => {
delete (window as any).ArchipelagoNative
})
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
it('shows credentials before handing an app to the native WebView', async () => {
const store = useAppLauncherStore()
store.openSession('filebrowser')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(openInApp).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
@@ -82,6 +91,16 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBeNull()
})
it('opens GitWorkshop in the companion native WebView, never a dashboard iframe', () => {
const store = useAppLauncherStore()
store.openSession('archipelago-source')
expect(openInApp).toHaveBeenCalledWith(
'http://192.0.2.10/app/archipelago-source/',
)
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
})
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown app' })
@@ -90,11 +109,13 @@ describe('useAppLauncherStore', () => {
})
})
it('routes known port apps to full-page session', () => {
it('routes known port apps to full-page session after the credential gate', async () => {
const store = useAppLauncherStore()
// Port 8083 maps to /app/filebrowser/ — should route to session
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Default panel mode: sets panelAppId, doesn't open overlay
expect(store.isOpen).toBe(false)
@@ -102,6 +123,29 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('gates a Home-style Portainer launch until its first-run token is shown', async () => {
mockRpcCall.mockResolvedValueOnce({
title: 'Portainer first-run token',
description: 'Use this token to create the administrator account.',
credentials: [{ label: 'Token', value: 'test-token', sensitive: true }],
})
const store = useAppLauncherStore()
store.openSession('portainer')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(store.credentialPrompt.credentials[0]?.value).toBe('test-token')
expect(mockWindowOpen).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(':9000'),
'_blank',
'noopener,noreferrer',
)
})
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
@@ -378,7 +422,7 @@ describe('useAppLauncherStore', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('routes HTTPS same-host apps via session view', () => {
it('routes HTTPS same-host apps via session view after the credential gate', async () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
@@ -387,6 +431,8 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Known port — routes to session (panel mode by default)
expect(store.isOpen).toBe(false)
+190 -35
View File
@@ -4,11 +4,18 @@ import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { portIsGateFronted } from '@/views/discover/curatedApps'
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from '@/views/appSession/nostrConsent'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -79,6 +86,22 @@ const NEW_TAB_APP_IDS = new Set([
'netbird',
])
/** Apps whose launch may require a platform-owned credential handoff. Keep
* this list deliberately narrow so ordinary new-tab launches retain their
* original synchronous user gesture. Portainer is dynamic (first-run only);
* File Browser and PhotoPrism have stable fallback credentials. */
export const CREDENTIAL_INTERSTITIAL_APPS = new Set([
'filebrowser',
'photoprism',
'portainer',
])
interface LaunchOptions {
path?: string
/** The shared interstitial already ran and the user pressed Continue. */
skipCredentialPrompt?: boolean
}
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -148,6 +171,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8337': 'archipelago-source',
'8888': 'searxng',
'9000': 'portainer',
'8087': 'netbird',
@@ -163,30 +187,12 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
if (!stored) return new Set()
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
} catch {
return new Set()
}
}
function saveApprovedOrigin(origin: string) {
const origins = getApprovedOrigins()
origins.add(origin)
try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ }
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
identityLabel?: string
resolve: (remember: boolean) => void
reject: () => void
}
@@ -208,6 +214,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const title = ref('')
const consentRequest = ref<NostrConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
const credentialPrompt = ref({
show: false,
loading: false,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
let pendingCredentialLaunch: { appId: string; path?: string } | null = null
let credentialGeneration = 0
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
let previousActiveElement: HTMLElement | null = null
/** Active app in the store-driven session (no route change) */
@@ -215,15 +237,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
function openSession(appId: string, opts: { path?: string } = {}) {
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Companion app: EVERY app opens in the native in-app WebView — never an
// iframe. The WebView is more performant on the phone and carries the
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
// keep the iframe session below.
if (!IS_DEMO && isCompanionApp()) {
// Companion app: ordinary apps open in the native in-app WebView for the
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -264,6 +286,94 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
panelAppId.value = appId
}
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
* Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) {
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path)
return
}
openSessionNow(appId, opts)
}
async function prepareCredentialLaunch(appId: string, path?: string) {
const generation = ++credentialGeneration
const appName = useAppStore().data?.['package-data']?.[appId]?.manifest?.title || appId
pendingCredentialLaunch = { appId, path }
credentialPrompt.value = {
show: true,
loading: true,
appId,
title: `Checking ${appName}`,
description: 'Checking whether this app needs a first-run token or login details…',
credentials: [],
copied: '',
}
let result: AppCredentialsResponse | null
try {
// Portainer's token is lifecycle-dependent, so this must be live on
// every launch. Caching a pre-initialisation null (or an already-used
// token) recreates the skipped/stale interstitial bug.
result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: appId },
timeout: 5000,
})
} catch {
result = null
}
if (generation !== credentialGeneration) return
const resolved = resolveAppCredentials(appId, result)
if (!resolved) {
credentialPrompt.value.show = false
pendingCredentialLaunch = null
openSessionNow(appId, { path, skipCredentialPrompt: true })
return
}
credentialPrompt.value = {
show: true,
loading: false,
appId,
title: resolved.title || `${appName} credentials`,
description: resolved.description || 'Use these credentials when the app asks you to sign in.',
credentials: resolved.credentials,
copied: '',
}
}
function cancelCredentialLaunch() {
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
}
function continueCredentialLaunch() {
const pending = pendingCredentialLaunch
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
if (pending) openSessionNow(pending.appId, { path: pending.path, skipCredentialPrompt: true })
}
async function copyCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialPrompt.value.copied = label
}
function closePanel() {
panelAppId.value = null
panelPath.value = null
@@ -287,7 +397,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const sameHost = u.hostname === window.location.hostname
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
const httpsPage = window.location.protocol === 'https:'
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
const gateFronted = !!resolvedId && appPortIsGateFronted(resolvedId, u.port)
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
// Pure prefix swap — never re-serialize the URL (URL.href would add
// a trailing slash and change the string the caller handed over).
@@ -366,6 +476,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
if (showConsent.value) denyConsent()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -384,22 +495,50 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function approveConsent(remember: boolean) {
if (consentRequest.value) {
consentRequest.value.resolve(remember)
consentRequest.value = null
}
showConsent.value = false
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
if (consentRequest.value) {
consentRequest.value.reject()
consentRequest.value = null
}
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
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'
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string, identityLabel?: string): Promise<boolean> {
return new Promise((resolve, reject) => {
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
consentGeneration += 1
consentRequest.value = {
appName, method, eventKind, content, identityLabel,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
@@ -416,6 +555,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
if (!senderMatchesApp(url.value, event.origin)) return
const origin = event.origin
let prompted = false
const activeAppId = resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app'
// Check if app has a per-app identity stored (from identity picker)
const IDENTITY_KEY = 'archipelago_app_identity_'
@@ -440,12 +581,18 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
if (CONSENT_METHODS.has(method) && !getApprovedOrigins().has(origin)) {
const scopedKey = consentKey(origin, activeAppId, appIdentityId || 'node-default', method)
const alreadyApproved = hasRememberedConsent(scopedKey)
if (CONSENT_METHODS.has(method) && !alreadyApproved) {
prompted = true
const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined
const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined
try {
const remember = await requestConsent(title.value || 'App', method, eventKind, content)
if (remember) saveApprovedOrigin(origin)
const remember = await requestConsent(
title.value || 'App', method, eventKind, content,
appIdentityId || 'Node default identity',
)
if (remember) rememberConsent(scopedKey)
} catch {
source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*')
return
@@ -508,9 +655,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
if (prompted) void finishConsentSuccess()
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
if (prompted && showConsent.value) finishConsentError(err)
}
}
@@ -533,8 +682,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
closePanel,
panelAppId,
panelPath,
credentialPrompt,
cancelCredentialLaunch,
continueCredentialLaunch,
copyCredential,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
+26 -8
View File
@@ -92,22 +92,40 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function checkSession(): Promise<boolean> {
if (!localStorage.getItem('neode-auth')) {
async function checkSession(options: {
allowCookieWithoutLocalMarker?: boolean
bootstrapDashboard?: boolean
} = {}): Promise<boolean> {
// `neode-auth` is only a client-side hint; the HttpOnly session cookie is
// the authority. Most dashboard navigations deliberately require the hint
// so logging out does not immediately resurrect a still-expiring cookie.
// The contained tab signer is the exception: an app-gate login happens on
// the app's port and sets the shared host cookie, but cannot set dashboard-
// origin localStorage. Let that route validate the real cookie explicitly.
if (!options.allowCookieWithoutLocalMarker && !localStorage.getItem('neode-auth')) {
return false
}
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
// Unlike public `server.echo`, this implemented read-only method requires
// a valid session while remaining CSRF-exempt. That makes checkSession a
// real authentication check, including for the app-gate cookie bootstrap.
await rpcClient.call({ method: 'system.get-hostname' })
isAuthenticated.value = true
sessionValidated = true
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
const sync = useSyncStore()
await sync.initializeData()
// The hidden signer broker only needs proof of the session cookie. Do
// not make its first consent prompt wait for a full dashboard snapshot
// and WebSocket connection; a normal dashboard check keeps this default.
if (options.bootstrapDashboard !== false) {
const sync = useSyncStore()
await sync.initializeData()
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
}
return true
} catch (err) {
+18
View File
@@ -3074,9 +3074,27 @@ html.modal-scroll-locked .dashboard-scroll-panel {
background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.5) 40%, rgba(0,0,0,0.1) 100%);
border-radius: 16px;
}
.source-banner {
border-color: rgba(147, 51, 234, 0.35);
}
.source-banner-img {
object-position: center center;
opacity: 0.72;
}
.source-banner:hover .source-banner-img {
opacity: 0.86;
}
.source-banner-overlay {
justify-content: center;
background: linear-gradient(90deg, rgba(3,5,9,0.97) 0%, rgba(3,5,9,0.86) 42%, rgba(3,5,9,0.2) 76%, rgba(3,5,9,0.08) 100%);
}
@media (max-width: 768px) {
.featured-banner { min-height: 240px; }
.featured-banner-overlay { padding: 1.5rem; min-height: 240px; }
.source-banner-img { object-position: 68% center; }
.source-banner-overlay {
background: linear-gradient(to right, rgba(3,5,9,0.96) 0%, rgba(3,5,9,0.78) 72%, rgba(3,5,9,0.42) 100%);
}
}
.discover-stat-pill {
@@ -1,5 +1,9 @@
import { describe, it, expect, afterEach } from 'vitest'
import { isCompanionApp } from '../openExternal'
import { describe, it, expect, afterEach, vi } from 'vitest'
import {
installCertificateInCompanion,
isCompanionApp,
openInAppOrNewTab,
} from '../openExternal'
// isCompanionApp() is the single companion-detection source used to skip the
// demo intro (App.vue + RootRedirect.vue) and by appLauncher — it must be true
@@ -26,4 +30,21 @@ describe('isCompanionApp', () => {
w.ArchipelagoNative = { openExternal: () => {} }
expect(isCompanionApp()).toBe(false)
})
it('absolutizes same-origin paths before handing them to the native WebView', () => {
const openInApp = vi.fn()
w.ArchipelagoNative = { openInApp }
openInAppOrNewTab('/app/archipelago-source/')
expect(openInApp).toHaveBeenCalledWith(`${window.location.origin}/app/archipelago-source/`)
})
it('hands a certificate to the native installer when available', () => {
const installNodeCertificate = vi.fn()
w.ArchipelagoNative = { openInApp: () => {}, installNodeCertificate }
expect(installCertificateInCompanion()).toBe(true)
expect(installNodeCertificate).toHaveBeenCalledOnce()
})
})
+23 -2
View File
@@ -15,6 +15,8 @@ interface ArchipelagoNativeBridge {
/** Richer launch (companion ≥0.5.26): catalog icon + display name drive the
* native branded loader instead of the site favicon. */
openInAppEx?: (url: string, iconUrl: string, name: string) => void
/** Install only the connected node's CA through Android's system prompt. */
installNodeCertificate?: () => void
}
/** Optional app identity for the native loading screen. */
@@ -58,15 +60,34 @@ export function openExternalUrl(url: string): void {
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void {
if (!url) return
const native = nativeBridge()
// Native WebView.loadUrl requires a complete URL. Browser APIs accept
// relative paths, so this only surfaced when a same-origin app was launched
// by the companion bridge.
let nativeUrl = url
if (!/^[a-z][a-z\d+.-]*:/i.test(url)) {
try {
nativeUrl = new URL(url, window.location.origin).href
} catch { /* keep as-is */ }
}
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
// Absolutize the icon path so the native shell can fetch it directly.
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
native.openInAppEx(url, icon, meta.name ?? '')
native.openInAppEx(nativeUrl, icon, meta.name ?? '')
return
}
if (native && typeof native.openInApp === 'function') {
native.openInApp(url)
native.openInApp(nativeUrl)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
/** Use Android's credential installer when the dashboard runs in the
* companion. Returns false in an ordinary browser so the caller can preserve
* the normal file-download behavior. */
export function installCertificateInCompanion(): boolean {
const native = nativeBridge()
if (!native || typeof native.installNodeCertificate !== 'function') return false
native.installNodeCertificate()
return true
}
+17 -6
View File
@@ -262,12 +262,23 @@ const canLaunch = computed(() => {
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default'
])
const features = computed(() => {
if (packageKey.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
'Automatic backups',
'Secure by default',
]
})
function goBack() {
if (route.query.from === 'discover') {
+36 -10
View File
@@ -85,14 +85,28 @@
</svg>
</button>
</div>
</div>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
<!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. -->
<NostrSignConsent
:show="nostrBridge.showConsent.value"
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
:method="nostrBridge.consentRequest.value?.method ?? ''"
:identity-label="nostrBridge.consentRequest.value?.identityLabel"
:event-kind="nostrBridge.consentRequest.value?.eventKind"
:content="nostrBridge.consentRequest.value?.content"
:phase="nostrBridge.consentPhase.value"
:error="nostrBridge.consentError.value"
@approve="nostrBridge.approveConsent"
@deny="nostrBridge.denyConsent"
/>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="identity.cancelIdentitySelection"
/>
</div>
</div>
</Teleport>
</div>
@@ -105,6 +119,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -265,7 +280,12 @@ function closeRouteSession() {
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
appId: () => appId.value,
appName: () => appTitle.value,
appUrl: () => appUrl.value,
frameWindow: () => iframeRef.value?.contentWindow ?? null,
})
// --- Display mode ---
@@ -349,7 +369,7 @@ const backdropClasses = computed(() => {
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
const base = 'app-session-panel glass-card relative overflow-hidden'
if (inlinePanelMode.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
@@ -443,6 +463,7 @@ function handleBackdropClick() {
}
function closeSession() {
if (nostrBridge.showConsent.value) nostrBridge.denyConsent()
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@@ -450,6 +471,11 @@ function closeSession() {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent()
e.preventDefault()
return
}
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
@@ -465,7 +491,7 @@ function onFullscreenChange() {
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
}
+4 -4
View File
@@ -637,10 +637,10 @@ function goToApp(id: string) {
async function launchApp(id: string) {
const shown = await maybeShowCredentialsBeforeLaunch(id)
if (shown) return
launchAppNow(id)
launchAppNow(id, true)
}
function launchAppNow(id: string) {
function launchAppNow(id: string, credentialsChecked = false) {
const pkg = packages.value[id]
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -664,7 +664,7 @@ function launchAppNow(id: string) {
return
}
}
useAppLauncherStore().openSession(id)
useAppLauncherStore().openSession(id, { skipCredentialPrompt: credentialsChecked })
}
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
@@ -721,7 +721,7 @@ function closeCredentialModal() {
function continueCredentialLaunch() {
const id = credentialModal.value.appId
closeCredentialModal()
if (id) launchAppNow(id)
if (id) launchAppNow(id, true)
}
async function copyModalCredential(label: string, value: string) {
+168 -22
View File
@@ -93,29 +93,18 @@
</div>
</div>
<!-- Hero + Featured + Banner (only when no search) -->
<!-- Hero + registry-owned storefront (only when no search) -->
<template v-if="!searchQuery">
<DiscoverHero
:total-apps="allApps.length"
:installed-count="installedCount"
/>
<FeaturedApps
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<!-- A registry storefront extends the featured story; it does not
replace it. Keep the primary banner above Popular, then place
registry promotions after the popular rows. -->
<div
v-if="featuredBanner"
v-if="catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
@@ -152,15 +141,136 @@
</div>
</div>
<!-- Mobile companion app banner — opens the download/pairing modal -->
<CompanionBanner />
<!-- New catalogs own the store composition. Six popular cards make two
rows on desktop; registry operators can reorder them without an OS
release. Older catalogs retain the legacy featured fallback. -->
<div v-if="catalogStorefront" class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">popular</span>
<h2 class="text-xl font-bold text-white">Popular Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ popularApps.length }} apps</span>
</div>
<AppGrid
v-if="catalogStorefront"
:filtered-apps="popularApps"
:show-stagger="showStagger"
:stagger-offset="0"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-installed-state="getInstalledState"
:get-app-tier="getAppTier"
:is-loading="loadingCommunity"
loading-message="Loading..."
:nostr-error="''"
:is-nostr-category="false"
:search-query="''"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<FeaturedApps
v-else
:featured-apps="featuredApps"
:show-stagger="showStagger"
:containers-scanned="containersScanned"
:installing-apps="installingApps"
:is-installed="isInstalled"
:is-starting-up="isStartingUp"
:get-app-tier="getAppTier"
@view-details="viewAppDetails"
@launch="launchInstalledApp"
@install="handleInstall"
/>
<!-- Featured App Banner (from catalog or hardcoded) -->
<div
v-if="!catalogStorefront && featuredBanner"
class="featured-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="featuredBannerApp && viewAppDetails(featuredBannerApp)"
>
<img
:src="featuredBanner.banner"
:alt="featuredBanner.headline"
class="featured-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">featured</span>
<span class="text-white/50 text-sm font-mono">{{ featuredBanner.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ featuredBanner.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ featuredBanner.description }}</p>
<div class="flex items-center gap-3">
<button
v-if="featuredBannerApp && isInstalled(featuredBannerApp.id) && !isStartingUp(featuredBannerApp.id)"
@click.stop="launchInstalledApp(featuredBannerApp)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>Launch</button>
<button
v-else-if="featuredBannerApp && !isInstalled(featuredBannerApp.id) && featuredBannerApp.dockerImage"
@click.stop="handleInstall(featuredBannerApp)"
:disabled="installingApps.has(featuredBannerApp.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
<span v-else>Install</span>
</button>
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
</div>
</div>
</div>
<!-- Promotions are ordered and written by the registry catalog. -->
<div
v-for="promotion in storefrontPromotions"
:key="promotion.id"
class="featured-banner source-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
@click="viewAppDetails(promotion.app)"
>
<img
:src="promotion.banner"
alt=""
class="featured-banner-img source-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div class="featured-banner-overlay source-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
<span class="text-white/50 text-sm font-mono">{{ promotion.tag }}</span>
</div>
<h2 class="text-3xl md:text-4xl font-extrabold text-white mb-2 tracking-tight">{{ promotion.headline }}</h2>
<p class="text-white/80 text-base md:text-lg max-w-2xl leading-relaxed mb-4">{{ promotion.description }}</p>
<div class="flex flex-wrap items-center gap-3">
<button
v-if="isInstalled(promotion.app.id) && !isStartingUp(promotion.app.id)"
@click.stop="launchInstalledApp(promotion.app)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium"
>{{ promotion.launchLabel || 'Launch' }}</button>
<button
v-else-if="!isInstalled(promotion.app.id) && promotion.app.dockerImage"
@click.stop="handleInstall(promotion.app)"
:disabled="installingApps.has(promotion.app.id)"
class="glass-button rounded-lg px-6 py-2.5 text-sm font-medium disabled:opacity-50"
>{{ installingApps.has(promotion.app.id) ? 'Installing…' : (promotion.installLabel || 'Install') }}</button>
<button
@click.stop="viewAppDetails(promotion.app)"
class="rounded-lg px-4 py-2.5 text-sm font-medium text-white/70 hover:text-white"
>{{ promotion.detailsLabel || 'View details →' }}</button>
</div>
</div>
</div>
<!-- Category Section Divider -->
<div class="flex items-center gap-3 mb-5">
<span class="discover-terminal-tag">all</span>
<h2 class="text-xl font-bold text-white">Available to Install</h2>
<h2 class="text-xl font-bold text-white">All Apps</h2>
<div class="flex-1 h-px bg-white/10"></div>
<span class="text-white/30 text-sm">{{ filteredApps.length }} apps</span>
<span class="text-white/30 text-sm">{{ remainingApps.length }} apps</span>
</div>
</template>
@@ -179,7 +289,7 @@
</div>
<AppGrid
:filtered-apps="filteredApps"
:filtered-apps="gridApps"
:show-stagger="showStagger"
:stagger-offset="selectedCategory === 'all' && !searchQuery ? 4 : 0"
:containers-scanned="containersScanned"
@@ -199,6 +309,10 @@
@retry-nostr="retryNostr"
/>
<!-- The node-owned companion download is operational UI, not registry
merchandising, so it follows the complete app listing. -->
<CompanionBanner v-if="!searchQuery" />
<!-- Manifesto Footer (only when no search) -->
<div v-if="!searchQuery && filteredApps.length > 0" class="discover-manifesto glass-card p-8 mt-4 mb-8">
<div class="flex items-center gap-3 mb-4">
@@ -250,7 +364,7 @@ import CompanionBanner from './discover/CompanionBanner.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured, type CatalogStorefront } from './discover/curatedApps'
const router = useRouter()
const store = useAppStore()
@@ -299,6 +413,12 @@ const catalogFeatured = useCachedResource<CatalogFeatured | null>({
ttlMs: 300_000,
persist: true,
}).data
const catalogStorefront = useCachedResource<CatalogStorefront | null>({
key: 'app-catalog:storefront',
fetcher: async () => (await fetchAppCatalog())?.storefront ?? null,
ttlMs: 300_000,
persist: true,
}).data
const communityApps = computed(() => catalogResource.data.value ?? [])
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
@@ -452,6 +572,32 @@ const filteredApps = computed(() => {
return apps
})
const popularApps = computed(() => {
const byId = new Map(allApps.value.map(app => [app.id, app]))
const popular: MarketplaceApp[] = []
for (const id of catalogStorefront.value?.popular ?? []) {
const app = byId.get(id)
if (app?.dockerImage) popular.push(app)
}
return popular
})
const popularIds = computed(() => new Set(popularApps.value.map(app => app.id)))
const remainingApps = computed(() =>
catalogStorefront.value
? filteredApps.value.filter(app => !popularIds.value.has(app.id))
: filteredApps.value
)
const gridApps = computed(() => searchQuery.value ? filteredApps.value : remainingApps.value)
const storefrontPromotions = computed(() => {
if (!catalogStorefront.value) return []
return catalogStorefront.value.promotions.flatMap(promotion => {
const app = allApps.value.find(candidate => candidate.id === promotion.id)
return app ? [{ ...promotion, app }] : []
})
})
const installedCount = computed(() => {
return allApps.value.filter(app => isInstalled(app.id)).length
})
@@ -497,6 +497,15 @@ function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefin
// Placeholder features
const features = computed(() => {
if (appId.value === 'archipelago-source') {
return [
'Browse Archipelago source through the established GitWorkshop interface',
'Clone with ngit and fetch Git objects from redundant GRASP servers',
'Open issues, propose patches, and review changes over Nostr',
'Use a selected node identity through an explicit consent prompt',
'Contribute without receiving maintainer merge or release authority',
]
}
return [
'Self-hosted and privacy-focused',
'Easy installation and updates',
+221
View File
@@ -0,0 +1,221 @@
<template>
<main class="relative h-screen w-screen overflow-hidden bg-transparent" aria-label="Archipelago Nostr signer">
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appName"
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
<NostrSignConsent
:show="bridge.showConsent.value"
:app-name="bridge.consentRequest.value?.appName ?? appName"
:method="bridge.consentRequest.value?.method ?? ''"
:identity-label="bridge.consentRequest.value?.identityLabel"
:event-kind="bridge.consentRequest.value?.eventKind"
:content="bridge.consentRequest.value?.content"
:phase="bridge.consentPhase.value"
:error="bridge.consentError.value"
@approve="bridge.approveConsent"
@deny="bridge.denyConsent"
/>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
const appId = ref('')
const appName = ref('App')
const appOrigin = ref('')
const showIdentityPicker = ref(false)
const queuedRequests: MessageEvent[] = []
let hideTimer: ReturnType<typeof setTimeout> | null = null
function getStoredIdentity(): SelectedIdentity | null {
if (!appId.value) return null
try {
const raw = localStorage.getItem(`archipelago_app_identity_${appId.value}`)
return raw ? JSON.parse(raw) as SelectedIdentity : null
} catch {
return null
}
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(`archipelago_app_identity_${appId.value}`, JSON.stringify(identity)) } catch {}
}
function parentPost(message: Record<string, unknown>) {
window.parent.postMessage(message, appOrigin.value || '*')
}
function showSigner() {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
parentPost({ type: 'archipelago:signer-show' })
}
function hideSigner(delay = 0) {
if (hideTimer !== null) clearTimeout(hideTimer)
const hide = () => {
hideTimer = null
if (!showIdentityPicker.value && !bridge.showConsent.value) {
parentPost({ type: 'archipelago:signer-hide' })
}
}
if (delay > 0) hideTimer = setTimeout(hide, delay)
else hide()
}
function sendIdentity(identity: SelectedIdentity) {
parentPost({ type: 'archipelago:signer-identity', identity })
}
const bridge = useNostrBridge(getStoredIdentity, {
appId: () => appId.value,
appName: () => appName.value,
appUrl: () => appOrigin.value,
frameWindow: () => window.parent,
})
// Keep the app-side broker frame hidden for silent/remembered requests. It is
// a full-viewport iframe in tabs and Companion WebViews, so showing it for
// every background getPublicKey/getRelays call produces a visible flash even
// when no consent card opens. Reveal it only when there is actually something
// for the user to review, and keep it visible through signing/result.
watch(bridge.showConsent, (show) => {
if (show) showSigner()
else if (!showIdentityPicker.value) hideSigner()
})
function isSameNodeAppOrigin(origin: string): boolean {
try {
const candidate = new URL(origin)
return (candidate.protocol === 'http:' || candidate.protocol === 'https:')
&& candidate.hostname === window.location.hostname
} catch {
return false
}
}
async function onIdentitySelected(identity: SelectedIdentity) {
storeIdentity(identity)
showIdentityPicker.value = false
sendIdentity(identity)
const requests = queuedRequests.splice(0)
if (requests.length) {
await nextTick()
for (const request of requests) await handleRequest(request)
} else {
// The host app normally follows the selected identity with getPublicKey
// and signEvent. Keep one continuous signer surface through that chain so
// the picker does not disappear and immediately flash back as consent.
hideSigner(400)
}
}
async function handleRequest(event: MessageEvent) {
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
await bridge.handleNostrRequest(event)
// getRelays and remembered approvals complete without opening the consent
// card. Do not leave the otherwise-transparent broker intercepting the app.
if (!bridge.showConsent.value && !showIdentityPicker.value) {
hideSigner()
}
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
const requests = queuedRequests.splice(0)
for (const request of requests) {
const id = (request.data as { id?: unknown } | null)?.id
window.parent.postMessage({ type: 'nostr-response', id, error: 'Identity selection cancelled' }, request.origin)
}
parentPost({ type: 'archipelago:signer-identity-cancelled' })
hideSigner()
}
function onMessage(event: MessageEvent) {
if (event.source !== window.parent) return
const data = event.data as Record<string, unknown> | null
if (!data) return
if (data.type === 'archipelago:signer-init') {
const id = typeof data.appId === 'string' ? data.appId : ''
const name = typeof data.appName === 'string' ? data.appName : 'App'
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id) || !isSameNodeAppOrigin(event.origin)) return
appId.value = id
appName.value = name.slice(0, 120)
appOrigin.value = event.origin
const stored = getStoredIdentity()
if (stored) {
sendIdentity(stored)
hideSigner(400)
} else {
showIdentityPicker.value = true
showSigner()
}
return
}
if (data.type === 'archipelago:signer-select-identity' && appId.value && event.origin === appOrigin.value) {
showIdentityPicker.value = true
showSigner()
return
}
if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return
if (!getStoredIdentity()) {
queuedRequests.push(event)
showIdentityPicker.value = true
showSigner()
return
}
void handleRequest(event)
}
onMounted(() => {
// This route is rendered inside a full-viewport broker iframe. The global
// dashboard stylesheet gives body a solid black canvas and animated
// compositor layers; Android WebView can retain that last iframe surface
// for a frame (or indefinitely) after the picker closes. Keep the broker's
// document genuinely transparent so even a stale surface cannot cover the
// app beneath it.
document.documentElement.classList.add('nostr-signer-route')
document.body.classList.add('nostr-signer-route')
window.addEventListener('message', onMessage)
window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
})
onBeforeUnmount(() => {
if (hideTimer !== null) clearTimeout(hideTimer)
window.removeEventListener('message', onMessage)
document.documentElement.classList.remove('nostr-signer-route')
document.body.classList.remove('nostr-signer-route')
})
</script>
<style>
html.nostr-signer-route,
html.nostr-signer-route body,
html.nostr-signer-route #app {
background: transparent !important;
}
html.nostr-signer-route body::before,
html.nostr-signer-route body::after,
html.nostr-signer-route::before {
content: none !important;
animation: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
</style>
+8 -3
View File
@@ -914,9 +914,10 @@ async function loadStatus() {
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
}
// Mirror the backend in both directions. The old one-way assignment could
// set this after a completed download but never clear it after cancellation,
// leaving the Install button visible until the component remounted.
downloaded.value = res.update_in_progress
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
}
@@ -1043,6 +1044,10 @@ async function cancelDownload() {
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
// `update_in_progress` is the backend's staged/installable flag. Leaving
// this true made the card render Install until the next page refresh even
// though cancellation had already removed the partial staging files.
updateInProgress.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
@@ -41,7 +41,15 @@ vi.mock('../appSession/useAppIdentity', () => ({
}))
vi.mock('../appSession/useNostrBridge', () => ({
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
useNostrBridge: () => ({
handleNostrRequest: vi.fn(),
showConsent: { value: false },
consentRequest: { value: null },
consentPhase: { value: 'review' },
consentError: { value: '' },
approveConsent: vi.fn(),
denyConsent: vi.fn(),
}),
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -0,0 +1,49 @@
import { shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NostrTabSigner from '@/views/NostrTabSigner.vue'
describe('NostrTabSigner visibility', () => {
beforeEach(() => {
localStorage.clear()
window.history.replaceState({}, '', '/nostr-signer')
})
afterEach(() => vi.restoreAllMocks())
function parentMessage(data: Record<string, unknown>) {
const event = new MessageEvent('message', { data, origin: window.location.origin })
Object.defineProperty(event, 'source', { value: window.parent })
window.dispatchEvent(event)
}
it('does not reveal the full-screen frame for a silent remembered request', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
id: 'identity-a',
name: 'Alice',
nostr_pubkey: 'abc123',
}))
const postMessage = vi.spyOn(window.parent, 'postMessage')
const wrapper = shallowMount(NostrTabSigner)
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(true)
expect(document.body.classList.contains('nostr-signer-route')).toBe(true)
parentMessage({
type: 'archipelago:signer-init',
appId: 'archipelago-source',
appName: 'GitWorkshop',
})
postMessage.mockClear()
parentMessage({ type: 'nostr-request', id: 1, method: 'getRelays', params: {} })
await Promise.resolve()
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-show' }),
expect.anything(),
)
wrapper.unmount()
expect(document.documentElement.classList.contains('nostr-signer-route')).toBe(false)
expect(document.body.classList.contains('nostr-signer-route')).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { HOST_FRAME_APPS, NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_HOST_FRAME_APPS, GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
import { __setSignedCatalogForTests } from '../../discover/curatedApps'
// Mirror of the live signed catalog's embedded manifests (the ports[] auth
@@ -45,6 +45,11 @@ describe('appSessionConfig', () => {
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('does not force GitWorkshop into a dashboard iframe in Companion', () => {
expect(GENERATED_HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
expect(HOST_FRAME_APPS.has('archipelago-source')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
@@ -147,4 +152,17 @@ describe('appSessionConfig', () => {
// Cuprate's UI port is auth:none — plain HTTP stays plain.
expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090')
})
it('keeps the pre-catalog Source app on the dashboard origin', () => {
stubLocation({ hostname: '192.0.2.10', protocol: 'https:' })
// Source is intentionally absent from SIGNED until owner UAT passes. It
// must follow the already-working dashboard ingress instead of assuming
// that the same address also exposes a dedicated high port.
expect(resolveAppUrl('archipelago-source')).toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', undefined, 'http://localhost:8337'))
.toBe('/app/archipelago-source/')
expect(resolveAppUrl('archipelago-source', '/search'))
.toBe('/app/archipelago-source/search')
})
})
@@ -0,0 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { consentKey, hasRememberedConsent, rememberConsent } from '../nostrConsent'
describe('NIP-07 consent storage', () => {
beforeEach(() => localStorage.clear())
it('binds remembered approval to origin, app, identity and method', () => {
const key = consentKey('https://node.example', 'archipelago-source', 'identity-a', 'signEvent')
rememberConsent(key)
expect(hasRememberedConsent(key)).toBe(true)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-b', 'signEvent'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://node.example', 'archipelago-source', 'identity-a', 'nip44.decrypt'))).toBe(false)
expect(hasRememberedConsent(consentKey('https://other-node.example', 'archipelago-source', 'identity-a', 'signEvent'))).toBe(false)
})
})
@@ -0,0 +1,314 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
type ProviderWindow = Window & {
__archipelagoNostr?: boolean
ArchipelagoSurface?: {
expectPageTransition: () => void
}
nostr?: { getPublicKey: () => Promise<string> }
archipelagoNostr?: {
selectIdentity: () => Promise<unknown>
getSelectedIdentity: () => { nostr_pubkey: string } | null
onIdentitySelected: (
callback: (identity: { nostr_pubkey: string }) => void,
) => () => void
}
}
describe('nostr-provider identity selection', () => {
let providerWindow: ProviderWindow
beforeEach(() => {
providerWindow = window as ProviderWindow
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
document.documentElement.innerHTML = '<head><title>IndeedHub</title></head><body></body>'
window.history.replaceState({}, '', '/app/indeedhub/')
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
Reflect.deleteProperty(document, 'readyState')
delete providerWindow.__archipelagoNostr
delete providerWindow.nostr
delete providerWindow.archipelagoNostr
delete providerWindow.ArchipelagoSurface
})
function loadProvider(userActivated: boolean) {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: userActivated },
})
window.eval(providerSource)
window.dispatchEvent(new Event('load'))
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
postMessage.mockClear()
return { frame, postMessage, signerOrigin }
}
it('reopens the chooser for a user-triggered NIP-07 login', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(true)
const publicKey = providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity', force: true }),
signerOrigin,
)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'abc123' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
await Promise.resolve()
await expect(publicKey).resolves.toBe('abc123')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('uses the remembered identity for background account restoration', () => {
const { postMessage, signerOrigin } = loadProvider(false)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:signer-select-identity' }),
expect.anything(),
)
})
it('keeps an eager picker choice for the app login that follows', async () => {
const { frame, postMessage, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'fast-choice' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
postMessage.mockClear()
await expect(providerWindow.nostr!.getPublicKey()).resolves.toBe('fast-choice')
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'nostr-request', method: 'getPublicKey' }),
signerOrigin,
)
})
it('parks the hidden broker off-screen and reuses it for the next request', async () => {
const surface = {
expectPageTransition: vi.fn(),
}
providerWindow.ArchipelagoSurface = surface
const { frame, postMessage, signerOrigin } = loadProvider(false)
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
expect(frame.style.display).toBe('block')
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
expect(frame.style.display).toBe('block')
expect(frame.style.width).toBe('1px')
expect(frame.style.height).toBe('1px')
expect(frame.style.opacity).toBe('0')
expect(frame.style.pointerEvents).toBe('none')
expect(frame.style.transform).toContain('-10000px')
expect(document.querySelector('#archipelago-nostr-signer')).toBe(frame)
const publicKey = providerWindow.nostr!.getPublicKey()
const replacement = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
expect(replacement).toBe(frame)
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: replacement.contentWindow })
window.dispatchEvent(ready)
const request = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request')!
expect(request).toBeDefined()
const response = new MessageEvent('message', {
data: { type: 'nostr-response', id: request.id, result: 'recreated-key' },
origin: signerOrigin,
})
Object.defineProperty(response, 'source', { value: replacement.contentWindow })
window.dispatchEvent(response)
await expect(publicKey).resolves.toBe('recreated-key')
})
it('delivers an eager identity to an app listener that mounts afterward', () => {
const { frame, signerOrigin } = loadProvider(false)
const selected = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'late-listener' } },
origin: signerOrigin,
})
Object.defineProperty(selected, 'source', { value: frame.contentWindow })
window.dispatchEvent(selected)
const listener = vi.fn()
const unsubscribe = providerWindow.archipelagoNostr!.onIdentitySelected(listener)
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith({ nostr_pubkey: 'late-listener' })
expect(providerWindow.archipelagoNostr!.getSelectedIdentity())
.toEqual({ nostr_pubkey: 'late-listener' })
unsubscribe()
const changed = new MessageEvent('message', {
data: { type: 'archipelago:signer-identity', identity: { nostr_pubkey: 'after-unsubscribe' } },
origin: signerOrigin,
})
Object.defineProperty(changed, 'source', { value: frame.contentWindow })
window.dispatchEvent(changed)
expect(listener).toHaveBeenCalledOnce()
})
it('turns an automatic IndeeHub identity into a NIP-98 signing request', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/nostr-auth/health`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'nostr-request',
method: 'signEvent',
params: { event: expect.objectContaining({ kind: 27235, pubkey: 'indeedhub-key' }) },
}),
signerOrigin,
)
})
it('waits for the signer success surface to hide before reloading after NIP-98', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ accessToken: 'real-token', refreshToken: 'refresh' }),
})
vi.stubGlobal('fetch', fetchMock)
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
const { frame, postMessage, signerOrigin } = loadProvider(false)
const identity = new MessageEvent('message', {
data: { type: 'archipelago:identity', nostr_pubkey: 'indeedhub-key' },
origin: window.location.origin,
})
Object.defineProperty(identity, 'source', { value: window })
window.dispatchEvent(identity)
await vi.advanceTimersByTimeAsync(1500)
const signRequest = postMessage.mock.calls
.map(call => call[0] as { type: string; id?: number })
.find(message => message.type === 'nostr-request' && message.id != null)!
const show = new MessageEvent('message', {
data: { type: 'archipelago:signer-show' },
origin: signerOrigin,
})
Object.defineProperty(show, 'source', { value: frame.contentWindow })
window.dispatchEvent(show)
const signed = new MessageEvent('message', {
data: { type: 'nostr-response', id: signRequest.id, result: { id: 'signed-event' } },
origin: signerOrigin,
})
Object.defineProperty(signed, 'source', { value: frame.contentWindow })
window.dispatchEvent(signed)
await vi.advanceTimersByTimeAsync(0)
expect(sessionStorage.getItem('nostr_token')).toBe('real-token')
expect(raf).not.toHaveBeenCalled()
const hide = new MessageEvent('message', {
data: { type: 'archipelago:signer-hide' },
origin: signerOrigin,
})
Object.defineProperty(hide, 'source', { value: frame.contentWindow })
window.dispatchEvent(hide)
await Promise.resolve()
expect(raf).toHaveBeenCalledOnce()
})
it('queues a request until signer-init when the signer iframe wins the load race', () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { isActive: false },
})
Object.defineProperty(document, 'readyState', {
configurable: true,
value: 'loading',
})
window.eval(providerSource)
const frame = document.querySelector<HTMLIFrameElement>('#archipelago-nostr-signer')!
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
const signerOriginUrl = new URL(window.location.href)
signerOriginUrl.port = ''
const signerOrigin = signerOriginUrl.origin
const ready = new MessageEvent('message', {
data: { type: 'archipelago:signer-ready' },
origin: signerOrigin,
})
Object.defineProperty(ready, 'source', { value: frame.contentWindow })
window.dispatchEvent(ready)
void providerWindow.nostr!.getPublicKey()
expect(postMessage).not.toHaveBeenCalled()
window.dispatchEvent(new Event('load'))
expect(postMessage.mock.calls.map(call => (call[0] as { type: string }).type))
.toEqual(['archipelago:signer-init', 'nostr-request'])
})
})
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useAppIdentity, type SelectedIdentity } from '../useAppIdentity'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const alice: SelectedIdentity = {
id: 'alice-id',
name: 'Alice',
did: 'did:key:alice',
pubkey: 'identity-key',
nostr_pubkey: 'nostr-key',
}
describe('useAppIdentity explicit identity selection', () => {
beforeEach(() => {
localStorage.clear()
vi.mocked(rpcClient.call).mockResolvedValue({ signature: 'proof' })
})
it('reuses a stored identity normally but reopens the picker for login', async () => {
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify(alice))
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(false)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.handleIdentityRequest()
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'archipelago:identity', nostr_pubkey: 'nostr-key' }),
'*',
))
postMessage.mockClear()
identity.handleIdentityRequest(true)
expect(showPicker.value).toBe(true)
expect(postMessage).not.toHaveBeenCalled()
})
it('notifies the requesting app when the chooser is cancelled', () => {
const postMessage = vi.fn()
const frame = ref({ contentWindow: { postMessage } } as unknown as HTMLIFrameElement)
const showPicker = ref(true)
const identity = useAppIdentity(ref('archipelago-source'), frame, showPicker)
identity.cancelIdentitySelection()
expect(showPicker.value).toBe(false)
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:identity-cancelled' }, '*')
})
})
@@ -0,0 +1,45 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { rpcClient } from '@/api/rpc-client'
import { useNostrBridge } from '../useNostrBridge'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('useNostrBridge consent presentation', () => {
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.mocked(rpcClient.call).mockResolvedValue({ id: 'signed-event' })
})
afterEach(() => vi.useRealTimers())
it('keeps the contained identity loader visible through signing and completion', async () => {
const source = { postMessage: vi.fn() } as unknown as Window
const bridge = useNostrBridge(
() => ({ id: 'identity-a', name: 'Alice', nostr_pubkey: 'pubkey-a' } as never),
{
appId: () => 'archipelago-source', appName: () => 'GitWorkshop',
appUrl: () => 'https://node.test/app/archipelago-source/', frameWindow: () => source,
},
)
const event = {
data: { type: 'nostr-request', id: 'request-1', method: 'signEvent', params: { event: { kind: 1621, content: 'Fix it' } } },
source, origin: 'https://node.test',
} as MessageEvent
const handling = bridge.handleNostrRequest(event)
await Promise.resolve()
expect(bridge.showConsent.value).toBe(true)
expect(bridge.consentPhase.value).toBe('review')
bridge.approveConsent(false)
expect(bridge.consentPhase.value).toBe('signing')
expect(bridge.showConsent.value).toBe(true)
await handling
expect(source.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'nostr-response', id: 'request-1' }), 'https://node.test')
await vi.advanceTimersByTimeAsync(350)
expect(bridge.consentPhase.value).toBe('success')
await vi.advanceTimersByTimeAsync(325)
expect(bridge.showConsent.value).toBe(false)
})
})
@@ -1,7 +1,12 @@
/** Static configuration maps for app session routing and display */
import { portIsGateFronted } from '../discover/curatedApps'
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import {
GENERATED_APP_PORTS,
GENERATED_APP_TITLES,
GENERATED_HOST_FRAME_APPS,
GENERATED_NEW_TAB_APPS,
} from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
@@ -50,6 +55,7 @@ export const APP_PORTS: Record<string, number> = {
/** Apps that need nginx proxy for iframe embedding.
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'archipelago-source': '/app/archipelago-source/',
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
@@ -59,6 +65,21 @@ export const PROXY_APPS: Record<string, string> = {
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
/**
* First-party apps that are deliberately being node-tested before their
* manifest reaches the release-signed catalog. Keep this list narrow: it is
* only a scheme-routing fallback, and does not make an app installable or
* trusted. Once the signed catalog carries the app, portIsGateFronted is the
* normal source of truth.
*/
const PRE_CATALOG_GATED_PORTS: Record<string, number> = {
'archipelago-source': 8337,
}
export function appPortIsGateFronted(appId: string, port: number | string): boolean {
return portIsGateFronted(appId, port) || PRE_CATALOG_GATED_PORTS[appId] === Number(port)
}
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nostrudel': 'https://nostrudel.ninja',
@@ -81,6 +102,13 @@ export const NEW_TAB_APPS = new Set([
'tailscale',
])
/** Apps that consume an integration supplied by the dashboard parent frame.
* The Android companion normally promotes sessions into a top-level native
* WebView; doing that to one of these apps would sever its postMessage bridge. */
export const HOST_FRAME_APPS = new Set([
...GENERATED_HOST_FRAME_APPS,
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
@@ -103,6 +131,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// GitWorkshop is deliberately mounted below the dashboard origin. This is
// the only launch shape that survives every supported ingress (LAN,
// Tailscale, FIPS, Tor and reverse proxies) without assuming that a second
// high port is reachable through the same address.
if (id === 'archipelago-source') {
const base = PROXY_APPS['archipelago-source']!
if (!routeQueryPath) return base
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : `/${routeQueryPath}`)
}
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
@@ -120,7 +158,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// would fail to connect over https at all.
try {
const port = new URL(base).port
if (portIsGateFronted(id, port)) base = matchPageScheme(base)
if (appPortIsGateFronted(id, port)) base = matchPageScheme(base)
} catch { /* keep as-is */ }
if (routeQueryPath) base += routeQueryPath
return base
@@ -152,7 +190,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
*/
export function appOrigin(port: number, appId?: string): string {
const https = appId
? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:')
? HTTPS_APP_IDS.has(appId) || (appPortIsGateFronted(appId, port) && pageScheme() === 'https:')
: pageScheme() === 'https:'
return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}`
}
@@ -4,6 +4,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"adguardhome": 3030,
"aiui": 5180,
"alby-hub": 8187,
"archipelago-source": 8337,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -42,6 +43,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"adguardhome": "AdGuard Home",
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archipelago-source": "GitWorkshop",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -114,3 +116,6 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"uptime-kuma",
"vaultwarden",
])
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
])
@@ -0,0 +1,30 @@
const CONSENT_KEY = 'archipelago_nostr_consent_v2'
function readRemembered(): Set<string> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(CONSENT_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [])
} catch {
return new Set()
}
}
/** Remembered NIP-07 access is scoped to the exact app, identity and method. */
export function consentKey(
origin: string,
appId: string,
identityId: string,
method: string,
): string {
return JSON.stringify(['v2', origin, appId, identityId, method])
}
export function hasRememberedConsent(key: string): boolean {
return readRemembered().has(key)
}
export function rememberConsent(key: string): void {
const remembered = readRemembered()
remembered.add(key)
try { localStorage.setItem(CONSENT_KEY, JSON.stringify([...remembered])) } catch { /* unavailable/full */ }
}
@@ -16,7 +16,7 @@ export interface SelectedIdentity {
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
return id === 'indeedhub' || id === 'nostrudel' || id === 'archipelago-source'
}
export function useAppIdentity(
@@ -68,18 +68,24 @@ export function useAppIdentity(
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
function handleIdentityRequest(force = false) {
if (IS_DEMO) return
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
if (stored && !force) sendIdentity(stored)
else showIdentityPicker.value = true
}
function cancelIdentitySelection() {
showIdentityPicker.value = false
iframeRef.value?.contentWindow?.postMessage({ type: 'archipelago:identity-cancelled' }, '*')
}
return {
getStoredIdentity,
sendIdentity,
onIdentitySelected,
onIframeLoadIdentity,
handleIdentityRequest,
cancelIdentitySelection,
}
}
+158 -32
View File
@@ -1,31 +1,147 @@
/** Composable for NIP-07 Nostr signing between parent and iframe apps.
*
* Replies always target event.origin — the frame's REAL origin. The app's
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
* dashboard); targeting it makes postMessage throw and the app never sees
* its response. */
/** Consent-gated NIP-07 bridge between the dashboard and an iframe app. */
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from './nostrConsent'
interface BridgeOptions {
appId: () => string
appName: () => string
appUrl: () => string
frameWindow: () => Window | null
}
export interface BridgeConsentRequest {
appName: string
method: string
identityLabel: string
eventKind?: number
content?: string
resolve: (remember: boolean) => void
reject: () => void
}
const CONSENT_METHODS = new Set([
'getPublicKey', 'signEvent',
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
function senderMatches(expectedUrl: string, senderOrigin: string): boolean {
try {
const expected = new URL(expectedUrl, window.location.origin)
const sender = new URL(senderOrigin)
return expected.hostname === sender.hostname && expected.port === sender.port
} catch {
return false
}
}
export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null,
options: BridgeOptions,
) {
const consentRequest = ref<BridgeConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
function requestConsent(
method: string,
identityLabel: string,
eventKind?: number,
content?: string,
): Promise<boolean> {
return new Promise((resolve, reject) => {
consentGeneration += 1
consentRequest.value = {
appName: options.appName(), method, identityLabel, eventKind, content,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
function approveConsent(remember: boolean) {
consentRequest.value?.resolve(remember)
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
consentRequest.value?.reject()
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
async function finishConsentSuccess() {
const generation = approvedGeneration
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
if (generation !== consentGeneration || !showConsent.value) return
consentPhase.value = 'success'
await new Promise(resolve => setTimeout(resolve, 325))
if (generation !== consentGeneration) return
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
}
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
}
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
if (
!source ||
source !== options.frameWindow() ||
!senderMatches(options.appUrl(), event.origin)
) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
const identityScope = identityId || 'node-default'
const identityLabel = storedIdentity?.name || 'Node default identity'
const origin = event.origin
let prompted = false
try {
if (CONSENT_METHODS.has(method)) {
const key = consentKey(origin, options.appId(), identityScope, method)
if (!hasRememberedConsent(key)) {
prompted = true
const remember = await requestConsent(
method,
identityLabel,
method === 'signEvent' ? params?.event?.kind : undefined,
method === 'signEvent' ? params?.event?.content : undefined,
)
if (remember) rememberConsent(key)
}
}
let result: unknown
if (method === 'getPublicKey') {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
if (import.meta.env.DEV) console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
@@ -34,30 +150,40 @@ export function useNostrBridge(
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (import.meta.env.DEV) console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
if (import.meta.env.DEV) console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
// Reply to the sender's REAL origin, never to the stored app URL:
// a scheme-upgraded frame (HSTS, or any future upgrade) makes the
// stored http:// URL a stale targetOrigin — postMessage then throws
// and the app never receives its response. nostr sign-in on IndeeHub
// over HTTPS died exactly there (2026-09-01).
source.postMessage({ type: 'nostr-response', id, result }, event.origin || '*')
result = identityId
? await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
: await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip04.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else if (method === 'nip44.encrypt') {
result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext
} else if (method === 'nip44.decrypt') {
result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext
} else {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, origin)
if (prompted) void finishConsentSuccess()
} catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, event.origin || '*')
source.postMessage({
type: 'nostr-response', id,
error: err instanceof Error ? err.message : 'Unknown error',
}, origin)
if (prompted && showConsent.value) finishConsentError(err)
}
}
return { handleNostrRequest }
return {
handleNostrRequest,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
}
+4 -4
View File
@@ -222,7 +222,7 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
if (shown) return
launchNow(id, pkg)
launchNow(id, pkg, true)
} else {
emit('goToApp', id)
}
@@ -248,7 +248,7 @@ function openAppOptions(id: string) {
emit('goToApp', id)
}
function launchNow(id: string, pkg: PackageDataEntry) {
function launchNow(id: string, pkg: PackageDataEntry, credentialsChecked = false) {
markLaunching(id)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@@ -270,7 +270,7 @@ function launchNow(id: string, pkg: PackageDataEntry) {
return
}
}
appLauncher.openSession(id)
appLauncher.openSession(id, { skipCredentialPrompt: credentialsChecked })
}
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
@@ -308,7 +308,7 @@ function continueCredentialLaunch() {
const id = credentialModal.value.appId
const entry = props.apps.find(([appId]) => appId === id)
closeCredentialModal()
if (entry) launchNow(entry[0], entry[1])
if (entry) launchNow(entry[0], entry[1], true)
}
async function copyModalCredential(label: string, value: string) {
+31 -2
View File
@@ -12,10 +12,31 @@ export interface CatalogFeatured {
tag: string
}
/** Registry-owned App Store ordering and promotions. Keeping this alongside
* the app entries lets a catalog release change merchandising without an OS
* or dashboard release. */
export interface CatalogPromotion {
id: string
banner: string
eyebrow: string
headline: string
description: string
tag: string
launchLabel?: string
installLabel?: string
detailsLabel?: string
}
export interface CatalogStorefront {
popular: string[]
promotions: CatalogPromotion[]
}
export interface AppCatalog {
version: number
registry: string
featured: CatalogFeatured
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: MarketplaceApp[]
}
@@ -28,6 +49,8 @@ export interface AppCatalog {
export interface SignedAppCatalog {
schema?: number
updated?: string
featured?: CatalogFeatured
storefront?: CatalogStorefront
apps: Record<string, SignedAppEntry>
}
@@ -171,6 +194,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// dashboard release. The community catalog supplies the featured banner
// and curated copy for shared ids; signed-only ids join the listing as-is.
let signedApps: MarketplaceApp[] = []
let signedFeatured: CatalogFeatured | undefined
let signedStorefront: CatalogStorefront | undefined
let signedOk = false
try {
const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) })
@@ -179,6 +204,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
if (data.apps && !Array.isArray(data.apps)) {
signedCatalogCache = data
signedApps = signedCatalogToApps(data)
signedFeatured = data.featured
signedStorefront = data.storefront
signedOk = signedApps.length > 0
}
}
@@ -214,7 +241,8 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' },
featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()],
}
cachedCatalog = merged
@@ -269,6 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
{ id: 'archipelago-source', title: 'GitWorkshop', version: '0.4.0', category: 'development', description: "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.", icon: '/assets/img/app-icons/gitworkshop-dc36db6.svg', author: 'GitWorkshop contributors', maintainerNpub: 'npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg', dockerImage: 'localhost/archipelago-source:local', repoUrl: 'https://github.com/DanConwayDev/gitworkshop' },
]
}
+1
View File
@@ -18,6 +18,7 @@ export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
containerConfig?: ContainerConfig
requires?: string[]
tier?: string
maintainerNpub?: string
}
export type FeaturedApp = MarketplaceApp & {
@@ -438,5 +438,16 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://gitea.com',
},
{
id: 'archipelago-source',
title: 'GitWorkshop',
version: '0.4.0',
category: 'development',
description: "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
icon: '/assets/img/app-icons/gitworkshop-dc36db6.svg',
author: 'GitWorkshop contributors',
dockerImage: 'localhost/archipelago-source:local',
repoUrl: 'https://github.com/DanConwayDev/gitworkshop',
},
]
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
@@ -73,6 +74,15 @@ async function probe() {
}
}
function downloadCertificate(event: MouseEvent) {
// Android WebView does not implement HTML downloads by itself. Ask the
// companion to fetch this connected node's CA and open the system credential
// installer; normal browsers keep the Content-Disposition download.
if (installCertificateInCompanion()) {
event.preventDefault()
}
}
onMounted(probe)
</script>
@@ -109,6 +119,7 @@ onMounted(probe)
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
@click="downloadCertificate"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -132,29 +143,85 @@ onMounted(probe)
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> — open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set “When using this certificate” to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> — download it in Safari and
allow the profile, then Settings → General → VPN &amp; Device Management to
install it, and finally Settings → General → About → Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> — right-click → Install
Certificate → Local Machine → place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> — Settings → Security →
Encryption &amp; credentials → Install a certificate → CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> — copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store — add it under Settings → Privacy &amp; Security →
View Certificates → Authorities.</p>
<p class="text-white/50">
<div class="mt-2 space-y-5 text-sm text-white/60">
<p class="text-white/70">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key — remove the certificate from your devices if you retire it.
</p>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">macOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Double-click the file to add it to your <em>login keychain</em>.</li>
<li>Open Keychain Access and find it under Certificates.</li>
<li>Open it, expand Trust, set “When using this certificate” to <em>Always Trust</em>, then close the window and enter your password.</li>
</ol>
<p>Quit and reopen your browser after changing the trust setting.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">iOS / iPadOS</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Open the file in Safari and tap Allow to download the profile.</li>
<li>Settings → Profile Downloaded, or General → VPN &amp; Device Management → Install.</li>
<li>Settings → General → About → Certificate Trust Settings → switch the certificate on.</li>
</ol>
<p class="text-orange-200/80">The final Certificate Trust Settings step is required.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Windows</h4>
<ol class="list-decimal pl-5 space-y-1">
<li>Right-click the file and choose Install Certificate.</li>
<li>Select Local Machine.</li>
<li>Choose “Place all certificates in the following store” → Trusted Root Certification Authorities → Finish.</li>
</ol>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Android</h4>
<p>Settings → Security → Encryption &amp; credentials → Install a certificate → CA certificate, then choose the file.</p>
<p>Browsers using the system certificate store will trust it after restart. Apps that pin their own certificates may still refuse it.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Linux</h4>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo install -m644 /path/to/node-ca.crt /usr/local/share/ca-certificates/node-ca.crt &amp;&amp; sudo update-ca-certificates</code></pre>
<p><strong class="text-white/70">Firefox:</strong> Settings → Privacy &amp; Security → View Certificates → Authorities → Import, then enable “Trust this CA to identify websites”.</p>
<p><strong class="text-white/70">Arch / Manjaro:</strong></p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>sudo cp node-ca.crt /etc/ca-certificates/trust-source/anchors/ &amp;&amp; sudo update-ca-trust extract</code></pre>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Restart the browser first</h4>
<p>Chrome, Brave, Firefox, and Safari cache certificate decisions. Fully quit and reopen the browser before troubleshooting a certificate that still appears untrusted.</p>
<p>For a one-visit sanity check on a machine you own, Chrome and Brave accept the keyboard shortcut <code class="px-1 py-0.5 bg-black/30 rounded text-xs">thisisunsafe</code> on the certificate error page. Use this only for testing.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">If the node name does not resolve</h4>
<p>Certificate trust and DNS are separate. If <code class="px-1 py-0.5 bg-black/30 rounded text-xs">node.local</code> does not resolve, prefer the node's Tailscale MagicDNS name when available.</p>
<p>To keep using a local name on Linux or macOS, add the node address to <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>:</p>
<pre class="overflow-x-auto rounded-lg bg-black/30 p-3 text-xs text-white/75"><code>echo '192.168.x.y mynode.local' | sudo tee -a /etc/hosts</code></pre>
<p>On Linux, if that still fails, inspect <code class="px-1 py-0.5 bg-black/30 rounded text-xs">grep '^hosts:' /etc/nsswitch.conf</code>. Put <code class="px-1 py-0.5 bg-black/30 rounded text-xs">files</code> before <code class="px-1 py-0.5 bg-black/30 rounded text-xs">mdns_minimal [NOTFOUND=return]</code> so an mDNS miss cannot block <code class="px-1 py-0.5 bg-black/30 rounded text-xs">/etc/hosts</code>.</p>
</section>
<section class="space-y-2">
<h4 class="font-semibold text-white/80">Symptoms</h4>
<div class="overflow-x-auto rounded-lg border border-white/10">
<table class="w-full text-left text-xs">
<thead class="bg-white/5 text-white/75">
<tr><th class="p-2">What you see</th><th class="p-2">Likely cause</th></tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr><td class="p-2">Not trusted / ERR_CERT_AUTHORITY_INVALID</td><td class="p-2">The certificate is not installed, or the browser was not restarted.</td></tr>
<tr><td class="p-2">This site can't be reached / DNS error</td><td class="p-2">Name resolution, not TLS. Check the DNS guidance above.</td></tr>
<tr><td class="p-2">curl works, browser does not</td><td class="p-2">A separate browser certificate store or a stale browser process.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</details>
</div>
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NodeCertificateSection from '../NodeCertificateSection.vue'
import { installCertificateInCompanion } from '@/utils/openExternal'
vi.mock('@/utils/openExternal', () => ({
installCertificateInCompanion: vi.fn(),
}))
const installCertificate = vi.mocked(installCertificateInCompanion)
describe('NodeCertificateSection', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => '-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----',
}))
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('uses the native installer and cancels WebView navigation in the companion', async () => {
installCertificate.mockReturnValue(true)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
expect(installCertificate).toHaveBeenCalledOnce()
expect(click.defaultPrevented).toBe(true)
})
it('preserves the ordinary browser download when no native installer exists', async () => {
installCertificate.mockReturnValue(false)
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('a[download]').exists()).toBe(true))
wrapper.get('a[download]').element.setAttribute('href', '#certificate-test')
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
wrapper.get('a[download]').element.dispatchEvent(click)
const componentPreservedDownload = !click.defaultPrevented
expect(installCertificate).toHaveBeenCalledOnce()
expect(componentPreservedDownload).toBe(true)
})
it('includes the complete trust, browser restart, DNS, and troubleshooting guidance', async () => {
const wrapper = mount(NodeCertificateSection)
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('details').exists()).toBe(true))
const text = wrapper.text()
expect(text).toContain('Certificate Trust Settings')
expect(text).toContain('Trusted Root Certification Authorities')
expect(text).toContain('update-ca-trust extract')
expect(text).toContain('Restart the browser first')
expect(text).toContain('thisisunsafe')
expect(text).toContain('Tailscale MagicDNS')
expect(text).toContain("This site can't be reached / DNS error")
expect(text).toContain('curl works, browser does not')
})
})
+58 -30
View File
@@ -392,6 +392,17 @@
<Teleport to="body">
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<IdentitySuccessPane
v-if="profileSuccess"
:identity-name="profileSuccess.identityName"
:event-id="profileSuccess.eventId"
:accepted="profileSuccess.accepted"
:attempted="profileSuccess.attempted"
:relay-note="profileSuccess.relayNote"
@again="profileSuccess = null"
@done="closeProfileEditor"
/>
<template v-else>
<div class="flex items-center gap-3 mb-5">
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
<img
@@ -455,11 +466,11 @@
</div>
</div>
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<div class="flex gap-3 mt-5">
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -471,6 +482,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
import type { ManagedIdentity, IdentityProfile } from './types'
import IdentitySuccessPane from '@/components/IdentitySuccessPane.vue'
const { t } = useI18n()
@@ -618,7 +630,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
}
}
const profileError = ref('')
const profileSuccess = ref('')
interface ProfilePublishSuccess {
identityName: string
eventId: string
accepted: number
attempted: number
relayNote: string
}
const profileSuccess = ref<ProfilePublishSuccess | null>(null)
async function loadIdentities() {
const hadIdentities = managedIdentities.value.length > 0
@@ -739,58 +758,67 @@ function openProfileEditor(identity: ManagedIdentity) {
profileEditorIdentity.value = identity
profileForm.value = { ...identity.profile }
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
function closeProfileEditor() {
profileEditorIdentity.value = null
profileForm.value = {}
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
}
async function publishProfile() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true
profileError.value = ''
profileSuccess.value = ''
profileSuccess.value = null
try {
const identity = profileEditorIdentity.value
await rpcClient.call({
method: 'identity.update-profile',
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
})
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({
method: 'identity.publish-profile',
params: { id: profileEditorIdentity.value.id },
params: { id: identity.id, ...profileForm.value },
})
await loadIdentities()
const n = res.accepted?.length ?? 0
const total = res.relays_attempted ?? 0
const tail = `(${res.event_id.slice(0, 12)}…)`
if (n === total) {
profileSuccess.value = `Published to all ${total} relays ${tail}`
} else if (n > 0) {
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
const first = res.rejected?.[0]
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
} else {
profileError.value = `Published to 0/${total} relays — check Manage Relays`
try {
const res = await rpcClient.call<{
event_id: string
accepted: string[]
rejected: Array<[string, string]>
relays_attempted: number
published: boolean
}>({ method: 'identity.publish-profile', params: { id: identity.id } })
const accepted = res.accepted?.length ?? 0
const attempted = res.relays_attempted ?? 0
const rejected = res.rejected?.[0]
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: res.event_id || '',
accepted,
attempted,
relayNote: accepted === attempted
? ''
: rejected
? `${rejected[0]} rejected the event: ${rejected[1]}`
: 'The profile is saved on this node. Check Manage Relays before retrying publication.',
}
} catch (publishError: unknown) {
profileSuccess.value = {
identityName: profileForm.value.display_name?.trim() || identity.name,
eventId: '',
accepted: 0,
attempted: 0,
relayNote: `The profile is saved on this node, but relay publication failed: ${publishError instanceof Error ? publishError.message : 'unknown error'}`,
}
}
setTimeout(() => { profileSuccess.value = '' }, 5000)
} catch (err: unknown) {
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
profileError.value = err instanceof Error ? err.message : 'Failed to save profile'
} finally {
profilePublishing.value = false
}
}
defineExpose({ loadIdentities, managedIdentities })
defineExpose({ loadIdentities, managedIdentities, openProfileEditor, publishProfile })
</script>
<style scoped>
@@ -5,6 +5,19 @@
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
<PaymentSuccessPane
v-if="sendSuccess"
:amount="sendSuccess.amount"
verb="SENT"
:method-label="sendSuccess.methodLabel"
:rows="sendSuccess.rows"
:note="sendSuccess.note"
again-label="Send another"
@again="sendAnother"
@done="closeUnifiedSendModal"
/>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -32,12 +45,6 @@
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
</div>
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
@@ -93,8 +100,6 @@
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
<div class="flex gap-3">
@@ -106,6 +111,7 @@
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
@@ -177,7 +183,6 @@
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
@@ -233,7 +238,12 @@ const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
const ecashSendToken = ref('')
const sendSuccess = ref<{
amount: number
methodLabel: string
rows: SuccessRow[]
note?: string
} | null>(null)
// Receive state
const showUnifiedReceiveModal = ref(false)
@@ -246,7 +256,6 @@ const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const unifiedReceiveProcessing = ref(false)
const unifiedReceiveError = ref('')
const ecashReceiveToken = ref('')
const ecashReceiveResult = ref('')
// Details of the last successful ecash receive, for the success screen.
// Null = nothing to celebrate yet, so the form shows.
const ecashSuccess = ref<{
@@ -265,12 +274,15 @@ const effectiveSendMethod = computed(() => {
return 'lightning'
})
function openSend() { showUnifiedSendModal.value = true }
function openSend() {
sendSuccess.value = null
showUnifiedSendModal.value = true
}
function openReceive() { showUnifiedReceiveModal.value = true }
function closeUnifiedSendModal() {
showUnifiedSendModal.value = false
ecashSendToken.value = ''
sendSuccess.value = null
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
@@ -284,16 +296,10 @@ function closeUnifiedReceiveModal() {
receiveInvoiceResult.value = ''
receiveOnchainAddress.value = ''
ecashReceiveToken.value = ''
ecashReceiveResult.value = ''
ecashSuccess.value = null
unifiedReceiveError.value = ''
}
function copyEcashToken(token: string) {
safeClipboardWrite(token)
emit('toast', t('web5.ecashTokenCopied'))
}
function copyToClipboard(text: string, msg: string) {
safeClipboardWrite(text)
emit('toast', msg)
@@ -303,7 +309,7 @@ async function unifiedSend() {
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true
unifiedSendError.value = ''
ecashSendToken.value = ''
sendSuccess.value = null
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
@@ -316,7 +322,16 @@ async function unifiedSend() {
method: 'wallet.ecash-send',
params: { amount_sats: unifiedSendAmount.value },
})
ecashSendToken.value = res.token
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent as Cashu',
rows: [{
label: 'Token to share',
value: res.token,
hint: 'The recipient needs this token to claim the sats. Keep it until they confirm receipt.',
truncate: true,
}],
}
} else if (method === 'lightning') {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.pasteInvoice')
@@ -327,6 +342,14 @@ async function unifiedSend() {
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
sendSuccess.value = {
amount: res.amount_sats || unifiedSendAmount.value,
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
rows: res.payment_hash ? [{ label: 'Payment hash', value: res.payment_hash }] : [],
...(res.status === 'pending'
? { note: 'This payment is taking longer than usual to settle. Check transactions before retrying.' }
: {}),
}
} else {
if (!unifiedSendDest.value.trim()) {
unifiedSendError.value = t('web5.enterBitcoinAddress')
@@ -354,6 +377,12 @@ async function unifiedSend() {
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain',
rows: [{ label: 'Transaction ID', value: res.txid }],
note: 'The transaction has been broadcast and will confirm over the next blocks.',
}
} catch (sendErr: unknown) {
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
@@ -416,6 +445,12 @@ function startMeshRelayPolling(_requestId: number) {
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain over mesh',
rows: [{ label: 'Transaction ID', value: match[1] }],
note: 'Broadcast confirmed. The transaction is waiting for block confirmations.',
}
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
}
}
@@ -454,6 +489,12 @@ async function finalizePsbt() {
psbtData.value = ''
signedPsbtInput.value = ''
sendResultTxid.value = t('web5.broadcastViaHwWallet')
sendSuccess.value = {
amount: unifiedSendAmount.value,
methodLabel: 'Sent on-chain with hardware wallet',
rows: [],
note: t('web5.broadcastViaHwWallet'),
}
emit('balancesChanged')
} catch (err: unknown) {
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
@@ -462,6 +503,19 @@ async function finalizePsbt() {
}
}
function sendAnother() {
sendSuccess.value = null
unifiedSendAmount.value = 0
unifiedSendDest.value = ''
unifiedSendError.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
psbtData.value = ''
psbtStep.value = 'idle'
signedPsbtInput.value = ''
useHardwareWallet.value = false
}
function copyPsbt() {
if (!psbtData.value) return
safeClipboardWrite(psbtData.value)
@@ -526,7 +580,6 @@ async function unifiedReceive() {
params: { token: ecashReceiveToken.value.trim() },
})
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
// Ecash leaves no public ledger entry behind, so the issuer and the
// redeemed token are the only things a person can quote later if the
// payment is ever questioned. Capture them before clearing the box.
@@ -73,4 +73,36 @@ describe('Web5Identities', () => {
expect(wrapper.text()).toContain('Personal')
expect(wrapper.text()).not.toContain('Refreshing identities...')
})
it('turns a saved profile into the identity success screen with honest relay coverage', async () => {
const identity = makeIdentity('Personal')
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.update-profile') return Promise.resolve({})
if (request.method === 'identity.list') return Promise.resolve({ identities: [identity] })
if (request.method === 'identity.publish-profile') {
return Promise.resolve({
event_id: 'event-123', accepted: ['wss://one'],
rejected: [['wss://two', 'write denied']], relays_attempted: 2, published: true,
})
}
return Promise.resolve({})
})
const wrapper = mount(Web5Identities, {
props: { showStagger: false },
global: { stubs: { Teleport: true } },
})
const vm = wrapper.vm as unknown as {
openProfileEditor: (identity: ManagedIdentity) => void
publishProfile: () => Promise<void>
}
vm.openProfileEditor(identity)
await wrapper.vm.$nextTick()
await vm.publishProfile()
await flushPromises()
expect(wrapper.text()).toContain('IDENTITY UPDATED')
expect(wrapper.text()).toContain('Published to 1/2 configured relays.')
expect(wrapper.text()).toContain('wss://two rejected the event: write denied')
expect(wrapper.text()).toContain('event-123')
})
})
+12 -1
View File
@@ -39,7 +39,17 @@ export default defineConfig({
workbox: {
// /packages/ must bypass the SPA fallback — otherwise clicking the
// companion APK download link gets index.html instead of the file.
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//, /^\/packages\//],
// The signer broker is a security-sensitive cross-window protocol,
// not an offline screen. Serving an old precached SPA shell here can
// preserve a broken session probe after the node has been updated.
navigateFallbackDenylist: [
/^\/app\//,
/^\/rpc\//,
/^\/ws/,
/^\/aiui\//,
/^\/packages\//,
/^\/nostr-signer(?:\/|$)/,
],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [
@@ -48,6 +58,7 @@ export default defineConfig({
'**/bg-*.mp4', // Exclude large background videos from precache
'**/video-intro*.mp4', // Exclude all intro video variants from precache
'**/assets/icon/**', // Icons are in includeAssets — don't duplicate in glob precache
'**/nostr-provider.js', // Always use the node's current signer protocol
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB limit
skipWaiting: false, // Wait for user to accept update