Merge branch 'refs/heads/fix/qr-no-redirect' into build154
This commit is contained in:
+5
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:");
|
||||
|
||||
Reference in New Issue
Block a user