diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index e239a80..00073ef 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -11247,10 +11247,7 @@ internal sealed partial class VaultViewModel( } catch (TimeoutException) { - Abandon( - attempt, - "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 " - + "runtime is probably missing or blocked; install it and try again."); + Abandon(attempt, RendererNeverStarted); } catch (SshHostKeyUnknownException exception) { @@ -11275,6 +11272,29 @@ internal sealed partial class VaultViewModel( } } + /// What a renderer that never attached is reported as. + /// + /// + /// The wait is translated rather than reported for the reason gives — + /// says only "The operation has timed out" — and the whole value of the + /// translation is naming where to look. Which is why it cannot be one sentence: the desktop's answer is + /// a runtime this application does not install, and the phone has no such runtime and no such answer. + /// Telling somebody on a handset to install Microsoft Edge WebView2 is worse than saying nothing, at the + /// one moment they are trying to work out what went wrong. + /// + /// + /// A runtime check rather than a constructor parameter, for the reason + /// MainWindowViewModel.GestureWait records at length: which renderer is behind the terminal is a + /// fact about the platform this assembly is running on, not about one installation of it. + /// + /// + private static string RendererNeverStarted => + OperatingSystem.IsAndroid() + ? "The terminal did not start, so nothing was connected. Android's WebView is probably " + + "disabled or updating; check it in Settings and try again." + : "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 " + + "runtime is probably missing or blocked; install it and try again."; + /// Says, in one place, that an attempt ended without a session and why. /// /// The reason goes to two places on purpose. The status line is where somebody watching this screen is diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js index 1d7f8b1..68298ee 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js @@ -255,8 +255,31 @@ function createSession(sessionId) { // WebGL where it is available. Falling back rather than failing matters because a software // renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs // routinely have no usable GPU context. + // + // ◆ THE CONTEXT-LOSS HANDLER IS THE HALF THAT WAS MISSING, AND ON A PHONE IT IS THE WHOLE THING. + // + // The addon does not recover from a lost GPU context by itself, and it does not fail loudly either: + // it stays loaded over a dead context and draws nothing at all. What that looks like from outside is + // a terminal that is connected, still accepting keystrokes, still acknowledging output — and blank. + // xterm's own guidance is to dispose the addon and let the DOM renderer take over, which is what this + // does; the addon is not reloaded afterwards, because a pane that lost the context once is on a + // surface that will do it again and thrashing between renderers is worse than being slow. + // + // Losing it is ordinary on Android and nearly unheard of on Windows, which is why this went unnoticed + // for so long. Collapsing the renderer sets the native view to GONE — see + // AndroidNativeControlHostImpl.HideWithSize — and a WebView with no surface has no GL context. The + // shell collapses it every time a tab starts connecting, every time the connect sheet opens and every + // time the app is backgrounded, so on a phone the first loss arrives within seconds of the first + // session. WebView2 hides a child HWND instead and keeps rendering throughout; see + // docs/platform-flags.md. try { - term.loadAddon(new WebglAddon.WebglAddon()); + const webgl = new WebglAddon.WebglAddon(); + + // Subscribed before loadAddon, because loadAddon is what activates the addon and a context that is + // already gone can be reported from inside that call. + webgl.onContextLoss(() => webgl.dispose()); + + term.loadAddon(webgl); } catch (error) { console.warn('WebGL renderer unavailable; falling back to canvas.', error); } @@ -296,10 +319,17 @@ function activate(sessionId) { // caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge // once splits land. // -// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed. -// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport -// does not change, no observer fires and this function is never called — measured with a live shell, and -// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md. +// It is *not* what protects the vault's lock screen on the desktop, which an earlier version of this +// comment claimed. Collapsing WebView2 hides a native child window without resizing it, so this page's +// viewport does not change, no observer fires and this function is never called — measured with a live +// shell, and confirmed by removing the guard and finding the lock cycle equally clean. See +// docs/platform-flags.md. +// +// On the phone it *is* load-bearing, and that is the one place the two heads differ here. Android hides a +// native child by setting it GONE, and a GONE view is skipped by its parent's layout — so collapsing the +// renderer really does take this page's viewport to nothing, the observer really does fire, and without +// the guard every lock, every connect sheet and every trip to the background would reflow the remote pty +// to 2x1 and mangle the scrollback it wrapped. const MINIMUM_FITTABLE_PIXELS = 40; function resize(session, sessionId) {