diff --git a/crates/tty7-gateway/src/main.rs b/crates/tty7-gateway/src/main.rs index 75a23f64..a80639e4 100644 --- a/crates/tty7-gateway/src/main.rs +++ b/crates/tty7-gateway/src/main.rs @@ -80,7 +80,7 @@ fn main() -> Result<()> { } fn pair(state: &State, ttl: u64) -> Result<()> { - let code = service::pair_code(state, Duration::from_secs(ttl))?; + let code = service::pair_code(state, Duration::from_secs(ttl))?.code; let qr = qrcode::QrCode::new(code.as_bytes()).context("drawing the pairing code")?; let art = qr .render::() diff --git a/crates/tty7-gateway/src/service.rs b/crates/tty7-gateway/src/service.rs index ed8601fe..afc273f5 100644 --- a/crates/tty7-gateway/src/service.rs +++ b/crates/tty7-gateway/src/service.rs @@ -227,17 +227,26 @@ pub fn serve_until_stdin_closes(state: State) -> Result<()> { Ok(()) } -/// Opens a pairing offer valid for `ttl`, and returns the code a phone scans -/// or pastes for it. The addresses in it are the ones the running gateway -/// last wrote down; with none running the code still pairs once one starts. -pub fn pair_code(state: &State, ttl: Duration) -> Result { +/// An open pairing offer: the code a phone scans or pastes, and the secret in +/// it, which [`State::pairing_is_open`] and [`State::close_pairing`] take. +pub struct PairOffer { + pub code: String, + pub secret: String, +} + +/// Opens a pairing offer valid for `ttl`, replacing any earlier one. The +/// addresses in its code are the ones the running gateway last wrote down; +/// with none running the code still pairs once one starts. +pub fn pair_code(state: &State, ttl: Duration) -> Result { let reachable = state.reachable(); - Ok(PairCode { + let secret = state.open_pairing(ttl.as_secs())?; + let code = PairCode { host_id: state.secret_key()?.public().to_string(), host_name: hostname(), relay: reachable.relay, addrs: reachable.addrs, - secret: state.open_pairing(ttl.as_secs())?, + secret: secret.clone(), } - .encode()) + .encode(); + Ok(PairOffer { code, secret }) } diff --git a/crates/tty7-gateway/src/state.rs b/crates/tty7-gateway/src/state.rs index e9871a38..5c6a30af 100644 --- a/crates/tty7-gateway/src/state.rs +++ b/crates/tty7-gateway/src/state.rs @@ -197,6 +197,34 @@ impl State { Ok(secret) } + /// Whether the offer with `secret` is still open: not spent, not expired, + /// and not replaced by a newer one. + pub fn pairing_is_open(&self, secret: &str) -> bool { + matches!( + read_json::(&self.dir.join(PAIRING_FILE)), + Ok(Some(p)) if p.expires_at >= unix_now() && constant_time_eq(&p.secret, secret) + ) + } + + /// Whether any offer is open, whoever opened it. + pub fn has_open_pairing(&self) -> bool { + matches!( + read_json::(&self.dir.join(PAIRING_FILE)), + Ok(Some(p)) if p.expires_at >= unix_now() + ) + } + + /// Withdraws the offer with `secret`, if it is still the open one. An + /// offer someone else has opened since is theirs, and stays. + pub fn close_pairing(&self, secret: &str) { + let path = self.dir.join(PAIRING_FILE); + if let Ok(Some(p)) = read_json::(&path) + && constant_time_eq(&p.secret, secret) + { + let _ = fs::remove_file(&path); + } + } + /// Spends the pairing offer if `secret` matches it and it has not expired. /// /// Any attempt at all closes the offer, right or wrong: a code is shown on @@ -354,6 +382,26 @@ mod tests { assert!(!state.take_pairing(&secret)); } + #[test] + fn a_withdrawn_offer_cannot_be_spent() { + let (_tmp, state) = state(); + let secret = state.open_pairing(60).unwrap(); + assert!(state.pairing_is_open(&secret)); + state.close_pairing(&secret); + assert!(!state.pairing_is_open(&secret)); + assert!(!state.take_pairing(&secret)); + } + + #[test] + fn closing_a_replaced_offer_leaves_the_new_one() { + let (_tmp, state) = state(); + let old = state.open_pairing(60).unwrap(); + let new = state.open_pairing(60).unwrap(); + assert!(!state.pairing_is_open(&old), "replaced"); + state.close_pairing(&old); + assert!(state.pairing_is_open(&new)); + } + #[test] fn an_expired_offer_is_refused() { let (_tmp, state) = state(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c04b4dad..ef4ea82d 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -65,7 +65,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsMobilePairScan => { "Scan this with the tty7 app on your phone, or copy the code and paste it there." } - L10nKey::SettingsMobilePairValid => "Valid for 10 minutes, for one phone.", + L10nKey::SettingsMobilePairValid => "Expires in {time}. Works for one phone.", + L10nKey::SettingsMobileNewCode => "New code", + L10nKey::SettingsMobilePairExpired => "This code has expired. Make a new one to pair.", + L10nKey::SettingsMobilePairTried => { + "This code was tried and no longer works — it may have been mistyped, or the connection dropped. Make a new one to pair." + } + L10nKey::SettingsMobilePairReplaced => { + "A newer code has replaced this one. Make a new one to pair." + } L10nKey::SettingsMobileCopyCode => "Copy code", L10nKey::SettingsMobilePaired => "Paired with {name}.", L10nKey::SettingsMobilePhones => "Paired phones", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 112f7a66..4498b6fc 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -73,7 +73,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsMobilePairScan => { "スマートフォンの tty7 アプリでスキャンするか、コードをコピーして貼り付けてください。" } - L10nKey::SettingsMobilePairValid => "有効期限は 10 分、1 台のスマートフォンに限ります。", + L10nKey::SettingsMobilePairValid => { + "あと {time} で失効します。1 台のスマートフォンに限ります。" + } + L10nKey::SettingsMobileNewCode => "新しいコード", + L10nKey::SettingsMobilePairExpired => { + "このコードは期限切れです。新しいコードを作成してペアリングしてください。" + } + L10nKey::SettingsMobilePairTried => { + "このコードは一度試されたため使えなくなりました(入力ミスや接続の中断など)。新しいコードを作成してペアリングしてください。" + } + L10nKey::SettingsMobilePairReplaced => { + "新しいコードに置き換えられました。新しいコードを作成してペアリングしてください。" + } L10nKey::SettingsMobileCopyCode => "コードをコピー", L10nKey::SettingsMobilePaired => "{name} とペアリングしました。", L10nKey::SettingsMobilePhones => "ペアリング済みのスマートフォン", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index bfb9d852..79d25f41 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -118,6 +118,10 @@ l10n_keys! { SettingsMobilePairNeedsAccess, SettingsMobilePairScan, SettingsMobilePairValid, + SettingsMobileNewCode, + SettingsMobilePairExpired, + SettingsMobilePairTried, + SettingsMobilePairReplaced, SettingsMobileCopyCode, SettingsMobilePaired, SettingsMobilePhones, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index bfb0638c..47ecea52 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -59,7 +59,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsMobilePairDesc => "生成一个一次性配对码,给手机上的 tty7 app 用。", L10nKey::SettingsMobilePairNeedsAccess => "请先打开手机访问。", L10nKey::SettingsMobilePairScan => "用手机上的 tty7 app 扫描,或者复制配对码粘贴过去。", - L10nKey::SettingsMobilePairValid => "10 分钟内有效,只能配对一台手机。", + L10nKey::SettingsMobilePairValid => "{time} 后失效,只能配对一台手机。", + L10nKey::SettingsMobileNewCode => "换一个配对码", + L10nKey::SettingsMobilePairExpired => "这个配对码已过期。生成一个新的再配对。", + L10nKey::SettingsMobilePairTried => { + "这个配对码已被尝试过,不能再用了 —— 可能是输错了,或者连接中途断了。生成一个新的再配对。" + } + L10nKey::SettingsMobilePairReplaced => "已有更新的配对码替换了它。生成一个新的再配对。", L10nKey::SettingsMobileCopyCode => "复制配对码", L10nKey::SettingsMobilePaired => "已与 {name} 配对。", L10nKey::SettingsMobilePhones => "已配对的手机", diff --git a/src/ui/settings/mobile.rs b/src/ui/settings/mobile.rs index c8aff95b..e9d1a8e2 100644 --- a/src/ui/settings/mobile.rs +++ b/src/ui/settings/mobile.rs @@ -29,10 +29,32 @@ const START_POLL: Duration = Duration::from_millis(500); /// A pairing code on screen. pub(crate) struct Pairing { code: String, + /// The offer's secret, to ask the gateway's state whether it is still open. + secret: String, qr: Option>, /// The phones paired before this code was made, to tell the new one by. before: Vec, until: Instant, + /// The code no longer pairs. It stays on screen, marked so, until it is + /// replaced or dismissed: a code that vanishes by itself leaves whoever + /// was about to scan it wondering where it went. + spent: Option, +} + +#[derive(Clone, Copy)] +enum Spent { + Expired, + /// A phone tried it and it did not pair — mistyped, or the connection + /// dropped. Any attempt closes an offer. + Tried, + /// Something else opened a newer offer, which replaces this one. + Replaced, +} + +/// Minutes and seconds, for the time a code has left. +fn countdown(left: Duration) -> String { + let secs = left.as_secs(); + format!("{}:{:02}", secs / 60, secs % 60) } /// What the switch shows: whether phones can actually reach this machine, @@ -109,8 +131,8 @@ impl Tty7App { ) { if !on { self.update_config(cx, |cfg| cfg.mobile_access = false); + self.close_mobile_pairing(cx); if let Some(s) = self.active_settings_mut() { - s.mobile_pairing = None; s.mobile_starting = false; } cx.notify(); @@ -164,12 +186,14 @@ impl Tty7App { .detach(); } + /// Shows a fresh code, replacing any on screen — which the new offer + /// closes, since the gateway keeps only one open at a time. fn start_mobile_pairing(&mut self, cx: &mut Context) { let Some(state) = gateway_state(true) else { return; }; - let code = match tty7_gateway::service::pair_code(&state, PAIR_TTL) { - Ok(code) => code, + let offer = match tty7_gateway::service::pair_code(&state, PAIR_TTL) { + Ok(offer) => offer, Err(e) => { log::warn!("could not make a pairing code: {e:#}"); return; @@ -183,18 +207,22 @@ impl Tty7App { .collect(); if let Some(s) = self.active_settings_mut() { s.mobile_pairing = Some(Pairing { - qr: qr_image(&code), - code, + qr: qr_image(&offer.code), + code: offer.code, + secret: offer.secret.clone(), before, until: Instant::now() + PAIR_TTL, + spent: None, }); s.mobile_paired = None; s.mobile_copied = false; } cx.notify(); - // Watch for the phone that uses it; the code closes itself then, or - // when it runs out. + // Watch for the phone that uses it, closing the code then, and mark + // it spent once it can no longer pair. A code replaced or dismissed + // on screen ends its own watch. + let secret = offer.secret; cx.spawn(async move |this, cx| { loop { smol::Timer::after(PAIR_POLL).await; @@ -202,7 +230,8 @@ impl Tty7App { let Some(s) = this.active_settings_mut() else { return false; }; - let Some(pairing) = &s.mobile_pairing else { + let Some(pairing) = s.mobile_pairing.as_mut().filter(|p| p.secret == secret) + else { return false; }; let new = state @@ -210,16 +239,28 @@ impl Tty7App { .unwrap_or_default() .into_iter() .find(|d| !pairing.before.contains(&d.id)); - let expired = Instant::now() >= pairing.until; if let Some(device) = new { s.mobile_paired = Some(device.name); - } - let done = s.mobile_paired.is_some() || expired; - if done { s.mobile_pairing = None; + cx.notify(); + return false; } + let expired = Instant::now() >= pairing.until; + if pairing.spent.is_none() { + pairing.spent = if expired { + Some(Spent::Expired) + } else if state.pairing_is_open(&secret) { + None + } else if state.has_open_pairing() { + Some(Spent::Replaced) + } else { + Some(Spent::Tried) + }; + } + // Ticks the countdown. Past expiry there is nothing left + // to watch for: a spent offer pairs no phone. cx.notify(); - !done + !expired }); if !matches!(alive, Ok(true)) { return; @@ -229,6 +270,21 @@ impl Tty7App { .detach(); } + /// Takes the code off screen and withdraws its offer, so a code dismissed + /// here cannot still be used from a photo of it. + fn close_mobile_pairing(&mut self, cx: &mut Context) { + let Some(pairing) = self + .active_settings_mut() + .and_then(|s| s.mobile_pairing.take()) + else { + return; + }; + if let Some(state) = gateway_state(false) { + state.close_pairing(&pairing.secret); + } + cx.notify(); + } + fn unpair_mobile_device(&mut self, id: String, cx: &mut Context) { if let Some(state) = gateway_state(true) && let Err(e) = state.revoke(&id) @@ -265,15 +321,24 @@ impl Tty7App { .active_settings() .and_then(|s| s.mobile_pairing.as_ref()); let paired = self.active_settings().and_then(|s| s.mobile_paired.clone()); - let pair_button = self - .settings_button( + // A spent code carries its own "New code" beside the reason it is + // spent; a second one up here would be the same button twice. + let pair_button = if pairing.is_some_and(|p| p.spent.is_some()) { + div().into_any_element() + } else { + self.settings_button( "mobile-pair", - t(L10nKey::SettingsMobileShowCode), + if pairing.is_some() { + t(L10nKey::SettingsMobileNewCode) + } else { + t(L10nKey::SettingsMobileShowCode) + }, cx, |this, _, cx| this.start_mobile_pairing(cx), ) - .disabled(!serving || pairing.is_some()) - .into_any_element(); + .disabled(!serving) + .into_any_element() + }; let pair_desc = match (serving, &paired) { (_, Some(name)) => t_fmt(L10nKey::SettingsMobilePaired, &[("name", name)]), (true, None) => t(L10nKey::SettingsMobilePairDesc).to_string(), @@ -350,41 +415,17 @@ impl Tty7App { ) -> AnyElement { let copied = self.active_settings().is_some_and(|s| s.mobile_copied); let code = pairing.code.clone(); - let copy = kit::button( - "mobile-copy-code", - if copied { - t(L10nKey::SettingsCopied) + let close = kit::button( + "mobile-pair-cancel", + t(if pairing.spent.is_some() { + L10nKey::Close } else { - t(L10nKey::SettingsMobileCopyCode) - }, - BtnKind::Secondary, + L10nKey::Cancel + }), + BtnKind::Link, ) - .on_click(cx.listener(move |this, _, _w, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(code.clone())); - if let Some(s) = this.active_settings_mut() { - s.mobile_copied = true; - } - cx.notify(); - cx.spawn(async move |this, cx| { - smol::Timer::after(Duration::from_millis(1500)).await; - let _ = this.update(cx, |this, cx| { - if let Some(s) = this.active_settings_mut() { - s.mobile_copied = false; - cx.notify(); - } - }); - }) - .detach(); - })) + .on_click(cx.listener(|this, _, _w, cx| this.close_mobile_pairing(cx))) .into_any_element(); - let cancel = kit::button("mobile-pair-cancel", t(L10nKey::Cancel), BtnKind::Link) - .on_click(cx.listener(|this, _, _w, cx| { - if let Some(s) = this.active_settings_mut() { - s.mobile_pairing = None; - } - cx.notify(); - })) - .into_any_element(); let qr = match &pairing.qr { Some(image) => gpui::img(image.clone()) @@ -392,12 +433,103 @@ impl Tty7App { .into_any_element(), None => div().size(px(QR_SIZE)).into_any_element(), }; + let side = v_flex().flex_1().min_w_0().gap(px(12.)); + let side = match pairing.spent { + // What happened, and the one way on from it. + Some(spent) => { + let renew = kit::button( + "mobile-pair-renew", + t(L10nKey::SettingsMobileNewCode), + BtnKind::Primary, + ) + .on_click(cx.listener(|this, _, _w, cx| this.start_mobile_pairing(cx))) + .into_any_element(); + side.child( + div() + .text_size(fs(13.)) + .text_color(tk.fg) + .child(t(match spent { + Spent::Expired => L10nKey::SettingsMobilePairExpired, + Spent::Tried => L10nKey::SettingsMobilePairTried, + Spent::Replaced => L10nKey::SettingsMobilePairReplaced, + })), + ) + .child( + h_flex() + .gap(px(12.)) + .items_center() + .child(renew) + .child(close), + ) + } + None => { + let copy = kit::button( + "mobile-copy-code", + if copied { + t(L10nKey::SettingsCopied) + } else { + t(L10nKey::SettingsMobileCopyCode) + }, + BtnKind::Secondary, + ) + .on_click(cx.listener(move |this, _, _w, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(code.clone())); + if let Some(s) = this.active_settings_mut() { + s.mobile_copied = true; + } + cx.notify(); + cx.spawn(async move |this, cx| { + smol::Timer::after(Duration::from_millis(1500)).await; + let _ = this.update(cx, |this, cx| { + if let Some(s) = this.active_settings_mut() { + s.mobile_copied = false; + cx.notify(); + } + }); + }) + .detach(); + })) + .into_any_element(); + let left = pairing.until.saturating_duration_since(Instant::now()); + side.child( + div() + .text_size(fs(13.)) + .text_color(tk.fg) + .child(t(L10nKey::SettingsMobilePairScan)), + ) + .child( + div() + .p(px(8.)) + .rounded(px(6.)) + .bg(tk.k04) + .text_size(fs(11.)) + .font_family(Tk::mono(cx)) + .text_color(tk.k6) + .line_clamp(3) + .text_ellipsis() + .child(pairing.code.clone()), + ) + .child( + h_flex() + .gap(px(12.)) + .items_center() + .child(copy) + .child(close), + ) + .child(div().text_size(fs(12.)).text_color(tk.k5).child(t_fmt( + L10nKey::SettingsMobilePairValid, + &[("time", &countdown(left))], + ))) + } + }; + h_flex() .id("mobile-pairing") .mt(px(12.)) .gap(px(24.)) .items_start() - // White whatever the theme: a camera reads dark on light. + // White whatever the theme: a camera reads dark on light. Faded + // once spent, so nobody scans a code that will be refused. .child( div() .flex_none() @@ -406,45 +538,10 @@ impl Tty7App { .bg(gpui::white()) .border_1() .border_color(tk.k08) + .when(pairing.spent.is_some(), |d| d.opacity(0.15)) .child(qr), ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap(px(12.)) - .child( - div() - .text_size(fs(13.)) - .text_color(tk.fg) - .child(t(L10nKey::SettingsMobilePairScan)), - ) - .child( - div() - .p(px(8.)) - .rounded(px(6.)) - .bg(tk.k04) - .text_size(fs(11.)) - .font_family(Tk::mono(cx)) - .text_color(tk.k6) - .line_clamp(3) - .text_ellipsis() - .child(pairing.code.clone()), - ) - .child( - h_flex() - .gap(px(12.)) - .items_center() - .child(copy) - .child(cancel), - ) - .child( - div() - .text_size(fs(12.)) - .text_color(tk.k5) - .child(t(L10nKey::SettingsMobilePairValid)), - ), - ) + .child(side) .into_any_element() } }