diff --git a/src/ui/app.rs b/src/ui/app.rs index edb31f6b..df47ad6c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6687,6 +6687,7 @@ impl Tty7App { mobile_pairing: None, mobile_paired: None, mobile_copied: false, + mobile_starting: false, ssh_filter, ssh_collapsed_groups: std::collections::HashSet::new(), agent_hooks_host: crate::ui::host_ops::HostId::LOCAL, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 64964d21..caf29282 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -52,12 +52,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsMobileAccessDesc => { "Phones you pair can see this machine's panes and type into them, even with every window closed. Connections are end-to-end encrypted." } - L10nKey::SettingsMobileStatus => "Status", - L10nKey::SettingsMobileStatusRunning => "Running — paired phones can reach this machine", L10nKey::SettingsMobileStatusStarting => "Starting…", - L10nKey::SettingsMobileStatusOff => "Off", L10nKey::SettingsMobileStatusFailed => "Not running: {error}", - L10nKey::SettingsMobileStatusElsewhere => "Running in a separate tty7-gateway process", + L10nKey::SettingsMobileStartFailed => "Phone access could not start: {error}", + L10nKey::SettingsMobileNoAnswer => { + "the tty7 server did not start it. Restart the server in Settings → About, then try again." + } L10nKey::SettingsMobilePair => "Pair a phone", L10nKey::SettingsMobileShowCode => "Show code", L10nKey::SettingsMobilePairDesc => "Shows a one-time code for the tty7 app on your phone.", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c32c78e2..626f0757 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -54,14 +54,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsMobileAccessDesc => { "ペアリングしたスマートフォンから、このマシンのペインを表示して入力できます。ウインドウをすべて閉じていても使えます。通信はエンドツーエンドで暗号化されます。" } - L10nKey::SettingsMobileStatus => "状態", - L10nKey::SettingsMobileStatusRunning => { - "実行中 — ペアリング済みのスマートフォンから接続できます" - } L10nKey::SettingsMobileStatusStarting => "起動しています…", - L10nKey::SettingsMobileStatusOff => "オフ", L10nKey::SettingsMobileStatusFailed => "実行されていません: {error}", - L10nKey::SettingsMobileStatusElsewhere => "別の tty7-gateway プロセスで実行中", + L10nKey::SettingsMobileStartFailed => { + "スマートフォンからのアクセスを開始できませんでした: {error}" + } + L10nKey::SettingsMobileNoAnswer => { + "tty7 サーバーが起動しませんでした。設定 → 情報 でサーバーを再起動してから、もう一度お試しください。" + } L10nKey::SettingsMobilePair => "スマートフォンをペアリング", L10nKey::SettingsMobileShowCode => "コードを表示", L10nKey::SettingsMobilePairDesc => { diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 61ee0b4f..a693028b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -108,12 +108,10 @@ l10n_keys! { SettingsNavMobile, SettingsMobileAccess, SettingsMobileAccessDesc, - SettingsMobileStatus, - SettingsMobileStatusRunning, SettingsMobileStatusStarting, - SettingsMobileStatusOff, SettingsMobileStatusFailed, - SettingsMobileStatusElsewhere, + SettingsMobileStartFailed, + SettingsMobileNoAnswer, SettingsMobilePair, SettingsMobileShowCode, SettingsMobilePairDesc, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6c298166..6f12e6ca 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -48,12 +48,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsMobileAccessDesc => { "配对过的手机可以查看并操作这台电脑上的窗格,关掉所有窗口也可以。连接端到端加密。" } - L10nKey::SettingsMobileStatus => "状态", - L10nKey::SettingsMobileStatusRunning => "运行中,配对过的手机可以连到这台电脑", L10nKey::SettingsMobileStatusStarting => "正在启动…", - L10nKey::SettingsMobileStatusOff => "已关闭", L10nKey::SettingsMobileStatusFailed => "未运行:{error}", - L10nKey::SettingsMobileStatusElsewhere => "由单独的 tty7-gateway 进程运行", + L10nKey::SettingsMobileStartFailed => "手机访问没能启动:{error}", + L10nKey::SettingsMobileNoAnswer => { + "tty7 server 没有启动它。请在 设置 → 关于 中重启 server 后再试。" + } L10nKey::SettingsMobilePair => "配对手机", L10nKey::SettingsMobileShowCode => "显示配对码", L10nKey::SettingsMobilePairDesc => "生成一个一次性配对码,给手机上的 tty7 app 用。", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e4eed029..a57a266c 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1223,6 +1223,8 @@ pub(crate) struct SettingsState { /// The phone the last pairing on screen ended with, said once. pub(crate) mobile_paired: Option, pub(crate) mobile_copied: bool, + /// Phone access was just switched on and no gateway is serving yet. + pub(crate) mobile_starting: bool, pub(crate) ssh_filter: Entity, pub(crate) ssh_collapsed_groups: std::collections::HashSet, pub(crate) agent_hooks_host: HostId, diff --git a/src/ui/settings/mobile.rs b/src/ui/settings/mobile.rs index a44d8c46..c8aff95b 100644 --- a/src/ui/settings/mobile.rs +++ b/src/ui/settings/mobile.rs @@ -21,8 +21,10 @@ const PAIR_TTL: Duration = Duration::from_secs(600); /// How often an open pairing looks for the phone that used it. const PAIR_POLL: Duration = Duration::from_secs(1); const QR_SIZE: f32 = 208.; -/// The desktop's own "running" green, from the agent status dots. -const RUNNING: u32 = 0x22C55E; +/// How long switching phone access on may take before it counts as failed: +/// the daemon notices within a couple of seconds, and a gateway binds in one. +const START_TIMEOUT: Duration = Duration::from_secs(15); +const START_POLL: Duration = Duration::from_millis(500); /// A pairing code on screen. pub(crate) struct Pairing { @@ -33,12 +35,13 @@ pub(crate) struct Pairing { until: Instant, } -enum Health { +/// What the switch shows: whether phones can actually reach this machine, +/// not merely whether they were asked to be allowed. +enum Access { Off, Starting, - Running, - /// A `tty7-gateway serve` holds the state directory, not the daemon. - Elsewhere, + On, + /// Asked for, and the daemon could not start it: shown off, with why. Failed(String), } @@ -49,16 +52,17 @@ fn gateway_state(on: bool) -> Option { (on || dir.is_dir()).then(State::open_default)?.ok() } -fn health(on: bool, state: Option<&State>) -> Health { - let Some(state) = state else { - return Health::Off; - }; - match (on, state.serving(), state.status()) { - (true, true, _) => Health::Running, - (false, true, _) => Health::Elsewhere, - (true, false, Some(Status::Failed { error })) => Health::Failed(error), - (true, false, _) => Health::Starting, - (false, false, _) => Health::Off, +fn access(on: bool, starting: bool, state: Option<&State>) -> Access { + let serving = state.is_some_and(State::serving); + match (on, serving, starting) { + (false, _, _) => Access::Off, + (true, true, _) => Access::On, + (true, false, true) => Access::Starting, + (true, false, false) => match state.and_then(State::status) { + Some(Status::Failed { error }) => Access::Failed(error), + // The daemon has not got to it yet, or is between retries. + _ => Access::Starting, + }, } } @@ -93,19 +97,68 @@ fn qr_image(code: &str) -> Option> { } impl Tty7App { - pub(crate) fn set_mobile_access(&mut self, on: bool, cx: &mut Context) { - self.update_config(cx, |cfg| cfg.mobile_access = on); - if !on && let Some(s) = self.active_settings_mut() { - s.mobile_pairing = None; + /// Switching phone access on is a request to the daemon, so the switch + /// holds at "starting" until a gateway is actually serving. If none comes + /// up, the switch goes back off and says why, rather than stay on over + /// a gateway that is not there. + pub(crate) fn set_mobile_access( + &mut self, + on: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !on { + self.update_config(cx, |cfg| cfg.mobile_access = false); + if let Some(s) = self.active_settings_mut() { + s.mobile_pairing = None; + s.mobile_starting = false; + } + cx.notify(); + return; } - // The daemon picks the switch up within a couple of seconds; keep the - // status line honest while it does. - cx.spawn(async move |this, cx| { - for _ in 0..10 { - smol::Timer::after(Duration::from_secs(1)).await; - if this.update(cx, |_, cx| cx.notify()).is_err() { - return; - } + // A failure from an earlier attempt must not be mistaken for this + // one's. Nothing is serving, so the file is nobody's to keep. + if let Some(state) = gateway_state(true) + && !state.serving() + { + let _ = state.set_status(&Status::Stopped); + } + self.update_config(cx, |cfg| cfg.mobile_access = true); + if let Some(s) = self.active_settings_mut() { + s.mobile_starting = true; + } + cx.notify(); + + let deadline = Instant::now() + START_TIMEOUT; + cx.spawn_in(window, async move |this, cx| { + loop { + smol::Timer::after(START_POLL).await; + let state = gateway_state(true); + let failure = match state.as_ref() { + Some(state) if state.serving() => None, + Some(state) => match state.status() { + Some(Status::Failed { error }) => Some(error), + _ if Instant::now() >= deadline => { + Some(t(L10nKey::SettingsMobileNoAnswer).to_string()) + } + _ => continue, + }, + None => Some(t(L10nKey::SettingsMobileNoAnswer).to_string()), + }; + let _ = this.update_in(cx, |this, window, cx| { + if let Some(s) = this.active_settings_mut() { + s.mobile_starting = false; + } + if let Some(error) = failure { + this.update_config(cx, |cfg| cfg.mobile_access = false); + window.push_notification( + t_fmt(L10nKey::SettingsMobileStartFailed, &[("error", &error)]), + cx, + ); + } + cx.notify(); + }); + return; } }) .detach(); @@ -189,32 +242,24 @@ impl Tty7App { let tk = Tk::of(cx); let on = cx.global::().mobile_access; let state = gateway_state(on); - let health = health(on, state.as_ref()); - let running = matches!(health, Health::Running | Health::Elsewhere); + let starting = self.active_settings().is_some_and(|s| s.mobile_starting); + let access = access(on, starting, state.as_ref()); + let serving = state.as_ref().is_some_and(State::serving); - let access = self.settings_switch("mobile-access", on, cx, |this, on, _, cx| { - this.set_mobile_access(on, cx) - }); - let (dot, status) = match &health { - Health::Off => (tk.k35, t(L10nKey::SettingsMobileStatusOff).to_string()), - Health::Starting => ( - tk.warn, - t(L10nKey::SettingsMobileStatusStarting).to_string(), - ), - Health::Running => ( - gpui::rgb(RUNNING).into(), - t(L10nKey::SettingsMobileStatusRunning).to_string(), - ), - Health::Elsewhere => ( - gpui::rgb(RUNNING).into(), - t(L10nKey::SettingsMobileStatusElsewhere).to_string(), - ), - Health::Failed(error) => ( - tk.danger, - t_fmt(L10nKey::SettingsMobileStatusFailed, &[("error", error)]), - ), + let switch = kit::switch("mobile-access") + .checked(matches!(access, Access::On | Access::Starting)) + .disabled(matches!(access, Access::Starting)) + .on_click( + cx.listener(|this, on: &bool, window, cx| this.set_mobile_access(*on, window, cx)), + ) + .into_any_element(); + let access_desc = match &access { + Access::Off | Access::On => t(L10nKey::SettingsMobileAccessDesc).to_string(), + Access::Starting => t(L10nKey::SettingsMobileStatusStarting).to_string(), + Access::Failed(error) => { + t_fmt(L10nKey::SettingsMobileStatusFailed, &[("error", error)]) + } }; - let status_dot = div().size(px(8.)).rounded_full().bg(dot).into_any_element(); let pairing = self .active_settings() @@ -227,9 +272,9 @@ impl Tty7App { cx, |this, _, cx| this.start_mobile_pairing(cx), ) - .disabled(!running || pairing.is_some()) + .disabled(!serving || pairing.is_some()) .into_any_element(); - let pair_desc = match (running, &paired) { + let pair_desc = match (serving, &paired) { (_, Some(name)) => t_fmt(L10nKey::SettingsMobilePaired, &[("name", name)]), (true, None) => t(L10nKey::SettingsMobilePairDesc).to_string(), (false, None) => t(L10nKey::SettingsMobilePairNeedsAccess).to_string(), @@ -238,17 +283,9 @@ impl Tty7App { let access_group = self.settings_group( None, None, - [ - self.settings_row( - t(L10nKey::SettingsMobileAccess), - t(L10nKey::SettingsMobileAccessDesc), - access, - cx, - ) - .into_any_element(), - self.settings_row(t(L10nKey::SettingsMobileStatus), status, status_dot, cx) - .into_any_element(), - ], + [self + .settings_row(t(L10nKey::SettingsMobileAccess), access_desc, switch, cx) + .into_any_element()], cx, ); diff --git a/src/ui/settings_window.rs b/src/ui/settings_window.rs index 96296b25..6166e6b3 100644 --- a/src/ui/settings_window.rs +++ b/src/ui/settings_window.rs @@ -107,5 +107,10 @@ impl Render for SettingsWindow { // ⌘, lands here while this window has focus; it is already open. .on_action(cx.listener(|_, _: &OpenSettings, window, _| window.activate_window())) .when_some(page.flatten(), |root, page| root.child(page)) + // The window is a `Root` like the workspace's, and like it has to + // draw its own notifications: without this layer a toast pushed + // from Settings — a failed switch, "Set as Default Terminal" — was + // queued and never shown. + .children(gpui_component::Root::render_notification_layer(window, cx)) } }