From 83ef561a36b61b018e556a762e08aff34bf411f1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:33:21 +0800 Subject: [PATCH] fix(mobile): keep the connection across a screen lock (#1104) Back from the background the app now asks the machine whether the connection still answers (Session::answers, up to 3s) and keeps its streams when it does, instead of closing the pane and replaying it every time. A dead connection is closed and dropped so the next dial starts fresh rather than waiting out the idle timeout on it. A dropped connection is retried quietly under the screen that is up; the "Reconnecting" banner comes after 3s, or at once if a keystroke was lost. iOS asks for the background task grace period on leaving, so a short lock comes back to the same connection. Android runs a foreground service (specialUse, with a "Connected to " notification and a partial wake lock) while a machine or pane screen is open. --- CHANGELOG.md | 14 ++ crates/tty7-gateway/tests/bridge.rs | 23 +++ crates/tty7-mobile-client/src/lib.rs | 17 ++ mobile/DESIGN.md | 2 +- .../android/app/src/main/AndroidManifest.xml | 14 ++ .../java/dev/tty7/mobile/KeepAliveService.kt | 79 +++++++++ .../main/java/dev/tty7/mobile/MainActivity.kt | 37 ++++ .../main/res/drawable/ic_stat_connected.xml | 14 ++ mobile/src-tauri/src/lib.rs | 167 +++++++++++++++++- mobile/src/api.ts | 10 +- mobile/src/main.ts | 129 +++++++++++--- 11 files changed, 470 insertions(+), 36 deletions(-) create mode 100644 mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/KeepAliveService.kt create mode 100644 mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_stat_connected.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb0ee19..c6a03258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 much live output again. A watcher that really does fall behind is told so and its stream ends, so the phone reconnects on its own. +- **The phone app no longer drops its connection every time the screen + locks.** Coming back from the background, the app now asks the computer + whether the connection still answers (a round trip of up to three seconds) + and, if it does, keeps the open pane and tree as they were. Before, it + closed the pane and opened it again every time, clearing the screen and + replaying it. A connection that did drop is retried quietly under the + screen that is up, and the "Reconnecting…" banner shows only if that takes + more than three seconds. Lost keystrokes are still reported at once. On + iOS, leaving the app asks for the system's background grace period (about + half a minute), so a short screen lock comes back to the same connection. + On Android, a foreground service with a "Connected to …" notification + keeps the connection up while a machine or pane is open, as SSH apps there + do. + - **The macOS traffic lights sit on the same line as the tiles beside them.** The buttons AppKit draws are 14 points tall and gpui hangs the top of the frame `y` points below the window's top edge, so the lights' centre falls diff --git a/crates/tty7-gateway/tests/bridge.rs b/crates/tty7-gateway/tests/bridge.rs index 08ed2667..22b10f76 100644 --- a/crates/tty7-gateway/tests/bridge.rs +++ b/crates/tty7-gateway/tests/bridge.rs @@ -415,6 +415,29 @@ async fn typing_that_fails_is_reported_not_dropped() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn a_connection_that_still_answers_keeps_its_streams() { + let rig = Rig::new().await; + let session = within(rig.paired()).await; + let (mut keys, mut screen) = within(session.pane(None, 1)).await.unwrap(); + within(screen.next()).await.unwrap(); // size + within(screen.next()).await.unwrap(); // the screen, drawn + + // What the app asks on its way back from the background. + assert!(within(session.answers(Duration::from_secs(3))).await); + // Asking left the pane's stream as it was: typing still comes back. + keys.input(b"ls\r").await.unwrap(); + assert_eq!( + within(screen.next()).await.unwrap(), + Some(PaneItem::Output(b"ls\r".to_vec())) + ); + + // The machine gone, the connection does not answer, and says so well + // within the wait. + rig.gateway.close().await; + assert!(!within(session.answers(Duration::from_secs(3))).await); +} + #[tokio::test(flavor = "multi_thread")] async fn a_phone_takes_a_pane_over_at_its_size_and_gives_it_back() { let rig = Rig::new().await; diff --git a/crates/tty7-mobile-client/src/lib.rs b/crates/tty7-mobile-client/src/lib.rs index b439d905..cf8644d5 100644 --- a/crates/tty7-mobile-client/src/lib.rs +++ b/crates/tty7-mobile-client/src/lib.rs @@ -179,6 +179,23 @@ impl Session { self.conn.close(0u32.into(), b"bye"); } + /// Whether the machine still answers on this connection within `wait`. + /// Back from the background a connection can look open while the machine + /// gave up on it long ago — nothing timed it out while the app was + /// suspended — so only a round trip tells. + pub async fn answers(&self, wait: Duration) -> bool { + if self.is_closed() { + return false; + } + match tokio::time::timeout(wait, open(&self.conn, &Open::Control)).await { + Ok(Ok((mut send, _recv))) => { + let _ = send.finish(); + true + } + _ => false, + } + } + /// Subscribes to the machine's tree. pub async fn control(&self) -> Result { let (send, recv) = open(&self.conn, &Open::Control).await?; diff --git a/mobile/DESIGN.md b/mobile/DESIGN.md index 6bf27cc1..1c6bf703 100644 --- a/mobile/DESIGN.md +++ b/mobile/DESIGN.md @@ -325,7 +325,7 @@ The ⋯ drops a 232px raised panel, 14px corners, Float shadow, from the top-rig Under a machine's title: an 8px dot plus words, 15px ink-2 with tabular numerals. "Direct · 4 ms" (done green), "Relay · n ms" (waiting amber), "Connecting…" (ink-3, breathing), "Offline" (danger). The terminal header repeats it compactly as a 7px dot and a 600-weight word ("Live", "Connecting", "Offline", "Closed"). ### Terminal screen and keybar (signature) -The header is a tinted bar with back chevron, small avatar, title over the state line and Hack cwd, and a single fit/zoom toggle shown only when the pane is too wide to read. The pane keeps the desktop's cols×rows; the font shrinks to fit, or holds at 11px and pans to follow the cursor. Errors float in as a raised banner over the top of the pane; a dropped connection is retried on its own (1s, 2s, 4s … 15s, and at once when the network comes back), with the last good screen left up under a "Reconnecting…" banner until the new replay replaces it. A phone key beside it takes the pane over: it fills tint while held, the pane runs at the phone's grid at 11px with nothing to fit or pan, and the grid follows the keyboard and rotation once they settle. When the desktop takes it back, the banner says so with "Take over again"; taking over again is always a tap, never automatic. A copy key beside fit/zoom lays the whole buffer out as a plain page over the pane, wrapped to the phone and rejoined where the terminal wrapped, so the phone's own selection handles and Copy work on it. The keybar is a horizontally scrolling row of keycaps (esc, tab, ⇧tab, ctrl, ^C, arrows, paste, | / ~ -) fading out at the right edge, plus two fixed accent keys: compose and keyboard. Keys press to 0.94 scale in cell-press; the ctrl latch and an open compose box fill their key accent. The compose box docks on the keybar in the same surface: a pill field (16px system face, so iOS never zooms on focus) growing to about five lines, and a round accent send button. Send types the text, then Enter; several lines go in as one bracketed paste where the program asked for it. An agent's pane opens on the box with the keyboard down, so the screen is read before anything is typed. +The header is a tinted bar with back chevron, small avatar, title over the state line and Hack cwd, and a single fit/zoom toggle shown only when the pane is too wide to read. The pane keeps the desktop's cols×rows; the font shrinks to fit, or holds at 11px and pans to follow the cursor. Errors float in as a raised banner over the top of the pane; a dropped connection is retried on its own (1s, 2s, 4s … 15s, and at once when the network comes back), with the last good screen left up until the new replay replaces it; the "Reconnecting…" banner comes only after 3s, or at once if a key was lost. Back from the background, a connection that still answers keeps its streams, so the pane is not redrawn at all. A phone key beside it takes the pane over: it fills tint while held, the pane runs at the phone's grid at 11px with nothing to fit or pan, and the grid follows the keyboard and rotation once they settle. When the desktop takes it back, the banner says so with "Take over again"; taking over again is always a tap, never automatic. A copy key beside fit/zoom lays the whole buffer out as a plain page over the pane, wrapped to the phone and rejoined where the terminal wrapped, so the phone's own selection handles and Copy work on it. The keybar is a horizontally scrolling row of keycaps (esc, tab, ⇧tab, ctrl, ^C, arrows, paste, | / ~ -) fading out at the right edge, plus two fixed accent keys: compose and keyboard. Keys press to 0.94 scale in cell-press; the ctrl latch and an open compose box fill their key accent. The compose box docks on the keybar in the same surface: a pill field (16px system face, so iOS never zooms on focus) growing to about five lines, and a round accent send button. Send types the text, then Enter; several lines go in as one bracketed paste where the program asked for it. An agent's pane opens on the box with the keyboard down, so the screen is read before anything is typed. ### Empty and loading states Empty: centred, 56px top padding, the Duo logo mark (76px) or a 64px accent tile, a 22px headline, a 16px ink-2 sentence, and at most one primary button. Loading: three skeleton rows with hairline avatars and 12px bones breathing at 1.6s. diff --git a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 8c4d452b..c091387e 100644 --- a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + @@ -31,6 +36,15 @@ + + + + = Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL, "Connection", NotificationManager.IMPORTANCE_LOW).apply { + description = "Shown while tty7 keeps a connection up in the background" + setShowBadge(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + val open = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notification = NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_connected) + .setContentTitle("Connected to $host") + .setContentText("Kept up while tty7 is in the background") + .setContentIntent(open) + .setOngoing(true) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + } + ServiceCompat.startForeground(this, NOTIFICATION, notification, type) + if (wakeLock == null) { + wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "tty7:connection") + .apply { acquire() } + } + return START_NOT_STICKY + } + + // Swiped away from recents, the app is done: nothing is left to keep up. + override fun onTaskRemoved(rootIntent: Intent?) { + stopSelf() + } + + override fun onDestroy() { + wakeLock?.release() + wakeLock = null + super.onDestroy() + } + + companion object { + const val EXTRA_HOST = "host" + private const val CHANNEL = "connection" + private const val NOTIFICATION = 1 + } +} diff --git a/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt b/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt index 0ebb96b2..69d0f1d1 100644 --- a/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt +++ b/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt @@ -1,8 +1,14 @@ package dev.tty7.mobile +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.webkit.WebView import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -27,4 +33,35 @@ class MainActivity : TauriActivity() { ViewCompat.onApplyWindowInsets(view, insets) } } + + // Keeps the connection to `host` up in the background, or stops when it is + // null (lib.rs `keep_alive`, called as the page moves between screens, so + // always while the app is in front, where a foreground service may start). + @Suppress("unused") + fun keepAlive(host: String?) { + val service = Intent(this, KeepAliveService::class.java) + if (host == null) { + stopService(service) + return + } + askToNotify() + service.putExtra(KeepAliveService.EXTRA_HOST, host) + try { + ContextCompat.startForegroundService(this, service) + } catch (e: Exception) { + // Not allowed just now: the connection does without, as it did. + } + } + + // The service's notification is only shown with leave to notify (Android + // 13 on). Asked once, the first time a connection is kept up; the service + // runs either way. + private fun askToNotify() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return + val prefs = getSharedPreferences("tty7", MODE_PRIVATE) + if (prefs.getBoolean("asked_notify", false)) return + prefs.edit().putBoolean("asked_notify", true).apply() + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0) + } } diff --git a/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_stat_connected.xml b/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_stat_connected.xml new file mode 100644 index 00000000..bb046eb7 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_stat_connected.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index 14d0f5e2..96fe80d3 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -215,14 +215,49 @@ async fn forget(state: State<'_, Arc>, host_id: String) -> CmdResult<( Ok(()) } +/// How long a machine has to answer, back from the background, before its +/// connection is taken for dead. A few round trips even over a relay; much +/// less than the idle timeout a stale connection would otherwise wait out. +const ALIVE_WAIT: Duration = Duration::from_secs(3); + +/// Whether the connection to a machine still works, asked as the app comes +/// back from the background. One that does keeps its streams, and what +/// happened meanwhile arrives on them; one that does not is closed and +/// dropped, so the next dial starts fresh rather than on the dead one. +#[tauri::command] +async fn alive(state: State<'_, Arc>, host_id: String) -> CmdResult { + let Some(session) = state.sessions.lock().await.get(&host_id).cloned() else { + return Ok(false); + }; + if session.answers(ALIVE_WAIT).await { + return Ok(true); + } + session.close(); + let mut sessions = state.sessions.lock().await; + // Dialed again meanwhile: that one is not this one's to drop. + if sessions.get(&host_id).is_some_and(|s| s.is_closed()) { + sessions.remove(&host_id); + } + Ok(false) +} + /// What a machine's screen hears about it. #[derive(Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum TreeMsg { - Tree { tree: Tree }, - Link { link: LinkInfo }, - Error { message: String }, - Closed, + Tree { + tree: Tree, + }, + Link { + link: LinkInfo, + }, + Error { + message: String, + }, + /// The stream ended; `message` says why when it broke rather than ended. + Closed { + message: Option, + }, } /// Subscribes to a machine's tree. Resolves once the subscription is up; the @@ -252,14 +287,13 @@ async fn watch( Ok(Some(ControlEvent::Error { message })) => { (TreeMsg::Error { message }, false) } - Ok(None) => (TreeMsg::Closed, true), - Err(e) => (TreeMsg::Error { message: err(e) }, true), + Ok(None) => (TreeMsg::Closed { message: None }, true), + Err(e) => (TreeMsg::Closed { message: Some(err(e)) }, true), }; if on_event.send(msg).is_err() { break 'stream; } if last { - let _ = on_event.send(TreeMsg::Closed); break 'stream; } } @@ -584,6 +618,8 @@ pub fn run() { edge_to_edge(&window); keyboard(&window); } + #[cfg(target_os = "ios")] + background_grace(); let dir = app.path().app_data_dir()?; app.manage(Arc::new(AppState { dir, @@ -603,6 +639,8 @@ pub fn run() { watch, unwatch, refresh, + alive, + keep_alive, tab_new, tab_close, pane_kill, @@ -935,6 +973,87 @@ fn keyboard(window: &tauri::WebviewWindow) { }); } +/// Leaving for the background, the app asks iOS for the time it gives to +/// finish what is under way — about half a minute — and spends it keeping the +/// connections up, so locking the phone for a moment or glancing at another +/// app comes back to the same streams, nothing redialed. Past that iOS +/// suspends the app and the connections lapse; the page finds out on its way +/// back (`alive`). The time is handed back on return, or when iOS calls it in. +#[cfg(target_os = "ios")] +fn background_grace() { + use block2::RcBlock; + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + use std::ptr::{NonNull, null_mut}; + use std::sync::atomic::AtomicUsize; + + /// The task iOS is giving time to; `UIBackgroundTaskInvalid` (0) when none. + static TASK: AtomicUsize = AtomicUsize::new(0); + + fn application() -> *mut AnyObject { + match AnyClass::get(c"UIApplication") { + Some(class) => unsafe { msg_send![class, sharedApplication] }, + None => null_mut(), + } + } + /// Hands the time back. Only on the main queue, where every block here runs. + fn end() { + let task = TASK.swap(0, Ordering::Relaxed); + let app = application(); + if task != 0 && !app.is_null() { + let _: () = unsafe { msg_send![app, endBackgroundTask: task] }; + } + } + unsafe fn string(s: &std::ffi::CStr) -> *mut AnyObject { + match AnyClass::get(c"NSString") { + Some(class) => msg_send![class, stringWithUTF8String: s.as_ptr()], + None => null_mut(), + } + } + + unsafe { + let (Some(center), Some(queue)) = ( + AnyClass::get(c"NSNotificationCenter"), + AnyClass::get(c"NSOperationQueue"), + ) else { + return; + }; + let center: *mut AnyObject = msg_send![center, defaultCenter]; + let queue: *mut AnyObject = msg_send![queue, mainQueue]; + // Called in when the time is up: handed back at once, or iOS ends the + // app rather than suspending it. + let expired = RcBlock::new(end); + let left = RcBlock::new(move |_: NonNull| { + end(); + let app = application(); + if app.is_null() { + return; + } + let task: usize = msg_send![ + app, + beginBackgroundTaskWithName: string(c"tty7 connections"), + expirationHandler: &*expired + ]; + TASK.store(task, Ordering::Relaxed); + }); + let back = RcBlock::new(|_: NonNull| end()); + for (name, block) in [ + (c"UIApplicationDidEnterBackgroundNotification", &left), + (c"UIApplicationWillEnterForegroundNotification", &back), + ] { + let token: *mut AnyObject = msg_send![ + center, + addObserverForName: string(name), + object: null_mut::(), + queue: queue, + usingBlock: &**block + ]; + // Watched for as long as the app runs. + let _: *mut AnyObject = msg_send![token, retain]; + } + } +} + /// The system bars and display cutout the page is drawn under, in CSS pixels. /// Android runs the WebView edge to edge, yet WebViews before 140 report /// `env(safe-area-inset-*)` as 0, so the page asks here. Zero elsewhere: iOS's @@ -989,6 +1108,40 @@ fn to_background(window: tauri::WebviewWindow) { let _ = window; } +/// Keeps the connection up while the app is in the background, for as long +/// as `host` is set: the machine it is connected to, or `None` once no screen +/// holds a connection. Android runs a foreground service for it, with its +/// notification, as terminal apps there do. iOS has no such thing for an app +/// like this one; it gets a short grace period instead (`background_grace`). +#[tauri::command] +fn keep_alive(window: tauri::WebviewWindow, host: Option) { + #[cfg(target_os = "android")] + let _ = window.with_webview(move |webview| { + webview.jni_handle().exec(move |env, activity, _| { + let name = match &host { + Some(name) => match env.new_string(name) { + Ok(name) => jni::objects::JObject::from(name), + Err(_) => return, + }, + None => jni::objects::JObject::null(), + }; + if env + .call_method( + activity, + "keepAlive", + "(Ljava/lang/String;)V", + &[(&name).into()], + ) + .is_err() + { + let _ = env.exception_clear(); + } + }) + }); + #[cfg(not(target_os = "android"))] + let _ = (window, host); +} + #[cfg(target_os = "android")] mod android { use jni::JNIEnv; diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 5fe5ae86..5fa9b4fb 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -85,7 +85,7 @@ export type TreeMsg = | { type: "tree"; tree: Tree } | { type: "link"; link: LinkInfo } | { type: "error"; message: string } - | { type: "closed" }; + | { type: "closed"; message: string | null }; export type PaneEvent = | { type: "size"; cols: number; rows: number } @@ -133,6 +133,14 @@ export const unwatch = (watch: number) => invoke("unwatch", { watch }); export const refresh = (watch: number) => invoke("refresh", { watch }); +/** Whether the connection to a machine still gets answers. A dead one is + * dropped, so whatever opens next dials afresh. */ +export const alive = (hostId: string) => invoke("alive", { hostId }); + +/** Keeps the connection up in the background while a screen holds one: `host` + * names the machine, `null` lets go. Android only; iOS gets a short grace. */ +export const keepAlive = (host: string | null) => invoke("keep_alive", { host }); + /** Output arrives as ArrayBuffers of raw terminal bytes, events as objects. */ /** `machine` is a remote's key, or null for the paired machine itself. */ export function paneOpen( diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 30a45e9a..cb79deae 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -313,13 +313,23 @@ async function canLock() { const still = matchMedia("(prefers-reduced-motion: reduce)"); +// The machine the screen on show is connected to, set as it renders: the +// connection is kept up in the background while one is (`api.keepAlive`). +let connectedTo: string | null = null; +let held: string | null = null; + function go(direction: "push" | "pop", render: () => HTMLElement) { const swap = () => { onLeave?.(); onLeave = null; onResume = null; onBack = null; + connectedTo = null; app.replaceChildren(render()); + if (connectedTo !== held) { + held = connectedTo; + api.keepAlive(held).catch(() => {}); + } }; if (!document.startViewTransition || still.matches || !app.firstChild) { swap(); @@ -1167,9 +1177,15 @@ function retrier(run: () => void) { /** How long a machine may stay silent before the screen says so. */ const SLOW_CONNECT_MS = 10_000; +/** How long a dropped connection is retried quietly, under what is on screen, + * before the screen says it dropped. Most drops — the phone locked, the + * network changed — are back well within it. */ +const QUIET_MS = 3_000; + function hostScreen(host: Host, direction: "push" | "pop" = "pop") { remember("last.host", host.id); go(direction, () => { + connectedTo = host.name; const link = h("p", { class: "link" }); // Shown here and remembered for the machine list. const showLink = (info: LinkInfo | null) => { @@ -1269,6 +1285,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { generation++; unwatch(); clearTimeout(slow); + clearTimeout(quiet); retry.cancel(); window.removeEventListener("online", online); }; @@ -1276,22 +1293,34 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { const failed = (message: string) => notice.replaceChildren(noticeCard({ title: "Couldn't open a tab", body: [sentence(message)] })); + // A drop under a tree is retried quietly at first: the link says it is + // connecting, and the notice comes only if that takes a while. + let quiet: number | undefined; const offline = (message: string) => { offlineNow = true; dropped = true; retry.schedule(); - showLink(null); - notice.replaceChildren( - noticeCard({ - title: `Can't reach ${host.name}`, - body: [why(message, host.name), ` Check that tty7 is running on ${host.name} with phone access on.`], - actions: [ - { label: "Try now", run: start }, - { label: "Pair again", run: () => pairScreen() }, - ], - }), - ); - if (!lastTree) body.replaceChildren(); + clearTimeout(quiet); + const say = () => { + showLink(null); + notice.replaceChildren( + noticeCard({ + title: `Can't reach ${host.name}`, + body: [why(message, host.name), ` Check that tty7 is running on ${host.name} with phone access on.`], + actions: [ + { label: "Try now", run: start }, + { label: "Pair again", run: () => pairScreen() }, + ], + }), + ); + }; + if (lastTree) { + showLink({ path: "connecting", rtt_ms: 0 }); + quiet = window.setTimeout(say, QUIET_MS); + } else { + say(); + body.replaceChildren(); + } }; const start = async () => { @@ -1328,6 +1357,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { switch (msg.type) { case "tree": clearTimeout(slow); + clearTimeout(quiet); retry.reset(); if (!lastTree || dropped) notice.replaceChildren(); dropped = false; @@ -1345,7 +1375,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { if (!lastTree) body.replaceChildren(); break; case "closed": - offline("The connection closed."); + offline(msg.message ?? "The connection closed."); break; } }); @@ -1360,10 +1390,20 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { } } }; - // Back from the background the stream may be dead, or fine and merely - // behind: watching again covers both, since the gateway sends the whole - // tree on every new watch. - onResume = start; + // Back from the background: a connection that still answers has kept the + // tree coming, and a refresh makes sure of it; one that does not is + // dialed again, under the tree that is up. + onResume = () => { + const id = watching; + if (id === null || offlineNow) return void start(); + const again = () => { + if (alive && watching === id) start(); + }; + api.alive(host.id).then((ok) => { + if (!ok) again(); + else if (alive && watching === id) api.refresh(id).catch(again); + }, again); + }; if (lastTree) draw(); start(); return view; @@ -2624,6 +2664,7 @@ const drafts = new Map(); * the start, since nobody is reading it anywhere else yet. */ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, tab?: TabRef, run?: string, made = false) { go("push", () => { + connectedTo = host.name; // What the pane is doing and whether keystrokes will land, in words: the // one line under the title. const stateWord = h("span", {}, "Connecting…"); @@ -3027,6 +3068,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, ended = true; live = false; retry.cancel(); + hush(); setState("offline", "Not running"); // Nothing typed here would go anywhere: the box and keys say so // rather than taking it and doing nothing. @@ -3054,20 +3096,38 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }; showBanner(`This pane isn't running. tty7 on ${host.name} starts it again when it next opens the tab.`, fresh || undefined); }; - const offline = (message: string) => { + // A drop is said after a moment, not at once: most come back within it, + // the screen as it was all along. Anything typed meanwhile says it now. + let quiet: { timer: number; say: () => void } | null = null; + const hush = () => { + if (quiet) clearTimeout(quiet.timer); + quiet = null; + }; + const speak = () => { + const say = quiet?.say; + hush(); + say?.(); + }; + const offline = (message: string, now = false) => { if (/no such pane/i.test(message)) return gone(); live = false; setState("connecting", "Reconnecting"); - showBanner(`${why(message, host.name)} Reconnecting…`, { label: "Try now", run: reopen }); + hush(); + const say = () => showBanner(`${why(message, host.name)} Reconnecting…`, { label: "Try now", run: reopen }); + if (now) say(); + else quiet = { timer: window.setTimeout(speak, QUIET_MS), say }; retry.schedule(); }; // Only the first refusal speaks: a key typed just before it fails on its - // own, with a vaguer reason. + // own, with a vaguer reason. Typing lost is said at once. const refused = (message: string) => { - if (alive && live) offline(message); + if (alive && live) offline(message, true); }; const input = (data: string): Promise => { - if (handle === null || !live) return Promise.resolve(false); + if (handle === null || !live) { + speak(); + return Promise.resolve(false); + } return api.paneInput(handle, data).then( () => true, (e) => { @@ -4186,7 +4246,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }; live = false; retry.cancel(); - if (!banner.hasChildNodes()) setState("connecting", "Connecting"); + if (!banner.hasChildNodes() && !quiet) setState("connecting", "Connecting"); try { const opened = await api.paneOpen( host.id, @@ -4223,13 +4283,16 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, live = false; handle = null; retry.cancel(); + hush(); setState("offline", "Closed"); showBanner( event.code === null ? "This pane closed." : `This pane exited with code ${event.code}.`, ); break; case "error": - offline(event.message); + // Keys that did not get through are said at once (lib.rs + // `pane_open`); a stream that broke, after a moment. + offline(event.message, /^typing didn't reach/.test(event.message)); break; case "lease": leaseEvent(event.held, event.refused); @@ -4249,6 +4312,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, sent = ""; askLease(); retry.reset(); + hush(); banner.replaceChildren(); setState("live", liveLabel()); if (cramped) hintPhoneSize(); @@ -4376,6 +4440,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, onLeave = () => { alive = false; retry.cancel(); + hush(); clearTimeout(peekTimer); if (peeking !== null) api.unwatch(peeking).catch(() => {}); window.removeEventListener("online", online); @@ -4387,9 +4452,19 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (handle !== null) api.paneClose(handle); term.dispose(); }; - // The pane replays its screen on every open, so coming back from the - // background is a fresh open onto a reset terminal — never a gap. - onResume = reopen; + // Back from the background, a stream whose connection still answers + // carries on: what the pane printed meanwhile is on its way. One that + // does not is opened again, and the pane replays its screen onto a reset + // terminal — never a gap. + onResume = () => { + if (ended) return; + const was = handle; + if (was === null || !live) return reopen(); + const again = () => { + if (alive && handle === was) reopen(); + }; + api.alive(host.id).then((ok) => ok || again(), again); + }; // Hack has to be loaded before xterm measures a cell, or the first fit is // taken with the fallback face's metrics; the symbols before a glyph is