goblin: complete the flow BEFORE return-to-caller + terse trust prompt
Root cause of the broken build 153 QR trust login, confirmed in code: cb.return_to_caller() fired in the sign handler right after spawning the callback POST worker. The app backgrounded immediately, the GUI frame pump stopped, and the rest of the grant lives on that pump: the outcome poll that consumes the POST result, the session creation, and the session-open channel publish (which the service loop only sends on its next tick, after add_session marks the set dirty). Backgrounded, none of that ran, so the site never received the session and the browser poll never logged in. The ordering fix, uniform across login, authorize, and trust: - the sign handlers no longer return-to-caller; the app stays foreground (frames pumping) until the flow is fully complete - login/authorize: the return decision happens in the outcome poll, only on a successful POST; a failed POST keeps the user in the wallet with the honest toast - trust: the decision waits further, for the session-open announce to be CONFIRMED handed to a relay. announce_new_sessions now records per-channel delivery (at least one relay accepted) in NostrService::session_announced; the new TrustWait GUI state polls it with a 15s deadline and an honest "announce unconfirmed" toast on timeout (goblin.trust.announce_failed x6) - a freshness bound on all three: a result consumed on a late resume (the user backgrounded the wallet themselves mid-flow) shows the toast but never bounces them back out - the decision itself is the pure authuri::should_return_to_caller(allowed, post_ok, publish_confirmed), unit-tested: pending, failed, or unconfirmed work never returns; rt=0 never returns Trust prompt copy, owner directive: the gist in one or two lines, detail tucked away. The modal now leads with a single plain line (act-for-you plus the money rule) and folds categories, money line, and duration behind the existing show-full disclosure idiom; the red caution lines stay visible. goblin.trust.* keys tightened in all six locales, drift test green.
This commit is contained in:
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "Vertrauen"
|
||||
headline: "%{domain} vertrauen?"
|
||||
identity: "Anmeldung als"
|
||||
grant_intro: "%{domain} kann diese für dich signieren, ohne erneut zu fragen, bis du dich abmeldest oder die Sitzung beendest:"
|
||||
lead: "%{domain} kann in dieser Sitzung für dich handeln, ohne jedes Mal zu fragen. Alles, was Geld ausgibt, fragt immer nach."
|
||||
grant_intro: "Darf ohne Nachfrage signieren:"
|
||||
cat_social: "Beiträge und Reaktionen"
|
||||
cat_dm: "Direktnachrichten"
|
||||
cat_market: "Angebote"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "Uploads und HTTP-Authentifizierung"
|
||||
cat_unknown: "Anderer Ereignistyp (Art %{n})"
|
||||
login_excluded: "Anmeldung wird einer Seite nie gewährt."
|
||||
money_line: "Alles, was Geld ausgibt oder empfängt, fragt weiterhin jedes Mal nach deinem Passwort. Kauf-, Verkaufs- und Zahlungsbestätigungen werden nie stillschweigend signiert."
|
||||
duration: "Dies gilt nur für diese Sitzung. Du kannst sie jederzeit unter Einstellungen, Vertrauenswürdige Seiten beenden. Dein Schlüssel verlässt nie deine Wallet."
|
||||
money_line: "Geld fragt immer nach deinem Passwort. Nichts wird still ausgegeben oder verkauft."
|
||||
duration: "Gilt nur für diese Sitzung. Jederzeit beendbar unter Einstellungen, Vertrauenswürdige Seiten."
|
||||
pass_prompt: "Gib dein Wallet-Passwort ein, um dieser Seite zu vertrauen."
|
||||
confirm_hold: "Halten, um für diese Sitzung zu vertrauen"
|
||||
sent: "%{domain} wird für diese Sitzung vertraut"
|
||||
failed: "%{domain} konnte nicht erreicht werden. Die Sitzung wurde nicht gestartet."
|
||||
announce_failed: "Vertraut, aber %{domain} hat die Sitzung noch nicht bestätigt."
|
||||
notice_volume: "Eine vertrauenswürdige Seite signiert sehr viel."
|
||||
notice_decrypt: "Eine vertrauenswürdige Seite liest deine Nachrichten."
|
||||
money:
|
||||
|
||||
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "Trust"
|
||||
headline: "Trust %{domain}?"
|
||||
identity: "Signing in as"
|
||||
grant_intro: "%{domain} will be able to sign these for you, without asking again, until you sign out or end the session:"
|
||||
lead: "%{domain} can act for you this session without asking each time. Anything that spends money always asks."
|
||||
grant_intro: "Can sign for you without asking:"
|
||||
cat_social: "Posts and reactions"
|
||||
cat_dm: "Direct messages"
|
||||
cat_market: "Listings"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "Uploads and HTTP auth"
|
||||
cat_unknown: "Other event type (kind %{n})"
|
||||
login_excluded: "Login is never granted to a site."
|
||||
money_line: "Anything that spends or receives money will still ask for your password, every time. Buying, selling, and payment confirmations are never signed silently."
|
||||
duration: "This lasts for this session only. You can end it any time from Settings, Trusted Sites. Your key never leaves your wallet."
|
||||
money_line: "Money always asks for your password. Nothing is spent or sold silently."
|
||||
duration: "Lasts this session only. End it any time in Settings, Trusted Sites."
|
||||
pass_prompt: "Enter your wallet password to trust this site."
|
||||
confirm_hold: "Hold to trust for this session"
|
||||
sent: "Trusting %{domain} for this session"
|
||||
failed: "Could not reach %{domain}. The session was not started."
|
||||
announce_failed: "Trusted, but %{domain} has not confirmed the session yet."
|
||||
notice_volume: "A trusted site is signing a lot."
|
||||
notice_decrypt: "A trusted site is reading your messages."
|
||||
money:
|
||||
|
||||
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "Confiance"
|
||||
headline: "Faire confiance à %{domain} ?"
|
||||
identity: "Connexion en tant que"
|
||||
grant_intro: "%{domain} pourra les signer pour vous, sans redemander, jusqu'à ce que vous vous déconnectiez ou terminiez la session :"
|
||||
lead: "%{domain} peut agir pour vous pendant cette session sans redemander à chaque fois. Tout ce qui dépense de l'argent demande toujours."
|
||||
grant_intro: "Peut signer sans demander :"
|
||||
cat_social: "Publications et réactions"
|
||||
cat_dm: "Messages privés"
|
||||
cat_market: "Annonces"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "Envois et authentification HTTP"
|
||||
cat_unknown: "Autre type d'événement (type %{n})"
|
||||
login_excluded: "La connexion n'est jamais accordée à un site."
|
||||
money_line: "Tout ce qui dépense ou reçoit de l'argent demandera toujours votre mot de passe, à chaque fois. Les achats, ventes et confirmations de paiement ne sont jamais signés silencieusement."
|
||||
duration: "Cela ne dure que le temps de cette session. Vous pouvez y mettre fin à tout moment depuis Réglages, Sites de confiance. Votre clé ne quitte jamais votre portefeuille."
|
||||
money_line: "L'argent demande toujours votre mot de passe. Rien n'est dépensé ni vendu en silence."
|
||||
duration: "Valable pour cette session seulement. Terminez-la à tout moment dans Réglages, Sites de confiance."
|
||||
pass_prompt: "Saisissez le mot de passe de votre portefeuille pour faire confiance à ce site."
|
||||
confirm_hold: "Maintenez pour faire confiance pour cette session"
|
||||
sent: "Confiance accordée à %{domain} pour cette session"
|
||||
failed: "Impossible de joindre %{domain}. La session n'a pas démarré."
|
||||
announce_failed: "Confiance accordée, mais %{domain} n'a pas encore confirmé la session."
|
||||
notice_volume: "Un site de confiance signe beaucoup."
|
||||
notice_decrypt: "Un site de confiance lit vos messages."
|
||||
money:
|
||||
|
||||
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "Доверие"
|
||||
headline: "Доверять %{domain}?"
|
||||
identity: "Вход как"
|
||||
grant_intro: "%{domain} сможет подписывать это за вас без повторных запросов, пока вы не выйдете или не завершите сессию:"
|
||||
lead: "%{domain} может действовать за вас в этой сессии, не спрашивая каждый раз. Всё, что тратит деньги, всегда спрашивает."
|
||||
grant_intro: "Может подписывать без запроса:"
|
||||
cat_social: "Публикации и реакции"
|
||||
cat_dm: "Личные сообщения"
|
||||
cat_market: "Объявления"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "Загрузки и HTTP-аутентификация"
|
||||
cat_unknown: "Другой тип события (вид %{n})"
|
||||
login_excluded: "Вход в систему сайту никогда не предоставляется."
|
||||
money_line: "Всё, что тратит или получает деньги, каждый раз будет запрашивать ваш пароль. Покупки, продажи и подтверждения платежей никогда не подписываются молча."
|
||||
duration: "Это действует только для текущей сессии. Вы можете завершить её в любой момент в Настройках, Доверенные сайты. Ваш ключ никогда не покидает кошелёк."
|
||||
money_line: "Деньги всегда требуют ваш пароль. Ничего не тратится и не продаётся молча."
|
||||
duration: "Действует только в этой сессии. Завершить можно в любой момент в Настройках, Доверенные сайты."
|
||||
pass_prompt: "Введите пароль кошелька, чтобы доверять этому сайту."
|
||||
confirm_hold: "Удерживайте, чтобы доверять на эту сессию"
|
||||
sent: "Доверие %{domain} на эту сессию"
|
||||
failed: "Не удалось связаться с %{domain}. Сессия не начата."
|
||||
announce_failed: "Доверие оформлено, но %{domain} ещё не подтвердил сессию."
|
||||
notice_volume: "Доверенный сайт подписывает очень много."
|
||||
notice_decrypt: "Доверенный сайт читает ваши сообщения."
|
||||
money:
|
||||
|
||||
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "Güven"
|
||||
headline: "%{domain} sitesine güvenilsin mi?"
|
||||
identity: "Giriş yapan"
|
||||
grant_intro: "%{domain}, siz çıkış yapana veya oturumu sonlandırana kadar bunları tekrar sormadan sizin için imzalayabilecek:"
|
||||
lead: "%{domain} bu oturumda her seferinde sormadan sizin adınıza işlem yapabilir. Para harcayan her şey her zaman sorar."
|
||||
grant_intro: "Sormadan imzalayabilir:"
|
||||
cat_social: "Gönderiler ve tepkiler"
|
||||
cat_dm: "Doğrudan mesajlar"
|
||||
cat_market: "İlanlar"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "Yüklemeler ve HTTP kimlik doğrulama"
|
||||
cat_unknown: "Diğer olay türü (tür %{n})"
|
||||
login_excluded: "Bir siteye asla giriş izni verilmez."
|
||||
money_line: "Para harcayan veya alan her şey, her seferinde parolanızı isteyecek. Alım, satım ve ödeme onayları asla sessizce imzalanmaz."
|
||||
duration: "Bu yalnızca bu oturum için geçerlidir. İstediğiniz zaman Ayarlar, Güvenilen Siteler'den sonlandırabilirsiniz. Anahtarınız cüzdanınızdan asla çıkmaz."
|
||||
money_line: "Para her zaman parolanızı ister. Hiçbir şey sessizce harcanmaz veya satılmaz."
|
||||
duration: "Yalnızca bu oturum için geçerlidir. İstediğiniz zaman Ayarlar, Güvenilen Siteler'den sonlandırabilirsiniz."
|
||||
pass_prompt: "Bu siteye güvenmek için cüzdan parolanızı girin."
|
||||
confirm_hold: "Bu oturum için güvenmek üzere basılı tutun"
|
||||
sent: "%{domain} bu oturum için güveniliyor"
|
||||
failed: "%{domain} sitesine ulaşılamadı. Oturum başlatılmadı."
|
||||
announce_failed: "Güvenildi, ancak %{domain} oturumu henüz onaylamadı."
|
||||
notice_volume: "Güvenilen bir site çok fazla imzalıyor."
|
||||
notice_decrypt: "Güvenilen bir site mesajlarınızı okuyor."
|
||||
money:
|
||||
|
||||
+5
-3
@@ -721,7 +721,8 @@ goblin:
|
||||
title: "信任"
|
||||
headline: "信任 %{domain}?"
|
||||
identity: "登录身份"
|
||||
grant_intro: "在你退出登录或结束会话之前,%{domain} 可以为你签署以下内容,无需再次询问:"
|
||||
lead: "本次会话中 %{domain} 可代表你操作,无需每次询问。任何花钱的操作始终会询问。"
|
||||
grant_intro: "可无需询问签署:"
|
||||
cat_social: "帖子和互动"
|
||||
cat_dm: "私信"
|
||||
cat_market: "商品列表"
|
||||
@@ -730,12 +731,13 @@ goblin:
|
||||
cat_http: "上传和 HTTP 认证"
|
||||
cat_unknown: "其他事件类型(kind %{n})"
|
||||
login_excluded: "登录权限绝不会授予网站。"
|
||||
money_line: "任何花费或收取资金的操作每次都会要求输入密码。买入、卖出和付款确认绝不会被静默签署。"
|
||||
duration: "此权限仅在本次会话有效。你可随时在设置,受信任网站中结束。你的密钥绝不离开钱包。"
|
||||
money_line: "涉及资金时始终需要你的密码。绝不会静默花费或出售。"
|
||||
duration: "仅本次会话有效。可随时在设置,受信任网站中结束。"
|
||||
pass_prompt: "输入钱包密码以信任此网站。"
|
||||
confirm_hold: "长按以在本次会话中信任"
|
||||
sent: "已在本次会话中信任 %{domain}"
|
||||
failed: "无法连接 %{domain}。会话未启动。"
|
||||
announce_failed: "已信任,但 %{domain} 尚未确认会话。"
|
||||
notice_volume: "某个受信任网站签署量过大。"
|
||||
notice_decrypt: "某个受信任网站正在读取您的消息。"
|
||||
money:
|
||||
|
||||
+191
-57
@@ -153,6 +153,10 @@ pub struct GoblinWalletView {
|
||||
/// this OR `login`/`authorize`/`money` is `Some`, new incoming requests are
|
||||
/// ignored (one approval at a time).
|
||||
trust: Option<TrustState>,
|
||||
/// A granted trust whose `session-open` announce is still unconfirmed: the
|
||||
/// toast and any return-to-caller wait here until the service loop confirms
|
||||
/// the publish reached a relay (or the deadline passes).
|
||||
trust_wait: Option<TrustWait>,
|
||||
/// The hold-to-confirm gesture for the single high-value trust decision.
|
||||
trust_hold: w::HoldToSend,
|
||||
/// A money-tier sign or encrypt arriving mid-session that must be
|
||||
@@ -299,6 +303,7 @@ impl Default for GoblinWalletView {
|
||||
authorize: None,
|
||||
login_toast: None,
|
||||
trust: None,
|
||||
trust_wait: None,
|
||||
trust_hold: w::HoldToSend::default(),
|
||||
money: None,
|
||||
money_hold: w::HoldToSend::default(),
|
||||
@@ -399,6 +404,29 @@ const LOGIN_EXPIRY_SECS: u64 = 120;
|
||||
/// The login callback POST gives up after this many seconds.
|
||||
const LOGIN_POST_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// How long the trust flow waits for the `session-open` announce to be
|
||||
/// confirmed handed to a relay before giving up with an honest toast. The
|
||||
/// service loop ticks every 2s and the publish itself is bounded by its own
|
||||
/// send timeout, so this comfortably covers the normal path.
|
||||
const TRUST_ANNOUNCE_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// A granted trust waiting on its `session-open` announce confirmation. The
|
||||
/// success toast and the return-to-caller decision (same-device flows) are
|
||||
/// deferred until [`crate::nostr::NostrService::session_announced`] turns true
|
||||
/// for `channel_pk`, or `deadline` passes. Keeping the app foreground for this
|
||||
/// window is what fixes the Build 153 QR-trust bug: backgrounding stops the
|
||||
/// frame pump and strands the announce in the paused service.
|
||||
struct TrustWait {
|
||||
/// The wallet channel pubkey (hex) identifying the announced session.
|
||||
channel_pk: String,
|
||||
/// The granted domain, for the toast.
|
||||
domain: String,
|
||||
/// Give-up time for the confirmation wait.
|
||||
deadline: std::time::Instant,
|
||||
/// The parsed `rt` flag: whether a completed flow may hand focus back.
|
||||
want_return: bool,
|
||||
}
|
||||
|
||||
/// One pending "Sign in with Goblin" approval. Single-use by construction:
|
||||
/// once the event is signed (`posting`), or on cancel/expiry, the state is
|
||||
/// dropped and only a fresh URI can start another.
|
||||
@@ -477,6 +505,9 @@ struct TrustState {
|
||||
channel: nostr_sdk::Keys,
|
||||
/// A friendly label for the identity (for the Trusted Sites list).
|
||||
label: String,
|
||||
/// Whether the collapsed permission detail (categories, money line,
|
||||
/// duration) is expanded. The modal leads with the one-line gist.
|
||||
show_full: bool,
|
||||
/// Result slot the login-POST worker fills.
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<Result<(), String>>>>,
|
||||
}
|
||||
@@ -865,18 +896,25 @@ impl GoblinWalletView {
|
||||
let mut login_outcome = None;
|
||||
if let Some(st) = &self.login {
|
||||
if st.posting {
|
||||
// The return decision only counts as FRESH within the posting
|
||||
// window: if the user backgrounded the wallet themselves and the
|
||||
// result is consumed on a much-later resume, bouncing them back
|
||||
// out again would be a surprise, not a completion.
|
||||
let fresh =
|
||||
st.created.elapsed().as_secs() <= LOGIN_EXPIRY_SECS + LOGIN_POST_TIMEOUT_SECS;
|
||||
login_outcome = st
|
||||
.result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.map(|res| (res, st.uri.domain.clone()));
|
||||
.map(|res| (res, st.uri.domain.clone(), st.uri.return_to_caller && fresh));
|
||||
if login_outcome.is_none() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((res, domain)) = login_outcome {
|
||||
if let Some((res, domain, want_return)) = login_outcome {
|
||||
let posted_ok = res.is_ok();
|
||||
let text = match res {
|
||||
Ok(()) => t!("goblin.login.sent", domain => domain).to_string(),
|
||||
Err(e) => {
|
||||
@@ -886,6 +924,11 @@ impl GoblinWalletView {
|
||||
};
|
||||
self.login_toast = Some((text, std::time::Instant::now()));
|
||||
self.login = None;
|
||||
// The flow is fully complete only NOW (POST result in hand); a
|
||||
// failed POST keeps the user in the wallet with the honest toast.
|
||||
if crate::nostr::authuri::should_return_to_caller(want_return, Some(posted_ok), true) {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
}
|
||||
// The sign-in approval modal itself.
|
||||
if Modal::opened() == Some(LOGIN_MODAL) {
|
||||
@@ -938,18 +981,23 @@ impl GoblinWalletView {
|
||||
let mut authorize_outcome = None;
|
||||
if let Some(st) = &self.authorize {
|
||||
if st.posting {
|
||||
// Same freshness rule as login: a result consumed on a late
|
||||
// resume must not bounce the user back out.
|
||||
let fresh =
|
||||
st.created.elapsed().as_secs() <= LOGIN_EXPIRY_SECS + LOGIN_POST_TIMEOUT_SECS;
|
||||
authorize_outcome = st
|
||||
.result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.map(|res| (res, st.uri.domain.clone()));
|
||||
.map(|res| (res, st.uri.domain.clone(), st.uri.return_to_caller && fresh));
|
||||
if authorize_outcome.is_none() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((res, domain)) = authorize_outcome {
|
||||
if let Some((res, domain, want_return)) = authorize_outcome {
|
||||
let posted_ok = res.is_ok();
|
||||
let text = match res {
|
||||
Ok(()) => t!("goblin.authorize.sent", domain => domain).to_string(),
|
||||
Err(e) => {
|
||||
@@ -959,6 +1007,11 @@ impl GoblinWalletView {
|
||||
};
|
||||
self.login_toast = Some((text, std::time::Instant::now()));
|
||||
self.authorize = None;
|
||||
// Fully complete only NOW (POST result in hand); a failed POST keeps
|
||||
// the user in the wallet with the honest toast.
|
||||
if crate::nostr::authuri::should_return_to_caller(want_return, Some(posted_ok), true) {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
}
|
||||
// The authorize approval modal itself.
|
||||
if Modal::opened() == Some(AUTHORIZE_MODAL) {
|
||||
@@ -992,6 +1045,7 @@ impl GoblinWalletView {
|
||||
posting: false,
|
||||
channel: nostr_sdk::Keys::generate(),
|
||||
label,
|
||||
show_full: false,
|
||||
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
});
|
||||
self.trust_hold = w::HoldToSend::default();
|
||||
@@ -1032,6 +1086,7 @@ impl GoblinWalletView {
|
||||
let st = self.trust.take();
|
||||
let text = match (res, st) {
|
||||
(Ok(()), Some(st)) => {
|
||||
let mut session_added = false;
|
||||
if let Some(svc) = wallet.nostr_service() {
|
||||
if let (Ok(site_pk), Ok(id_pk)) = (
|
||||
nostr_sdk::PublicKey::from_hex(&st.uri.site_session_pubkey),
|
||||
@@ -1052,17 +1107,78 @@ impl GoblinWalletView {
|
||||
now,
|
||||
);
|
||||
svc.add_session(session);
|
||||
session_added = true;
|
||||
}
|
||||
}
|
||||
t!("goblin.trust.sent", domain => domain).to_string()
|
||||
if session_added {
|
||||
// The grant is NOT complete yet: the session-open channel
|
||||
// event still has to reach the relay (the service loop
|
||||
// publishes it on its next tick). Hold the success toast
|
||||
// AND the return-to-caller decision until the announce is
|
||||
// confirmed, or the site never learns the session exists
|
||||
// (the Build 153 QR-trust bug).
|
||||
self.trust_wait = Some(TrustWait {
|
||||
channel_pk: st.channel.public_key().to_hex(),
|
||||
domain,
|
||||
deadline: std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(TRUST_ANNOUNCE_TIMEOUT_SECS),
|
||||
want_return: st.uri.return_to_caller,
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
None
|
||||
} else {
|
||||
// No service / bad keys: no session, no announce. Show the
|
||||
// login-sent toast (the 22242 POST did succeed) but never
|
||||
// return-to-caller on an incomplete grant.
|
||||
Some(t!("goblin.trust.sent", domain => domain).to_string())
|
||||
}
|
||||
}
|
||||
(Err(e), _) => {
|
||||
log::warn!("trust login callback failed: {e}");
|
||||
t!("goblin.trust.failed", domain => domain).to_string()
|
||||
Some(t!("goblin.trust.failed", domain => domain).to_string())
|
||||
}
|
||||
(Ok(()), None) => t!("goblin.trust.sent", domain => domain).to_string(),
|
||||
(Ok(()), None) => Some(t!("goblin.trust.sent", domain => domain).to_string()),
|
||||
};
|
||||
if let Some(text) = text {
|
||||
self.login_toast = Some((text, std::time::Instant::now()));
|
||||
}
|
||||
}
|
||||
// Wait out the session-open announce: the service loop confirms the
|
||||
// publish reached a relay, then (and only then) the grant is complete,
|
||||
// the toast shows, and a same-device flow may hand focus back. The
|
||||
// repaint keeps frames pumping so the app stays foreground while waiting.
|
||||
let mut trust_wait_done = None;
|
||||
if let Some(wt) = &self.trust_wait {
|
||||
let announced = wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.session_announced(&wt.channel_pk))
|
||||
.unwrap_or(false);
|
||||
if announced {
|
||||
// Fresh only within the wait window: an announce observed on a
|
||||
// late resume (the user backgrounded the wallet themselves) shows
|
||||
// the toast but must not bounce them back out.
|
||||
let fresh = std::time::Instant::now() < wt.deadline;
|
||||
trust_wait_done = Some((true, wt.domain.clone(), wt.want_return && fresh));
|
||||
} else if std::time::Instant::now() >= wt.deadline {
|
||||
trust_wait_done = Some((false, wt.domain.clone(), wt.want_return));
|
||||
} else {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
if let Some((announced, domain, want_return)) = trust_wait_done {
|
||||
self.trust_wait = None;
|
||||
let text = if announced {
|
||||
t!("goblin.trust.sent", domain => domain).to_string()
|
||||
} else {
|
||||
// Honest: the login went through and the session exists in the
|
||||
// wallet, but the site has not confirmably received it.
|
||||
log::warn!("trust session-open announce unconfirmed for {domain}");
|
||||
t!("goblin.trust.announce_failed", domain => domain).to_string()
|
||||
};
|
||||
self.login_toast = Some((text, std::time::Instant::now()));
|
||||
if crate::nostr::authuri::should_return_to_caller(want_return, Some(true), announced) {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
}
|
||||
|
||||
// A money-tier request arriving over a live session channel raises the
|
||||
@@ -6591,7 +6707,6 @@ impl GoblinWalletView {
|
||||
let st = self.login.as_mut().unwrap();
|
||||
st.pass.clear();
|
||||
st.wrong_pass = false;
|
||||
let return_to_caller = st.uri.return_to_caller;
|
||||
match crate::nostr::loginuri::build_login_event(
|
||||
&keys,
|
||||
&st.uri.challenge,
|
||||
@@ -6627,15 +6742,11 @@ impl GoblinWalletView {
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
Modal::close();
|
||||
// Signed and handed to the POST worker: return the user to
|
||||
// the app that deep-linked in (the polling browser tab),
|
||||
// without waiting on the HTTP response. Success path only, and
|
||||
// only for a same-device deep-link (the default); a QR-scanned
|
||||
// URI carries `rt=0`, so we stay in the wallet and the browser
|
||||
// picks the login up by polling.
|
||||
if return_to_caller {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
// The return-to-caller decision is DEFERRED to the outcome
|
||||
// poll: returning now would background the app with the POST
|
||||
// still in flight, stop the frame pump, and strand the
|
||||
// completion work (the Build 153 QR-trust bug). The app stays
|
||||
// foreground (frames pumping) until the POST result lands.
|
||||
}
|
||||
Err(e) => {
|
||||
// Signing failed (never expected): consume the request and
|
||||
@@ -7022,7 +7133,6 @@ impl GoblinWalletView {
|
||||
let st = self.authorize.as_mut().unwrap();
|
||||
st.pass.clear();
|
||||
st.wrong_pass = false;
|
||||
let return_to_caller = st.uri.return_to_caller;
|
||||
match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) {
|
||||
Ok(event) => {
|
||||
// Signed: the request is consumed from here on, whatever the
|
||||
@@ -7057,15 +7167,9 @@ impl GoblinWalletView {
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
Modal::close();
|
||||
// Signed and handed to the POST worker: return the user to
|
||||
// the app that deep-linked in (the polling browser tab),
|
||||
// without waiting on the HTTP response. Success path only, and
|
||||
// only for a same-device deep-link (the default); a QR-scanned
|
||||
// URI carries `rt=0`, so we stay in the wallet and the browser
|
||||
// picks the result up by polling.
|
||||
if return_to_caller {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
// Return-to-caller is DEFERRED to the outcome poll (see the
|
||||
// login flow): the app must stay foreground until the POST
|
||||
// result lands, or the completion work freezes backgrounded.
|
||||
}
|
||||
Err(e) => {
|
||||
// Signing failed (never expected): consume the request and
|
||||
@@ -7183,20 +7287,14 @@ impl GoblinWalletView {
|
||||
ui.label(RichText::new(&short).size(12.5).color(Colors::gray()));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
// What the silent grant covers, as human categories.
|
||||
// The gist in one short line (grant + the money rule), with the full
|
||||
// permission detail behind a small disclosure for anyone who wants it.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.grant_intro", domain => domain.clone()))
|
||||
.size(13.0)
|
||||
.color(Colors::gray()),
|
||||
RichText::new(t!("goblin.trust.lead", domain => domain.clone()))
|
||||
.size(13.5)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
for cat in &display.categories {
|
||||
ui.label(
|
||||
RichText::new(format!("• {}", t!(cat.key())))
|
||||
.size(13.5)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
}
|
||||
// Caution lines are safety-relevant and stay visible even collapsed.
|
||||
for kind in &display.unknown_kinds {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
@@ -7214,19 +7312,57 @@ impl GoblinWalletView {
|
||||
.color(Colors::red()),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// The fixed money line: the low-tier grant is not a money grant.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.money_line"))
|
||||
.size(13.5)
|
||||
.color(Colors::title(false)),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.duration"))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
// The disclosure toggle, same idiom as the authorize full-content view.
|
||||
let toggle = if st.show_full {
|
||||
t!("goblin.authorize.show_less")
|
||||
} else {
|
||||
t!("goblin.authorize.show_full")
|
||||
};
|
||||
let rect = ui
|
||||
.label(RichText::new(toggle).size(13.0).color(Colors::green()))
|
||||
.rect;
|
||||
let hit = ui.interact(
|
||||
rect,
|
||||
egui::Id::from(modal.id).with("trust_showfull"),
|
||||
Sense::click(),
|
||||
);
|
||||
if hit
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
st.show_full = !st.show_full;
|
||||
}
|
||||
if st.show_full {
|
||||
ui.add_space(6.0);
|
||||
// What the silent grant covers, as human categories.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.grant_intro"))
|
||||
.size(13.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
for cat in &display.categories {
|
||||
ui.label(
|
||||
RichText::new(format!("• {}", t!(cat.key())))
|
||||
.size(13.5)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// The fixed money line: the low-tier grant is not a money grant.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.money_line"))
|
||||
.size(13.0)
|
||||
.color(Colors::title(false)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.duration"))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.trust.pass_prompt"))
|
||||
@@ -7302,7 +7438,6 @@ impl GoblinWalletView {
|
||||
let st = self.trust.as_mut().unwrap();
|
||||
st.pass.clear();
|
||||
st.wrong_pass = false;
|
||||
let return_to_caller = st.uri.return_to_caller;
|
||||
// Sign and POST the kind-22242 login event exactly as Build 150 does;
|
||||
// the session is created by the router once this POST succeeds.
|
||||
match crate::nostr::loginuri::build_login_event(
|
||||
@@ -7337,12 +7472,11 @@ impl GoblinWalletView {
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
Modal::close();
|
||||
// Same-device deep-link only (the default); a QR-scanned trust
|
||||
// URI carries `rt=0`, so we stay in the wallet and the browser
|
||||
// picks the session up by polling.
|
||||
if return_to_caller {
|
||||
cb.return_to_caller();
|
||||
}
|
||||
// Return-to-caller is DEFERRED even further than login: past
|
||||
// the POST outcome AND past the session-open announce
|
||||
// confirmation (the trust_wait poll). Returning here was the
|
||||
// Build 153 QR-trust bug: the app backgrounded with the POST
|
||||
// in flight and the session-open never published.
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("trust login event signing failed: {e}");
|
||||
|
||||
+49
-2
@@ -309,10 +309,10 @@ pub(crate) fn domain_bound(callback: &str, domain: &str) -> bool {
|
||||
/// is a single boolean gate on the existing best-effort app-switch). Absent,
|
||||
/// empty, malformed, or any value other than an explicit `0` yields the default
|
||||
/// `true` (return enabled), so pre-flag URIs and any garbage keep the Build 151
|
||||
/// same-device behavior — the fail-closed default is the backward-compatible one.
|
||||
/// same-device behavior: the fail-closed default is the backward-compatible one.
|
||||
/// Only `rt=0` disables the return (the QR-scan case, where there is no caller and
|
||||
/// the browser picks the result up by polling); `rt=1` is the explicit default.
|
||||
/// The match is byte-exact on the (percent-decoded) value — no trimming — so only
|
||||
/// The match is byte-exact on the (percent-decoded) value, no trimming, so only
|
||||
/// a clean `0` disables and anything padded or decorated stays the safe default.
|
||||
/// Duplicate `rt=` is resolved by each parser's first-occurrence-wins loop before
|
||||
/// this is called.
|
||||
@@ -323,6 +323,25 @@ pub(crate) fn parse_return_flag(raw: Option<&str>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The return-to-caller DECISION for a login/authorize/trust flow. True only
|
||||
/// when the URI allowed the return (`allowed`, the parsed `rt` flag) AND the
|
||||
/// flow is FULLY complete: the callback POST finished successfully
|
||||
/// (`post_ok == Some(true)`; `None` means still in flight) and any follow-on
|
||||
/// publish is confirmed delivered (`publish_confirmed`; flows without one pass
|
||||
/// `true`, the trust flow passes its session-open announce confirmation).
|
||||
/// Returning any earlier is the Build 153 QR-trust bug: the app switch stops
|
||||
/// the GUI frame pump and the Build 95 heartbeat then idles the nostr side, so
|
||||
/// pending completion work (the outcome poll, session creation, the
|
||||
/// session-open publish) never runs and the site never sees the login.
|
||||
/// Pure, total, no I/O.
|
||||
pub fn should_return_to_caller(
|
||||
allowed: bool,
|
||||
post_ok: Option<bool>,
|
||||
publish_confirmed: bool,
|
||||
) -> bool {
|
||||
allowed && post_ok == Some(true) && publish_confirmed
|
||||
}
|
||||
|
||||
/// Decode and validate the `e` template. Unpadded base64url only (any `=`
|
||||
/// padding or non-`A-Za-z0-9-_` char rejects), decoding to a UTF-8 JSON object
|
||||
/// with EXACTLY the three keys `kind`, `content`, `tags` and nothing else. The
|
||||
@@ -1060,6 +1079,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_decision_waits_for_completion() {
|
||||
// The return decision is NEVER taken while the POST is pending (None) or
|
||||
// after it failed, and never while a required publish is unconfirmed:
|
||||
// backgrounding early strands the completion work in a paused app.
|
||||
assert!(
|
||||
!should_return_to_caller(true, None, true),
|
||||
"pending POST must not return"
|
||||
);
|
||||
assert!(
|
||||
!should_return_to_caller(true, Some(false), true),
|
||||
"failed POST must not return"
|
||||
);
|
||||
assert!(
|
||||
!should_return_to_caller(true, Some(true), false),
|
||||
"unconfirmed publish must not return"
|
||||
);
|
||||
assert!(
|
||||
!should_return_to_caller(true, None, false),
|
||||
"nothing done yet must not return"
|
||||
);
|
||||
// Only a fully complete flow with the flag allowing it returns.
|
||||
assert!(should_return_to_caller(true, Some(true), true));
|
||||
// And the rt=0 flag suppresses the return even when fully complete.
|
||||
assert!(!should_return_to_caller(false, Some(true), true));
|
||||
assert!(!should_return_to_caller(false, None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_event_signed_by_the_chosen_identity() {
|
||||
// Two held identities; the user picks the NON-active one. The event must
|
||||
|
||||
+36
-1
@@ -177,6 +177,13 @@ pub struct NostrService {
|
||||
/// A single non-blocking "signing a lot" notice for the GUI toast, set when a
|
||||
/// session trips the soft rate cap.
|
||||
session_notice: RwLock<Option<String>>,
|
||||
/// Wallet channel pubkeys (hex) whose `session-open` announce was CONFIRMED
|
||||
/// handed to at least one relay connection. The trust GUI waits on this
|
||||
/// before it may background the app (return-to-caller): backgrounding stops
|
||||
/// the frame pump, and an announce still queued behind `sessions_dirty`
|
||||
/// would freeze in the paused service (the Build 153 QR-trust bug).
|
||||
/// Transient and tiny (one entry per grant this run).
|
||||
announced_ok: RwLock<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
/// Phase of the most recent outgoing send, polled by the send UI.
|
||||
@@ -232,6 +239,7 @@ impl NostrService {
|
||||
money_pending: Mutex::new(Vec::new()),
|
||||
money_answers: Mutex::new(Vec::new()),
|
||||
session_notice: RwLock::new(None),
|
||||
announced_ok: RwLock::new(std::collections::HashSet::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +274,14 @@ impl NostrService {
|
||||
self.sessions_dirty.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// True once the `session-open` announce for the session with this wallet
|
||||
/// channel pubkey (hex) was actually handed to a relay connection. The trust
|
||||
/// GUI polls this before taking the return-to-caller decision, so the app
|
||||
/// never backgrounds with the announce still pending in the service.
|
||||
pub fn session_announced(&self, wallet_channel_pk_hex: &str) -> bool {
|
||||
self.announced_ok.read().contains(wallet_channel_pk_hex)
|
||||
}
|
||||
|
||||
/// True when at least one session is live (for the Trusted Sites badge/list).
|
||||
pub fn has_sessions(&self) -> bool {
|
||||
!self.sessions.read().is_empty()
|
||||
@@ -2325,7 +2341,26 @@ async fn announce_new_sessions(svc: &Arc<NostrService>, client: &Client) {
|
||||
}
|
||||
}
|
||||
for ev in events {
|
||||
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&relays, &ev)).await;
|
||||
// The event's own pubkey IS the wallet channel key (see
|
||||
// `session_open_event`). Record delivery only when at least one relay
|
||||
// actually accepted the event, so the GUI's return-to-caller wait is a
|
||||
// real confirmation, not a queued-and-hoping.
|
||||
let pk_hex = ev.pubkey.to_hex();
|
||||
let confirmed =
|
||||
match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&relays, &ev)).await {
|
||||
Ok(Ok(out)) => !out.success.is_empty(),
|
||||
Ok(Err(e)) => {
|
||||
warn!("nostr: session-open publish failed: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("nostr: session-open publish timed out");
|
||||
false
|
||||
}
|
||||
};
|
||||
if confirmed {
|
||||
svc.announced_ok.write().insert(pk_hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user