1
0
forked from GRIN/grim

Merge branch 'refs/heads/fix/qr-no-redirect' into build154

This commit is contained in:
2ro
2026-07-06 21:41:54 -04:00
11 changed files with 522 additions and 58 deletions
+5 -3
View File
@@ -723,7 +723,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"
@@ -732,12 +733,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
View File
@@ -723,7 +723,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"
@@ -732,12 +733,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
View File
@@ -723,7 +723,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"
@@ -732,12 +733,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
View File
@@ -723,7 +723,8 @@ goblin:
title: "Доверие"
headline: "Доверять %{domain}?"
identity: "Вход как"
grant_intro: "%{domain} сможет подписывать это за вас без повторных запросов, пока вы не выйдете или не завершите сессию:"
lead: "%{domain} может действовать за вас в этой сессии, не спрашивая каждый раз. Всё, что тратит деньги, всегда спрашивает."
grant_intro: "Может подписывать без запроса:"
cat_social: "Публикации и реакции"
cat_dm: "Личные сообщения"
cat_market: "Объявления"
@@ -732,12 +733,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
View File
@@ -723,7 +723,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"
@@ -732,12 +733,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
View File
@@ -723,7 +723,8 @@ goblin:
title: "信任"
headline: "信任 %{domain}"
identity: "登录身份"
grant_intro: "在你退出登录或结束会话之前,%{domain} 可以为你签署以下内容,无需次询问"
lead: "本次会话中 %{domain} 可代表你操作,无需次询问。任何花钱的操作始终会询问。"
grant_intro: "可无需询问签署:"
cat_social: "帖子和互动"
cat_dm: "私信"
cat_market: "商品列表"
@@ -732,12 +733,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 -39
View File
@@ -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
@@ -6667,10 +6783,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.
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
@@ -7091,10 +7208,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.
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
@@ -7212,20 +7328,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!(
@@ -7243,19 +7353,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"))
@@ -7365,7 +7513,11 @@ impl GoblinWalletView {
*slot.lock().unwrap() = Some(res);
});
Modal::close();
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}");
+159
View File
@@ -99,6 +99,11 @@ pub struct AuthorizeUri {
pub callback: String,
/// The event template the site wants signed.
pub template: Template,
/// Whether the wallet should hand focus back to a same-device caller after
/// the flow completes. `true` (the default when the `rt` flag is absent) is
/// the Build 151 deep-link behavior; a QR-scanned URI mints `rt=0` so the
/// wallet stays put and the browser picks the result up by polling.
pub return_to_caller: bool,
}
/// True when `scanned` carries a Goblin scheme with the `authorize` keyword,
@@ -139,6 +144,7 @@ pub fn parse(scanned: &str) -> Option<AuthorizeUri> {
let mut domain = None;
let mut callback = None;
let mut template = None;
let mut return_flag = None;
for pair in query.split('&') {
let Some((key, val)) = pair.split_once('=') else {
continue; // valueless / malformed segment, ignore
@@ -150,6 +156,8 @@ pub fn parse(scanned: &str) -> Option<AuthorizeUri> {
"d" if domain.is_none() => domain = Some(val),
"cb" if callback.is_none() => callback = Some(val),
"e" if template.is_none() => template = Some(val),
// Optional return-to-caller gate; first occurrence wins like the rest.
"rt" if return_flag.is_none() => return_flag = Some(val),
// Unknown params are ignored for forward-compat.
_ => {}
}
@@ -164,11 +172,13 @@ pub fn parse(scanned: &str) -> Option<AuthorizeUri> {
return None;
}
let template = validate_template(template?)?;
let return_to_caller = parse_return_flag(return_flag);
Some(AuthorizeUri {
challenge,
domain,
callback,
template,
return_to_caller,
})
}
@@ -292,6 +302,46 @@ pub(crate) fn domain_bound(callback: &str, domain: &str) -> bool {
host == domain || host.ends_with(&format!(".{domain}"))
}
/// Parse the optional `rt` return-to-caller flag shared by the login, authorize,
/// and trust URIs. This flag ONLY controls whether the wallet hands focus back to
/// a same-device caller after the flow completes; it can NEVER introduce a
/// redirect the URI could not already perform (no target is derived from it, it
/// 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.
/// 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
/// 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.
pub(crate) fn parse_return_flag(raw: Option<&str>) -> bool {
match raw {
Some(v) => String::from_utf8_lossy(&percent_decode(v)) != "0",
None => true,
}
}
/// 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
@@ -948,6 +998,115 @@ mod tests {
assert_eq!(parse(&broken), None);
}
#[test]
fn return_flag_absent_defaults_to_return() {
// No `rt` param: backward-compatible default is return enabled.
let out = parse(&valid_uri("goblin:")).expect("valid authorize URI");
assert!(out.return_to_caller, "absent rt must default to return");
}
#[test]
fn return_flag_explicit_values() {
// rt=0 disables the return (QR-scan case); rt=1 is the explicit default.
let no = format!(
"goblin:authorize?e={}&d=magick.market&cb=https://magick.market/cb&c={C}&rt=0",
valid_template()
);
assert!(
!parse(&no).unwrap().return_to_caller,
"rt=0 must disable return"
);
let yes = format!(
"goblin:authorize?e={}&d=magick.market&cb=https://magick.market/cb&c={C}&rt=1",
valid_template()
);
assert!(
parse(&yes).unwrap().return_to_caller,
"rt=1 must enable return"
);
}
#[test]
fn return_flag_garbage_fails_closed_to_default() {
// Anything that is not an explicit `0` keeps the default (return enabled),
// so a malformed flag can never silently suppress the same-device return.
for junk in [
"", "2", "true", "false", "yes", "no", "00", " 0", "0x0", "%30",
] {
let uri = format!(
"goblin:authorize?e={}&d=magick.market&cb=https://magick.market/cb&c={C}&rt={junk}",
valid_template()
);
// %30 decodes to "0" -> disabled; every other junk value stays default.
let expected = junk != "%30";
assert_eq!(
parse(&uri).unwrap().return_to_caller,
expected,
"rt={junk:?} must resolve to return={expected}"
);
}
}
#[test]
fn return_flag_duplicate_first_wins() {
// First occurrence wins, matching every other param.
let first_off = format!(
"goblin:authorize?e={}&d=magick.market&cb=https://magick.market/cb&c={C}&rt=0&rt=1",
valid_template()
);
assert!(!parse(&first_off).unwrap().return_to_caller);
let first_on = format!(
"goblin:authorize?e={}&d=magick.market&cb=https://magick.market/cb&c={C}&rt=1&rt=0",
valid_template()
);
assert!(parse(&first_on).unwrap().return_to_caller);
}
#[test]
fn parse_return_flag_unit() {
assert!(parse_return_flag(None), "absent -> default return");
assert!(parse_return_flag(Some("1")), "1 -> return");
assert!(!parse_return_flag(Some("0")), "0 -> no return");
assert!(parse_return_flag(Some("")), "empty -> default return");
assert!(
parse_return_flag(Some("garbage")),
"garbage -> default return"
);
// Percent-encoded 0 decodes to a real disable.
assert!(
!parse_return_flag(Some("%30")),
"%30 decodes to 0 -> no return"
);
}
#[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
View File
@@ -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);
}
}
}
+51
View File
@@ -62,6 +62,11 @@ pub struct LoginUri {
/// The callback URL the signed event is delivered to: `https://...`, or
/// `http://localhost[:port]...` for development.
pub callback: String,
/// Whether the wallet should hand focus back to a same-device caller after
/// the flow completes. `true` (the default when the `rt` flag is absent) is
/// the Build 151 deep-link behavior; a QR-scanned URI mints `rt=0` so the
/// wallet stays put and the browser picks the login up by polling.
pub return_to_caller: bool,
}
/// True when `scanned` carries a Goblin scheme with the `login` keyword, i.e.
@@ -99,6 +104,7 @@ pub fn parse(scanned: &str) -> Option<LoginUri> {
let mut challenge = None;
let mut domain = None;
let mut callback = None;
let mut return_flag = None;
for pair in query.split('&') {
let Some((key, val)) = pair.split_once('=') else {
continue; // valueless / malformed segment, ignore
@@ -109,6 +115,8 @@ pub fn parse(scanned: &str) -> Option<LoginUri> {
"c" if challenge.is_none() => challenge = Some(val),
"d" if domain.is_none() => domain = Some(val),
"cb" if callback.is_none() => callback = Some(val),
// Optional return-to-caller gate; first occurrence wins like the rest.
"rt" if return_flag.is_none() => return_flag = Some(val),
// Unknown params are ignored for forward-compat.
_ => {}
}
@@ -116,6 +124,7 @@ pub fn parse(scanned: &str) -> Option<LoginUri> {
let challenge = validate_challenge(challenge?)?;
let domain = validate_domain(domain?)?;
let callback = validate_callback(callback?)?;
let return_to_caller = super::authuri::parse_return_flag(return_flag);
// Domain binding: the callback host must belong to the displayed domain, so a
// site cannot show `d=magick.market` while pointing `cb` at its own host and
// harvest a login event for a domain it does not control. Same rule as
@@ -128,6 +137,7 @@ pub fn parse(scanned: &str) -> Option<LoginUri> {
challenge,
domain,
callback,
return_to_caller,
})
}
@@ -447,6 +457,47 @@ mod tests {
assert!(!is_login_shaped(&huge));
}
#[test]
fn return_flag_absent_defaults_to_return() {
// No `rt`: backward-compatible default is return enabled.
let out = parse(&valid_uri("goblin:")).expect("valid login URI");
assert!(out.return_to_caller, "absent rt must default to return");
}
#[test]
fn return_flag_explicit_and_garbage() {
// rt=0 disables (QR-scan case), rt=1 is the explicit default.
let no = format!("goblin:login?c={C}&d=magick.market&cb=https://magick.market/cb&rt=0");
assert!(
!parse(&no).unwrap().return_to_caller,
"rt=0 must disable return"
);
let yes = format!("goblin:login?c={C}&d=magick.market&cb=https://magick.market/cb&rt=1");
assert!(
parse(&yes).unwrap().return_to_caller,
"rt=1 must enable return"
);
// Garbage fails closed to the default (return enabled).
for junk in ["", "2", "true", "no", "00"] {
let uri =
format!("goblin:login?c={C}&d=magick.market&cb=https://magick.market/cb&rt={junk}");
assert!(
parse(&uri).unwrap().return_to_caller,
"rt={junk:?} must fail closed to return"
);
}
}
#[test]
fn return_flag_duplicate_first_wins() {
let first_off =
format!("goblin:login?c={C}&d=magick.market&cb=https://magick.market/cb&rt=0&rt=1");
assert!(!parse(&first_off).unwrap().return_to_caller);
let first_on =
format!("goblin:login?c={C}&d=magick.market&cb=https://magick.market/cb&rt=1&rt=0");
assert!(parse(&first_on).unwrap().return_to_caller);
}
#[test]
fn login_event_signed_by_the_chosen_identity() {
// Two held identities; the user picks the NON-active one. The event
+55
View File
@@ -78,6 +78,11 @@ pub struct TrustUri {
/// renders the remainder as categories and shows caution lines for anything
/// stripped. Guaranteed non-empty, and guaranteed non-empty after stripping.
pub requested_kinds: Vec<u16>,
/// Whether the wallet should hand focus back to a same-device caller after
/// the flow completes. `true` (the default when the `rt` flag is absent) is
/// the Build 151 deep-link behavior; a QR-scanned URI mints `rt=0` so the
/// wallet stays put and the browser picks the result up by polling.
pub return_to_caller: bool,
}
/// True when `scanned` carries a Goblin scheme with the `trust` keyword, i.e. it
@@ -121,6 +126,7 @@ pub fn parse(scanned: &str) -> Option<TrustUri> {
let mut site_key = None;
let mut relay = None;
let mut kinds = None;
let mut return_flag = None;
for pair in query.split('&') {
let Some((key, val)) = pair.split_once('=') else {
continue; // valueless / malformed segment, ignore
@@ -134,6 +140,8 @@ pub fn parse(scanned: &str) -> Option<TrustUri> {
"sk" if site_key.is_none() => site_key = Some(val),
"r" if relay.is_none() => relay = Some(val),
"k" if kinds.is_none() => kinds = Some(val),
// Optional return-to-caller gate; first occurrence wins like the rest.
"rt" if return_flag.is_none() => return_flag = Some(val),
// Unknown params are ignored for forward-compat.
_ => {}
}
@@ -150,6 +158,7 @@ pub fn parse(scanned: &str) -> Option<TrustUri> {
let site_session_pubkey = validate_x_only_hex(site_key?)?;
let relay = validate_relay(relay?)?;
let requested_kinds = validate_kinds(kinds?)?;
let return_to_caller = super::authuri::parse_return_flag(return_flag);
Some(TrustUri {
challenge,
domain,
@@ -157,6 +166,7 @@ pub fn parse(scanned: &str) -> Option<TrustUri> {
site_session_pubkey,
relay,
requested_kinds,
return_to_caller,
})
}
@@ -507,6 +517,51 @@ mod tests {
assert!(!is_trust_shaped(&huge));
}
#[test]
fn return_flag_absent_defaults_to_return() {
// No `rt`: backward-compatible default is return enabled.
let out = parse(&valid_uri("goblin:")).expect("valid trust URI");
assert!(out.return_to_caller, "absent rt must default to return");
}
#[test]
fn return_flag_explicit_garbage_and_duplicate() {
let base = format!(
"c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=1"
);
// rt=0 disables (QR-scan case), rt=1 is the explicit default.
assert!(
!parse(&format!("goblin:trust?{base}&rt=0"))
.unwrap()
.return_to_caller
);
assert!(
parse(&format!("goblin:trust?{base}&rt=1"))
.unwrap()
.return_to_caller
);
// Garbage fails closed to the default (return enabled).
for junk in ["", "2", "true", "no"] {
assert!(
parse(&format!("goblin:trust?{base}&rt={junk}"))
.unwrap()
.return_to_caller,
"rt={junk:?} must fail closed to return"
);
}
// First occurrence wins.
assert!(
!parse(&format!("goblin:trust?{base}&rt=0&rt=1"))
.unwrap()
.return_to_caller
);
assert!(
parse(&format!("goblin:trust?{base}&rt=1&rt=0"))
.unwrap()
.return_to_caller
);
}
#[test]
fn trust_is_not_pay_login_or_authorize() {
let trust = valid_uri("goblin:");