fix(mobile): let Settings renew a pairing code, and say when one is spent (#1019)

While a code was on screen the Show code button was disabled, so the only
way to a fresh code was Cancel then Show code. Worse, any attempt at a
code closes the offer, so after a mistyped paste or a dropped connection
the page kept showing a dead code for up to ten minutes with no hint.

- The button reads "New code" while a code is up, and replaces it.
- A code that can no longer pair stays on screen faded, saying why
  (expired, tried, or replaced by a newer offer), with New code beside it.
- The validity note counts down instead of saying "10 minutes".
- Cancel, and switching phone access off, now withdraw the offer on disk;
  before, a dismissed code could still pair until it expired. Only our own
  offer is withdrawn, never one opened elsewhere since.

The gateway's pair_code now returns the offer's secret alongside the code,
and State gains pairing_is_open / has_open_pairing / close_pairing.

Claude-Session: https://claude.ai/code/session_01BDVpJ78s7RQVcj57vjTcfA
This commit is contained in:
l0ng-ai
2026-09-29 22:50:59 +08:00
committed by GitHub
parent 9f44feed29
commit 5ba588f6d2
8 changed files with 283 additions and 99 deletions
+1 -1
View File
@@ -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::<qrcode::render::unicode::Dense1x2>()
+16 -7
View File
@@ -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<String> {
/// 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<PairOffer> {
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 })
}
+48
View File
@@ -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::<Pairing>(&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::<Pairing>(&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::<Pairing>(&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();
+9 -1
View File
@@ -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",
+13 -1
View File
@@ -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 => "ペアリング済みのスマートフォン",
+4
View File
@@ -118,6 +118,10 @@ l10n_keys! {
SettingsMobilePairNeedsAccess,
SettingsMobilePairScan,
SettingsMobilePairValid,
SettingsMobileNewCode,
SettingsMobilePairExpired,
SettingsMobilePairTried,
SettingsMobilePairReplaced,
SettingsMobileCopyCode,
SettingsMobilePaired,
SettingsMobilePhones,
+7 -1
View File
@@ -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 => "已配对的手机",
+185 -88
View File
@@ -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<Arc<gpui::Image>>,
/// The phones paired before this code was made, to tell the new one by.
before: Vec<String>,
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<Spent>,
}
#[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<Self>) {
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<Self>) {
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<Self>) {
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()
}
}