feat(mobile): the switch shows whether phones can reach you, not a status row

Settings → Mobile had an "Allow phone access" switch that showed intent and a
separate Status row that showed reality. That is one thing shown twice, and
the switch could sit on while nothing was running.

- The switch is the state. Switching on holds it at "Starting…", disabled,
  until a gateway is actually serving. If the daemon reports a failure, or
  nothing comes up within 15 s, the switch goes back off, `mobile_access`
  is reverted, and a notification says why.
- If the page opens on a failure the daemon is still retrying, the switch
  shows off with the reason in its description, and switching it on
  retries.
- The Status row is removed, with its four strings. There are two new
  strings for the failure, in en, zh and ja.

Also fixes the Settings window never showing notifications. It is a `Root`
like the workspace window but did not draw the notification layer, so any
toast pushed from Settings was queued and never shown. That included the
existing "Set as Default Terminal" result.

Checked in a dev instance. On a tty7-server daemon with no tty7-gateway:
Starting… first, then off, with "Phone access could not start: … no
tty7-gateway beside it or on PATH". On a tty7-app daemon: on, with Show
code enabled and no toast.
This commit is contained in:
l0ng-ai
2026-09-27 10:58:52 +02:00
parent 65d8915fd5
commit d814be18af
8 changed files with 126 additions and 83 deletions
+1
View File
@@ -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,
+4 -4
View File
@@ -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.",
+6 -6
View File
@@ -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 => {
+2 -4
View File
@@ -108,12 +108,10 @@ l10n_keys! {
SettingsNavMobile,
SettingsMobileAccess,
SettingsMobileAccessDesc,
SettingsMobileStatus,
SettingsMobileStatusRunning,
SettingsMobileStatusStarting,
SettingsMobileStatusOff,
SettingsMobileStatusFailed,
SettingsMobileStatusElsewhere,
SettingsMobileStartFailed,
SettingsMobileNoAnswer,
SettingsMobilePair,
SettingsMobileShowCode,
SettingsMobilePairDesc,
+4 -4
View File
@@ -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 用。",
+2
View File
@@ -1223,6 +1223,8 @@ pub(crate) struct SettingsState {
/// The phone the last pairing on screen ended with, said once.
pub(crate) mobile_paired: Option<String>,
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<InputState>,
pub(crate) ssh_collapsed_groups: std::collections::HashSet<String>,
pub(crate) agent_hooks_host: HostId,
+102 -65
View File
@@ -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<State> {
(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<Arc<gpui::Image>> {
}
impl Tty7App {
pub(crate) fn set_mobile_access(&mut self, on: bool, cx: &mut Context<Self>) {
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<Self>,
) {
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::<Config>().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,
);
+5
View File
@@ -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))
}
}