diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-06 02:27:06 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 02:27:06 +0300 |
| commit | 9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch) | |
| tree | 03f1259f91fd9edeacfd4b904f2e84d4acf25232 | |
| parent | 35b0dff96579f099e0436290085024424ffb289c (diff) | |
feat: manage multiple ssh machines from one client (#3670)
128 files changed, 17880 insertions, 2683 deletions
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index b625e276..b3990e96 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1689,6 +1689,18 @@ ], "type": "object" }, + "ClientShellSurfaceSetParams": { + "description": "Updates whether the requesting client shell receives and controls pane presentation.", + "properties": { + "active": { + "type": "boolean" + } + }, + "required": [ + "active" + ], + "type": "object" + }, "ClientWindowTitleSetParams": { "properties": { "title": { @@ -4930,6 +4942,22 @@ { "properties": { "method": { + "const": "client_shell.surface.set", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/ClientShellSurfaceSetParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { "const": "session.snapshot", "type": "string" }, @@ -10459,6 +10487,29 @@ "type": "object" }, { + "description": "Acknowledgement for the client-shell surface interest lease. This method is new on the\nendpoint protocol, so its revision-bearing result can establish an activation floor.", + "properties": { + "active": { + "type": "boolean" + }, + "projection_revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "client_shell_surface_set", + "type": "string" + } + }, + "required": [ + "type", + "active", + "projection_revision" + ], + "type": "object" + }, + { "properties": { "type": { "const": "ok", @@ -10487,8 +10538,18 @@ "null" ] }, + "health_check": { + "default": false, + "description": "Whether this server supports endpoint health probes.", + "type": "boolean" + }, "live_handoff": { "type": "boolean" + }, + "surface_interest": { + "default": false, + "description": "Whether this server supports explicit client-shell surface interest.", + "type": "boolean" } }, "required": [ diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 0e147934..d82a550a 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -45,6 +45,23 @@ herdr api schema --output herdr-api.schema.json with the installed binary. Use `--json` for the full JSON Schema document, or `--output PATH` to write that document to a file. +## Saved SSH machines + +```bash +herdr machine list [--json] +herdr machine add workbox --label "Build machine" [--remote-session agents] +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +`machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. + +Changes apply automatically to open local Herdr clients, normally within a second. Added or enabled machines connect in the background; renaming does not reconnect. Removing or disabling disconnects only that machine and leaves its remote sessions running. Removing the machine you are viewing returns to Local, or shows Local as unavailable until it reconnects. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. + +Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox --session agents --handoff`, to complete that setup in the foreground, then restart the client. + ## Shell completions ```bash diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 84eaedbc..63ce3186 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -352,7 +352,7 @@ The expanded desktop sidebar renders each inner array in `rows` as one line. The [ui.sidebar.agents] row_gap = 0 rows = [ - ["state_icon", "workspace", "tab"], + ["state_icon", "machine", "workspace", "tab"], ["agent"], ] @@ -368,6 +368,7 @@ Agent rows accept these built-in tokens: - `state_icon` — colored icon for the agent's semantic state. - `state_text` — `idle`, `working`, `blocked`, `done`, or `unknown`, including a reported display label when present. +- `machine` — machine label when the client has multiple machines; omitted for a single local machine. - `workspace` — workspace name. - `tab` — tab name when available. - `pane` — pane name when available. @@ -385,7 +386,7 @@ Space rows accept these built-in tokens: - `git_status` — Git ahead and behind counts when nonzero. - `$name` — custom workspace metadata named `name`. -Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Each layout may contain at most 16 rows, with at most 16 tokens in each row. +Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Existing custom rows remain unchanged, so add `machine` explicitly if you want machine identity in a custom multi-machine layout. Each layout may contain at most 16 rows, with at most 16 tokens in each row. A token entry can also be an inline style table: diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index b5930e1a..7448297a 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -43,6 +43,23 @@ herdr api schema --output herdr-api.schema.json `herdr api schema` は、インストール済みバイナリに同梱されたソケットプロトコルスキーマの短い概要を表示します。完全な JSON Schema ドキュメントが欲しいときは `--json` を使い、ファイルに書き出すには `--output PATH` を使ってください。 +## 保存済み SSH マシン + +```bash +herdr machine list [--json] +herdr machine add workbox --label "Build machine" [--remote-session agents] +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +`machine add` はリモート側の Herdr が必要な機能を備えているか確認し、必要な場合だけ承認を得てインストールまたは更新します。指定したセッションのバックグラウンドサーバーを起動してからプロファイルを保存します。互換性があればリリースバージョンは一致しなくてもかまいません。インストールや再起動の承認が必要な場合は、対話型ターミナルで実行してください。セットアップが失敗またはキャンセルされた場合、プロファイルは保存されません。 + +変更は、開いているローカルの Herdr クライアントに通常 1 秒以内に自動反映されます。追加または有効化したマシンにはバックグラウンドで接続し、名前の変更では再接続しません。削除または無効化すると、そのマシンだけが切断され、リモートセッションは動き続けます。表示中のマシンを削除すると Local に戻ります。Local に接続できない場合は、再接続するまで利用不可と表示します。各プロファイルは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態をクライアントの状態ファイルに保存します。パスワード、秘密鍵、その他の SSH 認証情報は保存しません。 + +自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox --session agents --handoff` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。 + ## シェル補完 ```bash diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 82248d4d..8377d56c 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -348,7 +348,7 @@ status_indicators = "symbols" [ui.sidebar.agents] row_gap = 0 rows = [ - ["state_icon", "workspace", "tab"], + ["state_icon", "machine", "workspace", "tab"], ["agent"], ] @@ -364,6 +364,7 @@ Agent の行では、次の組み込みトークンを使えます: - `state_icon` — エージェントの意味的な状態を示す色付きアイコン。 - `state_text` — `idle`、`working`、`blocked`、`done`、`unknown`。報告された表示ラベルがある場合はそれも含みます。 +- `machine` — クライアントに複数のマシンがある場合のマシンラベル。ローカルマシン 1 台だけの場合は省略されます。 - `workspace` — ワークスペース名。 - `tab` — 利用可能な場合はタブ名。 - `pane` — 利用可能な場合はペイン名。 @@ -381,7 +382,7 @@ Space の行では、次の組み込みトークンを使えます: - `git_status` — 0 でない場合は Git の ahead と behind の件数。 - `$name` — `name` という名前のカスタムワークスペースメタデータ。 -トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。各レイアウトは最大 16 行、各行は最大 16 トークンです。 +トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。既存のカスタム行は変更されません。カスタムの複数マシンレイアウトでマシンを識別したい場合は、`machine` を明示的に追加してください。各レイアウトは最大 16 行、各行は最大 16 トークンです。 トークンエントリにはインラインのスタイルテーブルも使えます: diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx index 23c6e817..b83c3b89 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -35,6 +35,43 @@ herdr session stop work --json herdr session delete side-project --json ``` +## 保存済み SSH マシン + +よく使う接続先を保存すると、1 つの Herdr クライアントから複数のマシンで作業できます: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add ssh://you@server:2222 --label "Production" --remote-session operations +herdr machine list +``` + +`machine add` はリモートマシンに Herdr を準備し、指定したセッションのバックグラウンドサーバーを起動してからプロファイルを保存します。インストール済みバイナリとは別に実行中のサーバーを確認し、クライアントとリリースバージョンが異なっていても、互換性があればそのまま使用します。実行中のサーバーに更新が必要な場合、対応していればライブハンドオフでペインのプロセスを維持します。対応していなければ、サーバーを停止する前に確認します。Herdr が未インストールまたは非互換の場合は、`--remote` と同じ承認付きのセットアップを行います。承認が必要な場合は対話型ターミナルで実行してください。キャンセルするとプロファイルは保存されません。セットアップ終了後もリモートサーバーは動き続けます。 + +保存済みマシンの変更は、開いているローカルの Herdr クライアントに通常 1 秒以内に自動反映されます。マシンの切り替え中の場合は、切り替えが完了してから反映します。追加または有効化した SSH マシンはサイドバーに Local と並んで表示され、選択を変えずにバックグラウンドで接続します。マシンまたはそのワークスペースを選ぶと切り替わります。接続中のマシンのメタデータ、エージェント状態、通知は引き続き利用できますが、ペイン画面、入力、表示領域、グラフィックスのやり取りは選択中のマシンだけに限定されます。2 つのサーバーが同じ内部 ID を使っていても、ワークスペース、タブ、ペイン、エージェント、グラフィックスの識別情報はマシンごとに分離されます。 + +起動時は保存済み SSH マシンを待たず、接続バッジも表示せずに Local を開きます。有効な保存済みマシンがあれば、Local への接続に失敗してもクライアントを開けます。Local の停止や再起動で正常な SSH 接続が閉じることはありません。Local のサーバーが戻ると、使用中のマシンから選択を奪わずに再接続します。応答しないマシンが、別のマシンへの入力や接続処理を妨げることはありません。 + +Herdr は接続中の SSH マシンのアプリケーションレベルの通信を監視し、通信がない間は軽量なヘルスチェックを行います。Local ではリモート用のヘルスチェックを使わず、接続の終了や失敗で切断を検知します。スリープ、ネットワークの中断、SSH の障害などで接続が不健全になると Reconnecting と表示し、上限のある待機間隔で再試行します。最後のワークスペース、エージェント、選択中のペインの状態は暗く表示されたまま固定され、新しい接続と整合した画面が届くまで入力やその状態への移動は無効になります。再接続がプロンプトに回答したり、リモートサーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。ホスト鍵の承認、認証、互換性のある Herdr のインストールが必要な接続先は Attention と表示され、Local とほかのマシンはそのまま使えます。プロファイルに対応する単独接続用のコマンドをターミナルで実行してセットアップを完了してください。例: + +```bash +herdr --remote workbox --session agents --handoff +``` + +Attention と表示されている場合は、セットアップ後にクライアントを再起動して接続を再試行してください。名前の変更、無効化、削除にはプロファイルの不透明な ID を使います: + +```bash +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +名前の変更はラベルだけを更新し、再接続しません。削除または無効化すると、クライアントからそのマシンだけを切断します。マシンに到達できなくても削除でき、リモートセッションは動き続けます。表示中のマシンを削除または無効化すると Local に戻ります。Local が利用できない場合は、別のリモートを選ぶのではなく、利用不可と表示して再接続します。保存済みマシンのファイルが読めない、または不正な場合は、現在の接続を変更せずに通知を表示し、自動で読み込みを再試行します。 + +保存するのは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態だけです。パスワード、秘密鍵、エージェントのチケット、SSH 制御ソケットをマシンカタログに保存することはありません。 + +クライアントはデフォルトでローカルのキーバインドを使います。選択中のマシンが公開するカスタムコマンドは、そのマシンで実行されます。Herdr はローカルのコマンドプラグイン、設定、実行ファイル、シークレットを SSH ホストにコピーしません。リモートに必要なコマンドやプラグインがなければ、エラーを表示します。 + ## SSH 越しのリモートアタッチ リモートモードは 2 つあり、[Herdr での作業の進め方](/ja/docs/how-to-work/)で比較しています。tmux スタイルの方法では、サーバーに SSH してそこで `herdr` を実行します。もうひとつは、ローカルマシンから SSH 越しにアタッチする方法です: diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index db9af01a..e5cec116 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -35,9 +35,46 @@ herdr session stop work --json herdr session delete side-project --json ``` +## Saved SSH machines + +Save repeat targets to work across several machines from one Herdr client: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add ssh://you@server:2222 --label "Production" --remote-session operations +herdr machine list +``` + +`machine add` prepares Herdr on the remote machine and starts the requested session's background server before saving the profile. It checks the running server separately from the installed binary and keeps compatible servers, even when their release versions differ from your client. If the running server needs an upgrade, setup uses live handoff when supported to preserve pane processes; otherwise it asks before stopping the server. Missing or incompatible installations use the same approval-based setup as `--remote`; run the command in an interactive terminal when approval is needed. Cancelling setup leaves the profile unsaved. The remote server keeps running after setup exits. + +Saved-machine changes apply to open local Herdr clients automatically, normally within a second. An in-progress machine switch finishes before the change is applied. Added or enabled SSH machines appear with Local in the machine sidebar and connect in the background without changing your selection. Select a machine or one of its workspaces to switch to it. Herdr keeps metadata, agent state, and notifications from connected machines available, but streams pane surfaces, input, geometry, and graphics only for the selected machine. Workspace, tab, pane, agent, and graphics identities stay scoped to their machine even when two servers happen to use the same internal ID. + +Local opens directly on startup, without waiting for saved SSH machines or showing a connection badge. With enabled saved machines, the client can also open if Local actually fails, and stopping or restarting Local does not close healthy SSH connections. Local reconnects when its server returns, without taking selection away from the machine you are using. A stalled machine cannot block another machine's input or connection handling. + +Herdr checks connected SSH machines for application-level activity and uses a lightweight health probe while they are quiet. Local relies on its native connection closing or failing, not remote health probes. After sleep, a network interruption, or SSH failure, an unhealthy machine changes to Reconnecting and retries with bounded backoff. Its last workspace, agent, and selected pane state remains visible but dimmed and frozen; input and navigation into that cached state stay disabled until a fresh connection and coherent surface arrive. Reconnects never answer prompts or install, update, restart, or hand off a remote server. A target that needs host-key approval, authentication, or a compatible Herdr install shows Attention while Local and other machines keep working. Run the profile's explicit standalone command in a terminal to finish setup, for example: + +```bash +herdr --remote workbox --session agents --handoff +``` + +If the machine shows Attention, restart the client after setup to retry its connection. Rename, disable, or remove profiles by their opaque ID: + +```bash +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +Renaming updates the label without reconnecting. Removing or disabling a machine disconnects only that machine from the client; its remote sessions keep running, even if the machine is unreachable. If you remove or disable the machine you are viewing, the client returns to Local. If Local is unavailable, it shows that and reconnects rather than selecting another remote. An unreadable or invalid saved-machine file leaves current connections unchanged and shows a notice; the client retries reading it automatically. + +Saved profiles contain only the opaque ID, label, SSH target, explicit remote session, and enabled state. Passwords, private keys, agent tickets, and SSH control sockets are never stored in the machine catalog. + +The client uses local keybindings by default. Custom commands advertised by the selected machine still execute on that machine. Herdr does not copy local command plugins, configuration, executables, or secrets to SSH hosts; a missing remote command or plugin fails visibly. + ## Remote attach over SSH -Herdr supports two remote modes. [How to work with Herdr](/docs/how-to-work/) compares them. SSH to the server and run `herdr` there for the tmux-style path, or attach through SSH from your local machine: +Herdr supports two remote modes. [How to work with Herdr](/docs/how-to-work/) compares them. SSH to the server and run `herdr` there for the tmux-style path, use saved SSH machines for persistent multi-machine work, or attach through SSH from your local machine: ```bash herdr --remote workbox diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index eb61b532..13a71abb 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -938,8 +938,11 @@ Errors look like this: The client-rendered Herdr UI uses a stable endpoint generation for local and SSH servers. Client and server builds do not need to match. During connection setup, they agree on the core snapshot, screen, input, and blob codecs, and the server advertises -the API methods it supports. A missing method disables only that action on that -server and shows a client-local notice; it does not disconnect the UI. Rejected +the API methods and optional capabilities it supports. Saved SSH federation requires the +`surface_interest` capability so only the selected machine streams a pane surface and the +`health_check` capability so a quiet broken connection cannot remain Online indefinitely. A server +without this lifecycle support stays at Attention until it is explicitly updated. Other missing methods disable +only those actions and show a client-local notice; they do not disconnect the UI. Rejected or timed-out actions are also reported without ending the connection. Servers from before endpoint generation 1 need one final update. diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index f6ec3b6a..874143d8 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -43,6 +43,23 @@ herdr api schema --output herdr-api.schema.json `herdr api schema` 会打印安装的二进制中包含的 socket 协议 schema 简短摘要。需要完整 JSON Schema 文档时使用 `--json`;要写入文件则使用 `--output PATH`。 +## 已保存的 SSH 机器 + +```bash +herdr machine list [--json] +herdr machine add workbox --label "Build machine" [--remote-session agents] +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +`machine add` 会检查远程 Herdr 是否具备所需能力,仅在必要时征得批准后安装或更新,并在保存配置前启动指定会话的后台服务器。只要兼容,发行版本不必一致。需要批准安装或重启时,请在交互式终端中运行;设置失败或取消时不会保存配置。 + +更改会自动应用到已打开的本地 Herdr 客户端,通常在一秒内生效。新增或启用的机器在后台连接;重命名不会重新连接。移除或禁用只会断开该机器,远程会话仍会继续运行。移除正在查看的机器会返回 Local;如果 Local 不可用,则显示不可用状态,直到重新连接。每份配置在客户端状态文件中保存一个不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不保存密码、私钥或其他 SSH 凭据。 + +自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox --session agents --handoff`,在前台完成设置后重启客户端。 + ## Shell 补全 ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 3943815f..a4c1a338 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -348,7 +348,7 @@ status_indicators = "symbols" [ui.sidebar.agents] row_gap = 0 rows = [ - ["state_icon", "workspace", "tab"], + ["state_icon", "machine", "workspace", "tab"], ["agent"], ] @@ -364,6 +364,7 @@ Agent 行支持以下内置 token: - `state_icon` — 智能体语义状态的彩色图标。 - `state_text` — `idle`、`working`、`blocked`、`done` 或 `unknown`;如果报告中包含显示标签,也会一并显示。 +- `machine` — 客户端连接多台机器时显示机器标签;只有一台本地机器时省略。 - `workspace` — 工作区名称。 - `tab` — 标签页名称(如有)。 - `pane` — 窗格名称(如有)。 @@ -381,7 +382,7 @@ Space 行支持以下内置 token: - `git_status` — 非零时显示 Git ahead 和 behind 数量。 - `$name` — 名为 `name` 的自定义工作区元数据。 -token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。每个布局最多可包含 16 行,每行最多可包含 16 个 token。 +token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。已有的自定义行不会改变;如果希望在自定义的多机器布局中显示机器标识,请显式添加 `machine`。每个布局最多可包含 16 行,每行最多可包含 16 个 token。 也可以用内联样式表定义 token: diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx index 3f142547..a89a8ea3 100644 --- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -35,6 +35,43 @@ herdr session stop work --json herdr session delete side-project --json ``` +## 已保存的 SSH 机器 + +保存常用目标后,即可从一个 Herdr 客户端跨多台机器工作: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add ssh://you@server:2222 --label "Production" --remote-session operations +herdr machine list +``` + +`machine add` 会在远程机器上准备 Herdr,并在保存配置前启动指定会话的后台服务器。它会分别检查运行中的服务器和已安装的二进制文件;即使服务器的发行版本与客户端不同,只要兼容就会保留。如果运行中的服务器需要升级,设置过程会在支持时使用实时交接来保留窗格进程,否则会在停止服务器前询问。未安装或不兼容时,使用与 `--remote` 相同的批准流程;需要批准时请在交互式终端中运行。取消设置不会保存配置。设置退出后,远程服务器会继续运行。 + +已保存机器的更改会自动应用到已打开的本地 Herdr 客户端,通常在一秒内生效。如果正在切换机器,会等待切换完成再应用更改。新增或启用的 SSH 机器会与 Local 一起显示在机器侧边栏中,并在后台连接,不会改变当前选择。选择机器或其工作区即可切换。Herdr 保留已连接机器的元数据、agent 状态和通知,但只为选中的机器传输窗格画面、输入、显示区域和图形。即使两个服务器碰巧使用相同的内部 ID,工作区、标签页、窗格、agent 和图形的身份也始终按机器隔离。 + +启动时会直接打开 Local,不等待已保存的 SSH 机器,也不显示连接标记。有已启用的保存机器时,即使 Local 连接失败,客户端仍能打开。停止或重启 Local 不会关闭正常的 SSH 连接。Local 服务器恢复后会自动重连,不会从正在使用的机器抢走选择。无响应的机器不会阻塞其他机器的输入或连接处理。 + +Herdr 会监测已连接 SSH 机器的应用层活动,并在空闲时使用轻量健康探测。Local 依靠本地连接关闭或失败来判断断开,不使用远程健康探测。睡眠、网络中断或 SSH 故障后,不健康的机器会显示 Reconnecting,并以有上限的退避间隔重试。最后的工作区、agent 和选中窗格状态会保留,但变暗并冻结;在新连接和一致的画面到达前,无法输入或导航到这些缓存状态。重连不会回答提示,也不会安装、更新、重启或交接远程服务器。需要批准主机密钥、完成认证或安装兼容 Herdr 的目标会显示 Attention,Local 和其他机器仍可正常使用。请在终端中运行该配置对应的独立连接命令来完成设置,例如: + +```bash +herdr --remote workbox --session agents --handoff +``` + +如果机器显示 Attention,请在设置完成后重启客户端以重试连接。使用配置的不透明 ID 来重命名、禁用或移除: + +```bash +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +重命名只更新标签,不会重新连接。移除或禁用只会将该机器从客户端断开;即使机器无法访问也可操作,其远程会话仍会继续运行。如果移除或禁用正在查看的机器,客户端会返回 Local。如果 Local 不可用,会显示该状态并尝试重连,而不是选择另一台远程机器。保存机器的文件无法读取或内容无效时,当前连接保持不变,客户端会显示通知并自动重试读取。 + +保存的配置仅包含不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。密码、私钥、agent 票据和 SSH 控制套接字绝不会存入机器目录。 + +客户端默认使用本地按键绑定。选中机器公布的自定义命令仍在该机器上执行。Herdr 不会将本地命令插件、配置、可执行文件或机密复制到 SSH 主机;远程缺少命令或插件时会明确报错。 + ## 通过 SSH 远程连接 远程模式有两种;[使用 Herdr 的工作方式](/zh-cn/docs/how-to-work/)对它们做了比较。tmux 风格的路径是 SSH 到服务器并在那里运行 `herdr`。另一种是从本地机器通过 SSH 连接: diff --git a/src/api/schema.rs b/src/api/schema.rs index 074cd4ed..ba9f0c3a 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -69,6 +69,8 @@ pub enum Method { ClientWindowTitleSet(ClientWindowTitleSetParams), #[serde(rename = "client.window_title.clear")] ClientWindowTitleClear(EmptyParams), + #[serde(rename = "client_shell.surface.set")] + ClientShellSurfaceSet(ClientShellSurfaceSetParams), #[serde(rename = "session.snapshot")] SessionSnapshot(EmptyParams), #[serde(rename = "workspace.create")] diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index 15f56e6c..bf0d7caf 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -61,6 +61,12 @@ pub struct ClientWindowTitleSetParams { pub title: String, } +/// Updates whether the requesting client shell receives and controls pane presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ClientShellSurfaceSetParams { + pub active: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SplitDirection { diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 180d6645..e6d4de4d 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -295,6 +295,12 @@ pub enum ResponseResult { status: crate::config::ConfigReloadStatus, diagnostics: Vec<String>, }, + /// Acknowledgement for the client-shell surface interest lease. This method is new on the + /// endpoint protocol, so its revision-bearing result can establish an activation floor. + ClientShellSurfaceSet { + active: bool, + projection_revision: u64, + }, Ok {}, } diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs index 96e51564..b274bc95 100644 --- a/src/api/schema/server.rs +++ b/src/api/schema/server.rs @@ -21,4 +21,10 @@ pub struct ServerCapabilities { /// Stable client-owned endpoint generation supported by this server. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint_protocol_generation: Option<u32>, + /// Whether this server supports explicit client-shell surface interest. + #[serde(default)] + pub surface_interest: bool, + /// Whether this server supports endpoint health probes. + #[serde(default)] + pub health_check: bool, } diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 693f0e85..b566e8f6 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -725,6 +725,8 @@ fn success_response_round_trips() { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: Some(1), + surface_interest: true, + health_check: true, }), }, }; diff --git a/src/api/server.rs b/src/api/server.rs index 655d89c5..b8ef7c38 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -69,6 +69,8 @@ fn default_capabilities() -> Option<ServerCapabilities> { live_handoff: crate::platform::capabilities().live_handoff, detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(), endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + surface_interest: true, + health_check: true, }) } @@ -352,6 +354,14 @@ fn handle_request( }); } + if matches!(&request.method, Method::ClientShellSurfaceSet(_)) { + return error_response_json( + request.id, + "connection_local_only", + "client_shell.surface.set is only available through a client shell endpoint".into(), + ); + } + if matches!(&request.method, Method::ServerStop(_)) { if let Some(server_stop) = server_stop { server_stop.store(true, Ordering::Release); @@ -386,6 +396,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::CommandInvoke(_) => "command.invoke", Method::ClientWindowTitleSet(_) => "client.window_title.set", Method::ClientWindowTitleClear(_) => "client.window_title.clear", + Method::ClientShellSurfaceSet(_) => "client_shell.surface.set", Method::SessionSnapshot(_) => "session.snapshot", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", @@ -1136,6 +1147,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), None, None, @@ -26,6 +26,7 @@ mod agent; mod api; mod completion; mod integration; +mod machine; mod notification; mod pane; mod plugin; @@ -113,6 +114,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "completion" | "completions" => completion::run_completion_command(&args[2..])?, "config" => run_config_command(&args[2..])?, "channel" => run_channel_command(&args[2..])?, + "machine" => machine::run_machine_command(&args[2..])?, "workspace" => workspace::run_workspace_command(&args[2..])?, "worktree" => worktree::run_worktree_command(&args[2..])?, "tab" => tab::run_tab_command(&args[2..])?, diff --git a/src/cli/machine.rs b/src/cli/machine.rs new file mode 100644 index 00000000..a55e4611 --- /dev/null +++ b/src/cli/machine.rs @@ -0,0 +1,295 @@ +use serde::Serialize; + +use crate::client::endpoint::{EndpointCatalog, ProfileId}; + +const HELP: &str = "Usage: + herdr machine list [--json] + herdr machine add <ssh-target> --label <label> [--remote-session <name>] + herdr machine rename <profile-id> --label <label> + herdr machine remove <profile-id> + herdr machine enable <profile-id> + herdr machine disable <profile-id> + +Add prepares the remote Herdr installation and starts its server before saving. +Missing or incompatible installations require approval in an interactive terminal. +Changes apply automatically to open local Herdr clients. +Removing or disabling a machine leaves its remote sessions running. +Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state. +SSH credentials and key material remain owned by OpenSSH."; + +#[derive(Serialize)] +struct MachineListRow<'a> { + id: &'a str, + label: &'a str, + target: &'a str, + session: &'a str, + enabled: bool, + selected: bool, +} + +pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> { + match args.first().map(String::as_str) { + Some("list") => list(&args[1..]), + Some("add") => add(&args[1..]), + Some("rename") => rename(&args[1..]), + Some("remove") => remove(&args[1..]), + Some("enable") => set_enabled(&args[1..], true), + Some("disable") => set_enabled(&args[1..], false), + Some("help" | "--help" | "-h") => { + println!("{HELP}"); + Ok(0) + } + _ => { + eprintln!("{HELP}"); + Ok(2) + } + } +} + +fn list(args: &[String]) -> std::io::Result<i32> { + let json = match args { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("usage: herdr machine list [--json]"); + return Ok(2); + } + }; + let catalog = load_catalog()?; + let rows = catalog + .ssh + .iter() + .map(|profile| MachineListRow { + id: profile.id.as_str(), + label: &profile.label, + target: &profile.target, + session: &profile.session, + enabled: profile.enabled, + selected: catalog.selected_profile.as_ref() == Some(&profile.id), + }) + .collect::<Vec<_>>(); + if json { + println!( + "{}", + serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)? + ); + return Ok(0); + } + if rows.is_empty() { + println!("No saved SSH machines."); + return Ok(0); + } + for row in rows { + let state = if row.enabled { "enabled" } else { "disabled" }; + println!( + "{}\t{}\t{}\t{}\t{}", + row.id, row.label, row.target, row.session, state + ); + } + Ok(0) +} + +fn add(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label", "--remote-session"]); + let Some(target) = args.first().filter(|value| !value.starts_with('-')) else { + eprintln!( + "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]" + ); + return Ok(2); + }; + let mut label = None; + let mut session = None; + let mut index = 1; + while index < args.len() { + let (name, value) = match args[index].as_str() { + "--label" | "--remote-session" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {}", args[index]); + return Ok(2); + }; + index += 2; + (args[index - 2].as_str(), value.clone()) + } + unknown => { + eprintln!("unknown machine add option: {unknown}"); + return Ok(2); + } + }; + match name { + "--label" if label.is_none() => label = Some(value), + "--remote-session" if session.is_none() => session = Some(value), + "--remote-session" => { + eprintln!("--remote-session can only be specified once"); + return Ok(2); + } + "--label" => { + eprintln!("--label can only be specified once"); + return Ok(2); + } + _ => unreachable!("validated machine add option"), + } + } + let Some(label) = label else { + eprintln!("--label is required"); + return Ok(2); + }; + let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); + let mut catalog = load_catalog()?; + match catalog.add_ssh(label.clone(), target, session.clone()) { + Ok(_) => {} + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_remote_error_hint(&error, target); + return Ok(1); + } + // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile. + let mut catalog = load_catalog().map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + let id = match catalog.add_ssh(label, target, session) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + store_catalog(&catalog).map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + println!("Saved SSH machine {id}. Remote server is ready."); + println!("Open Herdr clients connect automatically."); + Ok(0) +} + +fn rename(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label"]); + let [raw_id, flag, label] = args.as_slice() else { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + }; + if flag != "--label" { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + } + let id = match ProfileId::parse(raw_id.clone()) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + let mut catalog = load_catalog()?; + match catalog.rename_ssh(&id, label) { + Ok(true) => {} + Ok(false) => { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + store_catalog(&catalog)?; + println!("Renamed SSH machine {id}."); + Ok(0) +} + +fn remove(args: &[String]) -> std::io::Result<i32> { + let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.remove_ssh(&id) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!("Removed SSH machine {id}."); + Ok(0) +} + +fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> { + let action = if enabled { "enable" } else { "disable" }; + let usage = format!("usage: herdr machine {action} <profile-id>"); + let Some(id) = one_profile_id(args, &usage)? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.set_enabled(&id, enabled) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!( + "{} SSH machine {id}.", + if enabled { "Enabled" } else { "Disabled" } + ); + Ok(0) +} + +fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> { + let [raw] = args else { + eprintln!("{usage}"); + return Ok(None); + }; + match ProfileId::parse(raw.clone()) { + Ok(id) => Ok(Some(id)), + Err(error) => { + eprintln!("error: {error}"); + Ok(None) + } + } +} + +fn load_catalog() -> std::io::Result<EndpointCatalog> { + EndpointCatalog::load().map_err(std::io::Error::other) +} + +fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> { + catalog.store_profiles().map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_id_parser_rejects_target_text() { + assert!(one_profile_id(&["build.example".into()], "usage") + .unwrap() + .is_none()); + } + + #[test] + fn list_rows_do_not_have_credential_fields() { + let encoded = serde_json::to_string(&MachineListRow { + id: "0123456789abcdef0123456789abcdef", + label: "Build", + target: "dev@build", + session: "agents", + enabled: true, + selected: false, + }) + .unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("key")); + } +} diff --git a/src/cli/spec.rs b/src/cli/spec.rs index cf23e320..2da0a25c 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -2,6 +2,9 @@ use std::io::Write; use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; +mod completion; +mod machine; + pub(super) fn command() -> Command { let command = Command::new("herdr") .about("terminal workspace manager for AI coding agents") @@ -25,11 +28,12 @@ pub(super) fn command() -> Command { .action(ArgAction::SetTrue) .help("Print version and exit"), ) - .subcommand(completion_command()) + .subcommand(completion::command()) .subcommand(update_command()) .subcommand(status_command()) .subcommand(config_command()) .subcommand(channel_command()) + .subcommand(machine::command()) .subcommand(server_command()) .subcommand(api_command()) .subcommand(workspace_command()) @@ -108,19 +112,6 @@ fn write_requested_help( Ok(true) } -fn completion_command() -> Command { - Command::new("completion") - .visible_alias("completions") - .about("Generate shell completion scripts") - .arg( - Arg::new("shell") - .value_name("SHELL") - .required(true) - .value_parser(super::completion::SUPPORTED_SHELLS) - .help("Shell to generate completions for"), - ) -} - fn update_command() -> Command { Command::new("update") .about("Download and install the latest version") diff --git a/src/cli/spec/completion.rs b/src/cli/spec/completion.rs new file mode 100644 index 00000000..8cc27d7b --- /dev/null +++ b/src/cli/spec/completion.rs @@ -0,0 +1,14 @@ +use clap::{Arg, Command}; + +pub(super) fn command() -> Command { + Command::new("completion") + .visible_alias("completions") + .about("Generate shell completion scripts") + .arg( + Arg::new("shell") + .value_name("SHELL") + .required(true) + .value_parser(super::super::completion::SUPPORTED_SHELLS) + .help("Shell to generate completions for"), + ) +} diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs new file mode 100644 index 00000000..188e27b9 --- /dev/null +++ b/src/cli/spec/machine.rs @@ -0,0 +1,49 @@ +use clap::{Arg, Command}; + +use super::{json_flag, option}; + +pub(super) fn command() -> Command { + Command::new("machine") + .about("Manage saved SSH machines") + .subcommand( + Command::new("list") + .about("List saved SSH machines") + .arg(json_flag()), + ) + .subcommand( + Command::new("add") + .about("Prepare the remote Herdr server and save an SSH machine") + .arg( + Arg::new("ssh-target") + .value_name("SSH_TARGET") + .required(true), + ) + .arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ) + .arg( + option("remote-session", "NAME") + .help("Set the explicit Herdr session on the remote machine"), + ), + ) + .subcommand( + profile_command("rename", "Rename a saved SSH machine").arg( + option("label", "LABEL") + .required(true) + .help("Set the machine label shown in the sidebar"), + ), + ) + .subcommand(profile_command("remove", "Remove a saved SSH machine")) + .subcommand(profile_command("enable", "Enable a saved SSH machine")) + .subcommand(profile_command("disable", "Disable a saved SSH machine")) +} + +fn profile_command(name: &'static str, about: &'static str) -> Command { + Command::new(name).about(about).arg( + Arg::new("profile-id") + .value_name("PROFILE_ID") + .required(true), + ) +} diff --git a/src/cli/status.rs b/src/cli/status.rs index 4dba81b7..c6dda462 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -255,6 +255,7 @@ struct ClientStatusJson { channel: &'static str, protocol: u32, endpoint_protocol_generation: u32, + endpoint_capabilities: Vec<&'static str>, binary: String, session: Option<String>, } @@ -279,6 +280,8 @@ struct ServerCapabilitiesJson { live_handoff: bool, detached_server_daemon: bool, endpoint_protocol_generation: Option<u32>, + surface_interest: bool, + health_check: bool, } #[derive(Serialize)] @@ -293,6 +296,11 @@ fn client_status_json() -> ClientStatusJson { channel: crate::config::Config::load().config.update.channel.as_str(), protocol: crate::protocol::PROTOCOL_VERSION, endpoint_protocol_generation: crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, + endpoint_capabilities: vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, + ], binary: current_exe_label(), session: crate::session::active_name(), } @@ -315,6 +323,8 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { live_handoff: capabilities.live_handoff, detached_server_daemon: capabilities.detached_server_daemon, endpoint_protocol_generation: capabilities.endpoint_protocol_generation, + surface_interest: capabilities.surface_interest, + health_check: capabilities.health_check, }), compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION), endpoint_compatible: capabilities.as_ref().and_then(|capabilities| { @@ -404,6 +414,8 @@ mod tests { live_handoff: true, detached_server_daemon: true, endpoint_protocol_generation: endpoint_generation, + surface_interest: true, + health_check: true, }), } } diff --git a/src/client/attach.rs b/src/client/attach.rs index e385775c..2cabe5bb 100644 --- a/src/client/attach.rs +++ b/src/client/attach.rs @@ -9,7 +9,6 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; #[cfg(unix)] use super::write_to_server; #[cfg(unix)] -use crate::ipc::LocalStream; #[cfg(unix)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, ClientMessage}; @@ -260,7 +259,7 @@ fn attach_scroll_action( #[cfg(unix)] pub(super) fn write_attach_semantic_action( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, action: AttachSemanticAction, ) -> io::Result<()> { let message = match action { diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs new file mode 100644 index 00000000..21f33bea --- /dev/null +++ b/src/client/catalog_reload.rs @@ -0,0 +1,276 @@ +use super::*; + +pub(super) fn watch_profiles( + event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + should_quit: Arc<AtomicBool>, +) { + // One bounded read per second per client, independent of rendering and pane count. + std::thread::spawn(move || { + let mut previous = None; + while !should_quit.load(Ordering::Acquire) { + let current = endpoint::EndpointCatalog::load_profiles(); + if previous.as_ref() != Some(¤t) { + previous = Some(current.clone()); + if event_tx + .blocking_send(ClientLoopEvent::EndpointCatalog(current)) + .is_err() + { + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + } + }); +} + +// Only called between surface handoffs: removing a source must not invalidate an in-flight +// rollback. Connection attempts are independent and fenced by supervisor generations. +pub(super) fn apply_profiles( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + catalog: &mut endpoint::EndpointCatalog, + profiles: Vec<endpoint::SavedSshEndpoint>, + now: std::time::Instant, +) -> bool { + if catalog.ssh == profiles { + return false; + } + let previous_size = state + .shell + .as_ref() + .map(|shell| shell.surface_size(state.reported_size.0, state.reported_size.1)); + let retired = supervisors.reconcile_profiles(&profiles, now); + let active_removed = retired.contains(endpoints.active_id()); + for endpoint_id in retired { + endpoints.disconnect(&endpoint_id); + let cancelled = commands.disconnect(&endpoint_id); + #[cfg(unix)] + state.retire_endpoint_graphics(&endpoint_id); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.retire_endpoint(&endpoint_id); + } + } + catalog.ssh = profiles; + if active_removed { + endpoints.select_unavailable_local(); + catalog.select_local(); + state.freeze_presentation(); + if let Some(shell) = state.shell.as_mut() { + shell.select_unavailable_local(); + } + } else if catalog.selected_profile.as_ref().is_some_and(|selected| { + !catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + catalog.select_local(); + } + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_catalog(&catalog.ssh); + if endpoints.active_surface_available() + && previous_size + != Some(shell.surface_size(state.reported_size.0, state.reported_size.1)) + { + shell.invalidate_pane_surface(); + endpoints.send(&client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + )); + } + } + active_removed +} + +#[cfg(test)] +mod tests { + use super::*; + use endpoint::{ClientEndpointId, EndpointCatalog, EndpointRegistry, EndpointSupervisors}; + use std::sync::atomic::AtomicUsize; + use std::time::Instant; + + struct Transport(Arc<AtomicUsize>); + + impl endpoint::EndpointTransport for Transport { + fn send(&mut self, _: &ClientMessage) -> io::Result<()> { + Ok(()) + } + + fn disconnect(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + fn state() -> ClientState { + ClientState { + blit_encoder: render_ansi::BlitEncoder::new(), + mouse_capture_active: false, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), + direct_mouse_capture_preference: false, + shell_mouse_capture_preference: false, + direct_keyboard_protocol: Default::default(), + pane_keyboard_report_all: false, + keyboard_report_all_active: false, + reported_size: (100, 30), + reported_cell_size: (0, 0), + sound_config: Default::default(), + kitty_graphics_enabled: false, + pixel_geometry_enabled: false, + pixel_geometry_exact: false, + #[cfg(unix)] + direct_graphics_response: Default::default(), + #[cfg(unix)] + retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), + attach_escape: None, + #[cfg(unix)] + mouse_scroll_lines: 3, + remote_image_paste_key: None, + redraw_on_focus_gained: false, + repaint_pending: false, + presentation_frozen: false, + draw_host_cursor: false, + detached_process_children: Vec::new(), + shell: Some(shell::ClientShellState::new( + shell::ClientShellConfig::from_config(&crate::config::Config::default()), + )), + } + } + + #[test] + fn live_catalog_add_and_rename_keep_local_connection_and_selection() { + let now = Instant::now(); + let mut state = state(); + let disconnected = Arc::new(AtomicUsize::new(0)); + let mut endpoints = + EndpointRegistry::new(Transport(disconnected.clone()), 1, Default::default()); + let mut supervisors = EndpointSupervisors::new(&[], now); + let mut commands = endpoint_commands::EndpointCommands::default(); + let mut catalog = EndpointCatalog::default(); + let mut profile = endpoint::SavedSshEndpoint::new("Build", "build", "main").unwrap(); + for label in ["Build", "Renamed"] { + profile.label = label.into(); + assert!(!apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + vec![profile.clone()], + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(endpoints.active_surface_available()); + assert_eq!( + endpoints + .connection(&ClientEndpointId::Local) + .unwrap() + .generation, + 1 + ); + assert_eq!(catalog.selected_profile, None); + assert_eq!( + state + .shell + .as_ref() + .unwrap() + .endpoint_label(&ClientEndpointId::Ssh(profile.id.clone())), + label + ); + } + assert_eq!(disconnected.load(Ordering::Relaxed), 0); + } + + #[test] + fn live_catalog_remove_or_disable_active_machine_selects_local_without_input() { + for local_online in [false, true] { + for disable in [false, true] { + let now = Instant::now(); + let mut state = state(); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "main").unwrap(); + let remote = ClientEndpointId::Ssh(id.clone()); + catalog.select_ssh(&id); + state + .shell + .as_mut() + .unwrap() + .set_endpoint_catalog(&catalog.ssh); + let mut supervisors = EndpointSupervisors::new(&catalog.ssh, now); + let mut endpoints = EndpointRegistry::empty(); + let local_disconnects = Arc::new(AtomicUsize::new(0)); + if local_online { + endpoints.insert( + ClientEndpointId::Local, + Transport(local_disconnects.clone()), + 1, + Default::default(), + false, + ); + } + let remote_disconnects = Arc::new(AtomicUsize::new(0)); + endpoints.insert( + remote.clone(), + Transport(remote_disconnects.clone()), + 2, + Default::default(), + true, + ); + endpoints.set_active(&remote); + endpoints.unfreeze_input(); + let mut commands = endpoint_commands::EndpointCommands::default(); + let profiles = if disable { + let mut profiles = catalog.ssh.clone(); + profiles[0].enabled = false; + profiles + } else { + Vec::new() + }; + assert!(apply_profiles( + &mut state, + &mut endpoints, + &mut commands, + &mut supervisors, + &mut catalog, + profiles, + now + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert!(endpoints.connection(&remote).is_none()); + assert_eq!( + endpoints.connection(&ClientEndpointId::Local).is_some(), + local_online + ); + assert!(state + .shell + .as_ref() + .unwrap() + .endpoint_is_active(&ClientEndpointId::Local)); + assert!(!state.shell.as_ref().unwrap().has_presented_surface()); + assert!(state.presentation_frozen); + assert_eq!(catalog.selected_profile, None); + assert_eq!(remote_disconnects.load(Ordering::Relaxed), 1); + assert_eq!(local_disconnects.load(Ordering::Relaxed), 0); + assert!(!supervisors.record_status( + &remote, + 2, + endpoint::ClientEndpointStatus::Online, + now + )); + } + } + } +} diff --git a/src/client/clipboard_forwarding.rs b/src/client/clipboard_forwarding.rs new file mode 100644 index 00000000..2de5f49f --- /dev/null +++ b/src/client/clipboard_forwarding.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { + use base64::Engine; + + base64::engine::general_purpose::STANDARD.decode(data).ok() +} + +pub(super) fn forward_clipboard(data: &str) -> bool { + let Some(bytes) = decode_clipboard_payload(data) else { + warn!("received invalid clipboard payload from server"); + return false; + }; + crate::selection::write_osc52_bytes(&bytes); + true +} diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs index c1c49cbc..1faebf39 100644 --- a/src/client/clipboard_images.rs +++ b/src/client/clipboard_images.rs @@ -2,16 +2,15 @@ use std::path::PathBuf; use tracing::{info, warn}; -use crate::ipc::LocalStream; #[cfg(windows)] use crate::protocol::ClientInputEvent; use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ClientClipboardImageTarget, ClientMessage}; -use super::{is_remote_client_process, write_to_server, ClientError}; +use super::{write_to_server, ClientError}; pub(super) fn write_remote_image_to_server( - stream: &mut LocalStream, + stream: &mut impl super::ClientMessageSink, target: ClientClipboardImageTarget, image: crate::platform::ClipboardImage, source: &'static str, @@ -46,10 +45,6 @@ pub(super) fn write_remote_image_to_server( pub(super) fn client_remote_image_paste_key( config: &crate::config::Config, ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - match config.remote_image_paste_key() { Ok(key) => key, Err(diagnostic) => { @@ -59,14 +54,25 @@ pub(super) fn client_remote_image_paste_key( } } +pub(super) fn endpoint_accepts_local_images( + remote_client_process: bool, + endpoint_id: &super::endpoint::ClientEndpointId, + active_surface_available: bool, +) -> bool { + active_surface_available && (remote_client_process || !endpoint_id.is_local()) +} + #[cfg(unix)] pub(super) fn should_bridge_clipboard_image_paste( data: &[u8], is_remote_client: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, ) -> bool { + if !is_remote_client { + return false; + } if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; + return true; } let Some(remote_image_paste_key) = remote_image_paste_key else { diff --git a/src/client/config_reload.rs b/src/client/config_reload.rs new file mode 100644 index 00000000..c20de760 --- /dev/null +++ b/src/client/config_reload.rs @@ -0,0 +1,129 @@ +use super::*; + +pub(super) fn init_logging() { + crate::logging::init_file_logging("herdr-client.log"); +} + +pub(super) fn apply_reload( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; + reload_local_client_config( + &mut state.sound_config, + &mut state.redraw_on_focus_gained, + &mut state.draw_host_cursor, + &mut state.remote_image_paste_key, + &mut mouse_capture, + ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + endpoints, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels).map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + if let Some(resize) = resize { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + write_to_server(endpoints, &resize).map_err(ClientError::ConnectionLost)?; + } + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(()) +} + +pub(super) fn reload_local_client_config( + sound_config: &mut crate::config::SoundConfig, + redraw_on_focus_gained: &mut bool, + draw_host_cursor: &mut bool, + remote_image_paste_key: &mut Option<( + crossterm::event::KeyCode, + crossterm::event::KeyModifiers, + )>, + mouse_capture: &mut bool, +) { + match crate::config::load_live_config() { + Ok(loaded) => { + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); + } + debug!("reloaded local client config"); + } + Err(diagnostics) => { + warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); + } + } +} diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs new file mode 100644 index 00000000..77aaae3b --- /dev/null +++ b/src/client/endpoint.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +mod activation; +mod catalog; +mod control; +mod health; +mod message_policy; +mod registry; +mod supervisor; +mod writer; + +pub(crate) use activation::*; +pub(crate) use catalog::*; +pub(crate) use control::*; +pub(crate) use message_policy::*; +pub(crate) use registry::*; +pub(crate) use supervisor::*; +pub(crate) use writer::NativeEndpointTransport; + +const PROFILE_ID_BYTES: usize = 16; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct ProfileId(String); + +impl ProfileId { + pub(crate) fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() != PROFILE_ID_BYTES * 2 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("endpoint profile id must be 32 lowercase hexadecimal characters".into()); + } + Ok(Self(value)) + } + + pub(crate) fn generate() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let digest = Sha256::digest(format!("{}:{now}:{sequence}", std::process::id()).as_bytes()); + Self( + digest[..PROFILE_ID_BYTES] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ClientEndpointId { + Local, + Ssh(ProfileId), +} + +impl ClientEndpointId { + pub(crate) fn is_local(&self) -> bool { + matches!(self, Self::Local) + } + + pub(crate) fn storage_key(&self) -> String { + match self { + Self::Local => "local".into(), + Self::Ssh(profile_id) => format!("ssh:{profile_id}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointStatus { + Connecting, + Online, + Reconnecting, + Attention, + Disabled, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_ids_are_opaque_and_stable_when_parsed() { + let first = ProfileId::generate(); + let second = ProfileId::generate(); + assert_ne!(first, second); + assert_eq!(ProfileId::parse(first.to_string()).unwrap(), first); + assert_eq!(first.as_str().len(), 32); + } + + #[test] + fn endpoint_storage_keys_do_not_contain_ssh_targets() { + let profile = ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(); + assert_eq!( + ClientEndpointId::Ssh(profile).storage_key(), + "ssh:0123456789abcdef0123456789abcdef" + ); + } +} diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs new file mode 100644 index 00000000..a5b63344 --- /dev/null +++ b/src/client/endpoint/activation.rs @@ -0,0 +1,1152 @@ +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointRegistry, EndpointSendOutcome}; + +mod model; +mod protocol; +pub(crate) use model::{ + ActivationBeginError, ActivationCompletion, ActivationRollback, EndpointActivationIntent, + PendingEndpointActivation, SurfaceActivationProgress, +}; +use model::{ActivationEvidence, ActivationPhase, EndpointLease}; +use protocol::*; + +const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5); + +impl PendingEndpointActivation { + #[allow(clippy::too_many_arguments)] + pub(crate) fn begin( + shell: &crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + target: ClientEndpointId, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + resize: crate::protocol::ClientMessage, + serial: u64, + now: Instant, + ) -> Result<Self, ActivationBeginError> { + resize_geometry(&resize).ok_or_else(|| { + ActivationBeginError::Preflight( + "endpoint activation did not include a surface resize".to_owned(), + ) + })?; + let source_id = endpoints.active_id().clone(); + let source_has_live_surface = endpoints + .connection(&source_id) + .is_some_and(|connection| connection.surface_active); + let (source, source_available) = if source_has_live_surface { + ( + endpoint_lease(shell, endpoints, &source_id) + .map_err(ActivationBeginError::Preflight)?, + true, + ) + } else { + (disconnected_endpoint_lease(shell, &source_id), false) + }; + let target_lease = + endpoint_lease(shell, endpoints, &target).map_err(ActivationBeginError::Preflight)?; + let source_compatible = !source_available + || endpoints + .connection(&source_id) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + let target_compatible = endpoints + .connection(&target) + .is_some_and(|connection| connection.negotiation.supports_surface_interest()); + if !source_compatible || !target_compatible { + return Err(ActivationBeginError::Preflight( + "endpoint must be updated before it can join the selected surface".into(), + )); + } + + let source_is_target = source.endpoint_id == target_lease.endpoint_id; + // Validate every typed lifecycle and optional focus envelope before the first transport + // write. Any error above this line is guaranteed not to have changed either endpoint. + let source_release_request = (source_available && !source_is_target) + .then(|| { + surface_interest_request( + &source.boot_id, + format!("client-shell-surface:{serial}:off"), + false, + ) + }) + .transpose() + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + surface_interest_request( + &target_lease.boot_id, + format!("client-shell-surface:{serial}:on"), + true, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + if let Some(target) = focus.as_ref() { + focus_request( + &target_lease.boot_id, + format!("client-shell-focus:{serial}:1"), + target, + ) + .map_err(|error| ActivationBeginError::Preflight(error.to_string()))?; + } + + endpoints.freeze_input(); + let mut activation = Self { + source, + source_available, + target: target_lease, + focus, + host_focused: shell.host_focus_baseline(), + resize: resize.clone(), + phase: ActivationPhase::ReleasingSource { + request_id: format!("client-shell-surface:{serial}:off"), + }, + deadline: now + ACTIVATION_TIMEOUT, + epoch: serial, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + + // Reconnecting the selected endpoint has no live source surface to release. All normal + // handoffs must make the source locally inactive before a target request is even sent. + if source_is_target || !source_available { + if let Err(error) = activation.start_target(endpoints, resize) { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error, + }); + } + } else { + // `surface.set(false)` removes the viewer, but old servers only emit the PTY focus + // loss while the viewer is still active. Revoke it explicitly before source-off. + if endpoints.send_to( + &activation.source.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused: false }, + ) != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint focus revoke could not be sent".into(), + }); + } + let request = source_release_request.expect("validated source release request"); + if endpoints.send_to(&activation.source.endpoint_id, &request) + != EndpointSendOutcome::Sent + { + return Err(ActivationBeginError::Partial { + activation: Box::new(activation), + error: "source endpoint release could not be sent".into(), + }); + } + // This is deliberately before the acknowledgement: the source is no longer viewed + // locally while its release is in flight, and pane input is consequently blocked. + endpoints.set_surface_active(&activation.source.endpoint_id, false); + } + Ok(activation) + } + + pub(crate) fn target(&self) -> &ClientEndpointId { + &self.target.endpoint_id + } + + fn geometry(&self) -> crate::protocol::ClientSurfaceSize { + resize_geometry(&self.resize).expect("activation resize was validated before construction") + } + + pub(crate) fn presentation_sync_endpoint(&self) -> Option<&ClientEndpointId> { + match self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + _ => None, + } + } + + /// The complete source command lane cannot safely cross source-off into a later presentation + /// epoch. Other endpoint lanes are not part of this retirement. + pub(crate) fn source_command_lane(&self) -> Option<&ClientEndpointId> { + (self.source_available && self.source.endpoint_id != self.target.endpoint_id) + .then_some(&self.source.endpoint_id) + } + + pub(crate) fn can_retarget(&self, endpoint_id: &ClientEndpointId) -> bool { + self.target.endpoint_id == *endpoint_id + && self.successor.is_none() + && matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } | ActivationPhase::ActivatingTarget { .. } + ) + } + + /// Replace an in-flight handoff with the latest endpoint-qualified intent. The current + /// transaction is still reversed through target-off/source-on; the replacement is launched + /// by the caller only after the source's coherent restoration commits. + pub(crate) fn supersede( + &mut self, + endpoint_id: ClientEndpointId, + target: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> ActivationRollback { + self.successor = Some(EndpointActivationIntent { + endpoint_id, + target, + }); + // Source-on is already ordered and must finish before any replacement is allowed to + // begin. Later rapid selections only replace the retained intent; they never turn a + // safe restoration into an unavailable state. + let source_restoration_in_flight = matches!( + self.phase, + ActivationPhase::RestoringSource { .. } + ) || matches!( + &self.phase, + ActivationPhase::SynchronizingPresentation { completion, .. } + if matches!(completion.as_ref(), ActivationCompletion::RestoredSource { .. }) + ); + if source_restoration_in_flight { + return ActivationRollback::Pending; + } + self.rollback( + endpoints, + "endpoint handoff superseded by a newer selection".into(), + false, + ) + } + + pub(crate) fn accepts_endpoint(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + (self.source_available + && self.source.endpoint_id == *endpoint_id + && self.source.generation == generation) + || (self.target.endpoint_id == *endpoint_id && self.target.generation == generation) + } + + pub(crate) fn involves_endpoint(&self, endpoint_id: &ClientEndpointId) -> bool { + self.source.endpoint_id == *endpoint_id || self.target.endpoint_id == *endpoint_id + } + + pub(crate) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + ) -> bool { + match &self.phase { + ActivationPhase::ReleasingSource { + request_id: expected, + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::ActivatingTarget { + request_id: expected, + focus_request_id, + .. + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && (expected == request_id || focus_request_id.as_deref() == Some(request_id)) + } + ActivationPhase::ReleasingTargetForRollback { + request_id: expected, + } => { + endpoint_matches(&self.target, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::RestoringSource { + request_id: expected, + .. + } => { + endpoint_matches(&self.source, endpoint_id, generation, boot_id) + && expected == request_id + } + ActivationPhase::SynchronizingPresentation { + lease, + request_id: expected, + .. + } => { + endpoint_matches(lease, endpoint_id, generation, boot_id) && expected == request_id + } + ActivationPhase::AwaitingPresentationEffects { .. } => false, + } + } + + pub(crate) fn expired(&self, now: Instant) -> bool { + now >= self.deadline + } + + #[cfg(test)] + pub(crate) fn receive_response( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + let boot_id = if self.source.endpoint_id == *endpoint_id { + self.source.boot_id.clone() + } else { + self.target.boot_id.clone() + }; + self.receive_response_for_boot( + endpoint_id, + generation, + &boot_id, + request_id, + data, + endpoints, + ) + } + + pub(crate) fn receive_response_for_boot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + request_id: &str, + data: &[u8], + endpoints: &mut EndpointRegistry, + ) -> SurfaceActivationProgress { + if !self.accepts_response(endpoint_id, generation, boot_id, request_id) { + return SurfaceActivationProgress::Stale; + } + let result = match decode_endpoint_response(request_id, data) { + Ok(result) => result, + Err(error) => { + return SurfaceActivationProgress::Rejected { + source_release_rejected: matches!( + self.phase, + ActivationPhase::ReleasingSource { .. } + ) && error.code.is_some(), + message: error.message, + }; + } + }; + match &mut self.phase { + ActivationPhase::ReleasingSource { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + if let Err(message) = self.start_target(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::ActivatingTarget { + request_id: surface_request_id, + acknowledged_revision, + .. + } if surface_request_id == request_id => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } if focus_request_id.as_deref() == Some(request_id) => { + let requested = focus_request_target.clone(); + let Some(requested) = requested else { + return SurfaceActivationProgress::Stale; + }; + if !focus_result_matches(Some(&requested), &result) { + return SurfaceActivationProgress::Rejected { + message: "endpoint focus returned an unexpected result".into(), + source_release_rejected: false, + }; + } + *focus_request_id = None; + *focus_request_target = None; + if self.focus != Some(requested) { + *focus_acknowledged = false; + if let Err(message) = self.send_latest_focus(endpoints) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + return self.progress(); + } + *focus_acknowledged = true; + self.progress() + } + ActivationPhase::ReleasingTargetForRollback { .. } => { + if let Err(message) = surface_set_revision(&result, false) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + endpoints.set_surface_active(&self.target.endpoint_id, false); + if !self.source_available { + return SurfaceActivationProgress::Rejected { + message: self.rollback_error.clone().unwrap_or_else(|| { + "the previous endpoint is no longer connected".into() + }), + source_release_rejected: false, + }; + } + if let Err(message) = self.start_source_restore(endpoints, self.resize.clone()) { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + SurfaceActivationProgress::Pending + } + ActivationPhase::RestoringSource { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + .. + } => { + let revision = match surface_set_revision(&result, true) { + Ok(revision) => revision, + Err(message) => { + return SurfaceActivationProgress::Rejected { + message, + source_release_rejected: false, + }; + } + }; + *acknowledged_revision = Some(revision); + self.progress() + } + _ => SurfaceActivationProgress::Stale, + } + } + + pub(crate) fn receive_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: &crate::protocol::ClientShellSnapshot, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) + || snapshot.revision < lease.minimum_revision + { + return SurfaceActivationProgress::Stale; + } + let evidence = match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + if endpoint_matches(&self.target, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::RestoringSource { evidence, .. } + if endpoint_matches(&self.source, endpoint_id, generation, &snapshot.boot_id) => + { + evidence + } + ActivationPhase::SynchronizingPresentation { + lease, evidence, .. + } if endpoint_matches(lease, endpoint_id, generation, &snapshot.boot_id) => evidence, + _ => return SurfaceActivationProgress::Stale, + }; + evidence.record_snapshot(snapshot); + self.progress() + } + + pub(crate) fn receive_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + surface: crate::protocol::PaneSurfaceFrame, + ) -> SurfaceActivationProgress { + let lease = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => &self.target, + ActivationPhase::RestoringSource { .. } => &self.source, + ActivationPhase::SynchronizingPresentation { lease, .. } => lease, + _ => return SurfaceActivationProgress::Stale, + }; + if !endpoint_matches(lease, endpoint_id, generation, &surface.boot_id) { + return SurfaceActivationProgress::Stale; + } + if !surface_matches_geometry(&surface, self.geometry()) { + return SurfaceActivationProgress::Pending; + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.record_surface(surface) + } + _ => unreachable!("checked activation phase"), + } + self.progress() + } + + pub(crate) fn receive_presentation_effects_ready( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + token: &str, + ) -> SurfaceActivationProgress { + let ActivationPhase::AwaitingPresentationEffects { + lease, + token: expected, + ready, + .. + } = &mut self.phase + else { + return SurfaceActivationProgress::Stale; + }; + if lease.endpoint_id != *endpoint_id || lease.generation != generation || expected != token + { + return SurfaceActivationProgress::Stale; + } + *ready = true; + SurfaceActivationProgress::Ready + } + + /// A same-endpoint navigation request replaces the desired target but never joins the + /// in-flight focus RPC. Once that request resolves, `send_latest_focus` sends only the most + /// recent desired target. + pub(crate) fn retarget( + &mut self, + focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.focus = focus; + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = self.focus.is_none() && focus_request_id.is_none(); + } else { + // The latest target is retained and will be sent immediately after source release. + return Ok(()); + } + self.send_latest_focus(endpoints) + } + + pub(crate) fn update_resize( + &mut self, + resize: crate::protocol::ClientMessage, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + resize_geometry(&resize) + .ok_or_else(|| "endpoint activation did not include a surface resize".to_owned())?; + self.resize = resize.clone(); + let restart_effects_fence = match &self.phase { + ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + }; + if let Some((lease, completion)) = restart_effects_fence { + if endpoints.send_to(&lease.endpoint_id, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + return self.start_presentation_sync(endpoints, lease, completion); + } + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } + | ActivationPhase::SynchronizingPresentation { evidence, .. } => { + evidence.invalidate_surface() + } + _ => {} + } + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } => Some(&lease.endpoint_id), + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to(destination, &resize) != EndpointSendOutcome::Sent { + return Err("pending endpoint resize could not be sent".into()); + } + } + Ok(()) + } + + pub(crate) fn update_host_focus( + &mut self, + focused: bool, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + self.host_focused = focused; + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + if endpoints.send_to( + destination, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("pending endpoint focus baseline could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + pub(crate) fn update_host_theme( + &mut self, + update: crate::protocol::ClientHostThemeUpdate, + endpoints: &mut EndpointRegistry, + ) -> Result<(), String> { + let restart = self.presentation_restart(); + let destination = match &self.phase { + ActivationPhase::ActivatingTarget { .. } => Some(&self.target.endpoint_id), + ActivationPhase::RestoringSource { .. } => Some(&self.source.endpoint_id), + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } => { + Some(&lease.endpoint_id) + } + _ => None, + }; + if let Some(destination) = destination { + let message = crate::protocol::ClientMessage::ClientShellHostTheme { update }; + if endpoints.send_to(destination, &message) != EndpointSendOutcome::Sent { + return Err("pending endpoint host theme could not be sent".into()); + } + } + if let Some((lease, completion)) = restart { + self.start_presentation_sync(endpoints, lease, completion)?; + } else { + self.invalidate_current_evidence(); + } + Ok(()) + } + + fn presentation_restart(&self) -> Option<(EndpointLease, ActivationCompletion)> { + match &self.phase { + ActivationPhase::SynchronizingPresentation { + lease, completion, .. + } + | ActivationPhase::AwaitingPresentationEffects { + lease, completion, .. + } => Some((lease.clone(), (**completion).clone())), + _ => None, + } + } + + fn invalidate_current_evidence(&mut self) { + match &mut self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } + | ActivationPhase::RestoringSource { evidence, .. } => evidence.invalidate_surface(), + _ => {} + } + } + + /// Losing the source revokes its surface and removes the rollback destination; it must not + /// cancel a healthy target. Losing the target restores the source when it is still available. + pub(crate) fn endpoint_disconnected( + &mut self, + endpoints: &mut EndpointRegistry, + endpoint_id: &ClientEndpointId, + error: String, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if self.target.endpoint_id == *endpoint_id && self.source.endpoint_id != *endpoint_id { + return match self.phase { + ActivationPhase::RestoringSource { .. } => ActivationRollback::Pending, + _ => match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {restore_error}" + )), + }, + }; + } + if self.source.endpoint_id != *endpoint_id { + return ActivationRollback::Unavailable(error); + } + self.source_available = false; + if self.source.endpoint_id != self.target.endpoint_id { + match &self.phase { + ActivationPhase::ReleasingSource { .. } => { + return match self.start_target(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(message) => ActivationRollback::Unavailable(message), + }; + } + ActivationPhase::ActivatingTarget { .. } => return ActivationRollback::Pending, + ActivationPhase::SynchronizingPresentation { lease, .. } + | ActivationPhase::AwaitingPresentationEffects { lease, .. } + if lease.endpoint_id == self.target.endpoint_id => + { + return ActivationRollback::Pending + } + _ => {} + } + } + match self.phase { + ActivationPhase::ReleasingSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::ActivatingTarget { .. } => { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } + ActivationPhase::ReleasingTargetForRollback { .. } => ActivationRollback::Pending, + ActivationPhase::RestoringSource { .. } => ActivationRollback::Unavailable(error), + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + match self.start_target_release(endpoints) { + Ok(()) => ActivationRollback::Pending, + Err(release_error) => ActivationRollback::Unavailable(format!( + "{error}; target endpoint could not be released safely: {release_error}" + )), + } + } else { + ActivationRollback::Unavailable(error) + } + } + } + } + + pub(crate) fn rollback( + &mut self, + endpoints: &mut EndpointRegistry, + error: String, + source_release_rejected: bool, + ) -> ActivationRollback { + self.rollback_error = Some(error.clone()); + if matches!(self.phase, ActivationPhase::ReleasingSource { .. }) && source_release_rejected + { + // Rejection proves source-off did not commit, but cached source metadata may have + // advanced while the frame was frozen. Restore through the same coherent on/sync + // path rather than immediately exposing a stale source projection. + return match self.start_source_restore(endpoints, self.resize.clone()) { + Ok(()) => ActivationRollback::Pending, + Err(restore_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not resume: {restore_error}" + )), + }; + } + let result = match self.phase { + ActivationPhase::ReleasingSource { .. } => { + if self.source_available { + self.start_source_restore(endpoints, self.resize.clone()) + } else { + Err("the previous endpoint is no longer connected".into()) + } + } + ActivationPhase::ActivatingTarget { .. } => self.start_target_release(endpoints), + ActivationPhase::ReleasingTargetForRollback { .. } => { + // The target may have observed target-on or target-off. Closing this transport + // is the only safe local revocation when target-off is not acknowledged. + endpoints.fail( + &self.target.endpoint_id, + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "endpoint did not acknowledge surface revocation", + ), + ); + if !self.source_available { + return ActivationRollback::Unavailable(format!( + "{error}; the target connection was closed because no presentation owner could be proven" + )); + } + self.start_source_restore(endpoints, self.resize.clone()) + } + ActivationPhase::RestoringSource { .. } => { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored" + )); + } + ActivationPhase::SynchronizingPresentation { ref lease, .. } + | ActivationPhase::AwaitingPresentationEffects { ref lease, .. } => { + if lease.endpoint_id == self.target.endpoint_id { + self.start_target_release(endpoints) + } else { + return ActivationRollback::Unavailable(format!( + "{error}; source endpoint presentation could not be synchronized" + )); + } + } + }; + match result { + Ok(()) => ActivationRollback::Pending, + Err(rollback_error) => ActivationRollback::Unavailable(format!( + "{error}; source endpoint could not be restored safely: {rollback_error}" + )), + } + } + + pub(crate) fn complete( + &mut self, + shell: &mut crate::client::shell::ClientShellState, + endpoints: &mut EndpointRegistry, + ) -> Result<ActivationCompletion, String> { + if let ActivationPhase::SynchronizingPresentation { + lease, + acknowledged_revision, + evidence, + completion, + .. + } = &self.phase + { + let lease = lease.clone(); + let completion = (**completion).clone(); + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + *acknowledged_revision, + self.geometry(), + )?; + if endpoints.active_id() != &lease.endpoint_id + || !shell.endpoint_projection_available(&lease.endpoint_id) + || !shell.activate_endpoint_projection(&lease.endpoint_id) + { + return Err( + "endpoint became unavailable during presentation synchronization".into(), + ); + } + shell.set_pane_surface(surface); + self.start_presentation_effects_fence(endpoints, lease, completion)?; + return Ok(ActivationCompletion::AwaitingPresentationEffects); + } + if let ActivationPhase::AwaitingPresentationEffects { + ready: true, + completion, + .. + } = &self.phase + { + return Ok((**completion).clone()); + } + + let (lease, evidence, acknowledgement_revision, completion) = match &self.phase { + ActivationPhase::ActivatingTarget { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::Activated; + ( + self.target.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + ActivationPhase::RestoringSource { + evidence, + acknowledged_revision, + .. + } => { + let completion = ActivationCompletion::RestoredSource { + error: self + .rollback_error + .clone() + .unwrap_or_else(|| "endpoint handoff was rolled back".into()), + successor: self.successor.clone(), + }; + ( + self.source.clone(), + evidence, + *acknowledged_revision, + completion, + ) + } + _ => return Err("endpoint activation completed in an invalid phase".into()), + }; + let surface = coherent_completion_surface( + shell, + &lease, + evidence, + acknowledgement_revision, + self.geometry(), + )?; + endpoints.set_surface_active(&lease.endpoint_id, true); + shell.set_endpoint_status(&lease.endpoint_id, ClientEndpointStatus::Online); + if !shell.endpoint_projection_available(&lease.endpoint_id) + || !endpoints.set_active(&lease.endpoint_id) + { + return Err("endpoint became unavailable during activation".into()); + } + let activated = shell.activate_endpoint_projection(&lease.endpoint_id); + debug_assert!(activated, "preflighted endpoint projection must activate"); + debug_assert!(shell.endpoint_is_active(endpoints.active_id())); + shell.set_pane_surface(surface); + self.start_presentation_sync(endpoints, lease.clone(), completion)?; + Ok(ActivationCompletion::AwaitingPresentationSync { + previous: self.source.endpoint_id.clone(), + endpoint: lease.endpoint_id, + }) + } + + fn start_target( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:on", self.epoch); + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + // A transport may fail after writing any baseline or surface message. Enter the target + // phase first so every uncertain target write is reversed through target-off before + // source restoration is considered. + self.phase = ActivationPhase::ActivatingTarget { + request_id: request_id.clone(), + acknowledged_revision: None, + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: self.focus.is_none(), + evidence: ActivationEvidence::default(), + }; + send_surface_activation( + endpoints, + &self.target, + request_id, + &resize, + self.host_focused, + )?; + + // From this point the target may have processed surface.set(true). Optional navigation + // is serialized through one coalescing focus lane. + self.send_latest_focus(endpoints) + } + + fn start_presentation_sync( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:presentation-sync", self.epoch); + let request = surface_interest_request(&lease.boot_id, request_id.clone(), true) + .map_err(|error| error.to_string())?; + self.phase = ActivationPhase::SynchronizingPresentation { + lease: lease.clone(), + request_id, + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&lease.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint presentation synchronization could not be sent".into()); + } + Ok(()) + } + + fn start_presentation_effects_fence( + &mut self, + endpoints: &mut EndpointRegistry, + lease: EndpointLease, + completion: ActivationCompletion, + ) -> Result<(), String> { + let token = format!("{}:{}:{}", self.epoch, lease.generation, lease.boot_id); + self.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease.clone(), + token: token.clone(), + ready: false, + completion: Box::new(completion), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + let message = crate::protocol::ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND.into(), + data: token, + }; + if endpoints.send_to(&lease.endpoint_id, &message) != EndpointSendOutcome::Sent { + return Err("endpoint presentation effects fence could not be sent".into()); + } + Ok(()) + } + + fn start_target_release(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-target-off", self.epoch); + let request = surface_interest_request(&self.target.boot_id, request_id.clone(), false) + .map_err(|error| error.to_string())?; + // Set the rollback phase before the potentially observed target-off write. + self.phase = ActivationPhase::ReleasingTargetForRollback { request_id }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("target endpoint release could not be sent".into()); + } + Ok(()) + } + + fn start_source_restore( + &mut self, + endpoints: &mut EndpointRegistry, + resize: crate::protocol::ClientMessage, + ) -> Result<(), String> { + let request_id = format!("client-shell-surface:{}:rollback-source-on", self.epoch); + // Source baseline writes can also be observed before their send reports an error. + self.phase = ActivationPhase::RestoringSource { + request_id: request_id.clone(), + acknowledged_revision: None, + evidence: ActivationEvidence::default(), + }; + self.deadline = Instant::now() + ACTIVATION_TIMEOUT; + send_surface_activation( + endpoints, + &self.source, + request_id, + &resize, + self.host_focused, + ) + } + + fn send_latest_focus(&mut self, endpoints: &mut EndpointRegistry) -> Result<(), String> { + let desired = self.focus.clone(); + let Some(desired) = desired else { + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_acknowledged, + .. + } = &mut self.phase + { + if focus_request_id.is_none() { + *focus_acknowledged = true; + } + } + return Ok(()); + }; + if !matches!( + &self.phase, + ActivationPhase::ActivatingTarget { + focus_request_id: None, + .. + } + ) { + return Ok(()); + } + let request_id = self + .next_focus_request_id() + .expect("desired focus creates a request id"); + if let ActivationPhase::ActivatingTarget { + focus_request_id, + focus_request_target, + focus_acknowledged, + .. + } = &mut self.phase + { + *focus_acknowledged = false; + *focus_request_id = Some(request_id.clone()); + *focus_request_target = Some(desired.clone()); + } + let request = focus_request(&self.target.boot_id, request_id, &desired) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&self.target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint focus could not be sent".into()); + } + Ok(()) + } + + fn next_focus_request_id(&mut self) -> Option<String> { + self.focus.as_ref()?; + self.next_focus_serial = self.next_focus_serial.saturating_add(1); + Some(format!( + "client-shell-focus:{}:{}", + self.epoch, self.next_focus_serial + )) + } + + fn progress(&self) -> SurfaceActivationProgress { + match &self.phase { + ActivationPhase::ActivatingTarget { + acknowledged_revision, + focus_acknowledged, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + *focus_acknowledged + && evidence + .coherent_surface(revision, self.geometry()) + .is_some_and(|surface| self.target_matches(surface)) + }) => + { + SurfaceActivationProgress::Ready + } + ActivationPhase::RestoringSource { + acknowledged_revision, + evidence, + .. + } + | ActivationPhase::SynchronizingPresentation { + acknowledged_revision, + evidence, + .. + } if acknowledged_revision.is_some_and(|revision| { + evidence + .coherent_surface(revision, self.geometry()) + .is_some() + }) => + { + SurfaceActivationProgress::Ready + } + _ => SurfaceActivationProgress::Pending, + } + } + + fn target_matches(&self, surface: &crate::protocol::PaneSurfaceFrame) -> bool { + let evidence = match &self.phase { + ActivationPhase::ActivatingTarget { evidence, .. } => evidence, + _ => return false, + }; + match &self.focus { + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => { + evidence.focused_pane_id.as_deref() == Some(pane_id) + && surface + .panes + .iter() + .any(|pane| pane.focused && &pane.pane_id == pane_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id)) => { + evidence.focused_tab_id.as_deref() == Some(tab_id) + } + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id)) => { + evidence.focused_workspace_id.as_deref() == Some(workspace_id) + } + None => true, + } + } +} + +#[cfg(test)] +#[path = "activation_tests.rs"] +mod tests; diff --git a/src/client/endpoint/activation/model.rs b/src/client/endpoint/activation/model.rs new file mode 100644 index 00000000..c94029e9 --- /dev/null +++ b/src/client/endpoint/activation/model.rs @@ -0,0 +1,174 @@ +use std::time::Instant; + +use super::super::ClientEndpointId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct EndpointLease { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, + pub(super) boot_id: String, + /// The endpoint cache may already be newer than the first activation event. Never let an + /// activation prove coherence with a revision that the monotonic cache has discarded. + pub(super) minimum_revision: u64, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct ActivationEvidence { + pub(super) snapshot_revision: Option<u64>, + pub(super) focused_workspace_id: Option<String>, + pub(super) focused_tab_id: Option<String>, + pub(super) focused_pane_id: Option<String>, + pub(super) surface: Option<crate::protocol::PaneSurfaceFrame>, +} + +impl ActivationEvidence { + pub(super) fn record_snapshot(&mut self, snapshot: &crate::protocol::ClientShellSnapshot) { + if self + .snapshot_revision + .is_none_or(|current| snapshot.revision >= current) + { + self.snapshot_revision = Some(snapshot.revision); + self.focused_workspace_id = snapshot.focused_workspace_id.clone(); + self.focused_tab_id = snapshot.focused_tab_id.clone(); + self.focused_pane_id = snapshot.focused_pane_id.clone(); + } + } + + pub(super) fn record_surface(&mut self, surface: crate::protocol::PaneSurfaceFrame) { + let replace = self.surface.as_ref().is_none_or(|current| { + surface.projection_revision > current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision >= current.surface_revision) + }); + if replace { + self.surface = Some(surface); + } + } + + pub(super) fn invalidate_surface(&mut self) { + self.surface = None; + } + + pub(super) fn coherent_surface( + &self, + minimum_revision: u64, + geometry: crate::protocol::ClientSurfaceSize, + ) -> Option<&crate::protocol::PaneSurfaceFrame> { + self.surface.as_ref().filter(|surface| { + self.snapshot_revision == Some(surface.projection_revision) + && surface.projection_revision >= minimum_revision + && super::surface_matches_geometry(surface, geometry) + }) + } +} + +#[derive(Clone, Debug)] +pub(super) enum ActivationPhase { + ReleasingSource { + request_id: String, + }, + ActivatingTarget { + request_id: String, + acknowledged_revision: Option<u64>, + /// At most one focus request may be in flight. Retargets only replace `focus` until + /// this response arrives, at which point the latest target is sent. + focus_request_id: Option<String>, + focus_request_target: Option<crate::client::shell::ClientEndpointFocusTarget>, + focus_acknowledged: bool, + evidence: ActivationEvidence, + }, + ReleasingTargetForRollback { + request_id: String, + }, + RestoringSource { + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + }, + SynchronizingPresentation { + lease: EndpointLease, + request_id: String, + acknowledged_revision: Option<u64>, + evidence: ActivationEvidence, + completion: Box<ActivationCompletion>, + }, + AwaitingPresentationEffects { + lease: EndpointLease, + token: String, + ready: bool, + completion: Box<ActivationCompletion>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SurfaceActivationProgress { + Pending, + Ready, + Rejected { + message: String, + source_release_rejected: bool, + }, + Stale, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationRollback { + /// A correlated rollback request is in flight; retain the frozen frame. + Pending, + /// Neither endpoint can be made safe to present. Keep pane input frozen while presenting + /// client chrome and this error. + Unavailable(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ActivationCompletion { + AwaitingPresentationSync { + previous: ClientEndpointId, + endpoint: ClientEndpointId, + }, + AwaitingPresentationEffects, + Activated, + RestoredSource { + error: String, + /// A newer endpoint-qualified selection arrived while this handoff was frozen. It is + /// started only after the original source has been coherently restored. + successor: Option<EndpointActivationIntent>, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointActivationIntent { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) target: Option<crate::client::shell::ClientEndpointFocusTarget>, +} + +/// Begin failures are separated by whether the transport may already have observed a lifecycle +/// write. A partial transaction must be kept and rolled back under a frozen presentation. +#[derive(Debug)] +pub(crate) enum ActivationBeginError { + Preflight(String), + Partial { + activation: Box<PendingEndpointActivation>, + error: String, + }, +} + +/// The only owner of an endpoint handoff. The registry's active endpoint remains the committed +/// endpoint until the target commits, while this object owns the uncommitted lifecycle lane. +#[derive(Debug)] +pub(crate) struct PendingEndpointActivation { + pub(super) source: EndpointLease, + pub(super) source_available: bool, + pub(super) target: EndpointLease, + pub(super) focus: Option<crate::client::shell::ClientEndpointFocusTarget>, + pub(super) host_focused: bool, + pub(super) resize: crate::protocol::ClientMessage, + pub(super) phase: ActivationPhase, + pub(super) deadline: Instant, + pub(super) epoch: u64, + pub(super) next_focus_serial: u64, + pub(super) rollback_error: Option<String>, + /// A different endpoint was selected while this source-off-first transaction was in flight. + /// Keep only the latest intent until source restoration commits. + pub(super) successor: Option<EndpointActivationIntent>, +} diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs new file mode 100644 index 00000000..569d53f3 --- /dev/null +++ b/src/client/endpoint/activation/protocol.rs @@ -0,0 +1,219 @@ +use super::super::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; +use super::model::{ActivationEvidence, EndpointLease}; + +pub(super) fn focus_result_matches( + focus: Option<&crate::client::shell::ClientEndpointFocusTarget>, + result: &crate::api::schema::ResponseResult, +) -> bool { + match (focus, result) { + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)), + crate::api::schema::ResponseResult::PaneInfo { pane }, + ) => pane.focused && &pane.pane_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)), + crate::api::schema::ResponseResult::WorkspaceInfo { workspace }, + ) => workspace.focused && &workspace.workspace_id == expected, + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Tab(expected)), + crate::api::schema::ResponseResult::TabInfo { tab }, + ) => tab.focused && &tab.tab_id == expected, + _ => false, + } +} + +pub(super) fn endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoints: &EndpointRegistry, + endpoint_id: &ClientEndpointId, +) -> Result<EndpointLease, String> { + let connection = endpoints + .connection(endpoint_id) + .ok_or_else(|| "endpoint connection is unavailable".to_owned())?; + let (boot_id, minimum_revision) = shell + .endpoint_snapshot_identity(endpoint_id, connection.generation) + .ok_or_else(|| "endpoint metadata is not ready for this connection".to_owned())?; + Ok(EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: connection.generation, + boot_id: boot_id.to_owned(), + minimum_revision, + }) +} + +pub(super) fn disconnected_endpoint_lease( + shell: &crate::client::shell::ClientShellState, + endpoint_id: &ClientEndpointId, +) -> EndpointLease { + EndpointLease { + endpoint_id: endpoint_id.clone(), + generation: 0, + boot_id: shell + .endpoint_boot_id(endpoint_id) + .unwrap_or_default() + .to_owned(), + minimum_revision: 0, + } +} + +pub(super) fn endpoint_matches( + lease: &EndpointLease, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, +) -> bool { + lease.endpoint_id == *endpoint_id && lease.generation == generation && lease.boot_id == boot_id +} + +pub(super) fn coherent_completion_surface( + shell: &crate::client::shell::ClientShellState, + lease: &EndpointLease, + evidence: &ActivationEvidence, + acknowledgement_revision: Option<u64>, + geometry: crate::protocol::ClientSurfaceSize, +) -> Result<crate::protocol::PaneSurfaceFrame, String> { + let acknowledgement_revision = acknowledgement_revision.ok_or_else(|| { + "endpoint activation completed without a surface acknowledgement".to_owned() + })?; + let surface = evidence + .surface + .clone() + .ok_or_else(|| "endpoint activation completed without a surface".to_owned())?; + if surface.projection_revision < acknowledgement_revision + || !surface_matches_geometry(&surface, geometry) + { + return Err("endpoint activation lost its acknowledged surface evidence".into()); + } + if !shell.endpoint_snapshot_matches( + &lease.endpoint_id, + lease.generation, + &lease.boot_id, + surface.projection_revision, + ) { + return Err("endpoint activation lost its coherent snapshot/surface pair".into()); + } + Ok(surface) +} + +pub(super) fn resize_geometry( + message: &crate::protocol::ClientMessage, +) -> Option<crate::protocol::ClientSurfaceSize> { + match message { + crate::protocol::ClientMessage::ClientShellResize { surface_size, .. } => { + Some(*surface_size) + } + _ => None, + } +} + +pub(super) fn surface_matches_geometry( + surface: &crate::protocol::PaneSurfaceFrame, + geometry: crate::protocol::ClientSurfaceSize, +) -> bool { + surface.frame.width == geometry.cols && surface.frame.height == geometry.rows +} + +pub(super) fn send_surface_activation( + endpoints: &mut EndpointRegistry, + target: &EndpointLease, + request_id: String, + resize: &crate::protocol::ClientMessage, + focused: bool, +) -> Result<(), String> { + if endpoints.send_to(&target.endpoint_id, resize) != EndpointSendOutcome::Sent { + return Err("endpoint resize could not be sent".into()); + } + let request = surface_interest_request(&target.boot_id, request_id, true) + .map_err(|error| error.to_string())?; + if endpoints.send_to(&target.endpoint_id, &request) != EndpointSendOutcome::Sent { + return Err("endpoint activation could not be sent".into()); + } + // Inactive endpoints reject focus events. Activate first, then establish the host baseline + // on the same ordered transport before navigation or presentation can commit. + if endpoints.send_to( + &target.endpoint_id, + &crate::protocol::ClientMessage::ClientShellFocus { focused }, + ) != EndpointSendOutcome::Sent + { + return Err("endpoint focus baseline could not be sent".into()); + } + Ok(()) +} + +pub(super) fn decode_endpoint_response( + request_id: &str, + data: &[u8], +) -> Result<crate::api::schema::ResponseResult, crate::client::shell::ClientShellEndpointError> { + crate::client::endpoint_commands::parse_response(request_id, data) +} + +pub(super) fn surface_set_revision( + result: &crate::api::schema::ResponseResult, + expected_active: bool, +) -> Result<u64, String> { + match result { + crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + } if *active == expected_active => Ok(*projection_revision), + _ => Err("surface activation returned an invalid acknowledgement".into()), + } +} + +pub(super) fn focus_request( + boot_id: &str, + request_id: String, + focus: &crate::client::shell::ClientEndpointFocusTarget, +) -> std::io::Result<crate::protocol::ClientMessage> { + let method = match focus { + crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: workspace_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }) + } + crate::client::shell::ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: tab_id.clone(), + }) + } + }; + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method, + }, + ) +} + +pub(super) fn surface_interest_request( + boot_id: &str, + request_id: String, + active: bool, +) -> std::io::Result<crate::protocol::ClientMessage> { + endpoint_request( + boot_id, + crate::api::schema::Request { + id: request_id, + method: crate::api::schema::Method::ClientShellSurfaceSet( + crate::api::schema::ClientShellSurfaceSetParams { active }, + ), + }, + ) +} + +fn endpoint_request( + boot_id: &str, + request: crate::api::schema::Request, +) -> std::io::Result<crate::protocol::ClientMessage> { + Ok(crate::protocol::ClientMessage::ClientShellEndpointRequest { + boot_id: boot_id.to_owned(), + request: serde_json::to_string(&request) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + }) +} diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs new file mode 100644 index 00000000..b66cb98b --- /dev/null +++ b/src/client/endpoint/activation_tests.rs @@ -0,0 +1,1305 @@ +use super::*; + +fn endpoint() -> ClientEndpointId { + ClientEndpointId::Ssh( + super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ) +} + +fn lease(id: ClientEndpointId, generation: u64, boot: &str) -> EndpointLease { + EndpointLease { + endpoint_id: id, + generation, + boot_id: boot.into(), + minimum_revision: 0, + } +} + +#[derive(Clone)] +struct FakeTransport { + sent: std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>, + fail_after_write: bool, +} + +impl super::super::EndpointTransport for FakeTransport { + fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + if self.fail_after_write { + Err(std::io::Error::other("simulated observed write failure")) + } else { + Ok(()) + } + } +} + +fn negotiation() -> super::super::EndpointNegotiation { + super::super::EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + ], + ) +} + +fn test_snapshot(boot_id: &str, revision: u64) -> crate::protocol::ClientShellSnapshot { + crate::protocol::ClientShellSnapshot { + boot_id: boot_id.into(), + revision, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + } +} + +type SentMessages = std::sync::Arc<std::sync::Mutex<Vec<crate::protocol::ClientMessage>>>; +type TestFixture = ( + crate::client::ClientShellState, + EndpointRegistry, + SentMessages, + SentMessages, +); + +fn shell_and_registry() -> TestFixture { + shell_and_registry_with_source_failure(false) +} + +fn shell_and_registry_with_source_failure(source_fail_after_write: bool) -> TestFixture { + let mut shell = crate::client::ClientShellState::new( + crate::client::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + let profile = super::super::SavedSshEndpoint { + id: super::super::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Remote".into(), + target: "dev@example.com".into(), + session: "main".into(), + enabled: true, + }; + let target = ClientEndpointId::Ssh(profile.id.clone()); + shell.set_endpoint_catalog(&[profile]); + shell.set_snapshot(Box::new(test_snapshot("local-boot", 1))); + shell.set_endpoint_status(&target, ClientEndpointStatus::Online); + shell.set_endpoint_snapshot(&target, Box::new(test_snapshot("remote-boot", 1))); + + let local_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let remote_sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + fail_after_write: source_fail_after_write, + }, + 1, + negotiation(), + ); + endpoints.insert( + target, + FakeTransport { + sent: remote_sent.clone(), + fail_after_write: false, + }, + 7, + negotiation(), + false, + ); + (shell, endpoints, local_sent, remote_sent) +} + +fn surface_success(id: &str, active: bool, projection_revision: u64) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::ClientShellSurfaceSet { + active, + projection_revision, + }, + }) + .unwrap() +} + +fn workspace_focus_success(id: &str, workspace_id: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::SuccessResponse { + id: id.into(), + result: crate::api::schema::ResponseResult::WorkspaceInfo { + workspace: crate::api::schema::WorkspaceInfo { + workspace_id: workspace_id.into(), + number: 1, + label: workspace_id.into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "tab".into(), + agent_status: crate::api::schema::AgentStatus::Unknown, + tokens: Default::default(), + worktree: None, + }, + }, + }) + .unwrap() +} + +fn failure(id: &str, message: &str) -> Vec<u8> { + serde_json::to_vec(&crate::api::schema::ErrorResponse { + id: id.into(), + error: crate::api::schema::ErrorBody { + code: "surface_rejected".into(), + message: message.into(), + }, + }) + .unwrap() +} + +fn surface_set_active(message: &crate::protocol::ClientMessage) -> Option<bool> { + let crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } = message else { + return None; + }; + let request: crate::api::schema::Request = serde_json::from_str(request).ok()?; + match request.method { + crate::api::schema::Method::ClientShellSurfaceSet(params) => Some(params.active), + _ => None, + } +} + +fn resize() -> crate::protocol::ClientMessage { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: false, + } +} + +fn surface(boot_id: &str, revision: u64, pane: &str) -> crate::protocol::PaneSurfaceFrame { + crate::protocol::PaneSurfaceFrame { + boot_id: boot_id.into(), + projection_revision: revision, + surface_revision: revision, + frame: crate::protocol::FrameData { + cells: Vec::new(), + width: 80, + height: 24, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![crate::protocol::PaneSurfacePane { + pane_id: pane.into(), + content_revision: revision, + rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + inner_rect: crate::protocol::SurfaceRect { + x: 0, + y: 0, + width: 80, + height: 24, + }, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } +} + +fn machine() -> PendingEndpointActivation { + PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ActivatingTarget { + request_id: "client-shell-surface:3:on".into(), + acknowledged_revision: Some(1), + focus_request_id: None, + focus_request_target: None, + focus_acknowledged: true, + evidence: ActivationEvidence::default(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 3, + next_focus_serial: 0, + rollback_error: None, + successor: None, + } +} + +#[test] +fn source_off_request_is_distinct_and_precedes_target_on_phase() { + let activation = PendingEndpointActivation { + source: lease(ClientEndpointId::Local, 1, "local-boot"), + source_available: true, + target: lease(endpoint(), 7, "remote-boot"), + focus: None, + host_focused: true, + resize: resize(), + phase: ActivationPhase::ReleasingSource { + request_id: "client-shell-surface:9:off".into(), + }, + deadline: Instant::now() + ACTIVATION_TIMEOUT, + epoch: 9, + next_focus_serial: 0, + rollback_error: None, + successor: None, + }; + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:9:off" + )); + assert!(!activation.accepts_response( + &endpoint(), + 7, + "remote-boot", + "client-shell-surface:9:on" + )); +} + +#[test] +fn active_source_requires_metadata_from_its_current_connection_generation() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 99, + Box::new(test_snapshot("stale-local-boot", 1)), + ); + + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 26, + Instant::now(), + ); + + assert!( + matches!(result, Err(ActivationBeginError::Preflight(message)) if message.contains("this connection")) + ); + assert!(local_sent.lock().unwrap().is_empty()); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(endpoints.active_surface_available()); +} + +#[test] +fn observed_begin_write_failure_returns_recoverable_partial_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = + shell_and_registry_with_source_failure(true); + let result = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 10, + Instant::now(), + ); + + let ActivationBeginError::Partial { + mut activation, + error, + } = (match result { + Err(error) => error, + Ok(_) => panic!("an observed source write must return partial lifecycle state"), + }) + else { + panic!("an observed source write must return partial lifecycle state"); + }; + assert!(error.contains("focus revoke")); + assert_eq!( + local_sent.lock().unwrap().first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!(matches!( + activation.rollback(&mut endpoints, error, false), + ActivationRollback::Unavailable(_) + )); +} + +#[test] +fn source_release_is_sent_and_acknowledged_before_target_activation() { + let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 11, + Instant::now(), + ) + .unwrap(); + let local = local_sent.lock().unwrap(); + assert_eq!( + local.first(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }), + "source focus is revoked before source-off" + ); + assert_eq!( + local + .as_slice() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false], + "the source is released first" + ); + drop(local); + assert!(remote_sent.lock().unwrap().is_empty()); + assert!( + !endpoints.active_surface_available(), + "pane input is blocked while frozen" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:11:off", + &surface_success("client-shell-surface:11:off", false, 1), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let remote = remote_sent.lock().unwrap(); + assert!(matches!( + remote[0], + crate::protocol::ClientMessage::ClientShellResize { .. } + )); + assert_eq!( + remote.get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); + assert_eq!(remote.get(1).and_then(surface_set_active), Some(true)); +} + +#[test] +fn activation_requires_an_exact_snapshot_surface_revision_pair() { + let mut activation = machine(); + let target = endpoint(); + let snapshot = crate::protocol::ClientShellSnapshot { + boot_id: "remote-boot".into(), + revision: 2, + config_diagnostic: None, + product_announcement: None, + update_available: None, + update_install_command: String::new(), + server_keybindings_toml: None, + latest_release_notes_available: false, + integration_updates_available: false, + worktree_directory: String::new(), + release_notes: None, + focused_workspace_id: None, + focused_tab_id: None, + focused_pane_id: None, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), + agent_view_label: None, + agent_order: Vec::new(), + workspaces: Vec::new(), + tabs: Vec::new(), + panes: Vec::new(), + agents: Vec::new(), + commands: Vec::new(), + }; + assert_eq!( + activation.receive_snapshot(&target, 7, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn typed_target_ack_sets_a_floor_for_same_boot_activation_evidence() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 16, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:16:off", + &surface_success("client-shell-surface:16:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.receive_response( + &target, + 7, + "client-shell-surface:16:on", + &surface_success("client-shell-surface:16:on", true, 4), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 3)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 3, "pane")), + SurfaceActivationProgress::Pending, + "a delayed same-boot surface below the acknowledgement floor is not evidence" + ); + assert_eq!( + activation.receive_snapshot(&target, 7, &test_snapshot("remote-boot", 4)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&target, 7, surface("remote-boot", 4, "pane")), + SurfaceActivationProgress::Ready + ); +} + +#[test] +fn stale_generation_and_boot_are_not_activation_evidence() { + let mut activation = machine(); + assert_eq!( + activation.receive_surface(&endpoint(), 6, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("old-boot", 1, "pane")), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn stale_response_boot_is_not_consumed() { + let mut activation = machine(); + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + assert_eq!( + activation.receive_response_for_boot( + &endpoint(), + 7, + "old-boot", + "client-shell-surface:3:on", + &surface_success("client-shell-surface:3:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Stale + ); +} + +#[test] +fn same_target_retarget_is_latest_wins() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "old".into(), + )), + resize(), + 12, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:12:off", + &surface_success("client-shell-surface:12:off", false, 1), + &mut endpoints, + ); + let old_focus = remote_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if surface_set_active(message).is_none() => + { + Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ) + } + _ => None, + }) + .unwrap(); + let sent_before_retarget = remote_sent.lock().unwrap().len(); + activation + .retarget( + Some(crate::client::shell::ClientEndpointFocusTarget::Workspace( + "new".into(), + )), + &mut endpoints, + ) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().len(), sent_before_retarget); + assert!(activation.accepts_response(&target, 7, "remote-boot", &old_focus)); + assert_eq!( + activation.receive_response( + &target, + 7, + &old_focus, + &workspace_focus_success(&old_focus, "old"), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let latest_focus = remote_sent + .lock() + .unwrap() + .last() + .and_then(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } => Some( + serde_json::from_str::<crate::api::schema::Request>(request) + .unwrap() + .id, + ), + _ => None, + }) + .unwrap(); + assert_ne!(latest_focus, old_focus); + assert!(activation.accepts_response(&target, 7, "remote-boot", &latest_focus)); +} + +#[test] +fn latest_host_focus_is_replayed_to_the_eventual_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 25, + Instant::now(), + ) + .unwrap(); + activation.update_host_focus(false, &mut endpoints).unwrap(); + assert!(remote_sent.lock().unwrap().is_empty()); + + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:25:off", + &surface_success("client-shell-surface:25:off", false, 1), + &mut endpoints, + ); + assert_eq!( + remote_sent.lock().unwrap().get(2), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: false }) + ); + + activation.update_host_focus(true, &mut endpoints).unwrap(); + assert_eq!( + remote_sent.lock().unwrap().last(), + Some(&crate::protocol::ClientMessage::ClientShellFocus { focused: true }) + ); +} + +#[test] +fn host_focus_change_restarts_an_issued_presentation_effects_fence() { + let (_shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = machine(); + activation.phase = ActivationPhase::AwaitingPresentationEffects { + lease: lease(endpoint(), 7, "remote-boot"), + token: "old-token".into(), + ready: false, + completion: Box::new(ActivationCompletion::Activated), + }; + + activation.update_host_focus(false, &mut endpoints).unwrap(); + + assert!(matches!( + activation.phase, + ActivationPhase::SynchronizingPresentation { .. } + )); + assert_eq!( + activation.receive_presentation_effects_ready(&endpoint(), 7, "old-token"), + SurfaceActivationProgress::Stale + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); +} + +#[test] +fn source_release_rejection_restores_the_source_coherently() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 13, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:13:off", + &failure("client-shell-surface:13:off", "source rejected release"), + &mut endpoints, + ), + SurfaceActivationProgress::Rejected { + message: "source rejected release".into(), + source_release_rejected: true, + } + ); + assert_eq!( + activation.rollback(&mut endpoints, "source rejected release".into(), true), + ActivationRollback::Pending + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert!(!endpoints.active_surface_available()); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn source_release_timeout_starts_an_acknowledged_source_restore() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 14, + Instant::now(), + ) + .unwrap(); + assert_eq!( + activation.rollback(&mut endpoints, "source release timed out".into(), false), + ActivationRollback::Pending + ); + let sent = local_sent.lock().unwrap(); + assert_eq!( + sent.iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); + assert!(activation.accepts_response( + &ClientEndpointId::Local, + 1, + "local-boot", + "client-shell-surface:14:rollback-source-on" + )); +} + +#[test] +fn resize_invalidates_already_recorded_surface_evidence() { + let (_shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let mut activation = machine(); + assert_eq!( + activation.receive_snapshot(&endpoint(), 7, &test_snapshot("remote-boot", 1)), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Ready + ); + let resize = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation.update_resize(resize, &mut endpoints).unwrap(); + assert_eq!(activation.progress(), SurfaceActivationProgress::Pending); +} + +#[test] +fn resize_during_activation_reaches_the_pending_target() { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 15, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:15:off", + &surface_success("client-shell-surface:15:off", false, 1), + &mut endpoints, + ); + let resized = crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 9, + cell_height_px: 17, + surface_size: crate::protocol::ClientSurfaceSize { + cols: 100, + rows: 30, + }, + pixel_mouse: true, + }; + activation + .update_resize(resized.clone(), &mut endpoints) + .unwrap(); + assert_eq!(remote_sent.lock().unwrap().last(), Some(&resized)); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 1, "pane")), + SurfaceActivationProgress::Pending, + "a surface for the prior geometry cannot commit" + ); +} + +#[test] +fn rapid_a_to_b_to_a_restores_source_before_a_fresh_latest_epoch() { + let (mut shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 20, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:off", + &surface_success("client-shell-surface:20:off", false, 1), + &mut endpoints, + ); + + // The latest A-qualified target replaces B while B may already have accepted target-on. + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true, false], + "B is released before A can be restored" + ); + assert_eq!( + activation.receive_surface(&endpoint(), 7, surface("remote-boot", 2, "pane")), + SurfaceActivationProgress::Stale, + "delayed B activation evidence cannot satisfy A restoration" + ); + let _ = activation.receive_response( + &endpoint(), + 7, + "client-shell-surface:20:rollback-target-off", + &surface_success("client-shell-surface:20:rollback-target-off", false, 1), + &mut endpoints, + ); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true], + "source restoration is acknowledged rather than racing target ownership" + ); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:rollback-source-on", + &surface_success("client-shell-surface:20:rollback-source-on", true, 2), + &mut endpoints, + ); + let local_snapshot = test_snapshot("local-boot", 2); + shell.set_snapshot(Box::new(local_snapshot.clone())); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &local_snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + endpoint: ClientEndpointId::Local, + .. + }) + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:20:presentation-sync", + &surface_success("client-shell-surface:20:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "20:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::RestoredSource { + successor: Some(EndpointActivationIntent { + endpoint_id: ClientEndpointId::Local, + .. + }), + .. + }) + )); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + + // Runtime queues this successor with force=true, so even source==target receives a new + // activation epoch only after restoration committed. + let _fresh_epoch = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into(), + )), + resize(), + 21, + Instant::now(), + ) + .unwrap(); + assert!(local_sent.lock().unwrap().iter().any(|message| { + matches!( + message, + crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if serde_json::from_str::<crate::api::schema::Request>(request) + .is_ok_and(|request| request.id == "client-shell-surface:21:on") + ) + })); +} + +#[test] +fn disconnected_committed_source_does_not_block_switching_to_a_live_endpoint() { + let (mut shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let disconnected = endpoint(); + endpoints.set_surface_active(&ClientEndpointId::Local, false); + endpoints.set_surface_active(&disconnected, true); + assert!(endpoints.set_active(&disconnected)); + assert!(shell.activate_endpoint_projection(&disconnected)); + endpoints.disconnect(&disconnected); + + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + resize(), + 22, + Instant::now(), + ) + .unwrap(); + assert_eq!(activation.source_command_lane(), None); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true], + "there is no unavailable source release to await" + ); + + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:on", + &surface_success("client-shell-surface:22:on", true, 2), + &mut endpoints, + ), + SurfaceActivationProgress::Pending + ); + let snapshot = test_snapshot("local-boot", 2); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(snapshot.clone()), + ); + assert_eq!( + activation.receive_snapshot(&ClientEndpointId::Local, 1, &snapshot), + SurfaceActivationProgress::Pending + ); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 2, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationSync { + previous, + endpoint: ClientEndpointId::Local, + }) if previous == disconnected + )); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:22:presentation-sync", + &surface_success("client-shell-surface:22:presentation-sync", true, 3), + &mut endpoints, + ); + let sync_snapshot = test_snapshot("local-boot", 3); + shell.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(sync_snapshot.clone()), + ); + let _ = activation.receive_snapshot(&ClientEndpointId::Local, 1, &sync_snapshot); + assert_eq!( + activation.receive_surface( + &ClientEndpointId::Local, + 1, + surface("local-boot", 3, "pane") + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::AwaitingPresentationEffects) + ); + assert_eq!( + activation.receive_presentation_effects_ready( + &ClientEndpointId::Local, + 1, + "22:1:local-boot" + ), + SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(ActivationCompletion::Activated) + ); + assert_eq!(endpoints.active_id(), &ClientEndpointId::Local); + assert_ne!(endpoints.active_id(), &disconnected); +} + +#[test] +fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { + let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 23, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:23:off", + &surface_success("client-shell-surface:23:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.supersede( + ClientEndpointId::Local, + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "local-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert!(!activation.can_retarget(&target)); + assert_eq!( + activation.supersede( + target.clone(), + Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + &mut endpoints, + ), + ActivationRollback::Pending + ); + assert_eq!( + activation.successor, + Some(EndpointActivationIntent { + endpoint_id: target, + target: Some(crate::client::shell::ClientEndpointFocusTarget::Pane( + "remote-pane".into() + )), + }) + ); +} + +#[test] +fn unacknowledged_target_release_closes_target_before_restoring_source() { + let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 24, + Instant::now(), + ) + .unwrap(); + let _ = activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:24:off", + &surface_success("client-shell-surface:24:off", false, 1), + &mut endpoints, + ); + assert_eq!( + activation.rollback(&mut endpoints, "target activation timed out".into(), false), + ActivationRollback::Pending + ); + assert_eq!( + activation.rollback(&mut endpoints, "target release timed out".into(), false), + ActivationRollback::Pending + ); + assert!(endpoints.connection(&target).is_none()); + let failures = endpoints.take_failures(); + assert_eq!( + failures.len(), + 1, + "rollback revocation must reach the reconnect owner" + ); + assert_eq!(failures[0].endpoint_id, target); + assert_eq!(failures[0].generation, 7); + assert_eq!(failures[0].kind, std::io::ErrorKind::TimedOut); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn target_loss_at_activation_deadline_restores_source_before_timeout() { + let (shell, mut endpoints, local_sent, _) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 30, + Instant::now(), + ) + .unwrap(); + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:30:off", + &surface_success("client-shell-surface:30:off", false, 1), + &mut endpoints, + ); + let now = Instant::now(); + activation.deadline = now; + assert!(activation.expired(now)); + endpoints.fail(&target, std::io::ErrorKind::UnexpectedEof.into()); + // Match the client timer: apply transport failures before checking phase expiry. + for failure in endpoints.take_failures() { + assert_eq!( + activation.endpoint_disconnected(&mut endpoints, &failure.endpoint_id, failure.message), + ActivationRollback::Pending + ); + } + assert!(matches!( + activation.phase, + ActivationPhase::RestoringSource { .. } + )); + assert!(!activation.expired(now)); + assert_eq!( + local_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![false, true] + ); +} + +#[test] +fn losing_local_during_handoff_does_not_revoke_the_healthy_target() { + for source_released in [false, true] { + let (shell, mut endpoints, _local_sent, remote_sent) = shell_and_registry(); + let target = endpoint(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target.clone(), + None, + resize(), + 29, + Instant::now(), + ) + .unwrap(); + if source_released { + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:29:off", + &surface_success("client-shell-surface:29:off", false, 1), + &mut endpoints, + ); + } + if !source_released { + activation.deadline = Instant::now() - Duration::from_millis(1); + } + endpoints.fail( + &ClientEndpointId::Local, + std::io::ErrorKind::BrokenPipe.into(), + ); + assert_eq!( + activation.endpoint_disconnected( + &mut endpoints, + &ClientEndpointId::Local, + "Local stopped".into() + ), + ActivationRollback::Pending + ); + assert!(!activation.source_available); + assert!( + !activation.expired(Instant::now()), + "starting the healthy target must get a fresh deadline" + ); + assert!(matches!( + activation.phase, + ActivationPhase::ActivatingTarget { .. } + )); + assert!(endpoints.connection(&target).is_some()); + assert_eq!( + remote_sent + .lock() + .unwrap() + .iter() + .filter_map(surface_set_active) + .collect::<Vec<_>>(), + vec![true] + ); + } +} + +#[test] +fn resize_message_preserves_the_latest_surface_dimensions() { + assert_eq!( + resize_geometry(&resize()), + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }) + ); +} diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs new file mode 100644 index 00000000..23f26387 --- /dev/null +++ b/src/client/endpoint/catalog.rs @@ -0,0 +1,578 @@ +use std::collections::HashSet; +use std::io::{self, Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use super::ProfileId; + +const CATALOG_VERSION: u32 = 1; +const SELECTION_VERSION: u32 = 1; +const MAX_CATALOG_BYTES: u64 = 64 * 1024; +const MAX_PROFILES: usize = 64; +const MAX_LABEL_BYTES: usize = 128; +const MAX_TARGET_BYTES: usize = 1024; +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SavedSshEndpoint { + pub(crate) id: ProfileId, + pub(crate) label: String, + pub(crate) target: String, + pub(crate) session: String, + pub(crate) enabled: bool, +} + +impl SavedSshEndpoint { + pub(crate) fn new( + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<Self, String> { + let profile = Self { + id: ProfileId::generate(), + label: label.into(), + target: target.into(), + session: session.into(), + enabled: true, + }; + profile.validate()?; + Ok(profile) + } + + fn validate(&self) -> Result<(), String> { + ProfileId::parse(self.id.to_string())?; + let label = self.label.trim(); + if label.is_empty() { + return Err("SSH endpoint label cannot be empty".into()); + } + if label.len() > MAX_LABEL_BYTES || label.chars().any(char::is_control) { + return Err(format!( + "SSH endpoint label must be at most {MAX_LABEL_BYTES} bytes and contain no control characters" + )); + } + if self.target.len() > MAX_TARGET_BYTES || self.target.chars().any(char::is_control) { + return Err(format!( + "SSH target must be at most {MAX_TARGET_BYTES} bytes and contain no control characters" + )); + } + crate::remote::validate_remote_target(&self.target).map(|_| ())?; + let authority = self.target.strip_prefix("ssh://").unwrap_or(&self.target); + if authority + .rsplit_once('@') + .is_some_and(|(userinfo, _)| userinfo.contains(':')) + { + return Err("SSH target must not contain a password".into()); + } + crate::session::validate_name(&self.session)?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EndpointCatalog { + version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) selected_profile: Option<ProfileId>, + #[serde(default)] + pub(crate) ssh: Vec<SavedSshEndpoint>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EndpointSelection { + version: u32, + selected_profile: Option<ProfileId>, +} + +impl Default for EndpointCatalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + selected_profile: None, + ssh: Vec::new(), + } + } +} + +impl EndpointCatalog { + pub(crate) fn load() -> Result<Self, String> { + Self::load_from_paths(&catalog_path(), &selection_path()) + } + + pub(crate) fn load_profiles() -> Result<Vec<SavedSshEndpoint>, String> { + // Live clients keep their own selection, independent of other attached clients. + Self::load_from_path(&catalog_path()).map(|catalog| catalog.ssh) + } + + fn load_from_paths(catalog_path: &Path, selection_path: &Path) -> Result<Self, String> { + let mut catalog = Self::load_from_path(catalog_path)?; + match load_selection_from_path(selection_path) { + Ok(Some(selection)) => { + let valid = selection.selected_profile.as_ref().is_none_or(|selected| { + catalog + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }); + if valid { + catalog.selected_profile = selection.selected_profile; + } else { + tracing::warn!( + path = %selection_path.display(), + "saved endpoint selection is absent or disabled; using Local" + ); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + %error, + path = %selection_path.display(), + "saved endpoint selection is unavailable; using Local" + ); + } + } + Ok(catalog) + } + + pub(crate) fn store_profiles(&self) -> Result<(), String> { + self.store_to_path(&catalog_path()) + } + + pub(crate) fn store_selection(&self) -> Result<(), String> { + self.store_selection_to_path(&selection_path()) + } + + fn store_selection_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: self.selected_profile.clone(), + }) + .map_err(|error| format!("failed to encode endpoint selection: {error}"))?; + store_private_json(path, &content, "endpoint selection") + } + + pub(crate) fn add_ssh( + &mut self, + label: impl Into<String>, + target: impl Into<String>, + session: impl Into<String>, + ) -> Result<ProfileId, String> { + if self.ssh.len() >= MAX_PROFILES { + return Err(format!("at most {MAX_PROFILES} SSH endpoints can be saved")); + } + let profile = SavedSshEndpoint::new(label, target, session)?; + let id = profile.id.clone(); + self.ssh.push(profile); + Ok(id) + } + + pub(crate) fn rename_ssh( + &mut self, + id: &ProfileId, + label: impl Into<String>, + ) -> Result<bool, String> { + let Some(index) = self.ssh.iter().position(|profile| &profile.id == id) else { + return Ok(false); + }; + let mut renamed = self.ssh[index].clone(); + renamed.label = label.into(); + renamed.validate()?; + self.ssh[index] = renamed; + Ok(true) + } + + pub(crate) fn remove_ssh(&mut self, id: &ProfileId) -> bool { + let previous_len = self.ssh.len(); + self.ssh.retain(|profile| &profile.id != id); + if self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + self.ssh.len() != previous_len + } + + pub(crate) fn select_local(&mut self) { + self.selected_profile = None; + } + + pub(crate) fn select_endpoint(&mut self, endpoint_id: &super::ClientEndpointId) -> bool { + match endpoint_id { + super::ClientEndpointId::Local => { + self.select_local(); + true + } + super::ClientEndpointId::Ssh(profile_id) => self.select_ssh(profile_id), + } + } + + pub(crate) fn select_ssh(&mut self, id: &ProfileId) -> bool { + if !self + .ssh + .iter() + .any(|profile| &profile.id == id && profile.enabled) + { + return false; + } + self.selected_profile = Some(id.clone()); + true + } + + pub(crate) fn has_enabled_ssh(&self) -> bool { + self.ssh.iter().any(|profile| profile.enabled) + } + + pub(crate) fn contains_enabled_target_session(&self, target: &str, session: &str) -> bool { + self.ssh.iter().any(|profile| { + profile.enabled && profile.target == target && profile.session == session + }) + } + + pub(crate) fn set_enabled(&mut self, id: &ProfileId, enabled: bool) -> bool { + let Some(profile) = self.ssh.iter_mut().find(|profile| &profile.id == id) else { + return false; + }; + profile.enabled = enabled; + if !enabled && self.selected_profile.as_ref() == Some(id) { + self.selected_profile = None; + } + true + } + + fn validate(&self) -> Result<(), String> { + if self.version != CATALOG_VERSION { + return Err(format!( + "unsupported endpoint catalog version {}; expected {CATALOG_VERSION}", + self.version + )); + } + if self.ssh.len() > MAX_PROFILES { + return Err(format!( + "endpoint catalog contains more than {MAX_PROFILES} SSH profiles" + )); + } + let mut ids = HashSet::new(); + for profile in &self.ssh { + profile.validate()?; + if !ids.insert(profile.id.clone()) { + return Err(format!("duplicate endpoint profile id {}", profile.id)); + } + } + if self.selected_profile.as_ref().is_some_and(|selected| { + !self + .ssh + .iter() + .any(|profile| &profile.id == selected && profile.enabled) + }) { + return Err("selected SSH endpoint is absent or disabled in the catalog".into()); + } + Ok(()) + } + + fn load_from_path(path: &Path) -> Result<Self, String> { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(error) => { + return Err(format!( + "failed to open endpoint catalog {}: {error}", + path.display() + )) + } + }; + let metadata = file + .metadata() + .map_err(|error| format!("failed to inspect endpoint catalog: {error}"))?; + if metadata.len() > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let mut content = String::new(); + file.take(MAX_CATALOG_BYTES + 1) + .read_to_string(&mut content) + .map_err(|error| format!("failed to read endpoint catalog: {error}"))?; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint catalog exceeds the storage limit".into()); + } + let catalog: Self = serde_json::from_str(&content) + .map_err(|error| format!("stored endpoint catalog is invalid: {error}"))?; + catalog.validate()?; + Ok(catalog) + } + + fn store_to_path(&self, path: &Path) -> Result<(), String> { + self.validate()?; + let content = serde_json::to_vec_pretty(self) + .map_err(|error| format!("failed to encode endpoint catalog: {error}"))?; + store_private_json(path, &content, "endpoint catalog") + } +} + +fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, String> { + let content = match std::fs::read(path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("failed to read endpoint selection: {error}")), + }; + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err("endpoint selection exceeds the storage limit".into()); + } + let selection: EndpointSelection = serde_json::from_slice(&content) + .map_err(|error| format!("stored endpoint selection is invalid: {error}"))?; + if selection.version != SELECTION_VERSION { + return Err(format!( + "unsupported endpoint selection version {}; expected {SELECTION_VERSION}", + selection.version + )); + } + Ok(Some(selection)) +} + +fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> { + if content.len() as u64 > MAX_CATALOG_BYTES { + return Err(format!("{description} exceeds the storage limit")); + } + let parent = path + .parent() + .ok_or_else(|| format!("invalid {description} path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {description} directory: {error}"))?; + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "refusing to replace {description} through a non-file path" + )); + } + } + + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!(".endpoints-{}-{sequence}.tmp", std::process::id())); + let mut temp = crate::platform::create_private_state_file(&temp_path) + .map_err(|error| format!("failed to create {description}: {error}"))?; + if let Err(error) = temp.write_all(content).and_then(|()| temp.sync_all()) { + drop(temp); + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to write {description}: {error}")); + } + drop(temp); + if let Err(error) = crate::platform::replace_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("failed to activate {description}: {error}")); + } + crate::platform::sync_parent_directory(parent) + .map_err(|error| format!("failed to persist {description} directory: {error}")) +} + +pub(crate) fn catalog_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoints.json") +} + +fn selection_path() -> PathBuf { + crate::config::state_dir() + .join("client") + .join("endpoint-selection.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(name: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "herdr-endpoint-catalog-{}-{name}", + std::process::id() + )) + .join("endpoints.json") + } + + #[test] + fn catalog_roundtrip_persists_profiles_without_secret_fields() { + let path = path("roundtrip"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog + .add_ssh("Build", "ssh://dev@build.example:2222", "agents") + .unwrap(); + assert!(catalog.select_ssh(&id)); + catalog.store_to_path(&path).unwrap(); + + let encoded = std::fs::read_to_string(&path).unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("private_key")); + assert!(!encoded.contains("control_socket")); + let loaded = EndpointCatalog::load_from_path(&path).unwrap(); + assert_eq!(loaded, catalog); + assert_eq!(loaded.ssh[0].id, id); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn duplicate_target_and_session_profiles_keep_distinct_opaque_ids() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "build", "default").unwrap(); + let second = catalog.add_ssh("Two", "build", "default").unwrap(); + assert_ne!(first, second); + } + + #[test] + fn catalog_rejects_passwords_embedded_in_ssh_targets() { + let mut catalog = EndpointCatalog::default(); + assert!(catalog + .add_ssh("Build", "ssh://dev:secret@build.example", "default") + .unwrap_err() + .contains("must not contain a password")); + assert!(catalog + .add_ssh("Build", "dev:secret@build.example", "default") + .is_err()); + assert!(catalog + .add_ssh("Build", "ssh://dev@[::1]:2222", "default") + .is_ok()); + } + + #[test] + fn interactive_bootstrap_matches_only_enabled_target_and_session() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + assert!(catalog.contains_enabled_target_session("build", "agents")); + assert!(!catalog.contains_enabled_target_session("build", "default")); + assert!(catalog.set_enabled(&id, false)); + assert!(!catalog.contains_enabled_target_session("build", "agents")); + } + + #[test] + fn rename_changes_only_the_machine_label() { + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Old", "build", "agents").unwrap(); + let original = catalog.ssh[0].clone(); + + assert!(catalog.rename_ssh(&id, "New").unwrap()); + assert_eq!(catalog.ssh[0].label, "New"); + assert_eq!(catalog.ssh[0].id, original.id); + assert_eq!(catalog.ssh[0].target, original.target); + assert_eq!(catalog.ssh[0].session, original.session); + assert!(catalog.rename_ssh(&id, "\n").is_err()); + assert_eq!(catalog.ssh[0].label, "New"); + } + + #[test] + fn removal_and_disable_return_selection_to_local() { + let mut catalog = EndpointCatalog::default(); + let first = catalog.add_ssh("One", "one", "default").unwrap(); + assert!(catalog.select_ssh(&first)); + assert!(catalog.set_enabled(&first, false)); + assert_eq!(catalog.selected_profile, None); + + assert!(catalog.set_enabled(&first, true)); + assert!(catalog.select_ssh(&first)); + assert!(catalog.remove_ssh(&first)); + assert_eq!(catalog.selected_profile, None); + } + + #[test] + fn catalog_rejects_unknown_fields_instead_of_retaining_possible_secrets() { + let path = path("unknown-field"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{ + "version": 1, + "ssh": [{ + "id": "0123456789abcdef0123456789abcdef", + "label": "Build", + "target": "build", + "session": "default", + "enabled": true, + "password": "must-not-be-accepted" + }] + }"#, + ) + .unwrap(); + assert!(EndpointCatalog::load_from_path(&path) + .unwrap_err() + .contains("unknown field")); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn storing_selection_does_not_rewrite_profile_membership() { + let catalog_path = path("separate-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let profiles_before = std::fs::read(&catalog_path).unwrap(); + + assert!(catalog.select_ssh(&id)); + catalog.store_selection_to_path(&selection_path).unwrap(); + + assert_eq!(std::fs::read(&catalog_path).unwrap(), profiles_before); + assert_eq!( + load_selection_from_path(&selection_path) + .unwrap() + .unwrap() + .selected_profile, + Some(id) + ); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn malformed_selection_does_not_discard_saved_profiles() { + let catalog_path = path("malformed-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let id = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + std::fs::write(&selection_path, b"not json").unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh.len(), 1); + assert_eq!(loaded.ssh[0].id, id); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn absent_selected_profile_falls_back_without_discarding_catalog() { + let catalog_path = path("absent-selection"); + let selection_path = catalog_path.with_file_name("selection.json"); + let _ = std::fs::remove_dir_all(catalog_path.parent().unwrap()); + let mut catalog = EndpointCatalog::default(); + let saved = catalog.add_ssh("Build", "build", "agents").unwrap(); + catalog.store_to_path(&catalog_path).unwrap(); + let missing = ProfileId::parse("fedcba9876543210fedcba9876543210").unwrap(); + store_private_json( + &selection_path, + &serde_json::to_vec(&EndpointSelection { + version: SELECTION_VERSION, + selected_profile: Some(missing), + }) + .unwrap(), + "endpoint selection", + ) + .unwrap(); + + let loaded = EndpointCatalog::load_from_paths(&catalog_path, &selection_path).unwrap(); + assert_eq!(loaded.ssh[0].id, saved); + assert_eq!(loaded.selected_profile, None); + std::fs::remove_dir_all(catalog_path.parent().unwrap()).unwrap(); + } + + #[test] + fn invalid_or_missing_selected_profile_is_rejected() { + let catalog = EndpointCatalog { + selected_profile: Some(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()), + ..EndpointCatalog::default() + }; + assert!(catalog.validate().is_err()); + } +} diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs new file mode 100644 index 00000000..7cf1159f --- /dev/null +++ b/src/client/endpoint/control.rs @@ -0,0 +1,63 @@ +use super::ClientEndpointId; + +pub(crate) enum EndpointControlMessage { + HealthPong, + Snapshot(Box<crate::protocol::ClientShellSnapshot>), + Ignored, +} + +pub(crate) fn decode_endpoint_control( + kind: &str, + data: &str, +) -> Result<EndpointControlMessage, String> { + if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { + return Ok(EndpointControlMessage::HealthPong); + } + if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { + let snapshot = serde_json::from_str(data) + .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; + return Ok(EndpointControlMessage::Snapshot(Box::new(snapshot))); + } + if kind.starts_with("shell.snapshot.") { + return Err(format!( + "unsupported mandatory endpoint snapshot codec {kind:?}" + )); + } + Ok(EndpointControlMessage::Ignored) +} + +pub(crate) fn protocol_failure_is_fatal(endpoint_id: &ClientEndpointId) -> bool { + endpoint_id.is_local() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + #[test] + fn unknown_optional_controls_are_ignored() { + assert!(matches!( + decode_endpoint_control("future.optional", "not json").unwrap(), + EndpointControlMessage::Ignored + )); + } + + #[test] + fn unknown_snapshot_codecs_are_rejected() { + assert_eq!( + decode_endpoint_control("shell.snapshot.v2", "{}") + .err() + .as_deref(), + Some("unsupported mandatory endpoint snapshot codec \"shell.snapshot.v2\"") + ); + } + + #[test] + fn only_local_protocol_failures_end_the_client() { + let remote = + ClientEndpointId::Ssh(ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap()); + assert!(protocol_failure_is_fatal(&ClientEndpointId::Local)); + assert!(!protocol_failure_is_fatal(&remote)); + } +} diff --git a/src/client/endpoint/health.rs b/src/client/endpoint/health.rs new file mode 100644 index 00000000..87c08952 --- /dev/null +++ b/src/client/endpoint/health.rs @@ -0,0 +1,99 @@ +use std::time::{Duration, Instant}; + +pub(super) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +pub(super) const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HealthAction { + None, + Ping, + Expired, +} + +pub(super) struct EndpointHealth { + connected_at: Instant, + last_received: Instant, + ping_sent_at: Option<Instant>, + ready: bool, +} + +impl EndpointHealth { + pub(super) fn new(now: Instant) -> Self { + Self { + connected_at: now, + last_received: now, + ping_sent_at: None, + ready: false, + } + } + + pub(super) fn received(&mut self, now: Instant) { + self.last_received = now; + self.ping_sent_at = None; + } + + pub(super) fn ready(&mut self) { + self.ready = true; + } + + pub(super) fn action(&self, now: Instant) -> HealthAction { + let initial_snapshot_expired = + !self.ready && now.saturating_duration_since(self.connected_at) >= HEARTBEAT_TIMEOUT; + let probe_expired = self + .ping_sent_at + .is_some_and(|sent_at| now.saturating_duration_since(sent_at) >= HEARTBEAT_TIMEOUT); + if initial_snapshot_expired || probe_expired { + HealthAction::Expired + } else if self.ping_sent_at.is_none() + && now.saturating_duration_since(self.last_received) >= HEARTBEAT_INTERVAL + { + HealthAction::Ping + } else { + HealthAction::None + } + } + + pub(super) fn ping_sent(&mut self, now: Instant) { + self.ping_sent_at = Some(now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quiet_connection_is_probed_then_expires_without_a_reply() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + assert_eq!(health.action(now), HealthAction::None); + assert_eq!(health.action(now + HEARTBEAT_INTERVAL), HealthAction::Ping); + health.ping_sent(now + HEARTBEAT_INTERVAL); + assert_eq!( + health.action(now + HEARTBEAT_INTERVAL + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } + + #[test] + fn any_incoming_message_satisfies_an_outstanding_probe() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ready(); + health.ping_sent(now); + health.received(now + HEARTBEAT_TIMEOUT - Duration::from_millis(1)); + assert_eq!(health.action(now + HEARTBEAT_TIMEOUT), HealthAction::None); + } + + #[test] + fn heartbeats_do_not_hide_a_missing_initial_snapshot() { + let now = Instant::now(); + let mut health = EndpointHealth::new(now); + health.ping_sent(now + HEARTBEAT_INTERVAL); + health.received(now + HEARTBEAT_INTERVAL + Duration::from_secs(1)); + assert_eq!( + health.action(now + HEARTBEAT_TIMEOUT), + HealthAction::Expired + ); + } +} diff --git a/src/client/endpoint/message_policy.rs b/src/client/endpoint/message_policy.rs new file mode 100644 index 00000000..5e84325a --- /dev/null +++ b/src/client/endpoint/message_policy.rs @@ -0,0 +1,124 @@ +use crate::protocol::ServerMessage; + +/// Effects that are meaningful only for the endpoint currently holding the host presentation +/// lease. During a frozen handoff they are intentionally dropped and the committed target asks +/// for one bounded replay, rather than allowing target graphics or modes onto the source frame. +pub(crate) fn is_presentation_effect(message: &ServerMessage) -> bool { + matches!( + message, + ServerMessage::MouseCapture { .. } + | ServerMessage::ClientShellKeyboardReportAll { .. } + | ServerMessage::WindowTitle { .. } + | ServerMessage::Graphics { .. } + | ServerMessage::GraphicsFile { .. } + | ServerMessage::GraphicsTransmissionRetired { .. } + | ServerMessage::TerminalBell { .. } + ) +} + +pub(crate) fn accepts_endpoint_message( + endpoint_active: bool, + activation_message: bool, + command_response: bool, + message: &ServerMessage, +) -> bool { + endpoint_active + || matches!( + message, + ServerMessage::EndpointControl { .. } + | ServerMessage::SemanticNotification(_) + | ServerMessage::ServerShutdown { .. } + | ServerMessage::ClientShellSnapshot(_) + | ServerMessage::Welcome { .. } + ) + || (activation_message + && matches!( + message, + ServerMessage::PaneSurface(_) + | ServerMessage::ClientShellEndpointResponseChunk { .. } + )) + || (command_response + && matches!( + message, + ServerMessage::ClientShellEndpointResponseChunk { .. } + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_endpoints_deliver_metadata_but_not_presentation_effects() { + assert!(accepts_endpoint_message( + false, + false, + false, + &ServerMessage::EndpointControl { + kind: "snapshot".into(), + data: "{}".into(), + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::Clipboard { + data: "text".into() + } + )); + assert!(!accepts_endpoint_message( + false, + false, + false, + &ServerMessage::WindowTitle { + title: Some("remote".into()) + } + )); + } + + #[test] + fn frozen_handoffs_drop_target_effects_until_the_post_commit_resync() { + assert!(is_presentation_effect(&ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + })); + assert!(is_presentation_effect(&ServerMessage::GraphicsFile { + path: "asset".into(), + expected_len: 1, + image_id: 1, + transfer_id: 1, + leading: Vec::new(), + control: "a=t".into(), + surface_asset: None, + })); + assert!(!is_presentation_effect(&ServerMessage::EndpointControl { + kind: "shell.snapshot.v1".into(), + data: "{}".into(), + })); + } + + #[test] + fn pending_activation_accepts_only_its_surface_lane() { + assert!(accepts_endpoint_message( + false, + true, + false, + &ServerMessage::ClientShellEndpointResponseChunk { + boot_id: "boot".into(), + request_id: "surface".into(), + final_chunk: true, + data: Vec::new(), + } + )); + assert!(!accepts_endpoint_message( + false, + true, + false, + &ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false, + } + )); + } +} diff --git a/src/client/endpoint/registry.rs b/src/client/endpoint/registry.rs new file mode 100644 index 00000000..437f39f3 --- /dev/null +++ b/src/client/endpoint/registry.rs @@ -0,0 +1,629 @@ +use std::collections::{HashMap, HashSet}; +use std::io; +use std::time::Instant; + +use super::health::{EndpointHealth, HealthAction}; +use super::ClientEndpointId; +use crate::protocol::ClientMessage; + +pub(crate) trait EndpointTransport: Send { + fn send(&mut self, message: &ClientMessage) -> io::Result<()>; + + fn disconnect(&mut self) {} + + fn flush(&mut self, _deadline: Instant) -> io::Result<()> { + Ok(()) + } + + fn take_error(&mut self) -> Option<io::Error> { + None + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EndpointNegotiation { + methods: HashSet<String>, + capabilities: HashSet<String>, +} + +impl EndpointNegotiation { + pub(crate) fn new(methods: Vec<String>, capabilities: Vec<String>) -> Self { + Self { + methods: methods.into_iter().collect(), + capabilities: capabilities.into_iter().collect(), + } + } + + pub(crate) fn methods(&self) -> Vec<String> { + self.methods.iter().cloned().collect() + } + + pub(crate) fn supports_method(&self, method: &str) -> bool { + self.methods.contains(method) + } + + pub(crate) fn supports_capability(&self, capability: &str) -> bool { + self.capabilities.contains(capability) + } + + pub(crate) fn supports_surface_interest(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY) + && self.supports_capability( + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + ) + && self.supports_method("client_shell.surface.set") + } + + pub(crate) fn supports_health_check(&self) -> bool { + self.supports_capability(crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY) + } +} + +pub(crate) struct EndpointConnection { + transport: Box<dyn EndpointTransport>, + pub(crate) generation: u64, + pub(crate) surface_active: bool, + pub(crate) negotiation: EndpointNegotiation, + health: Option<EndpointHealth>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EndpointTransportFailure { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) generation: u64, + pub(crate) kind: io::ErrorKind, + pub(crate) message: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EndpointSendOutcome { + Sent, + NotSent, +} + +pub(crate) struct EndpointRegistry { + active: ClientEndpointId, + input_enabled: bool, + connections: HashMap<ClientEndpointId, EndpointConnection>, + failures: Vec<EndpointTransportFailure>, +} + +impl EndpointRegistry { + pub(crate) fn empty() -> Self { + Self { + active: ClientEndpointId::Local, + input_enabled: false, + connections: HashMap::new(), + failures: Vec::new(), + } + } + + pub(crate) fn new( + local: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + ) -> Self { + let mut registry = Self::empty(); + registry.input_enabled = true; + registry.insert( + ClientEndpointId::Local, + local, + generation, + negotiation, + true, + ); + registry + } + + pub(crate) fn active_id(&self) -> &ClientEndpointId { + &self.active + } + + pub(crate) fn active_surface_available(&self) -> bool { + self.input_enabled + && self + .connections + .get(&self.active) + .is_some_and(|connection| connection.surface_active) + } + + pub(crate) fn select_unavailable_local(&mut self) { + self.active = ClientEndpointId::Local; + self.freeze_input(); + } + + pub(crate) fn freeze_input(&mut self) { + self.input_enabled = false; + } + + pub(crate) fn unfreeze_input(&mut self) { + self.input_enabled = true; + } + + pub(crate) fn connection(&self, endpoint_id: &ClientEndpointId) -> Option<&EndpointConnection> { + self.connections.get(endpoint_id) + } + + pub(crate) fn insert( + &mut self, + endpoint_id: ClientEndpointId, + transport: impl EndpointTransport + 'static, + generation: u64, + negotiation: EndpointNegotiation, + surface_active: bool, + ) { + let health = (!endpoint_id.is_local() && negotiation.supports_health_check()) + .then(|| EndpointHealth::new(Instant::now())); + if let Some(mut previous) = self.connections.insert( + endpoint_id, + EndpointConnection { + transport: Box::new(transport), + generation, + surface_active, + negotiation, + health, + }, + ) { + previous.transport.disconnect(); + } + } + + pub(crate) fn accepts(&self, endpoint_id: &ClientEndpointId, generation: u64) -> bool { + self.connections + .get(endpoint_id) + .is_some_and(|connection| connection.generation == generation) + } + + pub(crate) fn received( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.received(now); + } + } + + pub(crate) fn mark_ready(&mut self, endpoint_id: &ClientEndpointId, generation: u64) { + if let Some(health) = self + .connections + .get_mut(endpoint_id) + .filter(|connection| connection.generation == generation) + .and_then(|connection| connection.health.as_mut()) + { + health.ready(); + } + } + + pub(crate) fn tick_health(&mut self, now: Instant) { + let actions = self + .connections + .iter() + .filter_map(|(endpoint_id, connection)| { + connection + .health + .as_ref() + .map(|health| (endpoint_id.clone(), health.action(now))) + }) + .filter(|(_, action)| *action != HealthAction::None) + .collect::<Vec<_>>(); + for (endpoint_id, action) in actions { + match action { + HealthAction::None => {} + HealthAction::Ping => { + let ping = ClientMessage::EndpointControl { + kind: crate::protocol::endpoint::HEALTH_PING_KIND.into(), + data: String::new(), + }; + if self.send_to(&endpoint_id, &ping) == EndpointSendOutcome::Sent { + if let Some(health) = self + .connections + .get_mut(&endpoint_id) + .and_then(|connection| connection.health.as_mut()) + { + health.ping_sent(now); + } + } + } + HealthAction::Expired => self.record_failure( + endpoint_id, + io::Error::new(io::ErrorKind::TimedOut, "endpoint health check timed out"), + ), + } + } + } + + pub(crate) fn set_active(&mut self, endpoint_id: &ClientEndpointId) -> bool { + if !self + .connections + .get(endpoint_id) + .is_some_and(|connection| connection.surface_active) + { + return false; + } + self.active = endpoint_id.clone(); + true + } + + pub(crate) fn set_surface_active( + &mut self, + endpoint_id: &ClientEndpointId, + active: bool, + ) -> bool { + let Some(connection) = self.connections.get_mut(endpoint_id) else { + return false; + }; + let changed = connection.surface_active != active; + connection.surface_active = active; + changed + } + + pub(crate) fn send(&mut self, message: &ClientMessage) -> EndpointSendOutcome { + let endpoint_id = self.active.clone(); + self.send_to(&endpoint_id, message) + } + + pub(crate) fn send_to( + &mut self, + endpoint_id: &ClientEndpointId, + message: &ClientMessage, + ) -> EndpointSendOutcome { + let result = self + .connections + .get_mut(endpoint_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "endpoint is unavailable")) + .and_then(|connection| connection.transport.send(message)); + match result { + Ok(()) => EndpointSendOutcome::Sent, + Err(error) => { + self.record_failure(endpoint_id.clone(), error); + EndpointSendOutcome::NotSent + } + } + } + + pub(crate) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) { + self.failures + .retain(|failure| &failure.endpoint_id != endpoint_id); + if let Some(mut connection) = self.connections.remove(endpoint_id) { + connection.transport.disconnect(); + } + } + + pub(crate) fn fail(&mut self, endpoint_id: &ClientEndpointId, error: io::Error) { + self.record_failure(endpoint_id.clone(), error); + } + + pub(crate) fn take_failures(&mut self) -> Vec<EndpointTransportFailure> { + let errors = self + .connections + .iter_mut() + .filter_map(|(id, connection)| { + connection + .transport + .take_error() + .map(|error| (id.clone(), error)) + }) + .collect::<Vec<_>>(); + for (endpoint_id, error) in errors { + self.record_failure(endpoint_id, error); + } + std::mem::take(&mut self.failures) + } + + fn record_failure(&mut self, endpoint_id: ClientEndpointId, error: io::Error) { + let Some(generation) = self + .connections + .get(&endpoint_id) + .map(|connection| connection.generation) + else { + return; + }; + let failure = EndpointTransportFailure { + endpoint_id: endpoint_id.clone(), + generation, + kind: error.kind(), + message: error.to_string(), + }; + if let Some(mut connection) = self.connections.remove(&endpoint_id) { + connection.transport.disconnect(); + } + if let Some(existing) = self + .failures + .iter_mut() + .find(|existing| existing.endpoint_id == endpoint_id) + { + *existing = failure; + } else { + self.failures.push(failure); + } + } +} + +impl Drop for EndpointRegistry { + fn drop(&mut self) { + let deadline = Instant::now() + std::time::Duration::from_millis(250); + for connection in self.connections.values_mut() { + let _ = connection.transport.send(&ClientMessage::Detach); + } + for connection in self.connections.values_mut() { + let _ = connection.transport.flush(deadline); + connection.transport.disconnect(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + struct FakeTransport { + sent: Arc<Mutex<Vec<ClientMessage>>>, + error: Option<io::ErrorKind>, + } + + impl EndpointTransport for FakeTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if let Some(kind) = self.error { + return Err(io::Error::new(kind, "fake transport failure")); + } + self.sent.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + fn negotiation() -> EndpointNegotiation { + EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), + ], + ) + } + + fn profile() -> crate::client::endpoint::ProfileId { + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + fn endpoint_failures_do_not_remove_other_connections() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: Some(io::ErrorKind::BrokenPipe), + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::NotSent + ); + assert!(registry.connection(&ssh_id).is_none()); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert_eq!(registry.take_failures()[0].endpoint_id, ssh_id); + + assert!(registry.set_active(&ClientEndpointId::Local)); + assert_eq!( + registry.send(&ClientMessage::ClientShellFocus { focused: true }), + EndpointSendOutcome::Sent + ); + assert_eq!(local_sent.lock().unwrap().len(), 1); + } + + #[test] + fn reconnecting_active_identity_does_not_count_as_an_active_surface() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + true, + ); + assert!(registry.set_active(&ssh_id)); + assert!(registry.active_surface_available()); + registry.disconnect(&ssh_id); + registry.insert( + ssh_id, + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 3, + negotiation(), + false, + ); + assert!(!registry.active_surface_available()); + } + + #[test] + fn recovered_local_uses_transport_failure_not_remote_health_probes() { + let mut registry = EndpointRegistry::empty(); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ClientEndpointId::Local, + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + registry.tick_health(Instant::now() + std::time::Duration::from_secs(300)); + assert!(registry.connection(&ClientEndpointId::Local).is_some()); + assert!(sent.lock().unwrap().is_empty()); + assert!(registry.take_failures().is_empty()); + } + + #[test] + fn negotiated_remote_health_probe_expires_the_connection() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + let sent = Arc::new(Mutex::new(Vec::new())); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.tick_health(now + super::super::health::HEARTBEAT_INTERVAL); + assert!(matches!( + sent.lock().unwrap().as_slice(), + [ClientMessage::EndpointControl { kind, .. }] + if kind == crate::protocol::endpoint::HEALTH_PING_KIND + )); + + registry.tick_health( + now + super::super::health::HEARTBEAT_INTERVAL + + super::super::health::HEARTBEAT_TIMEOUT, + ); + assert!(registry.connection(&ssh_id).is_none()); + assert_eq!(registry.take_failures()[0].kind, io::ErrorKind::TimedOut); + } + + #[test] + fn a_ready_endpoint_can_stay_connected_after_the_initial_deadline() { + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 1, + negotiation(), + ); + let ssh_id = ClientEndpointId::Ssh(profile()); + registry.insert( + ssh_id.clone(), + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 2, + negotiation(), + false, + ); + let now = Instant::now(); + registry.mark_ready(&ssh_id, 2); + registry.received(&ssh_id, 2, now + super::super::health::HEARTBEAT_INTERVAL); + registry.tick_health(now + super::super::health::HEARTBEAT_TIMEOUT); + assert!(registry.connection(&ssh_id).is_some()); + } + + #[test] + fn negotiated_surface_interest_requires_capability_and_method() { + assert!(negotiation().supports_surface_interest()); + assert!(negotiation().supports_health_check()); + assert!( + !EndpointNegotiation::new(vec!["client_shell.surface.set".into()], Vec::new()) + .supports_surface_interest() + ); + assert!(!EndpointNegotiation::new( + Vec::new(), + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + assert!(!EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into()] + ) + .supports_surface_interest()); + } + + #[test] + fn dropping_registry_detaches_every_connected_endpoint() { + let local_sent = Arc::new(Mutex::new(Vec::new())); + let remote_sent = Arc::new(Mutex::new(Vec::new())); + let mut registry = EndpointRegistry::new( + FakeTransport { + sent: local_sent.clone(), + error: None, + }, + 1, + negotiation(), + ); + registry.insert( + ClientEndpointId::Ssh(profile()), + FakeTransport { + sent: remote_sent.clone(), + error: None, + }, + 2, + negotiation(), + false, + ); + + drop(registry); + + assert!(matches!( + local_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + assert!(matches!( + remote_sent.lock().unwrap().as_slice(), + [ClientMessage::Detach] + )); + } + + #[test] + fn stale_generations_are_rejected() { + let registry = EndpointRegistry::new( + FakeTransport { + sent: Arc::new(Mutex::new(Vec::new())), + error: None, + }, + 7, + negotiation(), + ); + assert!(registry.accepts(&ClientEndpointId::Local, 7)); + assert!(!registry.accepts(&ClientEndpointId::Local, 6)); + } +} diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs new file mode 100644 index 00000000..458ce927 --- /dev/null +++ b/src/client/endpoint/supervisor.rs @@ -0,0 +1,492 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use super::{ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, NativeEndpointTransport}; +use crate::protocol::{ClientSurfaceSize, RenderEncoding}; +use interprocess::TryClone as _; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +pub(crate) struct EndpointConnectOptions { + pub(crate) cols: u16, + pub(crate) rows: u16, + pub(crate) cell_width_px: u32, + pub(crate) cell_height_px: u32, + pub(crate) pixel_geometry_exact: bool, + pub(crate) surface_size: ClientSurfaceSize, + pub(crate) endpoint_keybindings: bool, + pub(crate) mouse_capture: bool, +} + +pub(crate) enum EndpointSupervisorEvent { + Status { + endpoint_id: ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + message: String, + }, + Connected { + endpoint_id: ClientEndpointId, + generation: u64, + reader: crate::ipc::LocalStream, + writer: NativeEndpointTransport, + negotiation: EndpointNegotiation, + }, +} + +#[derive(Clone)] +enum ConnectTarget { + Local(PathBuf), + Ssh(super::SavedSshEndpoint), +} + +struct ReconnectState { + target: ConnectTarget, + attempts: u32, + next_attempt: Option<Instant>, + in_flight: bool, + generation: Option<u64>, +} + +impl ReconnectState { + fn new(target: ConnectTarget, now: Instant) -> Self { + Self { + target, + attempts: 0, + next_attempt: Some(now), + in_flight: false, + generation: None, + } + } +} + +pub(crate) struct EndpointSupervisors { + endpoints: HashMap<ClientEndpointId, ReconnectState>, + next_generation: u64, + shutdown: Arc<AtomicBool>, +} + +impl EndpointSupervisors { + pub(crate) fn new(profiles: &[super::SavedSshEndpoint], now: Instant) -> Self { + let endpoints = profiles + .iter() + .filter(|profile| profile.enabled) + .map(|profile| { + ( + ClientEndpointId::Ssh(profile.id.clone()), + ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now), + ) + }) + .collect(); + Self { + endpoints, + next_generation: 2, + shutdown: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn add_local(&mut self, path: PathBuf, generation: Option<u64>, now: Instant) { + let mut state = ReconnectState::new(ConnectTarget::Local(path), now); + state.generation = generation; + if generation.is_some() { + state.next_attempt = None; + } + self.endpoints.insert(ClientEndpointId::Local, state); + } + + pub(crate) fn reconcile_profiles( + &mut self, + profiles: &[super::SavedSshEndpoint], + now: Instant, + ) -> Vec<ClientEndpointId> { + let mut retired = Vec::new(); + self.endpoints.retain(|endpoint_id, state| { + let ConnectTarget::Ssh(previous) = &state.target else { + return true; + }; + let keep = profiles.iter().any(|profile| { + profile.id == previous.id + && profile.enabled + && profile.target == previous.target + && profile.session == previous.session + }); + if !keep { + retired.push(endpoint_id.clone()); + } + keep + }); + for profile in profiles.iter().filter(|profile| profile.enabled) { + let state = self + .endpoints + .entry(ClientEndpointId::Ssh(profile.id.clone())) + .or_insert_with(|| ReconnectState::new(ConnectTarget::Ssh(profile.clone()), now)); + state.target = ConnectTarget::Ssh(profile.clone()); + } + retired + } + + pub(crate) fn spawn_due( + &mut self, + now: Instant, + options: EndpointConnectOptions, + event_tx: &tokio::sync::mpsc::Sender<EndpointSupervisorEvent>, + ) { + for (endpoint_id, state) in &mut self.endpoints { + if state.in_flight || state.next_attempt.is_none_or(|deadline| deadline > now) { + continue; + } + state.in_flight = true; + state.next_attempt = None; + let generation = self.next_generation; + state.generation = Some(generation); + self.next_generation = self.next_generation.saturating_add(1); + let endpoint_id = endpoint_id.clone(); + let target = state.target.clone(); + let event_tx = event_tx.clone(); + let shutdown = self.shutdown.clone(); + tokio::spawn(async move { + if shutdown.load(Ordering::Acquire) { + return; + } + let task_endpoint_id = endpoint_id.clone(); + let result = tokio::task::spawn_blocking(move || { + connect_once(&target, options, endpoint_id, generation) + }) + .await; + let event = match result { + Ok(Ok(event)) => event, + Ok(Err(error)) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: if failure_needs_attention(&error) { + ClientEndpointStatus::Attention + } else { + ClientEndpointStatus::Reconnecting + }, + message: error.to_string(), + }, + Err(error) => EndpointSupervisorEvent::Status { + endpoint_id: task_endpoint_id, + generation, + status: ClientEndpointStatus::Reconnecting, + message: format!("endpoint connection task stopped unexpectedly: {error}"), + }, + }; + if !shutdown.load(Ordering::Acquire) { + let _ = event_tx.send(event).await; + } + }); + } + } + + pub(crate) fn record_status( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + status: ClientEndpointStatus, + now: Instant, + ) -> bool { + let Some(state) = self.endpoints.get_mut(endpoint_id) else { + return false; + }; + if state.generation != Some(generation) { + return false; + } + state.in_flight = false; + match status { + ClientEndpointStatus::Online => { + state.attempts = 0; + state.next_attempt = None; + } + ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => { + state.next_attempt = None + } + ClientEndpointStatus::Connecting | ClientEndpointStatus::Reconnecting => { + state.attempts = state.attempts.saturating_add(1); + state.next_attempt = Some(now + retry_delay(state.attempts)); + } + } + true + } + + pub(crate) fn disconnected( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + now: Instant, + ) -> bool { + self.record_status( + endpoint_id, + generation, + ClientEndpointStatus::Reconnecting, + now, + ) + } +} + +impl Drop for EndpointSupervisors { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + } +} + +fn connect_once( + target: &ConnectTarget, + options: EndpointConnectOptions, + endpoint_id: ClientEndpointId, + generation: u64, +) -> Result<EndpointSupervisorEvent, std::io::Error> { + let (mut stream, lifetime): (_, Box<dyn Send>) = match target { + ConnectTarget::Local(path) => { + let stream = crate::ipc::connect_local_stream(path).map_err(|error| { + // An absent Local socket is transient, unlike a missing SSH install. + if error.kind() == std::io::ErrorKind::NotFound { + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "Local is unavailable; start its server to reconnect", + ) + } else { + error + } + })?; + (stream, Box::new(())) + } + ConnectTarget::Ssh(profile) => { + let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| { + if failure_needs_attention(&error) { + std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session))) + } else { error } + })?; + (connected.stream, Box::new(connected.bridge)) + } + }; + let handshake = super::super::do_handshake( + &mut stream, + options.cols, + options.rows, + options.cell_width_px, + options.cell_height_px, + options.pixel_geometry_exact, + Some(options.surface_size), + options.endpoint_keybindings, + options.mouse_capture, + false, + ) + .map_err(handshake_error)?; + if handshake.encoding != RenderEncoding::SemanticFrame { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "endpoint did not negotiate the semantic client shell", + )); + } + let negotiation = EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + ); + if !negotiation.supports_surface_interest() + || (!endpoint_id.is_local() && !negotiation.supports_health_check()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "this machine needs a server update before it can participate in multi-machine viewing", + )); + } + let reader = stream.try_clone()?; + let writer = NativeEndpointTransport::with_lifetime(stream, lifetime)?; + Ok(EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + }) +} + +fn failure_needs_attention(error: &std::io::Error) -> bool { + crate::remote::saved_ssh_failure_needs_attention(error) +} + +fn handshake_error(error: crate::client::ClientError) -> std::io::Error { + use crate::client::ClientError; + use crate::protocol::FramingError; + match error { + ClientError::ConnectionFailed(error) | ClientError::ConnectionLost(error) => error, + ClientError::HandshakeRejected { error, .. } => { + std::io::Error::new(std::io::ErrorKind::Unsupported, error) + } + ClientError::Protocol(FramingError::Io(error)) => error, + ClientError::Protocol(error) => { + std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()) + } + ClientError::ServerShutdown { reason } => std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server shut down during handshake".into()), + ), + } +} + +fn retry_delay(attempt: u32) -> Duration { + INITIAL_RETRY_DELAY + .saturating_mul( + 1_u32 + .checked_shl(attempt.saturating_sub(1).min(6)) + .unwrap_or(u32::MAX), + ) + .min(MAX_RETRY_DELAY) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::endpoint::ProfileId; + + fn profile() -> super::super::SavedSshEndpoint { + super::super::SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + } + } + + #[test] + fn live_catalog_preserves_renamed_connections_and_local_recovery() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[profile.clone()], now); + supervisors.add_local(PathBuf::from("local"), Some(1), now); + let state = supervisors.endpoints.get_mut(&id).unwrap(); + state.generation = Some(7); + state.attempts = 3; + state.next_attempt = Some(now + Duration::from_secs(4)); + profile.label = "Renamed".into(); + assert!(supervisors.reconcile_profiles(&[profile], now).is_empty()); + let state = &supervisors.endpoints[&id]; + assert_eq!(state.generation, Some(7)); + assert_eq!(state.attempts, 3); + assert_eq!(state.next_attempt, Some(now + Duration::from_secs(4))); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].generation, + Some(1) + ); + } + + #[test] + fn live_catalog_add_disable_enable_and_remove_fence_late_connections() { + let now = Instant::now(); + let mut profile = profile(); + let id = ClientEndpointId::Ssh(profile.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[], now); + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert_eq!(supervisors.endpoints[&id].next_attempt, Some(now)); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(7); + supervisors.next_generation = 8; + profile.enabled = false; + assert_eq!( + supervisors.reconcile_profiles(&[profile.clone()], now), + vec![id.clone()] + ); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + profile.enabled = true; + assert!(supervisors + .reconcile_profiles(&[profile.clone()], now) + .is_empty()); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + assert_eq!(supervisors.next_generation, 8); + assert_eq!(supervisors.reconcile_profiles(&[], now), vec![id.clone()]); + assert!(!supervisors.record_status(&id, 7, ClientEndpointStatus::Online, now)); + } + + #[test] + fn live_catalog_destination_change_retires_only_that_machine() { + let now = Instant::now(); + let mut changed = profile(); + let other = super::super::SavedSshEndpoint::new("Other", "other", "main").unwrap(); + let id = ClientEndpointId::Ssh(changed.id.clone()); + let other_id = ClientEndpointId::Ssh(other.id.clone()); + let mut supervisors = EndpointSupervisors::new(&[changed.clone(), other.clone()], now); + supervisors.endpoints.get_mut(&id).unwrap().generation = Some(2); + supervisors.endpoints.get_mut(&other_id).unwrap().generation = Some(3); + changed.session = "another-session".into(); + assert_eq!( + supervisors.reconcile_profiles(&[changed, other], now), + vec![id.clone()] + ); + assert_eq!(supervisors.endpoints[&id].generation, None); + assert_eq!(supervisors.endpoints[&other_id].generation, Some(3)); + } + + #[test] + fn retry_backoff_is_bounded() { + assert_eq!(retry_delay(1), INITIAL_RETRY_DELAY); + assert_eq!(retry_delay(100), MAX_RETRY_DELAY); + } + + #[test] + fn handshake_network_failures_retry_but_incompatibility_needs_attention() { + let timeout = handshake_error(crate::client::ClientError::ConnectionLost( + std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"), + )); + assert!(!failure_needs_attention(&timeout)); + let rejected = handshake_error(crate::client::ClientError::HandshakeRejected { + version: 1, + error: "surface capability missing".into(), + }); + assert_eq!(rejected.kind(), std::io::ErrorKind::Unsupported); + assert!(failure_needs_attention(&rejected)); + } + + #[test] + fn healthy_local_only_retries_after_its_connection_fails() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + supervisors.add_local(PathBuf::from("local.sock"), Some(1), now); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(!supervisors.disconnected(&ClientEndpointId::Local, 0, now)); + assert!(supervisors.endpoints[&ClientEndpointId::Local] + .next_attempt + .is_none()); + assert!(supervisors.disconnected(&ClientEndpointId::Local, 1, now)); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Local].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert_eq!( + supervisors.endpoints[&ClientEndpointId::Ssh(profile().id)].next_attempt, + Some(now) + ); + } + + #[test] + fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() { + let now = Instant::now(); + let mut supervisors = EndpointSupervisors::new(&[profile()], now); + let endpoint_id = ClientEndpointId::Ssh(profile().id); + supervisors + .endpoints + .get_mut(&endpoint_id) + .unwrap() + .generation = Some(4); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Online, now)); + assert!(!supervisors.disconnected(&endpoint_id, 3, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + assert!(supervisors.disconnected(&endpoint_id, 4, now)); + assert_eq!( + supervisors.endpoints[&endpoint_id].next_attempt, + Some(now + INITIAL_RETRY_DELAY) + ); + assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now)); + assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + } +} diff --git a/src/client/endpoint/writer.rs b/src/client/endpoint/writer.rs new file mode 100644 index 00000000..973ff234 --- /dev/null +++ b/src/client/endpoint/writer.rs @@ -0,0 +1,400 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::traits::Stream as _; + +use super::EndpointTransport; +use crate::ipc::LocalStream; +use crate::protocol::ClientMessage; + +const MAX_QUEUED_MESSAGES: usize = 256; +const MAX_QUEUED_BYTES: usize = 2 * crate::protocol::MAX_GRAPHICS_FRAME_SIZE; +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const IO_POLL_INTERVAL: Duration = Duration::from_millis(2); + +enum WriterCommand { + Frame(Vec<u8>), + Flush(mpsc::Sender<()>), +} + +/// The UI only enqueues complete frames. A worker owns partial writes, cancellation, and the +/// bridge lifetime, so neither socket backpressure nor bridge teardown can block other endpoints. +pub(crate) struct NativeEndpointTransport { + sender: mpsc::SyncSender<WriterCommand>, + queued_bytes: Arc<AtomicUsize>, + stopped: Arc<AtomicBool>, + error: Arc<Mutex<Option<io::Error>>>, +} + +impl NativeEndpointTransport { + pub(crate) fn with_lifetime( + mut stream: LocalStream, + lifetime: impl Send + 'static, + ) -> io::Result<Self> { + stream.set_nonblocking(true)?; + let (sender, receiver) = mpsc::sync_channel::<WriterCommand>(MAX_QUEUED_MESSAGES); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicBool::new(false)); + let error = Arc::new(Mutex::new(None)); + let worker_bytes = queued_bytes.clone(); + let worker_stop = stopped.clone(); + let worker_error = error.clone(); + std::thread::Builder::new() + .name("endpoint-writer".into()) + .spawn(move || { + let _lifetime = lifetime; + while let Ok(command) = receiver.recv() { + if worker_stop.load(Ordering::Acquire) { + break; + } + let frame = match command { + WriterCommand::Frame(frame) => frame, + WriterCommand::Flush(done) => { + let _ = done.send(()); + continue; + } + }; + let result = write_frame(&mut stream, &frame, &worker_stop); + worker_bytes.fetch_sub(frame.len(), Ordering::AcqRel); + if let Err(error) = result { + if let Ok(mut slot) = worker_error.lock() { + *slot = Some(error); + } + worker_stop.store(true, Ordering::Release); + break; + } + } + })?; + Ok(Self { + sender, + queued_bytes, + stopped, + error, + }) + } + + pub(crate) fn stop_handle(&self) -> Arc<AtomicBool> { + self.stopped.clone() + } +} + +impl EndpointTransport for NativeEndpointTransport { + fn send(&mut self, message: &ClientMessage) -> io::Result<()> { + if self.stopped.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "endpoint writer stopped", + )); + } + let mut frame = Vec::new(); + crate::protocol::write_message(&mut frame, message) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + let len = frame.len(); + if self + .queued_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |bytes| { + bytes + .checked_add(len) + .filter(|total| *total <= MAX_QUEUED_BYTES) + }) + .is_err() + { + return Err(queue_full()); + } + self.sender + .try_send(WriterCommand::Frame(frame)) + .map_err(|error| { + self.queued_bytes.fetch_sub(len, Ordering::AcqRel); + match error { + mpsc::TrySendError::Full(_) => queue_full(), + mpsc::TrySendError::Disconnected(_) => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + } + }) + } + + fn disconnect(&mut self) { + self.stopped.store(true, Ordering::Release); + } + + fn flush(&mut self, deadline: Instant) -> io::Result<()> { + let (done, completion) = mpsc::channel(); + self.sender + .try_send(WriterCommand::Flush(done)) + .map_err(|_| queue_full())?; + completion + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => { + io::Error::new(io::ErrorKind::TimedOut, "endpoint flush timed out") + } + mpsc::RecvTimeoutError::Disconnected => { + io::Error::new(io::ErrorKind::BrokenPipe, "endpoint writer stopped") + } + }) + } + + fn take_error(&mut self) -> Option<io::Error> { + self.error.lock().ok()?.take() + } +} + +impl Drop for NativeEndpointTransport { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + } +} + +fn queue_full() -> io::Error { + // A message may already be partially written. Retrying or dropping only this message would + // lose input ordering; revoke the connection and recover through the normal lifecycle. + io::Error::new( + io::ErrorKind::ConnectionAborted, + "endpoint output queue is full", + ) +} + +fn write_frame( + writer: &mut impl io::Write, + mut frame: &[u8], + stopped: &AtomicBool, +) -> io::Result<()> { + let deadline = Instant::now() + WRITE_TIMEOUT; + while !frame.is_empty() && !stopped.load(Ordering::Acquire) { + match writer.write(frame) { + Ok(0) => {} + Ok(written) => { + frame = &frame[written..]; + continue; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Err(error), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "endpoint write timed out", + )); + } + std::thread::sleep(IO_POLL_INTERVAL); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn streams() -> (LocalStream, LocalStream, std::path::PathBuf) { + use interprocess::local_socket::traits::Listener as _; + let path = std::env::temp_dir().join(format!( + "herdr-writer-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let listener = crate::ipc::bind_private_local_listener(&path).unwrap(); + let accepting = std::thread::spawn(move || listener.accept().unwrap()); + let client = crate::ipc::connect_local_stream(&path).unwrap(); + (client, accepting.join().unwrap(), path) + } + + #[test] + fn native_endpoint_writer_delivers_ordered_protocol_frames() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + transport + .send(&ClientMessage::ClientShellFocus { focused: true }) + .unwrap(); + transport + .send(&ClientMessage::ClientShellFocus { focused: false }) + .unwrap(); + let messages = received.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!( + messages, + ( + ClientMessage::ClientShellFocus { focused: true }, + ClientMessage::ClientShellFocus { focused: false } + ) + ); + reader.join().unwrap(); + drop(transport); + let _ = std::fs::remove_file(path); + } + + #[test] + fn registry_exit_flushes_queued_input_and_a_complete_detach() { + let (stream, mut peer, path) = streams(); + let transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let result = (|| { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE)?; + Ok::<_, crate::protocol::FramingError>((first, second)) + })(); + done.send(result).unwrap(); + }); + let mut registry = super::super::EndpointRegistry::new( + transport, + 1, + super::super::EndpointNegotiation::default(), + ); + let input = ClientMessage::Input { + data: b"queued input".to_vec(), + }; + assert_eq!( + registry.send(&input), + super::super::EndpointSendOutcome::Sent + ); + drop(registry); + let (first, second) = received + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .expect("clean exit must flush complete frames before closing"); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_flush_drains_large_frames_before_detach() { + let (stream, mut peer, path) = streams(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, ()).unwrap(); + let (done, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let first: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + let second: ClientMessage = + crate::protocol::read_message(&mut peer, crate::protocol::MAX_FRAME_SIZE).unwrap(); + done.send((first, second)).unwrap(); + }); + let input = ClientMessage::Input { + data: vec![b'x'; 1024 * 1024], + }; + transport.send(&input).unwrap(); + transport.send(&ClientMessage::Detach).unwrap(); + // Large-frame correctness must not depend on the registry's short exit grace period. + transport + .flush(Instant::now() + Duration::from_secs(10)) + .unwrap(); + drop(transport); + let (first, second) = received.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(first, input); + assert_eq!(second, ClientMessage::Detach); + reader.join().unwrap(); + let _ = std::fs::remove_file(path); + } + + #[test] + fn native_endpoint_teardown_does_not_wait_for_a_stalled_peer() { + struct Lifetime(mpsc::Sender<()>); + impl Drop for Lifetime { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let (stream, peer, path) = streams(); + let (done, dropped) = mpsc::channel(); + let mut transport = NativeEndpointTransport::with_lifetime(stream, Lifetime(done)).unwrap(); + transport + .send(&ClientMessage::Input { + data: vec![b'x'; 2 * 1024 * 1024], + }) + .unwrap(); + drop(transport); + dropped + .recv_timeout(Duration::from_secs(3)) + .expect("worker lifetime is released without peer reads"); + drop(peer); + let _ = std::fs::remove_file(path); + } + + #[test] + fn partial_writes_preserve_frame_bytes() { + struct PartialWriter(Vec<u8>); + impl io::Write for PartialWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { + let count = bytes.len().min(3); + self.0.extend_from_slice(&bytes[..count]); + Ok(count) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = PartialWriter(Vec::new()); + write_frame(&mut writer, b"first frame", &AtomicBool::new(false)).unwrap(); + write_frame(&mut writer, b"second frame", &AtomicBool::new(false)).unwrap(); + assert_eq!(writer.0, b"first framesecond frame"); + } + + #[test] + fn a_stalled_write_is_cancellable_without_peer_progress() { + struct StalledWriter(mpsc::Sender<()>); + impl io::Write for StalledWriter { + fn write(&mut self, _: &[u8]) -> io::Result<usize> { + let _ = self.0.send(()); + Err(io::ErrorKind::WouldBlock.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let (attempted, attempts) = mpsc::channel(); + let (done, completion) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = write_frame(&mut StalledWriter(attempted), b"input", &worker_stop); + done.send(result).unwrap(); + }); + attempts.recv_timeout(Duration::from_secs(2)).unwrap(); + stop.store(true, Ordering::Release); + completion + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + worker.join().unwrap(); + } + + #[test] + fn a_full_queue_is_a_connection_failure_not_silent_input_loss() { + let (sender, _receiver) = mpsc::sync_channel(1); + let mut transport = NativeEndpointTransport { + sender, + queued_bytes: Arc::new(AtomicUsize::new(0)), + stopped: Arc::new(AtomicBool::new(false)), + error: Arc::new(Mutex::new(None)), + }; + transport.send(&ClientMessage::Detach).unwrap(); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + transport + .queued_bytes + .store(MAX_QUEUED_BYTES, Ordering::Release); + assert_eq!( + transport.send(&ClientMessage::Detach).unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + } +} diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs index d07b3d42..8e77b796 100644 --- a/src/client/endpoint_commands.rs +++ b/src/client/endpoint_commands.rs @@ -1,127 +1,269 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::io; use std::time::{Duration, Instant}; use crate::api::client::ApiClientError; use crate::api::schema::{Request, ResponseResult}; -use crate::ipc::LocalStream; use crate::protocol::ClientMessage; +use super::endpoint::{ClientEndpointId, EndpointRegistry, EndpointSendOutcome}; use super::shell::ClientShellEndpointError; const ENDPOINT_COMMAND_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_RETIRED_REQUESTS_PER_ENDPOINT: usize = 128; + +struct QueuedCommand { + generation: u64, + boot_id: String, + request: Box<Request>, +} struct InFlightCommand { + generation: u64, boot_id: String, request_id: String, response: Vec<u8>, sent_at: Instant, - timed_out: bool, } pub(super) struct EndpointCommandResult { + pub(super) endpoint_id: ClientEndpointId, + pub(super) generation: u64, pub(super) boot_id: String, pub(super) request_id: String, pub(super) result: Result<ResponseResult, ClientShellEndpointError>, } #[derive(Default)] -pub(super) struct EndpointCommands { - queued: VecDeque<(String, Box<Request>)>, +struct EndpointCommandLane { + queued: VecDeque<QueuedCommand>, in_flight: Option<InFlightCommand>, + retired: VecDeque<(u64, String, String)>, +} + +impl EndpointCommandLane { + fn retire(&mut self, request: (u64, String, String)) { + if self.retired.contains(&request) { + return; + } + if self.retired.len() == MAX_RETIRED_REQUESTS_PER_ENDPOINT { + self.retired.pop_front(); + } + self.retired.push_back(request); + } + + fn consume_retired(&mut self, request: &(u64, String, String), final_chunk: bool) -> bool { + let Some(index) = self.retired.iter().position(|retired| retired == request) else { + return false; + }; + if final_chunk { + self.retired.remove(index); + } + true + } +} + +#[derive(Default)] +pub(super) struct EndpointCommands { + lanes: HashMap<ClientEndpointId, EndpointCommandLane>, } impl EndpointCommands { - pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) { - self.queued.push_back((boot_id, request)); + pub(super) fn enqueue( + &mut self, + endpoint_id: ClientEndpointId, + generation: u64, + boot_id: String, + request: Box<Request>, + ) { + self.lanes + .entry(endpoint_id) + .or_default() + .queued + .push_back(QueuedCommand { + generation, + boot_id, + request, + }); } - pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> { - if self.in_flight.is_some() { - return Ok(()); + pub(super) fn send_next( + &mut self, + endpoint_id: &ClientEndpointId, + endpoints: &mut EndpointRegistry, + ) -> Vec<String> { + let lane = self.lanes.entry(endpoint_id.clone()).or_default(); + let mut cancelled = Vec::new(); + if lane.in_flight.is_some() { + return cancelled; } - let Some((boot_id, request)) = self.queued.pop_front() else { - return Ok(()); - }; - let request_id = request.id.clone(); - let request = serde_json::to_string(&request) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - super::write_to_server( - stream, - &ClientMessage::ClientShellEndpointRequest { - boot_id: boot_id.clone(), + while let Some(queued) = lane.queued.pop_front() { + let request_id = queued.request.id.clone(); + if !endpoints.accepts(endpoint_id, queued.generation) { + cancelled.push(request_id); + continue; + } + let request = match serde_json::to_string(&queued.request) { + Ok(request) => request, + Err(error) => { + tracing::warn!(%error, %request_id, "could not encode endpoint request"); + cancelled.push(request_id); + continue; + } + }; + let message = ClientMessage::ClientShellEndpointRequest { + boot_id: queued.boot_id.clone(), request, - }, - )?; - self.in_flight = Some(InFlightCommand { - boot_id, - request_id, - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }); - Ok(()) + }; + if endpoints.send_to(endpoint_id, &message) != EndpointSendOutcome::Sent { + cancelled.push(request_id); + continue; + } + lane.in_flight = Some(InFlightCommand { + generation: queued.generation, + boot_id: queued.boot_id, + request_id, + response: Vec::new(), + sent_at: Instant::now(), + }); + break; + } + cancelled } - pub(super) fn expire(&mut self, now: Instant) -> Option<EndpointCommandResult> { - let command = self.in_flight.as_mut()?; - if command.timed_out - || now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT - { - return None; + pub(super) fn accepts_response( + &self, + endpoint_id: &ClientEndpointId, + response_generation: u64, + response_boot_id: &str, + response_request_id: &str, + ) -> bool { + self.lanes + .get(endpoint_id) + .and_then(|lane| lane.in_flight.as_ref()) + .is_some_and(|command| { + command.generation == response_generation + && command.boot_id == response_boot_id + && command.request_id == response_request_id + }) + } + + /// Retire the complete source lane at source-off. The in-flight request is tombstoned for a + /// late endpoint-local response; every queued request is cancelled before it can run in a + /// later presentation epoch. Other endpoint lanes are deliberately untouched. + pub(super) fn retire_lane(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = Vec::new(); + if let Some(command) = lane.in_flight.take() { + lane.retire(( + command.generation, + command.boot_id, + command.request_id.clone(), + )); + request_ids.push(command.request_id); } - command.timed_out = true; - Some(EndpointCommandResult { - boot_id: command.boot_id.clone(), - request_id: command.request_id.clone(), - result: Err(ClientShellEndpointError { - code: Some("endpoint_timeout".into()), - message: "this server did not respond to the action".into(), - }), - }) + request_ids.extend(lane.queued.drain(..).map(|command| command.request.id)); + request_ids + } + + pub(super) fn expire(&mut self, now: Instant) -> Vec<EndpointCommandResult> { + self.lanes + .iter_mut() + .filter_map(|(endpoint_id, lane)| { + let command = lane.in_flight.as_ref()?; + if now.saturating_duration_since(command.sent_at) < ENDPOINT_COMMAND_TIMEOUT { + return None; + } + let command = lane.in_flight.take().expect("checked in-flight command"); + lane.retire(( + command.generation, + command.boot_id.clone(), + command.request_id.clone(), + )); + Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: command.generation, + boot_id: command.boot_id, + request_id: command.request_id, + result: Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "this server did not respond to the action".into(), + }), + }) + }) + .collect() } pub(super) fn receive_chunk( &mut self, + endpoint_id: &ClientEndpointId, + response_generation: u64, response_boot_id: &str, response_request_id: &str, final_chunk: bool, data: Vec<u8>, ) -> io::Result<Option<EndpointCommandResult>> { - let Some(in_flight) = self.in_flight.as_mut() else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response arrived without an in-flight command", - )); + let Some(lane) = self.lanes.get_mut(endpoint_id) else { + return Ok(None); }; - if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "endpoint response correlation did not match the in-flight command", - )); + let retired = ( + response_generation, + response_boot_id.to_owned(), + response_request_id.to_owned(), + ); + if lane.consume_retired(&retired, final_chunk) { + return Ok(None); + } + let Some(in_flight) = lane.in_flight.as_mut() else { + return Ok(None); + }; + if response_generation != in_flight.generation + || response_boot_id != in_flight.boot_id + || response_request_id != in_flight.request_id + { + return Ok(None); } in_flight.response.extend(data); if !final_chunk { return Ok(None); } - let in_flight = self.in_flight.take().expect("checked in-flight command"); - let response = String::from_utf8(in_flight.response) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let result = parse_response(&in_flight.request_id, &response); + let in_flight = lane.in_flight.take().expect("checked in-flight command"); + let result = parse_response(&in_flight.request_id, &in_flight.response); Ok(Some(EndpointCommandResult { + endpoint_id: endpoint_id.clone(), + generation: in_flight.generation, boot_id: in_flight.boot_id, request_id: in_flight.request_id, result, })) } + + /// Disconnecting an endpoint also cancels its shell-pending requests. Connection generation + /// rejection handles any late wire response after the lane itself is removed. + pub(super) fn disconnect(&mut self, endpoint_id: &ClientEndpointId) -> Vec<String> { + let Some(lane) = self.lanes.remove(endpoint_id) else { + return Vec::new(); + }; + let mut request_ids = lane + .queued + .into_iter() + .map(|command| command.request.id) + .collect::<Vec<_>>(); + if let Some(command) = lane.in_flight { + request_ids.push(command.request_id); + } + request_ids + } } -fn parse_response( +pub(super) fn parse_response( expected_id: &str, - response: &str, + response: &[u8], ) -> Result<ResponseResult, ClientShellEndpointError> { - let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError { + let value = serde_json::from_slice(response).map_err(|error| ClientShellEndpointError { code: None, message: format!("invalid endpoint response: {error}"), })?; @@ -159,19 +301,35 @@ mod tests { use super::*; use crate::api::schema::{ResponseResult, SuccessResponse}; + fn endpoint() -> ClientEndpointId { + ClientEndpointId::Local + } + fn commands_with_in_flight() -> EndpointCommands { EndpointCommands { - in_flight: Some(InFlightCommand { - boot_id: "boot-a".into(), - request_id: "request-a".into(), - response: Vec::new(), - sent_at: Instant::now(), - timed_out: false, - }), - ..EndpointCommands::default() + lanes: HashMap::from([( + endpoint(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 1, + boot_id: "boot-a".into(), + request_id: "request-a".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + )]), } } + fn has_in_flight(commands: &EndpointCommands) -> bool { + commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.in_flight.is_some()) + } + #[test] fn chunked_response_completion_is_correlated_and_clears_the_lane() { let mut commands = commands_with_in_flight(); @@ -184,6 +342,8 @@ mod tests { assert!(commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", false, @@ -193,6 +353,8 @@ mod tests { .is_none()); let completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", true, @@ -201,10 +363,12 @@ mod tests { .unwrap() .unwrap(); + assert_eq!(completed.endpoint_id, endpoint()); + assert_eq!(completed.generation, 1); assert_eq!(completed.boot_id, "boot-a"); assert_eq!(completed.request_id, "request-a"); assert!(matches!(completed.result, Ok(ResponseResult::Ok {}))); - assert!(commands.in_flight.is_none()); + assert!(!has_in_flight(&commands)); } #[test] @@ -224,6 +388,8 @@ mod tests { for (index, chunk) in response.chunks(128 * 1024).enumerate() { completed = commands .receive_chunk( + &endpoint(), + 1, "boot-a", "request-a", index + 1 == chunk_count, @@ -243,8 +409,10 @@ mod tests { let mut commands = commands_with_in_flight(); let expired = commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .pop() .expect("expired endpoint command"); + assert_eq!(expired.endpoint_id, endpoint()); assert_eq!(expired.boot_id, "boot-a"); assert_eq!(expired.request_id, "request-a"); assert!(matches!( @@ -254,30 +422,184 @@ mod tests { .. }) if code == "endpoint_timeout" )); - assert!(commands.in_flight.is_some()); + assert!(!has_in_flight(&commands)); assert!(commands .expire(std::time::Instant::now() + ENDPOINT_COMMAND_TIMEOUT) + .is_empty()); + let late_response = serde_json::to_vec(&SuccessResponse { + id: "request-a".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .expect("late retired response is ignored") .is_none()); + assert!(!has_in_flight(&commands)); + } + + #[test] + fn endpoint_lanes_complete_independently() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + in_flight: Some(InFlightCommand { + generation: 2, + boot_id: "boot-b".into(), + request_id: "request-b".into(), + response: Vec::new(), + sent_at: Instant::now(), + }), + ..EndpointCommandLane::default() + }, + ); + let response = serde_json::to_vec(&SuccessResponse { + id: "request-b".into(), + result: ResponseResult::Ok {}, + }) + .unwrap(); + + let completed = commands + .receive_chunk(&remote, 2, "boot-b", "request-b", true, response) + .unwrap() + .expect("remote response"); + + assert_eq!(completed.endpoint_id, remote); + assert!(has_in_flight(&commands)); + assert!(commands + .lanes + .get(&completed.endpoint_id) + .is_some_and(|lane| lane.in_flight.is_none())); + } + + #[test] + fn retiring_complete_source_lane_cancels_queued_ids_and_keeps_other_lanes() { + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-source".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + commands.lanes.insert( + remote.clone(), + EndpointCommandLane { + queued: VecDeque::from([QueuedCommand { + generation: 2, + boot_id: "boot-b".into(), + request: Box::new(Request { + id: "request-b".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }]), + ..EndpointCommandLane::default() + }, + ); + + assert_eq!( + commands.retire_lane(&endpoint()), + vec!["request-a", "queued-source"] + ); + assert!(!has_in_flight(&commands)); + assert!(commands + .lanes + .get(&endpoint()) + .is_some_and(|lane| lane.queued.is_empty())); + assert!(commands + .lanes + .get(&remote) + .is_some_and(|lane| !lane.queued.is_empty())); + let late_response = serde_json::to_vec(&SuccessResponse { id: "request-a".into(), result: ResponseResult::Ok {}, }) .unwrap(); assert!(commands - .receive_chunk("boot-a", "request-a", true, late_response) - .expect("late response releases the synchronized lane") - .is_some()); - assert!(commands.in_flight.is_none()); + .receive_chunk(&endpoint(), 1, "boot-a", "request-a", true, late_response) + .unwrap() + .is_none()); } #[test] - fn response_from_another_boot_is_rejected() { + fn disconnect_returns_every_request_that_must_be_discarded() { let mut commands = commands_with_in_flight(); + commands + .lanes + .get_mut(&endpoint()) + .unwrap() + .queued + .push_back(QueuedCommand { + generation: 1, + boot_id: "boot-a".into(), + request: Box::new(Request { + id: "queued-a".into(), + method: crate::api::schema::Method::WorkspaceList( + crate::api::schema::EmptyParams::default(), + ), + }), + }); + assert_eq!( + commands.disconnect(&endpoint()), + vec!["queued-a", "request-a"] + ); + assert!(!commands.lanes.contains_key(&endpoint())); + } - let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else { - panic!("mismatched boot should fail"); - }; + #[test] + fn retired_request_tombstones_are_bounded() { + let mut lane = EndpointCommandLane::default(); + for serial in 0..MAX_RETIRED_REQUESTS_PER_ENDPOINT + 10 { + lane.retire((1, "boot".into(), format!("request-{serial}"))); + } + assert_eq!(lane.retired.len(), MAX_RETIRED_REQUESTS_PER_ENDPOINT); + assert!(!lane + .retired + .contains(&(1, "boot".into(), "request-0".into()))); + assert!(lane.retired.contains(&( + 1, + "boot".into(), + format!("request-{}", MAX_RETIRED_REQUESTS_PER_ENDPOINT + 9) + ))); + } + + #[test] + fn stale_or_unknown_responses_do_not_damage_the_live_lane() { + let mut commands = commands_with_in_flight(); + let unknown = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(commands + .receive_chunk(&endpoint(), 2, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&endpoint(), 1, "boot-b", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(commands + .receive_chunk(&unknown, 1, "boot-a", "request-a", true, b"{}".to_vec()) + .unwrap() + .is_none()); + assert!(has_in_flight(&commands)); } } diff --git a/src/client/events.rs b/src/client/events.rs new file mode 100644 index 00000000..43f7b8ad --- /dev/null +++ b/src/client/events.rs @@ -0,0 +1,34 @@ +use super::*; + +/// Internal events for the client event loop. +pub(super) enum ClientLoopEvent { + #[cfg(unix)] + StdinInput(Vec<u8>), + #[cfg(unix)] + PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), + #[cfg(unix)] + DirectGraphicsResponse(direct_graphics::Response), + #[cfg(windows)] + StdinEvents(Vec<crate::protocol::ClientInputEvent>), + Resize(u16, u16, u32, u32, bool), + TerminalUnavailable(io::Error), + ServerMessage { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + message: Box<ServerMessage>, + }, + ServerDisconnected { + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + }, + EndpointSupervisor(endpoint::EndpointSupervisorEvent), + EndpointCatalog(Result<Vec<endpoint::SavedSshEndpoint>, String>), + ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + /// A superseded handoff deliberately starts a fresh target-on epoch even when source and + /// latest target have the same identity after restoration. + force: bool, + }, + Timer, +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 84dd6643..937f92ac 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -121,6 +121,29 @@ fn set_handshake_recv_timeout( pub(super) struct HandshakeResult { pub(super) encoding: RenderEncoding, pub(super) endpoint_methods: Option<Vec<String>>, + pub(super) endpoint_capabilities: Option<Vec<String>>, +} + +pub(crate) fn probe_endpoint_negotiation( + stream: &mut LocalStream, +) -> io::Result<super::endpoint::EndpointNegotiation> { + let handshake = do_handshake( + stream, + 80, + 24, + 0, + 0, + false, + Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }), + false, + false, + false, + ) + .map_err(io::Error::other)?; + Ok(super::endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), + )) } /// Performs the client→server handshake. @@ -138,6 +161,7 @@ pub(super) fn do_handshake( shell_surface_size: Option<crate::protocol::ClientSurfaceSize>, endpoint_keybindings: bool, mouse_capture: bool, + surface_active: bool, ) -> Result<HandshakeResult, ClientError> { stream .set_nonblocking(false) @@ -157,6 +181,7 @@ pub(super) fn do_handshake( && direct_graphics_profile_allowed(), endpoint_keybindings, mouse_capture, + surface_active, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -181,9 +206,14 @@ pub(super) fn do_handshake( protocol::write_message(stream, &hello) .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + let read_timeout = if endpoint_shell && !surface_active { + REMOTE_HANDSHAKE_READ_TIMEOUT + } else { + handshake_read_timeout() + }; set_handshake_recv_timeout( stream, - Some(handshake_read_timeout()), + Some(read_timeout), "client handshake read timeout unavailable", )?; let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; @@ -238,6 +268,7 @@ pub(super) fn do_handshake( return Ok(HandshakeResult { encoding: RenderEncoding::SemanticFrame, endpoint_methods: Some(welcome.methods), + endpoint_capabilities: Some(welcome.capabilities), }); } @@ -254,6 +285,7 @@ pub(super) fn do_handshake( Ok(HandshakeResult { encoding, endpoint_methods: None, + endpoint_capabilities: None, }) } _ => Err(ClientError::Protocol(protocol::FramingError::Io( diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs new file mode 100644 index 00000000..53ef9f38 --- /dev/null +++ b/src/client/loop_config.rs @@ -0,0 +1,16 @@ +use super::*; + +pub(super) struct ClientLoopConfig { + pub(super) sound_config: crate::config::SoundConfig, + pub(super) mouse_scroll_lines: usize, + pub(super) redraw_on_focus_gained: bool, + pub(super) host_cursor: crate::config::HostCursorModeConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_fallback: bool, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_keybindings: bool, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) shell_config: Option<shell::ClientShellConfig>, +} diff --git a/src/client/mod.rs b/src/client/mod.rs index ea789431..b815a057 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -13,23 +13,46 @@ //! - Displays sound/toast notifications forwarded from server mod attach; +mod catalog_reload; +mod clipboard_forwarding; mod clipboard_images; +mod config_reload; #[cfg(unix)] mod direct_graphics; +pub(crate) mod endpoint; mod endpoint_commands; mod errors; +mod events; mod frame_output; mod handshake; mod input; +mod loop_config; mod notifications; mod shell; +mod shell_runtime; +mod startup; +mod state; mod terminal_geometry; mod terminal_sessions; mod terminal_setup; mod timer; +mod transport; + +#[cfg(test)] +use clipboard_forwarding::decode_clipboard_payload; +use clipboard_forwarding::forward_clipboard; +#[cfg(test)] +use config_reload::reload_local_client_config; +use config_reload::{apply_reload, init_logging}; +use events::ClientLoopEvent; +use loop_config::ClientLoopConfig; +use shell_runtime::*; +use state::ClientState; +use transport::*; #[cfg(test)] pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use startup::{run_client, run_terminal_attach}; pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; #[cfg(not(windows))] @@ -65,7 +88,9 @@ use attach::direct_attach_pixel_mouse; use attach::AttachEscapeState; #[cfg(unix)] use attach::{write_attach_semantic_action, AttachInputAction}; -use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +use clipboard_images::{ + client_remote_image_paste_key, endpoint_accepts_local_images, write_remote_image_to_server, +}; #[cfg(windows)] use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; #[cfg(unix)] @@ -77,6 +102,7 @@ use frame_output::{ contains_kitty_graphics_bytes, record_received_kitty_graphics, write_encoded_frame_with_graphics, }; +pub(crate) use handshake::probe_endpoint_negotiation; use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; #[cfg(test)] use handshake::{ @@ -103,243 +129,11 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::{ - self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, -}; +use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; #[cfg(test)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -// --------------------------------------------------------------------------- -// Client state -// --------------------------------------------------------------------------- - -struct ClientLoopConfig { - sound_config: crate::config::SoundConfig, - mouse_scroll_lines: usize, - redraw_on_focus_gained: bool, - host_cursor: crate::config::HostCursorModeConfig, - kitty_graphics_enabled: bool, - pixel_geometry_enabled: bool, - pixel_geometry_fallback: bool, - mouse_capture_active: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - shell_config: Option<shell::ClientShellConfig>, -} - -/// State tracking for the thin client. -struct ClientState { - /// Stateful semantic-frame encoder used when the server sends FrameData. - blit_encoder: render_ansi::BlitEncoder, - /// Whether host mouse capture is currently active. - mouse_capture_active: bool, - /// Last mouse-capture demand published by the endpoint. - endpoint_mouse_capture_requested: bool, - /// Last exact-pixel mouse demand published by the endpoint. - endpoint_sgr_pixels_requested: bool, - /// Client-local direct-attach preference, combined with child mouse demand. - direct_mouse_capture_preference: bool, - /// Client-owned shell mouse-capture preference. - shell_mouse_capture_preference: bool, - /// Host keyboard protocol state currently owned for a direct terminal attach. - direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, - /// Focused endpoint pane/popup demand for Kitty report-all input. - pane_keyboard_report_all: bool, - /// Whether the client-owned shell currently enabled host report-all input. - keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last handshake/Resize. - reported_size: (u16, u16), - /// Last exact host cell size used by client-rendered surfaces. - reported_cell_size: (u32, u32), - /// Client-local sound playback config, refreshed on server request. - sound_config: crate::config::SoundConfig, - /// Whether this client may write Kitty graphics bytes to its host terminal. - kitty_graphics_enabled: bool, - /// Whether resize reports inspect host pixel geometry. - pixel_geometry_enabled: bool, - /// Whether the latest host pixel geometry is exact enough for pixel mouse input. - pixel_geometry_exact: bool, - /// One bounded matcher, inactive unless a direct transmission is armed. - #[cfg(unix)] - direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, - /// One server-retired direct transfer to suppress if it was still queued. - #[cfg(unix)] - retired_direct_graphics: Option<(u64, u32)>, - /// ClientShell assets waiting for the host terminal's direct-upload response. - #[cfg(unix)] - pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>, - /// Direct attach prefix escape state. None for ClientShell connections. - attach_escape: Option<AttachEscapeState>, - /// Rows scrolled for one direct-attach wheel notch. - #[cfg(unix)] - mouse_scroll_lines: usize, - /// Local-client shortcut that sends a clipboard image to a remote Herdr session. - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - /// Whether outer focus gain should force a full host-terminal redraw. - redraw_on_focus_gained: bool, - /// Whether the next semantic frame must repaint every cell without clearing the surface. - repaint_pending: bool, - /// Whether this client draws the cursor into frame cells instead of using the host cursor. - draw_host_cursor: bool, - /// Browser opener processes launched by client-owned link activation. - detached_process_children: Vec<std::process::Child>, - /// Experimental client-owned shell state. - shell: Option<shell::ClientShellState>, -} - -impl Drop for ClientState { - fn drop(&mut self) { - if self.attach_escape.is_some() { - let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut self.direct_keyboard_protocol, - 0, - 0, - ); - } - } -} - -impl ClientState { - fn request_repaint(&mut self) { - self.repaint_pending = true; - } - - fn present_graphics(&mut self, graphics: &[u8]) { - if graphics.is_empty() || !self.kitty_graphics_enabled { - return; - } - let mut stdout = io::stdout(); - let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); - let _ = stdout.flush(); - } - - fn present_surface_patch( - &mut self, - patch: shell::ClientComposedSurfacePatch, - ) -> io::Result<bool> { - if self.repaint_pending { - crate::render_prof::event("client_surface_patch.fallback.repaint"); - return Ok(false); - } - let rows = if self.draw_host_cursor { - let Some(rows) = self - .blit_encoder - .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) - else { - crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); - return Ok(false); - }; - rows - } else { - patch.rows - }; - let encode_started = crate::render_prof::timer(); - let Some(encoded) = - self.blit_encoder - .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) - else { - crate::render_prof::event("client_surface_patch.fallback.encode"); - return Ok(false); - }; - crate::render_prof::duration_since("client_surface_patch.encode", encode_started); - let write_started = crate::render_prof::timer(); - let mut stdout = io::stdout(); - stdout.write_all(&encoded.bytes)?; - stdout.flush()?; - crate::render_prof::duration_since("client_surface_patch.write", write_started); - let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); - crate::render_prof::event(if committed { - "client_surface_patch.success" - } else { - "client_surface_patch.fallback.commit" - }); - Ok(committed) - } - - fn present_frame(&mut self, frame_data: FrameData) { - let frame_data = if self.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if self.draw_host_cursor { - self.blit_encoder - .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) - } else { - self.blit_encoder.encode(&frame_data, self.repaint_pending) - }; - let mut stdout = io::stdout(); - let graphics = if self.kitty_graphics_enabled { - frame_data.graphics.as_slice() - } else { - &[] - }; - let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - self.blit_encoder.commit(frame_data, encoded); - self.repaint_pending = false; - } -} - -// --------------------------------------------------------------------------- -// Client event loop -// --------------------------------------------------------------------------- - -/// Internal events for the client event loop. -enum ClientLoopEvent { - /// Raw input bytes from stdin. - #[cfg(unix)] - StdinInput(Vec<u8>), - /// One confirmed SGR pixel report with geometry captured by the reader. - #[cfg(unix)] - PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry), - #[cfg(unix)] - DirectGraphicsResponse(direct_graphics::Response), - /// Structured input events from platforms without Unix-style stdin bytes. - #[cfg(windows)] - StdinEvents(Vec<crate::protocol::ClientInputEvent>), - /// Terminal resize detected, including current exact-pixel eligibility. - Resize(u16, u16, u32, u32, bool), - /// The client's host terminal can no longer report a valid grid. - TerminalUnavailable(io::Error), - /// Server message received. - ServerMessage(Box<ServerMessage>), - /// Server reader thread exited (connection lost). - ServerDisconnected, - /// Timer tick. - Timer, -} - -/// Runs the thin client: connects to the server, performs the handshake, -/// and enters the main event loop. -/// -/// This is the entry point called from `main.rs` when running in client mode. -pub fn run_client() -> io::Result<()> { - run_client_with_mode(None, None, "connecting to server") -} - -/// Runs a direct terminal attach client. -#[cfg(unix)] -pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { - run_client_with_mode( - Some((terminal_id, takeover)), - Some(AttachEscapeState::default()), - "attaching to terminal", - ) -} - -/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path. -#[cfg(windows)] -pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { - debug_assert!(!crate::platform::capabilities().direct_terminal_attach); - Err(io::Error::new( - io::ErrorKind::Unsupported, - "direct terminal attach is not supported on Windows yet", - )) -} - fn run_client_with_mode( attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, @@ -373,6 +167,9 @@ fn run_client_with_mode( let kitty_graphics_enabled = loaded_config.config.kitty_graphics_enabled() && client_rendered_shell; let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); + let endpoint_keybindings = shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, @@ -382,6 +179,7 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + endpoint_keybindings, remote_image_paste_key, shell_config, }; @@ -389,14 +187,26 @@ fn run_client_with_mode( crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); - // Try to connect to the server. - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(s) => s, - Err(err) => { - // Server unreachable — show clear error and exit. - let client_err = ClientError::ConnectionFailed(err); - eprintln!("herdr: {client_err}"); - std::process::exit(1); + let endpoint_catalog = if client_rendered_shell && !is_remote_client_process() { + endpoint::EndpointCatalog::load().unwrap_or_else(|error| { + warn!(%error, "saved SSH endpoint catalog is unavailable"); + endpoint::EndpointCatalog::default() + }) + } else { + endpoint::EndpointCatalog::default() + }; + let federated = endpoint_catalog.has_enabled_ssh(); + + let initial_stream = match crate::ipc::connect_local_stream(&socket_path) { + Ok(stream) => Some(stream), + Err(error) if federated => { + warn!(%error, "Local is unavailable; keeping saved machines available"); + None + } + Err(error) => { + return Err(io::Error::other( + ClientError::ConnectionFailed(error).to_string(), + )); } }; @@ -408,43 +218,56 @@ fn run_client_with_mode( .shell_config .as_ref() .map(|shell| shell.initial_surface_size(cols, rows)); - let endpoint_keybindings = loop_config - .shell_config - .as_ref() - .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - - // Perform handshake while the stream is still in blocking mode. - let handshake = match do_handshake( - &mut stream, - cols, - rows, - cell_width_px, - cell_height_px, - exact_cell_size, - shell_surface_size, - endpoint_keybindings, - loop_config.mouse_capture_active, - ) { - Ok(encoding) => encoding, - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); + // Healthy Local attaches directly; only an actual failure enters background recovery. + let initial = initial_stream + .map(|mut stream| { + let handshake = do_handshake( + &mut stream, + cols, + rows, + cell_width_px, + cell_height_px, + exact_cell_size, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, + true, + ) + .map_err(|error| io::Error::other(error.to_string()))?; + if federated + && !endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.clone().unwrap_or_default(), + handshake.endpoint_capabilities.clone().unwrap_or_default(), + ) + .supports_surface_interest() + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Local needs a server update before it can participate in multi-machine viewing", + )); + } + if let Some((terminal_id, takeover)) = attach_request { + write_to_server( + &mut stream, + &ClientMessage::AttachTerminal { + terminal_id, + takeover, + }, + )?; + } + Ok((stream, handshake)) + }) + .transpose(); + let initial = match initial { + Ok(initial) => initial, + Err(error) if federated => { + warn!(%error, "Local handshake failed; keeping saved machines available"); + None } + Err(error) => return Err(error), }; - if let Some((terminal_id, takeover)) = attach_request { - let attach = ClientMessage::AttachTerminal { - terminal_id, - takeover, - }; - if let Err(err) = write_to_server(&mut stream, &attach) { - eprintln!("herdr: failed to request terminal attach: {err}"); - std::process::exit(1); - } - } - - // Now set up the terminal. This must happen AFTER the handshake succeeds, - // so we don't leave the terminal in raw mode if the server rejects us. + // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); let terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) @@ -483,7 +306,8 @@ fn run_client_with_mode( let result = rt.block_on(async { run_client_loop( - stream, + initial, + endpoint_catalog, cols, rows, cell_width_px, @@ -491,8 +315,6 @@ fn run_client_with_mode( exact_cell_size, should_quit, loop_config, - handshake.encoding, - handshake.endpoint_methods, attach_escape, ) .await @@ -526,187 +348,6 @@ fn run_client_with_mode( Ok(()) } -fn dispatch_client_shell_actions( - actions: Vec<shell::ClientShellAction>, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - write_stream: &mut LocalStream, - detached_process_children: &mut Vec<std::process::Child>, -) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> { - let mut replay_mouse = Vec::new(); - for action in actions { - match action { - shell::ClientShellAction::Endpoint { boot_id, request } => { - endpoint_commands.enqueue(boot_id, request); - } - shell::ClientShellAction::ClipboardWrite(bytes) => { - crate::selection::write_osc52_bytes(&bytes); - } - shell::ClientShellAction::Request(request) => { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - shell::ClientShellAction::OpenSafeWebUrl(url) => { - if crate::app::actions::safe_web_url(&url).is_some() { - match crate::platform::open_url(&url) { - Ok(Some(child)) => detached_process_children.push(child), - Ok(None) => {} - Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), - } - } - } - shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), - shell::ClientShellAction::Keybind(action) => { - debug!( - ?action, - "client shell action awaits its presentation family" - ); - } - } - } - endpoint_commands - .send_next(write_stream) - .map_err(ClientError::ConnectionLost)?; - Ok(replay_mouse) -} - -fn client_shell_resize_message( - shell: &shell::ClientShellState, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - pixel_mouse: bool, -) -> ClientMessage { - ClientMessage::ClientShellResize { - cell_width_px, - cell_height_px, - surface_size: shell.surface_size(cols, rows), - pixel_mouse, - } -} - -fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { - let Some(shell) = state.shell.as_ref() else { - return Ok(()); - }; - let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); - if desired == state.keyboard_report_all_active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) - .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = desired; - Ok(()) -} - -fn apply_client_shell_input_source_changes( - state: &mut ClientState, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) { - let changes = state - .shell - .as_mut() - .map(shell::ClientShellState::take_input_source_changes) - .unwrap_or_default(); - for active in changes { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); - } - } -} - -fn install_client_shell_snapshot( - state: &mut ClientState, - snapshot: Box<crate::protocol::ClientShellSnapshot>, - write_stream: &mut LocalStream, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<(), ClientError> { - let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { - let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.set_snapshot(snapshot); - let graphics_cleanup = shell.take_pending_graphics_cleanup(); - let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - (previous_size != next_size).then(|| { - client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, - ) - }), - graphics_cleanup, - ) - } else { - (None, None, Vec::new()) - }; - apply_client_shell_input_source_changes(state, prefix_input_source); - state.present_graphics(&graphics_cleanup); - if let Some(resize) = resize { - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = composed { - state.present_frame(frame); - } - Ok(()) -} - -fn finish_client_shell_input( - state: &mut ClientState, - outcome: shell::ClientShellInput, - frame: Option<FrameData>, - write_stream: &mut LocalStream, - endpoint_commands: &mut endpoint_commands::EndpointCommands, - prefix_input_source: &mut impl crate::platform::PrefixInputSource, -) -> Result<bool, ClientError> { - apply_client_shell_input_source_changes(state, prefix_input_source); - if outcome.detach { - let _ = write_to_server(write_stream, &ClientMessage::Detach); - return Ok(true); - } - if outcome.resize { - let shell = state.shell.as_ref().expect("shell mode remains active"); - let resize = client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, - ); - write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; - } - #[cfg(not(windows))] - if outcome.query_host_appearance { - query_host_terminal_appearance(); - } - if outcome.query_host_theme { - query_host_terminal_theme(); - } - sync_client_shell_keyboard_report_all(state)?; - let replay = dispatch_client_shell_actions( - outcome.actions, - endpoint_commands, - write_stream, - &mut state.detached_process_children, - )?; - debug_assert!( - replay.is_empty(), - "mouse replay only follows endpoint results" - ); - for request in outcome.requests { - write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - Ok(false) -} - /// The main client event loop. /// /// Uses a threaded architecture: @@ -715,7 +356,8 @@ fn finish_client_shell_input( /// - server reader thread → reads ServerMessages and sends to main loop /// - main loop: coordinates input, output, and server communication async fn run_client_loop( - stream: LocalStream, + initial: Option<(LocalStream, handshake::HandshakeResult)>, + mut endpoint_catalog: endpoint::EndpointCatalog, cols: u16, rows: u16, initial_cell_width_px: u32, @@ -723,20 +365,20 @@ async fn run_client_loop( initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, - negotiated_encoding: RenderEncoding, - endpoint_methods: Option<Vec<String>>, attach_escape: Option<AttachEscapeState>, ) -> Result<(), ClientError> { #[cfg(windows)] let _ = config.mouse_scroll_lines; let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor); let is_remote_client = is_remote_client_process(); + let local_unavailable = initial.is_none(); let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, endpoint_mouse_capture_requested: false, endpoint_sgr_pixels_requested: false, + host_theme_updates: Vec::new(), direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, shell_mouse_capture_preference: config.mouse_capture_active, direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), @@ -760,15 +402,28 @@ async fn run_client_loop( remote_image_paste_key: config.remote_image_paste_key, redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, + presentation_frozen: false, draw_host_cursor, detached_process_children: Vec::new(), shell: config.shell_config.map(shell::ClientShellState::new), }; + let mut federated = endpoint_catalog.has_enabled_ssh(); if let Some(shell) = state.shell.as_mut() { shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); - shell.set_endpoint_methods(endpoint_methods); + shell.set_endpoint_catalog(&endpoint_catalog.ssh); + shell.set_endpoint_methods_for( + &endpoint::ClientEndpointId::Local, + initial + .as_ref() + .and_then(|(_, handshake)| handshake.endpoint_methods.clone()), + ); + if local_unavailable { + shell.set_endpoint_status( + &endpoint::ClientEndpointId::Local, + endpoint::ClientEndpointStatus::Connecting, + ); + } } - debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. // Zero means the host has not reported one. @@ -777,6 +432,8 @@ async fn run_client_loop( // Channel for events from the resize and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); + let (supervisor_tx, mut supervisor_rx) = + tokio::sync::mpsc::channel::<endpoint::EndpointSupervisorEvent>(64); // Keep Windows console draining independent of server-frame backpressure. #[cfg(windows)] let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -833,7 +490,6 @@ async fn run_client_loop( let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); - let kitty_graphics_enabled = state.kitty_graphics_enabled; let pixel_geometry_enabled = state.pixel_geometry_enabled; let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { @@ -851,31 +507,59 @@ async fn run_client_loop( ); }); - // Spawn the server reader thread (blocking reads from the socket). - // Clone the stream's file descriptor so we can read from a blocking stream. - let server_read_quit = should_quit.clone(); - let server_read_tx = event_tx.clone(); - let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?; - std::thread::spawn(move || { - let max_frame_size = if kitty_graphics_enabled { + let mut write_stream = if let Some((stream, handshake)) = initial { + let max_frame_size = if state.kitty_graphics_enabled { MAX_GRAPHICS_FRAME_SIZE } else { - MAX_FRAME_SIZE + crate::protocol::MAX_FRAME_SIZE }; - server_reader_thread( - read_stream, - server_read_tx, - &server_read_quit, + let transport = start_endpoint_transport( + stream, + (), + &event_tx, + endpoint::ClientEndpointId::Local, + 1, max_frame_size, + )?; + let negotiation = endpoint::EndpointNegotiation::new( + handshake.endpoint_methods.unwrap_or_default(), + handshake.endpoint_capabilities.unwrap_or_default(), ); - }); - - // Use the original stream for writing (blocking is fine since we write - // from the async loop). - let mut write_stream = stream; - write_stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; + let mut registry = endpoint::EndpointRegistry::new(transport, 1, negotiation); + if state.shell.is_some() { + registry.send(&ClientMessage::ClientShellFocus { focused: true }); + } + registry + } else { + endpoint::EndpointRegistry::empty() + }; + let mut supervisors = + endpoint::EndpointSupervisors::new(&endpoint_catalog.ssh, std::time::Instant::now()); + if federated { + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + std::time::Instant::now(), + ); + } + if local_unavailable { + if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(cols, rows)) + { + state.present_frame(frame); + } + } + let mut next_surface_serial = 1_u64; + let mut pending_activation: Option<endpoint::PendingEndpointActivation> = None; + let mut scheduled_activation = None; + let mut pending_catalog: Option<Result<Vec<endpoint::SavedSshEndpoint>, String>> = None; + if state.shell.is_some() && !is_remote_client && state.attach_escape.is_none() { + catalog_reload::watch_profiles(event_tx.clone(), should_quit.clone()); + } // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). @@ -886,6 +570,111 @@ async fn run_client_loop( #[cfg(windows)] let mut stdin_open = true; while !should_quit.load(Ordering::Acquire) { + if pending_activation.is_none() { + if let Some(reload) = pending_catalog.take() { + match reload { + Ok(profiles) => { + let now = std::time::Instant::now(); + if !federated && profiles.iter().any(|profile| profile.enabled) { + // Keep Local recovery once enabled, even after removing the last SSH profile. + federated = true; + supervisors.add_local( + client_socket_path(), + write_stream + .connection(&endpoint::ClientEndpointId::Local) + .map(|connection| connection.generation), + now, + ); + if write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some_and(|connection| { + !connection.negotiation.supports_surface_interest() + }) + { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable( + "Update the Local server before switching between machines" + .into(), + ); + } + } + } + let active_removed = catalog_reload::apply_profiles( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut endpoint_catalog, + profiles, + now, + ); + if active_removed { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + scheduled_activation = None; + if state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_projection_available( + &endpoint::ClientEndpointId::Local, + ) + }) && write_stream + .connection(&endpoint::ClientEndpointId::Local) + .is_some() + { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: endpoint::ClientEndpointId::Local, + target: None, + force: true, + }); + } else { + present_handoff_unavailable( + &mut state, + "Local is unavailable; reconnecting".into(), + ); + } + } + } + Err(error) => { + warn!(%error, "saved machines could not be reloaded; keeping current connections"); + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "Saved machines could not be reloaded; keeping current connections: {error}" + )); + } + } + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(shell) = state.shell.as_mut() { + let cleanup = shell.take_pending_graphics_cleanup(); + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + let frozen = state.presentation_frozen; + state.presentation_frozen = false; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + state.presentation_frozen = frozen; + } + } + } + if let Some(shell) = state.shell.as_ref() { + supervisors.spawn_due( + std::time::Instant::now(), + endpoint::EndpointConnectOptions { + cols: state.reported_size.0, + rows: state.reported_size.1, + cell_width_px: state.reported_cell_size.0, + cell_height_px: state.reported_cell_size.1, + pixel_geometry_exact: state.pixel_geometry_exact, + surface_size: shell.surface_size(state.reported_size.0, state.reported_size.1), + endpoint_keybindings: config.endpoint_keybindings, + mouse_capture: state.shell_mouse_capture_preference, + }, + &supervisor_tx, + ); + } let timer_delay = state .shell .as_ref() @@ -893,23 +682,34 @@ async fn run_client_loop( shell.timer_delay(std::time::Instant::now()) }); let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); + let immediate_event = scheduled_activation.take(); #[cfg(windows)] - let event = tokio::select! { - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = stdin_rx.recv(), if stdin_open => match ev { - Some(event) => event, - None => { - stdin_open = false; - ClientLoopEvent::Timer - } - }, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = stdin_rx.recv(), if stdin_open => match ev { + Some(event) => event, + None => { + stdin_open = false; + ClientLoopEvent::Timer + } + }, + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + } }; #[cfg(unix)] - let event = tokio::select! { - biased; - _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, - ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + let event = if let Some(event) = immediate_event { + event + } else { + tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, + ev = supervisor_rx.recv() => ev.map(ClientLoopEvent::EndpointSupervisor).unwrap_or(ClientLoopEvent::Timer), + ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), + } }; let now = std::time::Instant::now(); if let Some(shell) = state.shell.as_mut() { @@ -917,8 +717,14 @@ async fn run_client_loop( } match event { + ClientLoopEvent::EndpointCatalog(reload) => pending_catalog = Some(reload), #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { + let image_bridge_active = endpoint_accepts_local_images( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { if will_query_host_cell_size { let events = crate::raw_input::parse_raw_input_bytes_sync(&data); @@ -934,7 +740,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -951,7 +757,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_terminal_drop(&data, is_remote_client) + read_image_file_from_terminal_drop(&data, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -976,8 +782,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1048,7 +856,7 @@ async fn run_client_loop( }; if should_bridge_clipboard_image_paste( &data, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1064,7 +872,8 @@ async fn run_client_loop( "clipboard image paste trigger received, but local clipboard has no image" ); } - if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { + if let Some(image) = read_image_file_from_terminal_drop(&data, image_bridge_active) + { write_remote_image_to_server( &mut write_stream, crate::protocol::ClientClipboardImageTarget::DirectTerminal, @@ -1080,11 +889,28 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { - let composed = state + let pending_key = state .pending_surface_graphics - .remove(&response.transfer_id) + .keys() + .find(|(_, transfer_id, image_id)| { + *transfer_id == response.transfer_id && *image_id == response.image_id + }) + .cloned(); + let owner = pending_key + .as_ref() + .map(|(endpoint_id, _, _)| endpoint_id.clone()); + let composed = pending_key + .and_then(|key| { + state + .pending_surface_graphics + .remove(&key) + .map(|asset| (key, asset)) + }) .filter(|_| response.success) - .and_then(|asset| { + .and_then(|((endpoint_id, _, _), asset)| { + if write_stream.active_id() != &endpoint_id { + return None; + } let shell = state.shell.as_mut()?; shell .trust_direct_graphics_asset(&asset, response.image_id) @@ -1096,8 +922,8 @@ async fn run_client_loop( image_id: response.image_id, success: response.success, }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if let Some(owner) = owner { + write_stream.send_to(&owner, &message); } if let Some(frame) = composed { state.present_frame(frame); @@ -1120,8 +946,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1159,6 +987,11 @@ async fn run_client_loop( } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { + let image_bridge_active = endpoint_accepts_local_images( + is_remote_client, + write_stream.active_id(), + write_stream.active_surface_available(), + ); if state.shell.is_some() { let image_target = state .shell @@ -1167,7 +1000,7 @@ async fn run_client_loop( if let Some(target) = image_target.clone() { if should_bridge_clipboard_image_events( &events, - is_remote_client, + image_bridge_active, state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { @@ -1184,7 +1017,7 @@ async fn run_client_loop( ); } if let Some(image) = - read_image_file_from_client_events(&events, is_remote_client) + read_image_file_from_client_events(&events, image_bridge_active) { write_remote_image_to_server( &mut write_stream, @@ -1209,8 +1042,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1262,181 +1097,291 @@ async fn run_client_loop( pixel_mouse: pixel_geometry_exact, } }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(msg, &mut write_stream) { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + error, + false, + ); + } + } else if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::ServerMessage(msg) => match *msg { - ServerMessage::ClientShellSnapshot(_) => { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - "server sent an unnegotiated binary endpoint snapshot", - ), - ))); + ClientLoopEvent::EndpointSupervisor(event) => match event { + endpoint::EndpointSupervisorEvent::Status { + endpoint_id, + generation, + status, + message, + } => { + if !supervisors.record_status(&endpoint_id, generation, status, now) { + continue; + } + if status == endpoint::ClientEndpointStatus::Attention { + warn!(endpoint = %endpoint_id.storage_key(), generation, error = %message, "endpoint needs attention"); + } + let unavailable = state.shell.as_mut().and_then(|shell| { + shell.set_endpoint_status(&endpoint_id, status); + (status == endpoint::ClientEndpointStatus::Attention + && shell.endpoint_is_active(&endpoint_id)) + .then(|| format!("{}: {message}", shell.endpoint_label(&endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(&mut state, message); + } else if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } } - ServerMessage::PaneSurface(surface) => { - let composed = if let Some(shell) = &mut state.shell { - shell.set_pane_surface(surface); + endpoint::EndpointSupervisorEvent::Connected { + endpoint_id, + generation, + reader, + writer, + negotiation, + } => { + if !supervisors.record_status( + &endpoint_id, + generation, + endpoint::ClientEndpointStatus::Online, + now, + ) { + continue; + } + let frame = state.shell.as_mut().and_then(|shell| { + shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods())); shell.compose(state.reported_size.0, state.reported_size.1) - } else { - None - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(frame) = composed { + }); + let reader_quit = writer.stop_handle(); + write_stream.insert( + endpoint_id.clone(), + writer, + generation, + negotiation, + false, + ); + if let Some(frame) = frame { state.present_frame(frame); } + let reader_tx = event_tx.clone(); + std::thread::spawn(move || { + server_reader_thread( + reader, + reader_tx, + &reader_quit, + MAX_GRAPHICS_FRAME_SIZE, + endpoint_id, + generation, + ); + }); } - ServerMessage::PaneSurfacePatch(patch) => { - let patch_started = crate::render_prof::timer(); - let apply_started = crate::render_prof::timer(); - let outcome = state - .shell - .as_mut() - .map(|shell| shell.apply_pane_surface_patch(patch)); - crate::render_prof::duration_since("client_surface_patch.apply", apply_started); - let compose_fallback = match outcome { - Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { - match state.present_surface_patch(patch) { - Ok(presented) => !presented, - Err(error) => { - warn!(%error, "failed to present retained pane surface patch"); - state.request_repaint(); - false + }, + ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force, + } => { + if !endpoint_catalog.select_endpoint(&endpoint_id) { + continue; + } + if let Err(error) = endpoint_catalog.store_selection() { + warn!(%error, "failed to persist desired endpoint selection"); + } + begin_endpoint_activation( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut pending_activation, + &mut next_surface_serial, + endpoint_id, + target, + force, + now, + &event_tx, + )?; + } + ClientLoopEvent::ServerMessage { + endpoint_id, + generation, + message, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; + } + write_stream.received(&endpoint_id, generation, now); + let endpoint_active = write_stream.active_id() == &endpoint_id + && write_stream + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + let activation_message = pending_activation + .as_ref() + .is_some_and(|pending| pending.accepts_endpoint(&endpoint_id, generation)); + let command_response = match message.as_ref() { + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + .. + } => endpoint_commands.accepts_response( + &endpoint_id, + generation, + boot_id, + request_id, + ), + _ => false, + }; + if !endpoint::accepts_endpoint_message( + endpoint_active, + activation_message, + command_response, + message.as_ref(), + ) { + continue; + } + // Target presentation effects may arrive as soon as surface.set(true) is + // acknowledged. They cannot be applied while the source frame is frozen; the + // target receives one explicit replay after the coherent commit instead. + if state.presentation_frozen + && activation_message + && endpoint::is_presentation_effect(message.as_ref()) + { + continue; + } + match *message { + ServerMessage::ClientShellSnapshot(_) => { + let message = "server sent an unnegotiated binary endpoint snapshot"; + if federated || !endpoint_id.is_local() { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message.into(), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + ServerMessage::PaneSurface(surface) => { + if activation_message { + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_surface(&endpoint_id, generation, surface) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); } } + continue; } - Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, - Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if compose_fallback { - let composed = state.shell.as_mut().and_then(|shell| { + if !endpoint_active { + continue; + } + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); shell.compose(state.reported_size.0, state.reported_size.1) - }); + } else { + None + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); if let Some(frame) = composed { state.present_frame(frame); } } - crate::render_prof::duration_since("client_surface_patch.total", patch_started); - crate::render_prof::flush_if_due(); - } - ServerMessage::Terminal(frame) => { - if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { - record_received_kitty_graphics(&frame.bytes); + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since( + "client_surface_patch.apply", + apply_started, + ); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes( + &mut state, + &mut prefix_input_source, + ); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since( + "client_surface_patch.total", + patch_started, + ); + crate::render_prof::flush_if_due(); } - let mut stdout = io::stdout(); - let _ = stdout.write_all(&frame.bytes); - let _ = stdout.flush(); - } - ServerMessage::Graphics { bytes } => { - if state.kitty_graphics_enabled { - record_received_kitty_graphics(&bytes); + ServerMessage::Terminal(frame) => { + if state.kitty_graphics_enabled + && contains_kitty_graphics_bytes(&frame.bytes) + { + record_received_kitty_graphics(&frame.bytes); + } let mut stdout = io::stdout(); - let _ = stdout.write_all(&bytes); + let _ = stdout.write_all(&frame.bytes); let _ = stdout.flush(); } - } - ServerMessage::TerminalBell { count } => { - if let Err(err) = - crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) - { - warn!(err = %err, "failed to emit terminal bell"); - } - } - ServerMessage::GraphicsFile { - path, - expected_len, - image_id, - transfer_id, - leading, - control, - surface_asset, - } => { - #[cfg(unix)] - { - if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { - continue; - } - let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { - (Some(shell), Some(asset)) => { - crate::kitty_graphics::surface::host_image_id( - shell.graphics_scope(), - asset, - ) == image_id - } - (None, None) => true, - _ => false, - }; - let valid = state.kitty_graphics_enabled - && surface_asset_valid - && usize::try_from(expected_len).ok().is_some_and(|len| { - crate::pane_graphics_files::validate_direct_source( - std::path::Path::new(&path), - len, - ) - .is_ok() - && direct_graphics::valid_control(&control, image_id, len) - }) - && state - .direct_graphics_response - .lock() - .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); - let sent = if valid { - let mut command = Vec::new(); - crate::kitty_graphics::encode_kitty_regular_file( - &mut command, - &leading, - &control, - &path, - ); + ServerMessage::Graphics { bytes } => { + if state.kitty_graphics_enabled { + record_received_kitty_graphics(&bytes); let mut stdout = io::stdout(); - let written = stdout - .write_all(&command) - .and_then(|()| stdout.flush()) - .is_ok(); - if written { - record_received_kitty_graphics(&command); - } - written - } else { - false - }; - if sent { - if let Some(asset) = surface_asset { - state.pending_surface_graphics.insert(transfer_id, asset); - } - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.start(transfer_id); - } - let started = ClientMessage::GraphicsTransmissionStarted { - transfer_id, - image_id, - }; - if let Err(err) = write_to_server(&mut write_stream, &started) { - return Err(ClientError::ConnectionLost(err)); - } - } else { - state.pending_surface_graphics.remove(&transfer_id); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - if valid { - matcher.retire(transfer_id); - } else { - matcher.cancel(transfer_id); - } - } - let result = ClientMessage::GraphicsTransmissionResult { - transfer_id, - image_id, - success: false, - }; - if let Err(err) = write_to_server(&mut write_stream, &result) { - return Err(ClientError::ConnectionLost(err)); - } + let _ = stdout.write_all(&bytes); + let _ = stdout.flush(); + } + } + ServerMessage::TerminalBell { count } => { + if let Err(err) = + crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) + { + warn!(err = %err, "failed to emit terminal bell"); } } - #[cfg(not(unix))] - let _ = ( + ServerMessage::GraphicsFile { path, expected_len, image_id, @@ -1444,319 +1389,546 @@ async fn run_client_loop( leading, control, surface_asset, - ); - } - ServerMessage::GraphicsTransmissionRetired { - transfer_id, - image_id, - } => { - #[cfg(unix)] - { - state.retired_direct_graphics = Some((transfer_id, image_id)); - state.pending_surface_graphics.remove(&transfer_id); - let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { - shell.retire_direct_graphics_image(image_id); - shell - .compose(state.reported_size.0, state.reported_size.1) - .map(|frame| frame.graphics) - .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) - }); - state.present_graphics(&cleanup); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.retire(transfer_id); + } => { + #[cfg(unix)] + { + if state.retired_direct_graphics.take() + == Some((endpoint_id.clone(), transfer_id, image_id)) + { + continue; + } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; + let valid = state.kitty_graphics_enabled + && surface_asset_valid + && usize::try_from(expected_len).ok().is_some_and(|len| { + crate::pane_graphics_files::validate_direct_source( + std::path::Path::new(&path), + len, + ) + .is_ok() + && direct_graphics::valid_control(&control, image_id, len) + }) + && state + .direct_graphics_response + .lock() + .is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id)); + let sent = if valid { + let mut command = Vec::new(); + crate::kitty_graphics::encode_kitty_regular_file( + &mut command, + &leading, + &control, + &path, + ); + let mut stdout = io::stdout(); + let written = stdout + .write_all(&command) + .and_then(|()| stdout.flush()) + .is_ok(); + if written { + record_received_kitty_graphics(&command); + } + written + } else { + false + }; + if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert( + (endpoint_id.clone(), transfer_id, image_id), + asset, + ); + } + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.start(transfer_id); + } + let started = ClientMessage::GraphicsTransmissionStarted { + transfer_id, + image_id, + }; + if let Err(err) = write_to_server(&mut write_stream, &started) { + return Err(ClientError::ConnectionLost(err)); + } + } else { + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + if valid { + matcher.retire(transfer_id); + } else { + matcher.cancel(transfer_id); + } + } + let result = ClientMessage::GraphicsTransmissionResult { + transfer_id, + image_id, + success: false, + }; + if let Err(err) = write_to_server(&mut write_stream, &result) { + return Err(ClientError::ConnectionLost(err)); + } + } } + #[cfg(not(unix))] + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } - #[cfg(not(unix))] - let _ = (transfer_id, image_id); - } - ServerMessage::ServerShutdown { reason } => { - return Err(ClientError::ServerShutdown { reason }); - } - ServerMessage::Notify { - kind, - message, - body, - } => { - if state.shell.is_none() { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + } => { + #[cfg(unix)] + { + state.retired_direct_graphics = + Some((endpoint_id.clone(), transfer_id, image_id)); + state.pending_surface_graphics.remove(&( + endpoint_id.clone(), + transfer_id, + image_id, + )); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); + if let Ok(mut matcher) = state.direct_graphics_response.lock() { + matcher.retire(transfer_id); + } + } + #[cfg(not(unix))] + let _ = (transfer_id, image_id); } - } - ServerMessage::SemanticNotification(event) => { - if state.shell.is_some() { - let (effects, frame) = { - let shell = state.shell.as_mut().expect("checked shell mode"); - let (effects, repaint) = - shell.receive_notification(event, std::time::Instant::now()); - let frame = repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (effects, frame) - }; - handle_shell_notification_effects(effects, &state.sound_config); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::ServerShutdown { reason } => { + if !federated && endpoint_id.is_local() { + return Err(ClientError::ServerShutdown { reason }); } + write_stream.fail( + &endpoint_id, + io::Error::new( + io::ErrorKind::ConnectionAborted, + reason.unwrap_or_else(|| "server stopped".into()), + ), + ); } - } - ServerMessage::ClientShellError { message } => { - if let Some(shell) = state.shell.as_mut() { - if shell.receive_endpoint_error(message) { - let frame = shell.compose(state.reported_size.0, state.reported_size.1); + ServerMessage::Notify { + kind, + message, + body, + } => { + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = shell.receive_notification( + &endpoint_id, + event, + std::time::Instant::now(), + ); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); if let Some(frame) = frame { state.present_frame(frame); } } } - } - ServerMessage::ClientShellEndpointResponseChunk { - boot_id, - request_id, - final_chunk, - data, - } => { - let completed = endpoint_commands - .receive_chunk(&boot_id, &request_id, final_chunk, data) - .map_err(ClientError::ConnectionLost)?; - let Some(completed) = completed else { - continue; - }; - let (repaint, actions) = state.shell.as_mut().map_or_else( - || (false, Vec::new()), - |shell| { - shell.handle_endpoint_result( - &completed.boot_id, - &completed.request_id, - completed.result, - ) - }, - ); - if let Some(shell) = state.shell.as_mut() { - shell.reconcile_input_source(); + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = + shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } } - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - let replay_mouse = dispatch_client_shell_actions( - actions, - &mut endpoint_commands, - &mut write_stream, - &mut state.detached_process_children, - )?; - if replay_mouse.is_empty() { - if repaint { - if let Some(frame) = state.shell.as_mut().and_then(|shell| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) { - state.present_frame(frame); + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + if pending_activation.as_ref().is_some_and(|pending| { + pending.accepts_response( + &endpoint_id, + generation, + &boot_id, + &request_id, + ) + }) { + if !final_chunk { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + "endpoint returned a chunked activation acknowledgement".into(), + false, + ); + continue; + } + let progress = pending_activation.as_mut().map(|pending| { + pending.receive_response_for_boot( + &endpoint_id, + generation, + &boot_id, + &request_id, + &data, + &mut write_stream, + ) + }); + match progress { + Some(endpoint::SurfaceActivationProgress::Ready) => { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + Some(endpoint::SurfaceActivationProgress::Rejected { + message, + source_release_rejected, + }) => { + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + message, + source_release_rejected, + ); + } + _ => {} } + continue; } - } else { - let (outcome, frame) = { - let shell = state.shell.as_mut().expect("shell endpoint response"); - let mut outcome = shell.replay_mouse_events(replay_mouse); - outcome.repaint |= repaint; - let frame = outcome - .repaint - .then(|| { - shell.compose(state.reported_size.0, state.reported_size.1) - }) - .flatten(); - (outcome, frame) + if request_id.starts_with("client-shell-surface:") { + continue; + } + let completed = endpoint_commands + .receive_chunk( + &endpoint_id, + generation, + &boot_id, + &request_id, + final_chunk, + data, + ) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; }; - if finish_client_shell_input( + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + if completed.generation == generation + && shell.endpoint_is_active(&completed.endpoint_id) + { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + } else { + ( + shell.cancel_endpoint_request(&completed.request_id), + Vec::new(), + ) + } + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes( &mut state, - outcome, - frame, - &mut write_stream, - &mut endpoint_commands, &mut prefix_input_source, - )? { - return Ok(()); + ); + let (replay_mouse, dispatch_repaint) = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + state.shell.as_mut(), + &mut state.detached_process_children, + &event_tx, + )?; + let repaint = repaint || dispatch_repaint; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &event_tx, + )? { + return Ok(()); + } } } - } - ServerMessage::Clipboard { data } => { - if forward_clipboard(&data) { - let (width, height) = state.reported_size; - let frame = state.shell.as_mut().and_then(|shell| { - shell - .show_copy_feedback(std::time::Instant::now()) - .then(|| shell.compose(width, height)) - .flatten() - }); - if let Some(frame) = frame { - state.present_frame(frame); + ServerMessage::Clipboard { data } => { + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } } + let _ = io::stdout().flush(); } - let _ = io::stdout().flush(); - } - ServerMessage::WindowTitle { title } => { - let _ = crate::terminal_effects::write_window_title( - &mut io::stdout(), - title.as_deref(), - ); - } - ServerMessage::ReloadSoundConfig => { - let previous_mouse_capture = state.shell_mouse_capture_preference; - let mut mouse_capture = previous_mouse_capture; - reload_local_client_config( - &mut state.sound_config, - &mut state.redraw_on_focus_gained, - &mut state.draw_host_cursor, - &mut state.remote_image_paste_key, - &mut mouse_capture, - ); - state.shell_mouse_capture_preference = mouse_capture; - state.direct_mouse_capture_preference = - state.attach_escape.is_some() && mouse_capture; - if state.shell.is_some() && previous_mouse_capture != mouse_capture { - write_to_server( - &mut write_stream, - &ClientMessage::ClientShellMouseCapture { - enabled: mouse_capture, - }, - ) - .map_err(ClientError::ConnectionLost)?; - } - if state.attach_escape.is_some() { - let enabled = effective_mouse_capture( - state.endpoint_mouse_capture_requested, - state.direct_mouse_capture_preference, + ServerMessage::WindowTitle { title } => { + let _ = crate::terminal_effects::write_window_title( + &mut io::stdout(), + title.as_deref(), ); - let sgr_pixels = effective_sgr_pixel_mouse( + } + ServerMessage::ReloadSoundConfig => apply_reload( + &mut state, + &mut write_stream, + &mut pending_activation, + &host_mouse_capture_active, + &host_sgr_pixels_active, + &mut prefix_input_source, + )?, + ServerMessage::MouseCapture { + enabled, + sgr_pixels, + } => { + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = effective_sgr_pixel_mouse( enabled, - state.endpoint_sgr_pixels_requested, + sgr_pixels, state.pixel_geometry_exact, ); - if enabled != state.mouse_capture_active - || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) - { - set_mouse_capture(enabled, sgr_pixels) + let mouse_mode_changed = enabled != state.mouse_capture_active + || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); + if mouse_mode_changed { + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } + set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { + let _ = enable_windows_virtual_terminal_input(); + } } state.mouse_capture_active = enabled; host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - let (frame, resize) = if let Some(shell) = state.shell.as_mut() { - let previous_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - shell.reload_client_config(); - let next_size = - shell.surface_size(state.reported_size.0, state.reported_size.1); - let resize = (previous_size != next_size).then(|| { - shell.invalidate_pane_surface(); - client_shell_resize_message( - shell, - state.reported_size.0, - state.reported_size.1, - state.reported_cell_size.0, - state.reported_cell_size.1, - state.pixel_geometry_exact, + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) - }); - ( - shell.compose(state.reported_size.0, state.reported_size.1), - resize, - ) - } else { - (None, None) - }; - apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); - if let Some(resize) = resize { - write_to_server(&mut write_stream, &resize) - .map_err(ClientError::ConnectionLost)?; - } - if let Some(frame) = frame { - state.present_frame(frame); - } - } - ServerMessage::MouseCapture { - enabled, - sgr_pixels, - } => { - state.endpoint_mouse_capture_requested = enabled; - state.endpoint_sgr_pixels_requested = sgr_pixels; - let enabled = - effective_mouse_capture(enabled, state.direct_mouse_capture_preference); - let next_sgr_pixels = - effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); - let mouse_mode_changed = enabled != state.mouse_capture_active - || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); - if mouse_mode_changed { - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); - } - set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); } } - state.mouse_capture_active = enabled; - host_mouse_capture_active.store(enabled, Ordering::Release); - host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); - } - ServerMessage::DirectTerminalKeyboardProtocol { - flags, - modify_other_keys_level, - } => { - if state.attach_escape.is_some() { - crate::terminal_modes::set_direct_host_keyboard_protocol( - &mut io::stdout(), - &mut state.direct_keyboard_protocol, - flags, - modify_other_keys_level, - ) - .map_err(ClientError::ConnectionFailed)?; - } - } - ServerMessage::ClientShellKeyboardReportAll { enabled } => { - if state.shell.is_some() { - state.pane_keyboard_report_all = enabled; - sync_client_shell_keyboard_report_all(&mut state)?; + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; + } } - } - ServerMessage::EndpointControl { kind, data } => { - if kind != crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { - if kind.starts_with("shell.snapshot.") { - return Err(ClientError::Protocol(protocol::FramingError::Io( - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "unsupported mandatory endpoint snapshot codec {kind:?}" - ), - ), - ))); + ServerMessage::EndpointControl { kind, data } => { + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND { + let progress = pending_activation.as_mut().map(|activation| { + activation.receive_presentation_effects_ready( + &endpoint_id, + generation, + &data, + ) + }); + if matches!(progress, Some(endpoint::SurfaceActivationProgress::Ready)) + { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + continue; + } + let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { + Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::Ignored) => { + debug!(%kind, "ignoring unknown endpoint control message"); + continue; + } + Ok(endpoint::EndpointControlMessage::Snapshot(snapshot)) => snapshot, + Err(message) + if federated + || !endpoint::protocol_failure_is_fatal(&endpoint_id) => + { + if handle_endpoint_attention( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &endpoint_id, + generation, + now, + message, + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + continue; + } + Err(message) => { + return Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, message), + ))); + } + }; + let projection_pending = activation_message; + let activation_progress = activation_message + .then(|| { + pending_activation.as_mut().map(|pending| { + pending.receive_snapshot(&endpoint_id, generation, &snapshot) + }) + }) + .flatten(); + install_client_shell_snapshot( + &mut state, + &endpoint_id, + snapshot, + projection_pending, + &mut write_stream, + &mut prefix_input_source, + )?; + if matches!( + activation_progress, + Some(endpoint::SurfaceActivationProgress::Ready) + ) { + if let Some(event) = complete_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + )? { + scheduled_activation = Some(event); + } + } + write_stream.mark_ready(&endpoint_id, generation); + let selected_endpoint = endpoint_catalog + .selected_profile + .as_ref() + .map_or(endpoint::ClientEndpointId::Local, |profile_id| { + endpoint::ClientEndpointId::Ssh(profile_id.clone()) + }); + let activation_ready = state.shell.as_ref().is_some_and(|shell| { + shell.endpoint_has_snapshot(&selected_endpoint) + && (!write_stream + .connection(write_stream.active_id()) + .is_some_and(|connection| connection.surface_active) + || shell.endpoint_boot_id(write_stream.active_id()).is_some()) + }); + let needs_surface = write_stream + .connection(&selected_endpoint) + .is_some_and(|connection| !connection.surface_active); + if activation_ready && needs_surface && pending_activation.is_none() { + scheduled_activation = Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: selected_endpoint, + target: None, + force: false, + }); } - debug!(%kind, "ignoring unknown endpoint control message"); - continue; } - let snapshot: crate::protocol::ClientShellSnapshot = - serde_json::from_str(&data).map_err(|error| { - ClientError::Protocol(protocol::FramingError::Io(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid endpoint snapshot: {error}"), - ))) - })?; - install_client_shell_snapshot( - &mut state, - Box::new(snapshot), - &mut write_stream, - &mut prefix_input_source, - )?; + ServerMessage::Welcome { .. } => { + debug!("received unexpected Welcome in main loop"); + } } - ServerMessage::Welcome { .. } => { - debug!("received unexpected Welcome in main loop"); + } + ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + } => { + if !write_stream.accepts(&endpoint_id, generation) { + continue; } - }, - ClientLoopEvent::ServerDisconnected => { - return Err(ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - ))); + write_stream.fail( + &endpoint_id, + io::Error::new(io::ErrorKind::UnexpectedEof, "connection was lost"), + ); } ClientLoopEvent::Timer => { client_timer.fired(); @@ -1767,12 +1939,80 @@ async fn run_client_loop( state .detached_process_children .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + write_stream.tick_health(now); + for failure in write_stream.take_failures() { + if write_stream.connection(&failure.endpoint_id).is_some() + && !write_stream.accepts(&failure.endpoint_id, failure.generation) + { + continue; + } + warn!( + endpoint = %failure.endpoint_id.storage_key(), + error = %failure.message, + "endpoint transport failed" + ); + if !federated && failure.endpoint_id.is_local() { + return Err(ClientError::ConnectionLost(io::Error::new( + failure.kind, + failure.message, + ))); + } + if handle_endpoint_disconnect( + &mut state, + &mut write_stream, + &mut endpoint_commands, + &mut supervisors, + &mut pending_activation, + &failure.endpoint_id, + failure.generation, + now, + &format!("{}; reconnecting", failure.message), + ) { + clear_endpoint_host_effects( + &mut state, + &host_mouse_capture_active, + &host_sgr_pixels_active, + ); + } + } + // A revoked transport changes the safe rollback destination. Handle those + // failures before applying a timeout to the remaining activation phase. + if pending_activation + .as_ref() + .is_some_and(|activation| activation.expired(now)) + { + let endpoint_id = pending_activation + .as_ref() + .map(|activation| activation.target().clone()) + .expect("checked pending activation"); + let label = state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| "Endpoint".into()); + rollback_endpoint_activation( + &mut state, + &mut write_stream, + &mut pending_activation, + format!("{label} did not produce a coherent surface in time"), + false, + ); + } if state.shell.is_some() { - let expired_endpoint = endpoint_commands.expire(now); + let expired_endpoints = endpoint_commands + .expire(now) + .into_iter() + .filter(|expired| { + write_stream.accepts(&expired.endpoint_id, expired.generation) + }) + .collect::<Vec<_>>(); let (effects, outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); let mut outcome = shell.tick_selection_autoscroll(now); - if let Some(expired) = expired_endpoint { + for expired in expired_endpoints { + if !shell.endpoint_is_active(&expired.endpoint_id) { + continue; + } let (repaint, actions) = shell.handle_endpoint_result( &expired.boot_id, &expired.request_id, @@ -1795,8 +2035,10 @@ async fn run_client_loop( outcome, frame, &mut write_stream, + &mut pending_activation, &mut endpoint_commands, &mut prefix_input_source, + &event_tx, )? { return Ok(()); } @@ -1813,141 +2055,5 @@ async fn run_client_loop( Ok(()) } -// --------------------------------------------------------------------------- -// Server reader thread -// --------------------------------------------------------------------------- - -/// Blocking thread that reads ServerMessages from the server and sends them -/// to the main event loop. -fn server_reader_thread( - mut stream: LocalStream, - event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, - should_quit: &Arc<AtomicBool>, - max_frame_size: usize, -) { - // Ensure the read stream is in blocking mode to avoid WouldBlock errors - // from read_exact inside read_message. The stream should already be - // blocking after handshake, but we enforce it here as a safety measure. - if stream.set_nonblocking(false).is_err() { - // If we can't set blocking mode, the stream is likely broken. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - return; - } - - loop { - if should_quit.load(Ordering::Acquire) { - break; - } - - match protocol::read_message(&mut stream, max_frame_size) { - Ok(msg) => { - if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) - .is_err() - { - break; // Main loop gone. - } - } - Err(protocol::FramingError::UnexpectedEof) => { - // Server closed connection. - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { - // Should not happen with blocking mode, but handle gracefully - // in case the stream was set nonblocking by another clone. - std::thread::sleep(Duration::from_millis(1)); - continue; - } - Err(err) => { - warn!(err = %err, "server read error"); - let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected); - break; - } - } - } -} - -// --------------------------------------------------------------------------- -// Write helper -// --------------------------------------------------------------------------- - -/// Writes a message to the server stream (blocking). -fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> { - protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) -} - -// --------------------------------------------------------------------------- -// Notifications -// --------------------------------------------------------------------------- - -fn reload_local_client_config( - sound_config: &mut crate::config::SoundConfig, - redraw_on_focus_gained: &mut bool, - draw_host_cursor: &mut bool, - remote_image_paste_key: &mut Option<( - crossterm::event::KeyCode, - crossterm::event::KeyModifiers, - )>, - mouse_capture: &mut bool, -) { - match crate::config::load_live_config() { - Ok(loaded) => { - let invalid_section = |section: &str| { - loaded - .invalid_sections - .iter() - .any(|invalid| invalid == section) - }; - if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() - { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); - } - *sound_config = loaded.config.ui.sound.clone(); - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *mouse_capture = loaded.config.ui.mouse_capture; - } - if !invalid_section("keys") { - *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - } - debug!("reloaded local client config"); - } - Err(diagnostics) => { - warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config"); - } - } -} - -// --------------------------------------------------------------------------- -// Clipboard forwarding -// --------------------------------------------------------------------------- - -/// Decode a clipboard payload forwarded by the server. -fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(data).ok() -} - -/// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) -> bool { - let Some(bytes) = decode_clipboard_payload(data) else { - warn!("received invalid clipboard payload from server"); - return false; - }; - - crate::selection::write_osc52_bytes(&bytes); - true -} - -fn init_logging() { - crate::logging::init_file_logging("herdr-client.log"); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests; diff --git a/src/client/shell.rs b/src/client/shell.rs index 9a877744..14f4f660 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -2,15 +2,25 @@ use std::collections::{HashMap, HashSet, VecDeque}; mod actions; mod agent_sidebar; +mod aggregate_navigation; mod composition; mod config; mod context_menu; mod copy_mode; +mod endpoint_agent_state; +mod endpoint_agents; +mod endpoint_navigation; +mod endpoint_notices; +mod endpoint_sidebar; +mod endpoints; +pub(super) use endpoints::*; mod global_menu; mod graphics; mod input; +mod input_source; mod mobile; mod mouse; +mod notification_policy; mod notifications; mod overlay_input; mod preferences; @@ -21,6 +31,7 @@ mod state; mod surface_patch; mod worktrees; +pub(in crate::client::shell) use render::sidebar; pub(crate) use state::*; #[cfg(test)] pub(super) use surface_patch::apply_composed_surface_patch; @@ -34,6 +45,7 @@ use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; +use super::endpoint::{ClientEndpointId, ClientEndpointStatus, SavedSshEndpoint}; use crate::app::state::Palette; use crate::config::{ Config, LiveKeybindConfig, SidebarCollapsedModeConfig, SpacesSidebarConfig, diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 1ebd4e29..f99d8f3a 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -147,6 +147,9 @@ impl ClientShellState { } return; } + if self.handle_endpoint_navigation(action, outcome) { + return; + } if let Some(method) = self.endpoint_method_for_action(action) { self.push_endpoint_method(method, outcome); return; @@ -408,7 +411,9 @@ impl ClientShellState { kind: PendingEndpointKind, outcome: &mut ClientShellInput, ) -> bool { - if self.snapshot.is_none() { + if !self.endpoint_is_online(&self.active_endpoint_id) { + let label = self.active_endpoint_label().to_owned(); + outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready")); return false; } let method_name = crate::api::api_method_name(&method).to_owned(); @@ -423,7 +428,9 @@ impl ClientShellState { ); return false; } - let snapshot = self.snapshot.as_deref().expect("checked snapshot"); + let Some(snapshot) = self.snapshot.as_deref() else { + return false; + }; let confirmation_workspace_id = match &method { crate::api::schema::Method::TabClose(target) => snapshot .tabs @@ -450,6 +457,7 @@ impl ClientShellState { }, ); outcome.actions.push(ClientShellAction::Endpoint { + endpoint_id: self.active_endpoint_id.clone(), boot_id: snapshot.boot_id.clone(), request: Box::new(crate::api::schema::Request { id: request_id, @@ -468,6 +476,58 @@ impl ClientShellState { ) } + pub(crate) fn receive_endpoint_unavailable(&mut self, message: String) -> bool { + self.push_endpoint_notice( + ClientEndpointNoticeKind::Unavailable, + message.clone(), + "Endpoint unavailable", + message, + ) + } + + pub(crate) fn focus_endpoint_target( + &mut self, + target: ClientEndpointFocusTarget, + ) -> Vec<ClientShellAction> { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } + }; + let mut outcome = ClientShellInput::default(); + self.push_endpoint_method(method, &mut outcome); + outcome.actions + } + + pub(crate) fn cancel_endpoint_request(&mut self, request_id: &str) -> bool { + let Some(pending) = self.pending_requests.get(request_id) else { + return false; + }; + let boot_id = pending.boot_id.clone(); + let (repaint, actions) = self.handle_endpoint_result( + &boot_id, + request_id, + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "This server action was interrupted. Check its state before retrying." + .into(), + }), + ); + debug_assert!( + actions.is_empty(), + "cancellation must not start another action" + ); + repaint + } + pub(crate) fn handle_endpoint_result( &mut self, boot_id: &str, @@ -506,6 +566,12 @@ impl ClientShellState { "Server timed out", format!("This server did not respond to {}.", pending.method_name), ), + "endpoint_cancelled" => ( + ClientEndpointNoticeKind::Unavailable, + "cancelled".to_owned(), + "Action interrupted", + error.message.clone(), + ), "server_unavailable" => ( ClientEndpointNoticeKind::Unavailable, "server".to_owned(), @@ -613,6 +679,9 @@ impl ClientShellState { Some("endpoint returned an unexpected selection result".to_owned()); (true, fallback()) } + Err(error) if error.code.as_deref() == Some("endpoint_cancelled") => { + (true, Vec::new()) + } Err(_) => (true, fallback()), }; } @@ -724,7 +793,7 @@ impl ClientShellState { Err(error) if matches!( error.code.as_deref(), - Some("stale_content" | "stale_target") + Some("stale_content" | "stale_target" | "endpoint_cancelled") ) => { self.url_click_consumes_until_up = completed_before_release; diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index 8f87b6ad..d6fe3352 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -10,11 +10,11 @@ use ratatui::{ use super::*; -struct AgentRow { - pane_id: String, - status: crate::api::schema::AgentStatus, - focused: bool, - rows: Vec<Vec<crate::ui::ResolvedToken>>, +pub(super) struct AgentRow { + pub(super) pane_id: String, + pub(super) status: crate::api::schema::AgentStatus, + pub(super) focused: bool, + pub(super) rows: Vec<Vec<crate::ui::ResolvedToken>>, } pub(super) fn ordered_agent_pane_ids( @@ -57,9 +57,46 @@ pub(super) fn render_agent_panel( agent_scroll: &mut usize, hits: &mut ShellHitMap, ) { - if area.height == 0 { + if !render_agent_panel_header( + buffer, + area, + snapshot.agent_view_label.as_deref(), + config, + hits, + ) { return; } + + let rows = agent_rows(snapshot, config, None); + render_agent_list( + buffer, + area, + &rows, + snapshot + .agent_view_label + .as_ref() + .map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.rows.len(), + |buffer, rect, row, hits| { + hits.agents.push((rect, row.pane_id.clone())); + render_agent_row(buffer, rect, row, config); + }, + ); +} + +pub(super) fn render_agent_panel_header( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) -> bool { + if area.height == 0 { + return false; + } put_text( buffer, area.x, @@ -69,7 +106,7 @@ pub(super) fn render_agent_panel( Style::default().fg(config.palette.surface_dim), ); if area.height < 2 { - return; + return false; } put_text( buffer, @@ -81,14 +118,10 @@ pub(super) fn render_agent_panel( .fg(config.palette.overlay0) .add_modifier(Modifier::BOLD), ); - let sort_label = - snapshot - .agent_view_label - .as_deref() - .unwrap_or(match config.agent_panel_sort { - crate::config::AgentPanelSortConfig::Spaces => "grouped", - crate::config::AgentPanelSortConfig::Priority => "priority", - }); + let sort_label = agent_view_label.unwrap_or(match config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => "grouped", + crate::config::AgentPanelSortConfig::Priority => "priority", + }); let sort_width = display_width(sort_label).min(area.width as usize) as u16; let sort_rect = Rect::new( area.right().saturating_sub(sort_width), @@ -96,7 +129,7 @@ pub(super) fn render_agent_panel( sort_width, 1, ); - hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { + hits.agent_sort_toggle = if config.mouse_capture && agent_view_label.is_none() { sort_rect } else { Rect::default() @@ -108,15 +141,27 @@ pub(super) fn render_agent_panel( sort_rect.width, sort_label, Style::default() - .fg(if snapshot.agent_view_label.is_some() { + .fg(if agent_view_label.is_some() { config.palette.accent } else { config.palette.overlay0 }) .add_modifier(Modifier::BOLD), ); + true +} - let rows = agent_rows(snapshot, config); +pub(super) fn render_agent_list<T>( + buffer: &mut Buffer, + area: Rect, + rows: &[T], + empty_message: Option<&str>, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, + row_lines: impl Fn(&T) -> usize, + mut render_row: impl FnMut(&mut Buffer, Rect, &T, &mut ShellHitMap), +) { let body = Rect::new( area.x, area.y.saturating_add(3), @@ -126,13 +171,13 @@ pub(super) fn render_agent_panel( hits.agent_body = body; if body.is_empty() || rows.is_empty() { *agent_scroll = 0; - if !body.is_empty() && snapshot.agent_view_label.is_some() { + if let Some(message) = empty_message.filter(|_| !body.is_empty()) { put_text( buffer, body.x, body.y, body.width, - " no matching agents", + message, Style::default() .fg(config.palette.overlay0) .add_modifier(Modifier::DIM), @@ -143,7 +188,7 @@ pub(super) fn render_agent_panel( let row_heights = rows .iter() - .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) + .map(|row| row_lines(row).max(1).min(u16::MAX as usize) as u16) .collect::<Vec<_>>(); let gaps = rows .iter() @@ -167,13 +212,12 @@ pub(super) fn render_agent_panel( let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); let mut y = body.y; for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { - let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + let height = row_heights[index].min(body.height); if y.saturating_add(height) > body.bottom() { break; } let rect = Rect::new(body.x, y, content_width, height); - hits.agents.push((rect, row.pane_id.clone())); - render_agent_row(buffer, rect, row, config); + render_row(buffer, rect, row, hits); y = y .saturating_add(height) .saturating_add(if index + 1 < rows.len() { @@ -190,7 +234,11 @@ pub(super) fn render_agent_panel( } } -fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec<AgentRow> { +pub(super) fn agent_rows( + snapshot: &ClientShellSnapshot, + config: &ClientShellConfig, + machine: Option<&str>, +) -> Vec<AgentRow> { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() .filter_map(|pane_id| { @@ -238,6 +286,7 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec let rows = crate::ui::sidebar_agent_rows( &config.agents, crate::ui::AgentTokenContext { + machine, workspace: &workspace.label, tab: tab_label, pane: agent @@ -262,7 +311,12 @@ fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec .collect() } -fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { +pub(super) fn render_agent_row( + buffer: &mut Buffer, + rect: Rect, + row: &AgentRow, + config: &ClientShellConfig, +) { let palette = &config.palette; let row_style = if row.focused { Style::default().bg(palette.active_row_bg) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs new file mode 100644 index 00000000..5c826bf3 --- /dev/null +++ b/src/client/shell/aggregate_navigation.rs @@ -0,0 +1,262 @@ +//! Endpoint-qualified rows shared by aggregate navigation surfaces. + +use super::*; +use crate::protocol::ClientShellAgent; + +#[derive(Clone, Copy)] +pub(super) struct CachedEndpointSnapshot<'a> { + pub(super) endpoint_id: &'a ClientEndpointId, + pub(super) label: &'a str, + pub(super) status: ClientEndpointStatus, + pub(super) snapshot: &'a ClientShellSnapshot, + pub(super) agent_recency: &'a HashMap<String, u64>, +} + +impl CachedEndpointSnapshot<'_> { + pub(super) fn stale(self) -> bool { + self.status != ClientEndpointStatus::Online + } +} + +pub(super) fn cached_endpoint_snapshots( + endpoints: &[ClientShellEndpoint], +) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> { + endpoints.iter().filter_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| CachedEndpointSnapshot { + endpoint_id: &endpoint.endpoint_id, + label: &endpoint.label, + status: endpoint.status, + snapshot, + agent_recency: &endpoint.agent_recency, + }) + }) +} + +pub(super) struct AggregateAgentRow<'a> { + pub(super) endpoint: CachedEndpointSnapshot<'a>, + pub(super) agent: &'a ClientShellAgent, + pub(super) recency: u64, +} + +pub(super) struct AggregateAgentTarget { + pub(super) endpoint_id: ClientEndpointId, + pub(super) pane_id: String, +} + +pub(super) fn aggregate_agent_rows( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentRow<'_>> { + let mut rows = cached_endpoint_snapshots(endpoints) + .flat_map(|endpoint| { + super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort) + .into_iter() + .filter_map(move |pane_id| { + let agent = endpoint + .snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + Some(AggregateAgentRow { + recency: endpoint + .agent_recency + .get(&pane_id) + .copied() + .unwrap_or_default(), + endpoint, + agent, + }) + }) + }) + .collect::<Vec<_>>(); + if sort == crate::config::AgentPanelSortConfig::Priority { + rows.sort_by_key(|row| { + ( + row.endpoint.stale(), + std::cmp::Reverse(status_priority(row.agent.agent_status)), + std::cmp::Reverse(row.recency), + ) + }); + } + rows +} + +pub(super) fn online_agent_targets( + endpoints: &[ClientShellEndpoint], + sort: crate::config::AgentPanelSortConfig, +) -> Vec<AggregateAgentTarget> { + aggregate_agent_rows(endpoints, sort) + .into_iter() + .filter(|row| !row.endpoint.stale()) + .map(|row| AggregateAgentTarget { + endpoint_id: row.endpoint.endpoint_id.clone(), + pane_id: row.agent.pane_id.clone(), + }) + .collect() +} + +pub(super) fn navigator_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + navigator: &ClientNavigatorOverlay, +) -> Vec<ClientNavigatorRow> { + let query = navigator.query.trim().to_lowercase(); + let filter = |status| match navigator.filter { + Some(ClientNavigatorFilter::Blocked) => status == crate::api::schema::AgentStatus::Blocked, + Some(ClientNavigatorFilter::Working) => status == crate::api::schema::AgentStatus::Working, + Some(ClientNavigatorFilter::Idle) => status == crate::api::schema::AgentStatus::Idle, + Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done, + None => true, + }; + let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query); + let filtering = navigator.filter.is_some() || !query.is_empty(); + let federated = endpoints.len() > 1; + let depth_offset = u8::from(federated); + let mut rows = Vec::new(); + + for endpoint in endpoints { + let stale = endpoint.status != ClientEndpointStatus::Online; + let endpoint_query_matches = !query.is_empty() && text(&endpoint.label); + let mut endpoint_rows = Vec::new(); + if let Some(snapshot) = endpoint.snapshot.as_deref() { + for workspace in &snapshot.workspaces { + let workspace_meta = workspace.branch.clone().unwrap_or_default(); + let mut children = Vec::new(); + for tab in snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + { + let mut panes = Vec::new(); + for (index, pane) in snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .enumerate() + { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id); + let status = agent + .map_or(crate::api::schema::AgentStatus::Unknown, |agent| { + agent.agent_status + }); + let label = pane + .label + .clone() + .or_else(|| agent.and_then(|agent| agent.name.clone())) + .or_else(|| agent.and_then(|agent| agent.display_agent.clone())) + .or_else(|| agent.and_then(|agent| agent.title.clone())) + .unwrap_or_else(|| format!("pane {}", index + 1)); + let meta = pane + .foreground_cwd + .clone() + .or_else(|| pane.cwd.clone()) + .unwrap_or_default(); + if !filtering + || filter(status) + && (endpoint_query_matches || text(&label) || text(&meta)) + { + panes.push(ClientNavigatorRow { + depth: 2 + depth_offset, + label, + meta, + status: Some(status), + stale, + current: endpoint.endpoint_id == *active_endpoint_id + && snapshot.focused_pane_id.as_deref() == Some(&pane.pane_id), + target: ClientNavigatorTarget::Pane { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: pane.pane_id.clone(), + }, + }); + } + } + if !filtering + || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label)) + || !panes.is_empty() + { + children.push(ClientNavigatorRow { + depth: 1 + depth_offset, + label: tab.label.clone(), + meta: format!( + "{} panes", + snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .count() + ), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Tab { + endpoint_id: endpoint.endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }, + }); + children.extend(panes); + } + } + let workspace_matches = filter(workspace.agent_status) + && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta)); + if !filtering || workspace_matches || !children.is_empty() { + let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()); + endpoint_rows.push(ClientNavigatorRow { + depth: depth_offset, + label: workspace.label.clone(), + meta: workspace_meta, + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }, + }); + if navigator.expanded_workspaces.contains(&key) || filtering { + endpoint_rows.extend(children); + } + } + } + } + if !filtering || endpoint_query_matches || !endpoint_rows.is_empty() { + if federated { + rows.push(ClientNavigatorRow { + depth: 0, + label: endpoint.label.to_owned(), + meta: String::new(), + status: None, + stale, + current: false, + target: ClientNavigatorTarget::Machine { + endpoint_id: endpoint.endpoint_id.clone(), + }, + }); + } + rows.extend(endpoint_rows); + } + } + rows +} + +pub(super) fn navigator_selected_index( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<usize> { + match navigator.selected.as_ref() { + Some(target) => rows.iter().position(|row| row.target == *target), + None => (!rows.is_empty()).then_some(0), + } +} + +pub(super) fn selected_navigator_target( + rows: &[ClientNavigatorRow], + navigator: &ClientNavigatorOverlay, +) -> Option<ClientNavigatorTarget> { + navigator_selected_index(rows, navigator).map(|index| rows[index].target.clone()) +} diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index bcbf92fa..a80ebb3f 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -20,9 +20,95 @@ fn restore_mode_bar( } impl ClientShellState { + fn compose_unavailable(&mut self, cols: u16, rows: u16) -> FrameData { + let layout = self.layout(cols, rows); + let mut buffer = Buffer::empty(Rect::new(0, 0, cols, rows)); + buffer.set_style( + buffer.area, + Style::default() + .fg(self.config.palette.text) + .bg(self.config.palette.panel_bg), + ); + self.hits = ShellHitMap::default(); + let sidebar = if layout.sidebar.width > 0 { + layout.sidebar + } else { + Rect::new(0, 1, cols, rows.saturating_sub(2)) + }; + super::endpoint_sidebar::render_expanded( + &mut buffer, + sidebar, + self.snapshot.as_deref(), + &self.config, + &mut render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, + collapsed_groups: &self.collapsed_groups, + workspace_scroll: &mut self.workspace_scroll, + agent_scroll: &mut self.agent_scroll, + tab_scroll: &mut self.tab_scroll, + reveal_focused_workspace: &mut self.reveal_focused_workspace, + reveal_focused_tab: &mut self.reveal_focused_tab, + sidebar_collapsed: false, + sidebar_section_split: self.sidebar_section_split, + tab_drag_insert_index: None, + selected_workspace_id: self.navigate_workspace_id.as_deref(), + dragged_workspace_id: None, + workspace_drop_indicator_row: None, + }, + &mut self.hits, + ); + if !self.config.mouse_capture { + self.hits = ShellHitMap::default(); + } + let message = self.endpoint_error.clone().unwrap_or_else(|| { + let status = self + .endpoint_status(&self.active_endpoint_id) + .unwrap_or(ClientEndpointStatus::Connecting); + let (_, label, _) = endpoint_status_presentation(status, &self.config.palette); + format!( + "{}: {label}. Select a connected machine.", + self.active_endpoint_label() + ) + }); + let message_area = if layout.sidebar.width > 0 { + layout.pane_surface + } else { + Rect::new(0, 0, cols, 1) + }; + render::put_text( + &mut buffer, + message_area.x, + message_area.y, + message_area.width, + &message, + Style::default().fg(self.config.palette.overlay0), + ); + render::render_mode_bar( + &mut buffer, + Rect::new(0, 0, cols, rows), + self.mode, + None, + self.endpoint_error.as_deref(), + false, + &self.config.keybinds, + &self.config.palette, + ); + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]) + } + pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { self.last_composed_size = Some((cols, rows)); + if self.snapshot.is_none() || self.pane_surface.is_none() { + return Some(self.compose_unavailable(cols, rows)); + } let snapshot = self.snapshot.as_deref()?; + // A one-step successor is retained separately until its exact snapshot arrives; do not + // keep composing the now-superseded current pair while it is pending. + if self.pending_pane_surface.is_some() { + return None; + } let surface = self.pane_surface.as_ref()?; if snapshot.revision != surface.projection_revision { return None; @@ -53,6 +139,9 @@ impl ClientShellState { snapshot, &self.config, render::ShellRenderState { + endpoints: &self.endpoints, + active_endpoint_id: &self.active_endpoint_id, + collapsed_endpoints: &self.collapsed_endpoints, collapsed_groups: &self.collapsed_groups, workspace_scroll: &mut self.workspace_scroll, agent_scroll: &mut self.agent_scroll, @@ -270,7 +359,14 @@ impl ClientShellState { restore_mode_bar(&mut frame, mode_bar, mode_bar_cells.as_deref()); self.hits.notification_toast = Rect::default(); let has_config_diagnostic = self.config_diagnostic.is_some(); + let active_lifecycle = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .filter(|endpoint| endpoint.status != ClientEndpointStatus::Online) + .map(|endpoint| (endpoint.label.clone(), endpoint.status)); if has_config_diagnostic + || active_lifecycle.is_some() || self.visible_endpoint_notice.is_some() || self.visible_notification.is_some() { @@ -289,21 +385,32 @@ impl ClientShellState { &self.config.palette, ); } + let lifecycle_offset = active_lifecycle.as_ref().map_or(0, |(label, status)| { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + u16::from(has_config_diagnostic) + layout.mobile_header.height, + &self.config.palette, + ); + 1 + }); if let Some(notice) = self.visible_endpoint_notice.as_ref() { self.hits.notification_toast = if layout.mobile_header.is_empty() { - notifications::render_endpoint_notice( + endpoint_notices::render_notice( &mut composed, Rect::new(0, 0, cols, rows), notice, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { - notifications::render_mobile_endpoint_notice_banner( + endpoint_notices::render_mobile_banner( &mut composed, Rect::new(0, 0, cols, rows), notice, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -314,7 +421,7 @@ impl ClientShellState { Rect::new(0, 0, cols, rows), notification, self.config.toast_position, - u16::from(has_config_diagnostic), + u16::from(has_config_diagnostic) + lifecycle_offset, &self.config.palette, ) } else { @@ -322,7 +429,7 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), notification, - has_config_diagnostic, + has_config_diagnostic || lifecycle_offset > 0, &self.config.palette, ) }; @@ -399,12 +506,33 @@ impl ClientShellState { &mut composed, Rect::new(0, 0, cols, rows), snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config, self.navigate_workspace_id.as_deref(), &mut self.mobile_switcher_scroll, &mut self.reveal_mobile_workspace, &mut self.hits, ); + if let Some((label, status)) = active_lifecycle.as_ref() { + let _ = endpoint_notices::render_lifecycle_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + label, + *status, + 2, + &self.config.palette, + ); + } + if let Some(notice) = self.visible_endpoint_notice.as_ref() { + self.hits.notification_toast = endpoint_notices::render_mobile_banner( + &mut composed, + Rect::new(0, 0, cols, rows), + notice, + active_lifecycle.is_some(), + &self.config.palette, + ); + } frame.replace_from_ratatui_buffer_preserving_effects(&composed, None); self.hits.panes.clear(); self.hits.pane_splits.clear(); @@ -431,6 +559,8 @@ impl ClientShellState { &mut composed, overlay, snapshot, + &self.endpoints, + &self.active_endpoint_id, &self.config.keybinds, &self.config.palette, )?; @@ -474,6 +604,12 @@ impl ClientShellState { .scroll .min(u16::try_from(self.hits.release_notes_max_scroll).unwrap_or(u16::MAX)); } + if self.endpoint_status(&self.active_endpoint_id) != Some(ClientEndpointStatus::Online) { + frame.cursor = None; + self.hits.panes.clear(); + self.hits.pane_splits.clear(); + self.hits.popup = None; + } self.compose_graphics(&mut frame, layout); Some(frame) } diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs new file mode 100644 index 00000000..fdcd7478 --- /dev/null +++ b/src/client/shell/endpoint_agent_state.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use crate::api::schema::AgentStatus; +use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; + +#[derive(Clone, Debug, Default)] +pub(super) struct EndpointAgentPresentation { + boot_id: Option<String>, + acknowledged: HashMap<String, u64>, +} + +impl EndpointAgentPresentation { + pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) { + if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) { + self.boot_id = Some(snapshot.boot_id.clone()); + self.acknowledged.clear(); + self.acknowledged.extend( + snapshot + .agents + .iter() + .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)), + ); + } + self.acknowledged.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + + pub(super) fn acknowledge_surface( + &mut self, + snapshot: &mut ClientShellSnapshot, + surface: &PaneSurfaceFrame, + outer_focused: Option<bool>, + ) -> bool { + if outer_focused == Some(false) + || self.boot_id.as_deref() != Some(surface.boot_id.as_str()) + || snapshot.boot_id != surface.boot_id + || snapshot.revision != surface.projection_revision + { + return false; + } + + let mut changed = false; + for pane in &surface.panes { + let Some(agent) = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane.pane_id) + else { + continue; + }; + let acknowledged = self.acknowledged.entry(agent.pane_id.clone()).or_default(); + if *acknowledged < agent.state_change_seq { + *acknowledged = agent.state_change_seq; + changed = true; + } + } + if changed { + for agent in &mut snapshot.agents { + agent.agent_status = self.projected_status(agent); + } + project_aggregate_status(snapshot); + } + changed + } + + fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { + match agent.agent_status { + AgentStatus::Idle | AgentStatus::Done => { + if self + .acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| *sequence >= agent.state_change_seq) + { + AgentStatus::Idle + } else { + AgentStatus::Done + } + } + status => status, + } + } +} + +fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) { + for tab in &mut snapshot.tabs { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.tab_id == tab.tab_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + tab.agent_status = status; + } + } + for workspace in &mut snapshot.workspaces { + if let Some(status) = snapshot + .agents + .iter() + .filter(|agent| agent.workspace_id == workspace.workspace_id) + .map(|agent| agent.agent_status) + .max_by_key(|status| super::status_priority(*status)) + { + workspace.agent_status = status; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect}; + + fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent { + ClientShellAgent { + pane_id: "agent-pane".into(), + workspace_id: "workspace".into(), + tab_id: "tab".into(), + name: None, + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq: sequence, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } + } + + fn snapshot(status: AgentStatus, sequence: u64, revision: u64) -> ClientShellSnapshot { + let mut snapshot = crate::client::shell::tests::snapshot(); + snapshot.boot_id = "endpoint-boot".into(); + snapshot.revision = revision; + snapshot.agents = vec![agent(status, sequence)]; + snapshot + } + + fn surface(revision: u64) -> PaneSurfaceFrame { + let rect = SurfaceRect { + x: 0, + y: 0, + width: 1, + height: 1, + }; + PaneSurfaceFrame { + boot_id: "endpoint-boot".into(), + projection_revision: revision, + surface_revision: 1, + frame: FrameData { + cells: Vec::new(), + width: 0, + height: 0, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }, + panes: vec![PaneSurfacePane { + pane_id: "agent-pane".into(), + content_revision: 1, + rect, + inner_rect: rect, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + }], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } + } + + #[test] + fn first_snapshot_establishes_an_idle_baseline_without_server_seen_authority() { + let mut presentation = EndpointAgentPresentation::default(); + let mut snapshot = snapshot(AgentStatus::Done, 4, 1); + + presentation.project_snapshot(&mut snapshot); + + assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn unpresented_working_completion_projects_done() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + + presentation.project_snapshot(&mut completed); + + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } + + #[test] + fn coherent_presented_surface_acknowledges_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(presentation.acknowledge_surface(&mut completed, &surface(2), Some(true))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn clients_acknowledge_the_same_endpoint_completion_independently() { + let mut viewing_client = EndpointAgentPresentation::default(); + let mut background_client = EndpointAgentPresentation::default(); + let mut initial_for_viewer = snapshot(AgentStatus::Working, 4, 1); + let mut initial_for_background = initial_for_viewer.clone(); + viewing_client.project_snapshot(&mut initial_for_viewer); + background_client.project_snapshot(&mut initial_for_background); + let mut completed_for_viewer = snapshot(AgentStatus::Idle, 5, 2); + let mut completed_for_background = completed_for_viewer.clone(); + viewing_client.project_snapshot(&mut completed_for_viewer); + background_client.project_snapshot(&mut completed_for_background); + + assert!(viewing_client.acknowledge_surface( + &mut completed_for_viewer, + &surface(2), + Some(true) + )); + + assert_eq!( + completed_for_viewer.agents[0].agent_status, + AgentStatus::Idle + ); + assert_eq!( + completed_for_background.agents[0].agent_status, + AgentStatus::Done + ); + } + + #[test] + fn stale_or_unfocused_surface_does_not_acknowledge_completion() { + let mut presentation = EndpointAgentPresentation::default(); + let mut initial = snapshot(AgentStatus::Working, 4, 1); + presentation.project_snapshot(&mut initial); + let mut completed = snapshot(AgentStatus::Idle, 5, 2); + presentation.project_snapshot(&mut completed); + + assert!(!presentation.acknowledge_surface(&mut completed, &surface(1), Some(true))); + assert!(!presentation.acknowledge_surface(&mut completed, &surface(2), Some(false))); + assert_eq!(completed.agents[0].agent_status, AgentStatus::Done); + } +} diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs new file mode 100644 index 00000000..a49fccf9 --- /dev/null +++ b/src/client/shell/endpoint_agents.rs @@ -0,0 +1,129 @@ +use super::render::put_text; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + hits: &mut ShellHitMap, +) { + let rows = agent_rows(endpoints, active_endpoint_id, config); + for (index, row) in rows.into_iter().take(area.height as usize).enumerate() { + let rect = Rect::new(area.x, area.y + index as u16, area.width, 1); + if row.agent.focused { + buffer.set_style(rect, Style::default().bg(config.palette.active_row_bg)); + } + let initial = row.machine_label.chars().next().unwrap_or('?'); + put_text( + buffer, + rect.x, + rect.y, + rect.width, + &format!( + "{initial}{}", + status_icon(row.agent.status, config.status_indicators) + ), + Style::default() + .fg(if row.stale { + config.palette.overlay0 + } else { + status_color(row.agent.status, &config.palette) + }) + .add_modifier(if row.stale { + Modifier::DIM + } else { + Modifier::empty() + }), + ); + hits.endpoint_agents + .push((rect, row.endpoint_id, row.agent.pane_id)); + } +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + agent_view_label: Option<&str>, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, + agent_scroll: &mut usize, + hits: &mut ShellHitMap, +) { + if !super::agent_sidebar::render_agent_panel_header( + buffer, + area, + agent_view_label, + config, + hits, + ) { + return; + } + let rows = agent_rows(endpoints, active_endpoint_id, config); + super::agent_sidebar::render_agent_list( + buffer, + area, + &rows, + agent_view_label.map(|_| " no matching agents"), + config, + agent_scroll, + hits, + |row| row.agent.rows.len(), + |buffer, rect, row, hits| { + super::agent_sidebar::render_agent_row(buffer, rect, &row.agent, config); + if row.stale { + buffer.set_style( + rect, + Style::default() + .fg(config.palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.endpoint_agents + .push((rect, row.endpoint_id.clone(), row.agent.pane_id.clone())); + }, + ); +} + +struct EndpointAgentRow { + endpoint_id: ClientEndpointId, + machine_label: String, + stale: bool, + agent: super::agent_sidebar::AgentRow, +} + +fn agent_rows( + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, + config: &ClientShellConfig, +) -> Vec<EndpointAgentRow> { + let mut rendered_rows = endpoints + .iter() + .filter_map(|endpoint| { + endpoint.snapshot.as_deref().map(|snapshot| { + super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label)) + .into_iter() + .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent)) + .collect::<Vec<_>>() + }) + }) + .flatten() + .collect::<HashMap<_, _>>(); + + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort) + .into_iter() + .filter_map(|row| { + let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); + let mut agent = rendered_rows.remove(&key)?; + agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; + Some(EndpointAgentRow { + endpoint_id: row.endpoint.endpoint_id.clone(), + machine_label: row.endpoint.label.to_owned(), + stale: row.endpoint.stale(), + agent, + }) + }) + .collect() +} diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs new file mode 100644 index 00000000..adb29c93 --- /dev/null +++ b/src/client/shell/endpoint_navigation.rs @@ -0,0 +1,290 @@ +use super::*; + +impl ClientShellState { + pub(super) fn active_endpoint_workspace_at(&self, point: (u16, u16)) -> Option<String> { + self.hits + .workspaces + .iter() + .find(|hit| { + hit.endpoint_id == self.active_endpoint_id && super::contains(hit.rect, point) + }) + .map(|hit| hit.workspace_id.clone()) + } + + pub(super) fn endpoint_workspace_is_draggable(&self, press: &ClientWorkspacePress) -> bool { + press.endpoint_id == self.active_endpoint_id + && self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == press.workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + } + + pub(super) fn finish_endpoint_workspace_press( + &mut self, + press: ClientWorkspacePress, + outcome: &mut ClientShellInput, + ) { + if press.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }), + outcome, + ); + } else if self.endpoint_is_online(&press.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: press.endpoint_id, + target: Some(ClientEndpointFocusTarget::Workspace(press.workspace_id)), + }); + } else { + let label = self.endpoint_label(&press.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + } + + pub(super) fn handle_endpoint_machine_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some(endpoint_id) = self + .hits + .machines + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.endpoint_id.clone()) + else { + return false; + }; + if endpoint_id == self.active_endpoint_id { + if !self.collapsed_endpoints.remove(&endpoint_id) { + self.collapsed_endpoints.insert(endpoint_id); + } + outcome.repaint = true; + } else if self.endpoint_is_online(&endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + } + true + } + + pub(super) fn handle_endpoint_agent_click( + &mut self, + point: (u16, u16), + outcome: &mut ClientShellInput, + ) -> bool { + let Some((endpoint_id, pane_id)) = self + .hits + .endpoint_agents + .iter() + .find(|(rect, _, _)| super::contains(*rect, point)) + .map(|(_, endpoint_id, pane_id)| (endpoint_id.clone(), pane_id.clone())) + else { + return false; + }; + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is reconnecting")); + outcome.repaint = true; + } else if endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + true + } + + pub(super) fn handle_endpoint_navigation( + &mut self, + action: crate::input::KeybindAction, + outcome: &mut ClientShellInput, + ) -> bool { + use crate::input::KeybindAction; + if !self.multi_endpoint_active() { + return false; + } + if matches!( + action, + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace + ) { + let workspaces = self + .endpoints + .iter() + .filter(|endpoint| endpoint.status == ClientEndpointStatus::Online) + .flat_map(|endpoint| { + endpoint + .snapshot + .as_deref() + .map_or_else(Vec::new, |snapshot| { + render::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .filter_map(|entry| { + snapshot.workspaces.get(entry.index).map(|workspace| { + ( + endpoint.endpoint_id.clone(), + workspace.workspace_id.clone(), + ) + }) + }) + .collect() + }) + }) + .collect::<Vec<_>>(); + if workspaces.is_empty() { + return true; + } + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.as_deref()); + let current = workspaces.iter().position(|(endpoint_id, workspace_id)| { + endpoint_id == &self.active_endpoint_id && Some(workspace_id.as_str()) == focused + }); + let next = match (current, action) { + (Some(index), KeybindAction::PreviousWorkspace) => { + (index + workspaces.len() - 1) % workspaces.len() + } + (Some(index), KeybindAction::NextWorkspace) => (index + 1) % workspaces.len(), + (None, KeybindAction::PreviousWorkspace) => workspaces.len() - 1, + (None, KeybindAction::NextWorkspace) => 0, + _ => unreachable!("endpoint workspace navigation"), + }; + let (endpoint_id, workspace_id) = workspaces[next].clone(); + self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ); + return true; + } + if matches!( + action, + KeybindAction::PreviousAgent | KeybindAction::NextAgent | KeybindAction::FocusAgent(_) + ) { + let agents = super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return true; + } + let next = match action { + KeybindAction::FocusAgent(index) => { + if index >= agents.len() { + return true; + } + index + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let focused = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.as_deref()); + let current = agents.iter().position(|target| { + target.endpoint_id == self.active_endpoint_id + && Some(target.pane_id.as_str()) == focused + }); + match (current, action) { + (Some(index), KeybindAction::PreviousAgent) => { + (index + agents.len() - 1) % agents.len() + } + (Some(index), KeybindAction::NextAgent) => (index + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + _ => 0, + } + } + _ => unreachable!("endpoint agent navigation"), + }; + let target = &agents[next]; + self.focus_or_activate( + target.endpoint_id.clone(), + ClientEndpointFocusTarget::Pane(target.pane_id.clone()), + outcome, + ); + return true; + } + false + } + + pub(super) fn activate_endpoint( + &mut self, + endpoint_id: ClientEndpointId, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id != self.active_endpoint_id { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } + true + } + + pub(super) fn focus_or_activate( + &mut self, + endpoint_id: ClientEndpointId, + target: ClientEndpointFocusTarget, + outcome: &mut ClientShellInput, + ) -> bool { + if !self.endpoint_is_online(&endpoint_id) { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + outcome.repaint = true; + return false; + } + if endpoint_id == self.active_endpoint_id { + let method = match target { + ClientEndpointFocusTarget::Workspace(workspace_id) => { + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { workspace_id }, + ) + } + ClientEndpointFocusTarget::Tab(tab_id) => { + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + } + ClientEndpointFocusTarget::Pane(pane_id) => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } + }; + self.push_endpoint_method(method, outcome); + } else { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(target), + }); + } + true + } +} diff --git a/src/client/shell/endpoint_notices.rs b/src/client/shell/endpoint_notices.rs new file mode 100644 index 00000000..e796d2c5 --- /dev/null +++ b/src/client/shell/endpoint_notices.rs @@ -0,0 +1,80 @@ +use super::*; +use ratatui::widgets::{Clear, Widget}; + +pub(super) fn render_mobile_banner( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + offset_for_warning: bool, + palette: &Palette, +) -> Rect { + super::notifications::render_mobile_notice_banner( + buffer, + area, + ¬ice.title, + Some(¬ice.body), + palette.red, + offset_for_warning, + palette, + ) +} + +pub(super) fn render_lifecycle_banner( + buffer: &mut Buffer, + area: Rect, + label: &str, + status: ClientEndpointStatus, + top_offset: u16, + palette: &Palette, +) -> Rect { + if area.is_empty() || status == ClientEndpointStatus::Online { + return Rect::default(); + } + let (symbol, state, color) = endpoint_status_presentation(status, palette); + let text = format!("{symbol} {label} · {state}"); + let width = u16::try_from(unicode_width::UnicodeWidthStr::width(text.as_str()) + 2) + .unwrap_or(u16::MAX) + .min(area.width); + let y = area + .y + .saturating_add(top_offset) + .min(area.bottom().saturating_sub(1)); + let rect = Rect::new(area.right().saturating_sub(width), y, width, 1); + Clear.render(rect, buffer); + buffer.set_style(rect, Style::default().bg(palette.surface0)); + super::render::put_text( + buffer, + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(2), + &text, + Style::default().fg(color).bg(palette.surface0), + ); + rect +} + +pub(super) fn render_notice( + buffer: &mut Buffer, + area: Rect, + notice: &ClientVisibleEndpointNotice, + top_offset: u16, + palette: &Palette, +) -> Rect { + super::notifications::render_notification_card( + buffer, + area, + ¬ice.title, + ¬ice.body, + crate::config::ToastHerdrPosition::TopRight, + top_offset, + match notice.key.kind { + ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { + palette.red + } + ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { + palette.yellow + } + }, + palette, + ) +} diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs new file mode 100644 index 00000000..51c940cd --- /dev/null +++ b/src/client/shell/endpoint_sidebar.rs @@ -0,0 +1,452 @@ +use super::render::{display_width, put_right_text, put_text, ShellRenderState}; +use super::*; + +pub(super) fn render_collapsed( + buffer: &mut Buffer, + area: Rect, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + let (workspace_area, divider_y, detail_area) = super::sidebar::collapsed_sidebar_sections(area); + let mut y = workspace_area.y; + for (index, endpoint) in state.endpoints.iter().enumerate() { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let active = &endpoint.endpoint_id == state.active_endpoint_id; + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + if active && collapsed { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let label = if endpoint.endpoint_id.is_local() { + "L".to_owned() + } else { + (index + 1).to_string() + }; + let marker = if collapsed { "▸" } else { "▾" }; + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(1), + &format!("{marker}{label}"), + Style::default().fg(if endpoint.status == ClientEndpointStatus::Online { + palette.text + } else { + palette.overlay0 + }), + ); + if !endpoint.endpoint_id.is_local() { + let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette); + put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color)); + } + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + if collapsed { + continue; + } + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + for workspace in &snapshot.workspaces { + if y >= workspace_area.bottom() { + break; + } + let rect = Rect::new(workspace_area.x, y, workspace_area.width, 1); + let focused = active && workspace.focused; + if focused { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let stale = endpoint.status != ClientEndpointStatus::Online; + let number = format!(" {}", workspace.number); + let number_width = super::render::display_width(&number).min(rect.width); + let dim = if stale { + Modifier::DIM + } else { + Modifier::empty() + }; + put_text( + buffer, + rect.x, + rect.y, + number_width, + &number, + Style::default() + .fg(if focused && !stale { + palette.text + } else { + palette.overlay0 + }) + .add_modifier(dim), + ); + put_text( + buffer, + rect.x.saturating_add(number_width), + rect.y, + rect.width.saturating_sub(number_width), + status_icon(workspace.agent_status, config.status_indicators), + Style::default() + .fg(if stale { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }) + .add_modifier(dim), + ); + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: false, + group_toggle: None, + }); + y = y.saturating_add(1); + } + } + if let Some(divider_y) = divider_y { + put_text( + buffer, + workspace_area.x, + divider_y, + workspace_area.width, + &"─".repeat(workspace_area.width as usize), + Style::default().fg(palette.surface_dim), + ); + } + super::endpoint_agents::render_collapsed( + buffer, + detail_area, + state.endpoints, + state.active_endpoint_id, + config, + hits, + ); + hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 { + Rect::default() + } else { + Rect::new( + workspace_area.x + workspace_area.width / 2, + area.bottom().saturating_sub(1), + 1, + 1, + ) + }; + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "»", + Style::default().fg(palette.overlay0), + ); +} + +pub(super) fn render_expanded( + buffer: &mut Buffer, + area: Rect, + active_snapshot: Option<&ClientShellSnapshot>, + config: &ClientShellConfig, + state: &mut ShellRenderState<'_>, + hits: &mut ShellHitMap, +) { + let palette = &config.palette; + super::render::render_sidebar_background(buffer, area, palette); + hits.sidebar_divider = if area.is_empty() { + Rect::default() + } else { + Rect::new(area.right().saturating_sub(1), area.y, 1, area.height) + }; + let (workspace_area, detail_area) = + crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split); + hits.sidebar_section_divider = + crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split); + put_text( + buffer, + workspace_area.x, + workspace_area.y, + workspace_area.width, + " machines", + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::BOLD), + ); + + enum Row { + Endpoint(usize), + Workspace { + endpoint: usize, + entry: WorkspaceEntry, + }, + } + let mut rows = Vec::new(); + for (endpoint_index, endpoint) in state.endpoints.iter().enumerate() { + rows.push(Row::Endpoint(endpoint_index)); + if state.collapsed_endpoints.contains(&endpoint.endpoint_id) { + continue; + } + if let Some(snapshot) = endpoint.snapshot.as_deref() { + rows.extend( + super::sidebar::workspace_entries(snapshot, &HashSet::new()) + .into_iter() + .map(|entry| Row::Workspace { + endpoint: endpoint_index, + entry, + }), + ); + } + } + let body = Rect::new( + workspace_area.x, + workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS), + workspace_area.width, + workspace_area + .height + .saturating_sub(WORKSPACE_HEADER_ROWS + 1), + ); + hits.workspace_body = body; + let row_heights = rows + .iter() + .map(|row| match row { + Row::Endpoint(_) => 1, + Row::Workspace { endpoint, entry } => state.endpoints[*endpoint] + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.workspaces.get(entry.index)) + .map(|workspace| { + super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ) + .len() + .max(1) + .min(u16::MAX as usize) as u16 + }) + .unwrap_or(1), + }) + .collect::<Vec<_>>(); + let gaps = vec![0; rows.len()]; + let metrics = super::scroll::list_scroll_metrics( + &row_heights, + &gaps, + body.height, + *state.workspace_scroll, + ); + hits.workspace_max_scroll = metrics.max_offset_from_bottom; + hits.workspace_scroll_metrics = Some(metrics); + *state.workspace_scroll = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; + let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); + let mut y = body.y; + for row in rows.iter().skip(*state.workspace_scroll) { + match row { + Row::Endpoint(index) => { + if y >= body.bottom() { + break; + } + let endpoint = &state.endpoints[*index]; + let rect = Rect::new(body.x, y, content_width, 1); + let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); + let marker = if collapsed { "▸" } else { "▾" }; + render_endpoint_row( + buffer, + rect, + marker, + endpoint, + collapsed && &endpoint.endpoint_id == state.active_endpoint_id, + palette, + ); + hits.machines.push(MachineHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + }); + y = y.saturating_add(1); + } + Row::Workspace { endpoint, entry } => { + let endpoint = &state.endpoints[*endpoint]; + let Some(snapshot) = endpoint.snapshot.as_deref() else { + continue; + }; + let Some(workspace) = snapshot.workspaces.get(entry.index) else { + continue; + }; + let tokens = super::sidebar::workspace_rows( + workspace, + workspace.agent_status, + entry.indented, + &config.spaces, + ); + let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height); + if y.saturating_add(height) > body.bottom() { + break; + } + let rect = Rect::new(body.x, y, content_width, height); + let nested = Rect::new( + rect.x.saturating_add(2), + rect.y, + rect.width.saturating_sub(2), + rect.height, + ); + let endpoint_active = &endpoint.endpoint_id == state.active_endpoint_id; + super::sidebar::render_workspace_rows( + buffer, + nested, + workspace, + workspace.agent_status, + config.status_indicators, + entry, + tokens, + endpoint_active, + false, + false, + palette, + ); + if endpoint.status != ClientEndpointStatus::Online { + buffer.set_style( + rect, + Style::default() + .fg(palette.overlay0) + .add_modifier(Modifier::DIM), + ); + } + hits.workspaces.push(WorkspaceHit { + rect, + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + indented: entry.indented, + group_toggle: None, + }); + y = y.saturating_add(height); + } + } + } + if show_scrollbar { + let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); + hits.workspace_scrollbar = track; + super::scroll::render_list_scrollbar(buffer, track, metrics, palette); + } + + let footer_y = workspace_area.bottom().saturating_sub(1); + if config.mouse_capture { + let label = format!(" new · {}", active_endpoint_label(state)); + hits.new_workspace = Rect::new( + workspace_area.x, + footer_y, + display_width(&label).min(workspace_area.width), + u16::from(workspace_area.height > 0), + ); + put_text( + buffer, + workspace_area.x, + footer_y, + workspace_area.width, + &label, + Style::default().fg(palette.overlay0), + ); + let attention = active_snapshot.is_some_and(super::global_menu::global_menu_attention); + let width = if attention { 8 } else { 6 }.min(workspace_area.width); + hits.global_launcher = Rect::new( + workspace_area.right().saturating_sub(width), + footer_y, + width, + 1, + ); + put_right_text( + buffer, + workspace_area, + footer_y, + if attention { "● menu" } else { "menu" }, + Style::default().fg(if attention { + palette.accent + } else { + palette.overlay0 + }), + ); + } + super::endpoint_agents::render_expanded( + buffer, + detail_area, + active_snapshot.and_then(|snapshot| snapshot.agent_view_label.as_deref()), + state.endpoints, + state.active_endpoint_id, + config, + state.agent_scroll, + hits, + ); + hits.sidebar_toggle = Rect::new( + area.right().saturating_sub(2), + area.bottom().saturating_sub(1), + u16::from(area.width > 1), + u16::from(area.height > 0), + ); + put_text( + buffer, + hits.sidebar_toggle.x, + hits.sidebar_toggle.y, + hits.sidebar_toggle.width, + "«", + Style::default().fg(palette.overlay0), + ); +} + +fn active_endpoint_label<'a>(state: &'a ShellRenderState<'_>) -> &'a str { + state + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == state.active_endpoint_id) + .map_or("Local", |endpoint| endpoint.label.as_str()) +} + +fn render_endpoint_row( + buffer: &mut Buffer, + rect: Rect, + marker: &str, + endpoint: &ClientShellEndpoint, + highlighted: bool, + palette: &Palette, +) { + if highlighted { + buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + } + let (glyph, state, color) = endpoint_status_presentation(endpoint.status, palette); + let state = if endpoint.status == ClientEndpointStatus::Online { + "" + } else { + state + }; + let signal = if endpoint.endpoint_id.is_local() { + String::new() + } else if state.is_empty() { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_width = display_width(&signal).min(rect.width); + put_text( + buffer, + rect.x, + rect.y, + rect.width.saturating_sub(signal_width.saturating_add(1)), + &format!(" {marker} {}", endpoint.label), + Style::default() + .fg( + if matches!(endpoint.status, ClientEndpointStatus::Disabled) { + palette.overlay0 + } else { + palette.text + }, + ) + .add_modifier(Modifier::BOLD), + ); + put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color)); +} diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs new file mode 100644 index 00000000..2f5a8e46 --- /dev/null +++ b/src/client/shell/endpoints.rs @@ -0,0 +1,506 @@ +use super::*; + +#[derive(Clone, Debug)] +pub(crate) struct ClientShellEndpoint { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) label: String, + pub(crate) status: ClientEndpointStatus, + pub(crate) snapshot: Option<Box<ClientShellSnapshot>>, + /// Connection generation that produced `snapshot`. `None` is reserved for local tests. + pub(crate) snapshot_generation: Option<u64>, + pub(crate) agent_recency: HashMap<String, u64>, + pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) methods: Option<HashSet<String>>, +} + +pub(super) struct MachineHit { + pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClientEndpointFocusTarget { + Workspace(String), + Tab(String), + Pane(String), +} + +impl ClientShellState { + pub(crate) fn set_endpoint_catalog(&mut self, profiles: &[SavedSshEndpoint]) { + let mut next = Vec::with_capacity(profiles.len().saturating_add(1)); + let local = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .cloned() + .unwrap_or_else(local_endpoint); + next.push(local); + for profile in profiles { + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + let previous = self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .filter(|endpoint| { + profile.enabled && endpoint.status != ClientEndpointStatus::Disabled + }); + next.push(ClientShellEndpoint { + endpoint_id, + label: profile.label.clone(), + status: previous.map_or( + if profile.enabled { + ClientEndpointStatus::Connecting + } else { + ClientEndpointStatus::Disabled + }, + |endpoint| endpoint.status, + ), + snapshot: previous.and_then(|endpoint| endpoint.snapshot.clone()), + snapshot_generation: previous.and_then(|endpoint| endpoint.snapshot_generation), + agent_recency: previous + .map(|endpoint| endpoint.agent_recency.clone()) + .unwrap_or_default(), + agent_presentation: previous + .map(|endpoint| endpoint.agent_presentation.clone()) + .unwrap_or_default(), + methods: previous.and_then(|endpoint| endpoint.methods.clone()), + }); + } + + if !next + .iter() + .any(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + { + self.select_unavailable_local(); + } + self.collapsed_endpoints.retain(|endpoint_id| { + next.iter() + .any(|endpoint| &endpoint.endpoint_id == endpoint_id) + }); + self.endpoints = next; + } + + pub(crate) fn select_unavailable_local(&mut self) { + self.reset_endpoint_projection(); + self.active_endpoint_id = ClientEndpointId::Local; + self.mode = ClientShellMode::Terminal; + self.snapshot = None; + self.graphics.set_scope("local:unavailable"); + self.reconcile_input_source(); + } + + pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + self.retire_endpoint_notifications(endpoint_id); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = ClientEndpointStatus::Disabled; + endpoint.snapshot = None; + endpoint.snapshot_generation = None; + endpoint.methods = None; + endpoint.agent_recency.clear(); + endpoint.agent_presentation = Default::default(); + } + } + + pub(crate) fn set_endpoint_status( + &mut self, + endpoint_id: &ClientEndpointId, + status: ClientEndpointStatus, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.status = status; + } + } + + pub(crate) fn mark_endpoint_disconnected(&mut self, endpoint_id: &ClientEndpointId) { + self.set_endpoint_status(endpoint_id, ClientEndpointStatus::Reconnecting); + if endpoint_id == &self.active_endpoint_id { + let pending = self.pending_requests.keys().cloned().collect::<Vec<_>>(); + for request_id in pending { + self.cancel_endpoint_request(&request_id); + } + self.pending_integration_installs = 0; + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + } + } + + pub(crate) fn set_endpoint_methods_for( + &mut self, + endpoint_id: &ClientEndpointId, + methods: Option<Vec<String>>, + ) { + let methods = methods.map(|methods| methods.into_iter().collect::<HashSet<_>>()); + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.methods = methods; + } + } + + pub(crate) fn endpoint_projection_available(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints.iter().any(|endpoint| { + &endpoint.endpoint_id == endpoint_id + && endpoint.status == ClientEndpointStatus::Online + && endpoint.snapshot.is_some() + }) + } + + pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool { + let Some(endpoint) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return false; + }; + if endpoint.status != ClientEndpointStatus::Online { + return false; + } + let Some(snapshot) = endpoint.snapshot.clone() else { + return false; + }; + if endpoint_id != &self.active_endpoint_id { + self.active_endpoint_id = endpoint_id.clone(); + self.pane_surface = None; + self.pending_pane_surface = None; + } + self.apply_active_snapshot(snapshot); + true + } + + pub(crate) fn endpoint_status( + &self, + endpoint_id: &ClientEndpointId, + ) -> Option<ClientEndpointStatus> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status) + } + + pub(crate) fn endpoint_has_snapshot(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| endpoint.snapshot.is_some()) + } + + pub(crate) fn endpoint_is_online(&self, endpoint_id: &ClientEndpointId) -> bool { + self.endpoint_status(endpoint_id) == Some(ClientEndpointStatus::Online) + && self.endpoint_has_snapshot(endpoint_id) + } + + pub(crate) fn endpoint_boot_id(&self, endpoint_id: &ClientEndpointId) -> Option<&str> { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)? + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()) + } + + pub(crate) fn endpoint_snapshot_matches( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + boot_id: &str, + revision: u64, + ) -> bool { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .is_some_and(|endpoint| { + endpoint + .snapshot_generation + .is_none_or(|snapshot_generation| snapshot_generation == generation) + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.boot_id == boot_id && snapshot.revision == revision + }) + }) + } + + pub(crate) fn endpoint_snapshot_identity( + &self, + endpoint_id: &ClientEndpointId, + generation: u64, + ) -> Option<(&str, u64)> { + let endpoint = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id)?; + if endpoint + .snapshot_generation + .is_some_and(|snapshot_generation| snapshot_generation != generation) + { + return None; + } + endpoint + .snapshot + .as_deref() + .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) + } + + /// A terminal normally starts focused. `None` means this host cannot report focus events, + /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline. + pub(crate) fn host_focus_baseline(&self) -> bool { + self.outer_focused.unwrap_or(true) + } + + pub(crate) fn endpoint_label(&self, endpoint_id: &ClientEndpointId) -> &str { + self.endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map_or("Unknown endpoint", |endpoint| endpoint.label.as_str()) + } + + pub(crate) fn active_endpoint_label(&self) -> &str { + self.endpoint_label(&self.active_endpoint_id) + } + + pub(crate) fn endpoint_is_active(&self, endpoint_id: &ClientEndpointId) -> bool { + &self.active_endpoint_id == endpoint_id + } + + pub(crate) fn multi_endpoint_active(&self) -> bool { + self.endpoints.len() > 1 + } + + #[cfg(test)] + pub(crate) fn set_snapshot(&mut self, snapshot: Box<ClientShellSnapshot>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_snapshot(&endpoint_id, snapshot); + } + + #[cfg(test)] + pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { + let endpoint_id = self.active_endpoint_id.clone(); + self.set_endpoint_methods_for(&endpoint_id, methods); + } + + pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { + self.endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) + } + + pub(super) fn focused_tab_count(&self) -> usize { + let Some(snapshot) = self.snapshot.as_deref() else { + return 0; + }; + snapshot + .tabs + .iter() + .filter(|tab| { + Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() + }) + .count() + } + + #[cfg(test)] + pub(crate) fn cache_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, None, snapshot, true); + } + + pub(crate) fn cache_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, true); + } + + /// Metadata delivered while an endpoint surface is inactive must never advance this + /// aggregate client's viewed watermark, even if the frozen source frame still exists. + pub(crate) fn cache_endpoint_snapshot_inactive_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_with_surface(endpoint_id, Some(generation), snapshot, false); + } + + fn cache_endpoint_snapshot_with_surface( + &mut self, + endpoint_id: &ClientEndpointId, + generation: Option<u64>, + mut snapshot: Box<ClientShellSnapshot>, + acknowledge_surface: bool, + ) { + snapshot + .commands + .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return; + }; + if self.endpoints[index].snapshot_generation == generation + && self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| { + previous.boot_id == snapshot.boot_id && previous.revision > snapshot.revision + }) + { + return; + } + let boot_changed = self.endpoints[index] + .snapshot + .as_deref() + .is_some_and(|previous| previous.boot_id != snapshot.boot_id); + if boot_changed { + self.retire_endpoint_notifications(endpoint_id); + } + self.endpoints[index] + .agent_presentation + .project_snapshot(&mut snapshot); + let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id { + self.pane_surface.as_ref() + } else { + None + }; + if let Some(surface) = presented_surface { + self.endpoints[index] + .agent_presentation + .acknowledge_surface(&mut snapshot, surface, self.outer_focused); + } + let previous = self.endpoints[index].snapshot.as_deref(); + let mut next_recency = self + .endpoints + .iter() + .flat_map(|endpoint| endpoint.agent_recency.values()) + .copied() + .max() + .unwrap_or_default(); + let mut agents = snapshot.agents.iter().collect::<Vec<_>>(); + agents.sort_by_key(|agent| agent.state_change_seq); + let mut recency = self.endpoints[index].agent_recency.clone(); + for agent in agents { + let changed = previous + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|previous| previous.pane_id == agent.pane_id) + }) + .is_none_or(|previous| previous.state_change_seq != agent.state_change_seq); + if changed { + next_recency = next_recency.saturating_add(1); + recency.insert(agent.pane_id.clone(), next_recency); + } + } + recency.retain(|pane_id, _| { + snapshot + .agents + .iter() + .any(|agent| &agent.pane_id == pane_id) + }); + let endpoint = &mut self.endpoints[index]; + endpoint.agent_recency = recency; + endpoint.snapshot_generation = generation; + endpoint.snapshot = Some(snapshot); + } + + pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool { + let Some(index) = self + .endpoints + .iter() + .position(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + else { + return false; + }; + let changed = { + let endpoint = &mut self.endpoints[index]; + let Some(snapshot) = endpoint.snapshot.as_deref_mut() else { + return false; + }; + endpoint + .agent_presentation + .acknowledge_surface(snapshot, surface, self.outer_focused) + }; + if changed { + self.snapshot = self.endpoints[index].snapshot.clone(); + } + changed + } + + #[cfg(test)] + pub(crate) fn set_endpoint_snapshot( + &mut self, + endpoint_id: &ClientEndpointId, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot(endpoint_id, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + pub(crate) fn set_endpoint_snapshot_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + snapshot: Box<ClientShellSnapshot>, + ) { + self.cache_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + self.apply_cached_endpoint_snapshot(endpoint_id); + } + + fn apply_cached_endpoint_snapshot(&mut self, endpoint_id: &ClientEndpointId) { + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.clone()) + else { + return; + }; + if endpoint_id == &self.active_endpoint_id { + self.apply_active_snapshot(snapshot); + } + } +} + +pub(super) fn endpoint_status_presentation( + status: ClientEndpointStatus, + palette: &Palette, +) -> (&'static str, &'static str, ratatui::style::Color) { + match status { + ClientEndpointStatus::Connecting => ("◐", "connecting", palette.yellow), + ClientEndpointStatus::Online => ("●", "online", palette.green), + ClientEndpointStatus::Reconnecting => ("◐", "reconnecting", palette.yellow), + ClientEndpointStatus::Attention => ("!", "attention", palette.red), + ClientEndpointStatus::Disabled => ("·", "disabled", palette.overlay0), + } +} + +pub(super) fn local_endpoint() -> ClientShellEndpoint { + ClientShellEndpoint { + endpoint_id: ClientEndpointId::Local, + label: "Local".into(), + status: ClientEndpointStatus::Online, + snapshot: None, + snapshot_generation: None, + agent_recency: HashMap::new(), + agent_presentation: Default::default(), + methods: None, + } +} diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index 8a512d3a..31061c9b 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -3,10 +3,7 @@ use super::*; impl ClientShellState { #[cfg(unix)] pub(crate) fn graphics_scope(&self) -> &str { - self.snapshot - .as_deref() - .map(|snapshot| snapshot.boot_id.as_str()) - .unwrap_or_default() + self.graphics.scope() } #[cfg(unix)] diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index d2ca3a82..f8826136 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -236,6 +236,9 @@ impl ClientShellState { self.outer_focused = Some(true); outcome.query_host_appearance = true; outcome.repaint |= self.config.redraw_on_focus_gained; + if let Some(surface) = self.pane_surface.clone() { + outcome.repaint |= self.acknowledge_active_surface_agents(&surface); + } outcome .requests .push(ClientMessage::ClientShellFocus { focused: true }); @@ -798,7 +801,36 @@ impl ClientShellState { ) { use crate::input::{KeybindAction, KeybindMatch}; - let indexed_target_exists = match &binding { + if !self.indexed_navigation_target_exists(&binding) { + return; + } + + if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { + self.cycle_pane(false, outcome); + } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { + self.cycle_pane(true, outcome); + } else { + if !preserve_navigate { + self.mode = ClientShellMode::Terminal; + } + self.record_binding(binding, outcome); + } + if !preserve_navigate { + if self.mode == ClientShellMode::Navigate { + self.mode = self.copy_or_terminal_mode(); + } + self.navigate_workspace_id = None; + } + outcome.repaint = true; + } + + pub(super) fn indexed_navigation_target_exists( + &self, + binding: &crate::input::KeybindMatch, + ) -> bool { + use crate::input::{KeybindAction, KeybindMatch}; + + match binding { KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => { self.snapshot.as_deref().is_some_and(|snapshot| { self.navigation_workspace_entries(snapshot) @@ -819,38 +851,15 @@ impl ClientShellState { }) .is_some(), KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { - self.snapshot.as_deref().is_some_and(|snapshot| { - super::agent_sidebar::ordered_agent_pane_ids( - snapshot, - self.config.agent_panel_sort, - ) - .get(*index) - .is_some() - }) + super::aggregate_navigation::online_agent_targets( + &self.endpoints, + self.config.agent_panel_sort, + ) + .get(*index) + .is_some() } _ => true, - }; - if !indexed_target_exists { - return; } - - if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding { - self.cycle_pane(false, outcome); - } else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding { - self.cycle_pane(true, outcome); - } else { - if !preserve_navigate { - self.mode = ClientShellMode::Terminal; - } - self.record_binding(binding, outcome); - } - if !preserve_navigate { - if self.mode == ClientShellMode::Navigate { - self.mode = self.copy_or_terminal_mode(); - } - self.navigate_workspace_id = None; - } - outcome.repaint = true; } fn move_navigate_workspace(&mut self, delta: isize) { diff --git a/src/client/shell/input_source.rs b/src/client/shell/input_source.rs new file mode 100644 index 00000000..7997c72f --- /dev/null +++ b/src/client/shell/input_source.rs @@ -0,0 +1,41 @@ +use super::*; + +impl ClientShellState { + fn wants_ascii_input(&self) -> bool { + if let Some(overlay) = self.overlay.as_ref() { + return matches!( + overlay, + ClientShellOverlay::ConfirmClose(_) + | ClientShellOverlay::Help(_) + | ClientShellOverlay::Navigator(_) + | ClientShellOverlay::WorktreeRemove(_) + | ClientShellOverlay::ContextMenu(_) + | ClientShellOverlay::GlobalMenu(_) + ); + } + matches!( + self.mode, + ClientShellMode::Prefix + | ClientShellMode::Navigate + | ClientShellMode::Resize + | ClientShellMode::Copy + ) + } + + pub(crate) fn reconcile_input_source(&mut self) { + // Keep the platform restore token while another window has focus. Restoring + // through a global key injection is only safe after this client regains focus. + if self.outer_focused == Some(false) { + return; + } + let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); + if desired != self.ascii_input_source_active { + self.ascii_input_source_active = desired; + self.pending_input_source_changes.push(desired); + } + } + + pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { + std::mem::take(&mut self.pending_input_source_changes) + } +} diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 014f51a0..abe50590 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -202,6 +202,16 @@ fn render_header_button( } } +fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str { + match status { + ClientEndpointStatus::Connecting => "connecting", + ClientEndpointStatus::Online => "online", + ClientEndpointStatus::Reconnecting => "reconnecting", + ClientEndpointStatus::Attention => "attention", + ClientEndpointStatus::Disabled => "disabled", + } +} + fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { let tabs = snapshot .tabs @@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher( buffer: &mut Buffer, area: Rect, snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, scroll: &mut usize, @@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher( let items = mobile_items( snapshot, + endpoints, + active_endpoint_id, config, selected_workspace_id, viewport.width.saturating_sub(1), @@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher( let end = start.saturating_add(item.lines.len()); if matches!( item.target.as_ref(), - Some(ClientMobileTarget::Workspace(workspace_id)) - if workspace_id == selected_workspace_id + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id ) { if start < *scroll { *scroll = start; @@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { fn mobile_items( snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, content_width: u16, ) -> Vec<MobileItem> { let palette = &config.palette; let mut items = Vec::new(); - let ordered_agents = - super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); - if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { - let title = snapshot - .agent_view_label - .as_deref() + if endpoints.len() > 1 { + items.push(MobileItem::section("machines", palette)); + for endpoint in endpoints { + let background = palette.panel_bg; + let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette); + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled(symbol, Style::default().fg(color).bg(background)), + Span::styled( + format!(" {}", endpoint.label), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + format!(" {state}"), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())), + }); + } + } + let agents = + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agent_view_label = snapshot.agent_view_label.as_deref(); + if !agents.is_empty() || agent_view_label.is_some() { + let title = agent_view_label .map(|label| format!("agents · {label}")) .unwrap_or_else(|| "agents".to_owned()); items.push(MobileItem::section(title, palette)); - if ordered_agents.is_empty() { + if agents.is_empty() { items.push(MobileItem { lines: vec![Line::from(Span::styled( " no matching agents", @@ -586,19 +631,19 @@ fn mobile_items( target: None, }); } - for pane_id in ordered_agents { - let Some(agent) = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - else { - continue; - }; - let workspace = snapshot + for row in agents { + let endpoint = row.endpoint; + let agent = row.agent; + let workspace = endpoint + .snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == agent.workspace_id); - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let tab = endpoint + .snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == agent.tab_id); let agent_label = agent .display_agent .as_deref() @@ -609,7 +654,8 @@ fn mobile_items( .map(|workspace| workspace.label.as_str()) .unwrap_or(agent_label); let mut detail = Vec::new(); - let workspace_tab_count = snapshot + let workspace_tab_count = endpoint + .snapshot .tabs .iter() .filter(|candidate| candidate.workspace_id == agent.workspace_id) @@ -633,11 +679,24 @@ fn mobile_items( }), ); detail.push(agent_label.to_owned()); - let background = if agent.focused { + if endpoint.stale() { + detail.push(mobile_endpoint_state(endpoint.status).to_owned()); + } + let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused { palette.surface_dim } else { palette.panel_bg }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; items.push(MobileItem { lines: vec![ Line::from(vec![ @@ -645,19 +704,24 @@ fn mobile_items( Span::styled( status_icon(agent.agent_status, config.status_indicators), Style::default() - .fg(status_color(agent.agent_status, palette)) - .bg(background), + .fg(if endpoint.stale() { + palette.overlay0 + } else { + status_color(agent.agent_status, palette) + }) + .bg(background) + .add_modifier(dim), ), Span::styled(" ", Style::default().bg(background)), Span::styled( crate::ui::truncate_end( - primary, + &format!("{} · {primary}", endpoint.label), usize::from(content_width.saturating_sub(5)), ), Style::default() - .fg(palette.text) + .fg(foreground) .bg(background) - .add_modifier(Modifier::BOLD), + .add_modifier(Modifier::BOLD | dim), ), ]), Line::from(Span::styled( @@ -665,11 +729,17 @@ fn mobile_items( &format!(" {}", detail.join(" · ")), usize::from(content_width), ), - Style::default().fg(palette.overlay0).bg(background), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), )), ], background, - target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + target: Some(ClientMobileTarget::Agent { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: agent.pane_id.clone(), + }), }); } } @@ -680,91 +750,119 @@ fn mobile_items( ClientMobileTarget::NewWorkspace, palette, )); - for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { - let Some(workspace) = snapshot.workspaces.get(entry.index) else { - continue; - }; - let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let background = if selected { - palette.surface0 - } else if workspace.focused { - palette.surface_dim - } else { - palette.panel_bg - }; - let connector = if entry.indented { - if entry.last_child { - "└─ " + for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) { + for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) { + let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = endpoint.endpoint_id == active_endpoint_id + && selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { + palette.surface_dim } else { - "├─ " - } - } else { - "" - }; - let name = if entry.indented && !workspace.custom_label { - workspace - .branch - .as_deref() - .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) - .unwrap_or(&workspace.label) - } else { - &workspace.label - }; - let branch = workspace.branch.as_deref().unwrap_or("shell"); - let detail_prefix = if entry.indented { - if entry.last_child { - " " + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } } else { - " │ " - } - } else { - " " - }; - items.push(MobileItem { - lines: vec![ - Line::from(vec![ - Span::styled( - format!(" {connector}"), - Style::default().fg(palette.overlay0).bg(background), - ), - Span::styled( - status_icon(workspace.agent_status, config.status_indicators), - Style::default() - .fg(status_color(workspace.agent_status, palette)) - .bg(background), - ), - Span::styled(" ", Style::default().bg(background)), - Span::styled( + "" + }; + let name = if entry.indented && !workspace.custom_label { + workspace + .branch + .as_deref() + .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch))) + .unwrap_or(&workspace.label) + } else { + &workspace.label + }; + let branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let status = if endpoint.stale() { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }; + let stale_detail = if endpoint.stale() { + format!(" · {}", mobile_endpoint_state(endpoint.status)) + } else { + String::new() + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default().fg(status).bg(background).add_modifier(dim), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + &format!("{} · {name}", endpoint.label), + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(foreground) + .bg(background) + .add_modifier(Modifier::BOLD | dim), + ), + ]), + Line::from(Span::styled( crate::ui::truncate_end( - name, - usize::from(content_width.saturating_sub(if entry.indented { - 8 - } else { - 5 - })), + &format!( + "{detail_prefix}{branch} · {}{stale_detail}", + compact_tab_status(endpoint.snapshot, workspace) + ), + usize::from(content_width), ), Style::default() - .fg(palette.text) + .fg(palette.overlay0) .bg(background) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(Span::styled( - crate::ui::truncate_end( - &format!( - "{detail_prefix}{branch} · {}", - compact_tab_status(snapshot, workspace) - ), - usize::from(content_width), - ), - Style::default().fg(palette.overlay0).bg(background), - )), - ], - background, - target: Some(ClientMobileTarget::Workspace( - workspace.workspace_id.clone(), - )), - }); + .add_modifier(dim), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }), + }); + } } if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { @@ -803,7 +901,10 @@ fn mobile_items( .add_modifier(Modifier::BOLD), ))], background, - target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + target: Some(ClientMobileTarget::Tab { + endpoint_id: active_endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }), }); } } @@ -890,6 +991,22 @@ impl ClientShellState { .find(|(rect, _)| super::contains(*rect, point)) .map(|(_, target)| target.clone()); match target { + Some(ClientMobileTarget::Machine(endpoint_id)) => { + if endpoint_id == self.active_endpoint_id { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } else if self.endpoint_is_online(&endpoint_id) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + } + } Some(ClientMobileTarget::NewWorkspace) => { self.mobile_switcher_suspended = true; self.record_binding( @@ -897,34 +1014,44 @@ impl ClientShellState { outcome, ); } - Some( - target @ (ClientMobileTarget::Workspace(_) - | ClientMobileTarget::Tab(_) - | ClientMobileTarget::Agent(_)), - ) => { - let method = match target { - ClientMobileTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { workspace_id }, - ) - } - ClientMobileTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { - tab_id, - }) - } - ClientMobileTarget::Agent(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { - pane_id, - }) - } - ClientMobileTarget::NewWorkspace - | ClientMobileTarget::NewTab - | ClientMobileTarget::Menu(_) => return true, - }; - self.mode = ClientShellMode::Terminal; - self.navigate_workspace_id = None; - self.push_endpoint_method(method, outcome); + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Agent { + endpoint_id, + pane_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Tab { + endpoint_id, + tab_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Tab(tab_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } } Some(ClientMobileTarget::NewTab) => { self.mobile_switcher_suspended = true; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 612319e2..48646f28 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -466,6 +466,9 @@ impl ClientShellState { if self.hits.workspace_body.height == 0 || point.1 < self.hits.workspace_body.y.saturating_sub(1) || point.1 >= self.hits.new_workspace.y + || self.hits.workspaces.iter().any(|hit| { + hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point) + }) { return None; } @@ -473,12 +476,17 @@ impl ClientShellState { .hits .workspaces .iter() - .filter(|hit| !hit.indented) + .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented) .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) .collect::<Vec<_>>(); let snapshot = self.snapshot.as_deref()?; let entries = render::workspace_entries(snapshot, &self.collapsed_groups); - let last_hit = self.hits.workspaces.last()?; + let last_hit = self + .hits + .workspaces + .iter() + .rev() + .find(|hit| hit.endpoint_id == self.active_endpoint_id)?; let last_position = entries.iter().position(|entry| { snapshot .workspaces @@ -1109,21 +1117,7 @@ impl ClientShellState { .max(mouse.row.abs_diff(press.start_row)); if delta >= 1 { let source_workspace_id = press.workspace_id.clone(); - let draggable = self - .snapshot - .as_deref() - .and_then(|snapshot| { - snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == source_workspace_id) - }) - .is_some_and(|workspace| { - !workspace - .worktree - .as_ref() - .is_some_and(|worktree| worktree.is_linked_worktree) - }); + let draggable = self.endpoint_workspace_is_draggable(press); if draggable { if let Some(target) = self.workspace_drop_target_at(point) { self.chrome_drag = Some(ClientChromeDrag::Workspace { @@ -1268,14 +1262,7 @@ impl ClientShellState { return; } if let Some(press) = self.workspace_press.take() { - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { - workspace_id: press.workspace_id, - }, - ), - outcome, - ); + self.finish_endpoint_workspace_press(press, outcome); return; } if let Some(press) = self.tab_press.take() { @@ -1552,14 +1539,14 @@ impl ClientShellState { .navigator_rows .iter() .find(|(rect, _)| super::contains(*rect, point)) - .copied(); + .cloned(); match mouse.kind { MouseEventKind::Moved => { - if let Some((_, index)) = row_hit { + if let Some((_, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target); } outcome.repaint = true; } @@ -1573,30 +1560,13 @@ impl ClientShellState { navigator.filter = None; } outcome.repaint = true; - } else if let Some((rect, index)) = row_hit { + } else if let Some((rect, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = index; + navigator.selected = Some(target.clone()); } - let workspace = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - render::client_navigator_rows(snapshot, navigator) - .get(index) - .map(|row| { - matches!( - row.target, - ClientNavigatorTarget::Workspace(_) - ) - }) - } - _ => None, - }) - .unwrap_or(false); + let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. }); if workspace && mouse.column <= rect.x.saturating_add(3) { self.toggle_selected_navigator_workspace(); outcome.repaint = true; @@ -1749,13 +1719,7 @@ impl ClientShellState { return; } let workspace_id = (!self.sidebar_collapsed) - .then(|| { - self.hits - .workspaces - .iter() - .find(|hit| super::contains(hit.rect, point)) - .map(|hit| hit.workspace_id.clone()) - }) + .then(|| self.active_endpoint_workspace_at(point)) .flatten(); if let Some(workspace_id) = workspace_id { self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); @@ -1949,6 +1913,9 @@ impl ClientShellState { outcome.repaint = true; return; } + if self.handle_endpoint_machine_click(point, outcome) { + return; + } if super::contains(self.hits.global_launcher, point) { self.toggle_global_menu(); outcome.repaint = true; @@ -2023,6 +1990,7 @@ impl ClientShellState { .iter() .find(|hit| super::contains(hit.rect, point)) .map(|hit| ClientWorkspacePress { + endpoint_id: hit.endpoint_id.clone(), workspace_id: hit.workspace_id.clone(), start_column: mouse.column, start_row: mouse.row, @@ -2059,6 +2027,9 @@ impl ClientShellState { self.tab_press = Some(tab_press); return; } + if self.handle_endpoint_agent_click(point, outcome) { + return; + } let agent_pane_id = self .hits .agents diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs new file mode 100644 index 00000000..f3eeeb3c --- /dev/null +++ b/src/client/shell/notification_policy.rs @@ -0,0 +1,319 @@ +use super::*; + +const MAX_QUEUED_NOTIFICATIONS: usize = 8; +const COMPLETION_EVIDENCE_GRACE: std::time::Duration = std::time::Duration::from_secs(1); +pub(super) const COMPLETION_RECHECK_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(50); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotificationValidation { + Current, + AwaitingSnapshot, + Stale, +} + +fn notification_duration(kind: SemanticNotificationKind) -> std::time::Duration { + std::time::Duration::from_secs(match kind { + SemanticNotificationKind::NeedsAttention => 8, + SemanticNotificationKind::Finished => 5, + SemanticNotificationKind::UpdateInstalled => 3, + SemanticNotificationKind::Custom => 5, + }) +} + +impl ClientShellState { + pub(super) fn retire_endpoint_notifications(&mut self, endpoint_id: &ClientEndpointId) { + self.pending_notifications + .retain(|pending| &pending.endpoint_id != endpoint_id); + self.queued_notifications + .retain(|queued| &queued.endpoint_id != endpoint_id); + if self + .visible_notification + .as_ref() + .is_some_and(|visible| &visible.endpoint_id == endpoint_id) + { + self.visible_notification = None; + self.promote_queued_notification(std::time::Instant::now()); + } + } + + fn queue_visible_notification( + &mut self, + mut notification: ClientVisibleNotification, + now: std::time::Instant, + ) { + if self.visible_notification.is_none() { + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + return; + } + if self.queued_notifications.len() == MAX_QUEUED_NOTIFICATIONS { + self.queued_notifications.pop_front(); + } + self.queued_notifications.push_back(notification); + } + + fn promote_queued_notification(&mut self, now: std::time::Instant) -> bool { + let Some(mut notification) = self.queued_notifications.pop_front() else { + return false; + }; + notification.deadline = now + notification_duration(notification.event.kind); + self.visible_notification = Some(notification); + true + } + + pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { + let Some(notification) = self.visible_notification.as_ref() else { + return; + }; + if notification.event.pane_id.is_some() + && !self.endpoint_is_online(¬ification.endpoint_id) + { + let label = self.endpoint_label(¬ification.endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is unavailable")); + outcome.repaint = true; + return; + } + let notification = self + .visible_notification + .take() + .expect("checked visible notification"); + self.promote_queued_notification(std::time::Instant::now()); + outcome.repaint = true; + let Some(pane_id) = notification.event.pane_id else { + return; + }; + if notification.endpoint_id == self.active_endpoint_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } else if self.endpoint_is_online(¬ification.endpoint_id) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: notification.endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }); + } + } + + pub(crate) fn receive_notification( + &mut self, + endpoint_id: &ClientEndpointId, + event: SemanticNotification, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let delay = if event.kind == SemanticNotificationKind::Custom { + 0 + } else { + self.config.toast_delay_seconds + }; + let deadline = now + .checked_add(std::time::Duration::from_secs(delay)) + .unwrap_or(now); + let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { + self.visible_notification.as_ref().is_some_and(|visible| { + visible.endpoint_id == *endpoint_id + && visible.event.pane_id.as_deref() == Some(pane_id) + }) + }); + if let Some(pane_id) = event.pane_id.as_deref() { + self.pending_notifications.retain(|pending| { + pending.endpoint_id != *endpoint_id + || pending.event.pane_id.as_deref() != Some(pane_id) + }); + self.queued_notifications.retain(|queued| { + queued.endpoint_id != *endpoint_id + || queued.event.pane_id.as_deref() != Some(pane_id) + }); + if cleared_visible { + self.visible_notification = None; + self.promote_queued_notification(now); + } + } + // Completion evidence is advisory. A Finished effect is valid only while the + // client-projected pane remains Done, even when delivery is immediate. + let validate_state = delay > 0 || event.kind == SemanticNotificationKind::Finished; + self.pending_notifications.push(ClientPendingNotification { + endpoint_id: endpoint_id.clone(), + event, + deadline, + expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now), + validate_state, + }); + let (effects, repaint) = self.tick_notifications(now); + (effects, repaint || cleared_visible) + } + + pub(crate) fn tick_notifications( + &mut self, + now: std::time::Instant, + ) -> (Vec<ClientShellNotificationEffect>, bool) { + let mut repaint = false; + if self + .visible_notification + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_notification = None; + self.promote_queued_notification(now); + repaint = true; + } + if self + .visible_endpoint_notice + .as_ref() + .is_some_and(|visible| now >= visible.deadline) + { + self.visible_endpoint_notice = None; + repaint = true; + } + + let pending = std::mem::take(&mut self.pending_notifications); + let mut effects = Vec::new(); + for pending in pending { + if pending.deadline > now { + self.pending_notifications.push(pending); + continue; + } + if pending.validate_state { + match self.notification_validation(&pending.endpoint_id, &pending.event) { + NotificationValidation::Current => {} + NotificationValidation::AwaitingSnapshot if now < pending.expires_at => { + let mut pending = pending; + pending.deadline = now + .checked_add(COMPLETION_RECHECK_INTERVAL) + .unwrap_or(now) + .min(pending.expires_at); + self.pending_notifications.push(pending); + continue; + } + NotificationValidation::AwaitingSnapshot | NotificationValidation::Stale => { + continue; + } + } + } + let target_active = + self.notification_target_is_active(&pending.endpoint_id, &pending.event); + let suppress_external = target_active && self.outer_focused != Some(false); + if let Some(sound) = pending.event.sound { + let suppress_sound = + pending.event.kind == SemanticNotificationKind::Finished && suppress_external; + if !suppress_sound { + effects.push(ClientShellNotificationEffect::Sound { + sound: match sound { + SemanticNotificationSound::Done => crate::sound::Sound::Done, + SemanticNotificationSound::Request => crate::sound::Sound::Request, + }, + agent: pending.event.agent.clone(), + }); + } + } + + match self.config.toast_delivery { + crate::config::ToastDelivery::Off => {} + crate::config::ToastDelivery::Herdr if !target_active => { + self.queue_visible_notification( + ClientVisibleNotification { + endpoint_id: pending.endpoint_id, + event: pending.event, + deadline: now, + }, + now, + ); + repaint = true; + } + crate::config::ToastDelivery::Herdr => {} + crate::config::ToastDelivery::Terminal if !suppress_external => { + effects.push(ClientShellNotificationEffect::Terminal { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::System if !suppress_external => { + effects.push(ClientShellNotificationEffect::System { + title: pending.event.title, + body: pending.event.body, + }); + } + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} + } + } + (effects, repaint) + } + + fn notification_target_is_active( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> bool { + if endpoint_id != &self.active_endpoint_id { + return false; + } + let Some(snapshot) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + else { + return false; + }; + if let Some(tab_id) = event.tab_id.as_deref() { + return snapshot.focused_tab_id.as_deref() == Some(tab_id); + } + event.workspace_id.as_deref().is_some_and(|workspace_id| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id) + }) + } + + fn notification_validation( + &self, + endpoint_id: &ClientEndpointId, + event: &SemanticNotification, + ) -> NotificationValidation { + let Some(pane_id) = event.pane_id.as_deref() else { + // Finished notifications carry no independently trustworthy completion state. Without + // a projected pane to verify as Done, do not emit completion chrome or sound. + return if event.kind == SemanticNotificationKind::Finished { + NotificationValidation::Stale + } else { + NotificationValidation::Current + }; + }; + let Some(agent) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| { + snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id) + }) + else { + return NotificationValidation::AwaitingSnapshot; + }; + match event.kind { + SemanticNotificationKind::NeedsAttention + if agent.agent_status == crate::api::schema::AgentStatus::Blocked => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Done => + { + NotificationValidation::Current + } + SemanticNotificationKind::Finished + if agent.agent_status == crate::api::schema::AgentStatus::Working => + { + NotificationValidation::AwaitingSnapshot + } + SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => { + NotificationValidation::Current + } + SemanticNotificationKind::NeedsAttention | SemanticNotificationKind::Finished => { + NotificationValidation::Stale + } + } + } +} diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs index 43fe566a..b8437ea7 100644 --- a/src/client/shell/notifications.rs +++ b/src/client/shell/notifications.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use super::notification_policy::COMPLETION_RECHECK_INTERVAL; use super::*; use ratatui::{ style::Color, @@ -5,7 +7,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -fn render_mobile_notice_banner( +pub(super) fn render_mobile_notice_banner( buffer: &mut Buffer, area: Rect, title: &str, @@ -102,25 +104,7 @@ pub(super) fn render_mobile_notification_banner( ) } -pub(super) fn render_mobile_endpoint_notice_banner( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - offset_for_warning: bool, - palette: &Palette, -) -> Rect { - render_mobile_notice_banner( - buffer, - area, - ¬ice.title, - Some(¬ice.body), - palette.red, - offset_for_warning, - palette, - ) -} - -fn render_notification_card( +pub(super) fn render_notification_card( buffer: &mut Buffer, area: Rect, title: &str, @@ -221,205 +205,13 @@ pub(super) fn render_visible_notification( ) } -pub(super) fn render_endpoint_notice( - buffer: &mut Buffer, - area: Rect, - notice: &ClientVisibleEndpointNotice, - top_offset: u16, - palette: &Palette, -) -> Rect { - render_notification_card( - buffer, - area, - ¬ice.title, - ¬ice.body, - crate::config::ToastHerdrPosition::TopRight, - top_offset, - match notice.key.kind { - ClientEndpointNoticeKind::Unsupported | ClientEndpointNoticeKind::Rejected => { - palette.red - } - ClientEndpointNoticeKind::Timeout | ClientEndpointNoticeKind::Unavailable => { - palette.yellow - } - }, - palette, - ) -} - -impl ClientShellState { - pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) { - let Some(notification) = self.visible_notification.take() else { - return; - }; - outcome.repaint = true; - if let Some(pane_id) = notification.event.pane_id { - self.push_endpoint_method( - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), - outcome, - ); - } - } - - pub(crate) fn receive_notification( - &mut self, - event: SemanticNotification, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let delay = if event.kind == SemanticNotificationKind::Custom { - 0 - } else { - self.config.toast_delay_seconds - }; - let deadline = now - .checked_add(std::time::Duration::from_secs(delay)) - .unwrap_or(now); - let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| { - self.visible_notification - .as_ref() - .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id)) - }); - if let Some(pane_id) = event.pane_id.as_deref() { - self.pending_notifications - .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id)); - if cleared_visible { - self.visible_notification = None; - } - } - self.pending_notifications.push(ClientPendingNotification { - event, - deadline, - validate_state: delay > 0, - }); - let (effects, repaint) = self.tick_notifications(now); - (effects, repaint || cleared_visible) - } - - pub(crate) fn tick_notifications( - &mut self, - now: std::time::Instant, - ) -> (Vec<ClientShellNotificationEffect>, bool) { - let mut repaint = false; - if self - .visible_notification - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_notification = None; - repaint = true; - } - if self - .visible_endpoint_notice - .as_ref() - .is_some_and(|visible| now >= visible.deadline) - { - self.visible_endpoint_notice = None; - repaint = true; - } - - let pending = std::mem::take(&mut self.pending_notifications); - let mut effects = Vec::new(); - for pending in pending { - if pending.deadline > now { - self.pending_notifications.push(pending); - continue; - } - if pending.validate_state && !self.notification_still_current(&pending.event) { - continue; - } - let target_active = self.notification_target_is_active(&pending.event); - let suppress_external = target_active && self.outer_focused != Some(false); - if let Some(sound) = pending.event.sound { - let suppress_sound = - pending.event.kind == SemanticNotificationKind::Finished && suppress_external; - if !suppress_sound { - effects.push(ClientShellNotificationEffect::Sound { - sound: match sound { - SemanticNotificationSound::Done => crate::sound::Sound::Done, - SemanticNotificationSound::Request => crate::sound::Sound::Request, - }, - agent: pending.event.agent.clone(), - }); - } - } - - match self.config.toast_delivery { - crate::config::ToastDelivery::Off => {} - crate::config::ToastDelivery::Herdr if !target_active => { - let duration = match pending.event.kind { - SemanticNotificationKind::NeedsAttention => 8, - SemanticNotificationKind::Finished => 5, - SemanticNotificationKind::UpdateInstalled => 3, - SemanticNotificationKind::Custom => 5, - }; - self.visible_notification = Some(ClientVisibleNotification { - event: pending.event, - deadline: now + std::time::Duration::from_secs(duration), - }); - repaint = true; - } - crate::config::ToastDelivery::Herdr => {} - crate::config::ToastDelivery::Terminal if !suppress_external => { - effects.push(ClientShellNotificationEffect::Terminal { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::System if !suppress_external => { - effects.push(ClientShellNotificationEffect::System { - title: pending.event.title, - body: pending.event.body, - }); - } - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} - } - } - (effects, repaint) - } - - fn notification_target_is_active(&self, event: &SemanticNotification) -> bool { - let Some(snapshot) = self.snapshot.as_deref() else { - return false; - }; - if let Some(tab_id) = event.tab_id.as_deref() { - return snapshot.focused_tab_id.as_deref() == Some(tab_id); - } - event.workspace_id.as_deref().is_some_and(|workspace_id| { - snapshot.focused_workspace_id.as_deref() == Some(workspace_id) - }) - } - - fn notification_still_current(&self, event: &SemanticNotification) -> bool { - let Some(pane_id) = event.pane_id.as_deref() else { - return true; - }; - let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| { - snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - }) else { - return false; - }; - match event.kind { - SemanticNotificationKind::NeedsAttention => { - agent.agent_status == crate::api::schema::AgentStatus::Blocked - } - SemanticNotificationKind::Finished => matches!( - agent.agent_status, - crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done - ), - SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true, - } - } -} - #[cfg(test)] mod tests { use super::*; fn notification() -> ClientVisibleNotification { ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notice".into(), @@ -463,6 +255,205 @@ mod tests { } #[test] + fn unavailable_notification_target_stays_visible_and_reports_the_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, + event: SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "agent needs attention".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("workspace".into()), + tab_id: Some("tab".into()), + pane_id: Some("pane".into()), + position: None, + }, + deadline: std::time::Instant::now() + std::time::Duration::from_secs(5), + }); + let mut outcome = ClientShellInput::default(); + + state.focus_visible_notification(&mut outcome); + + assert!(state.visible_notification.is_some()); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.body.contains("Local is unavailable"))); + assert!(outcome.repaint); + } + + #[test] + fn immediate_finished_evidence_for_acknowledged_idle_never_toasts_or_sounds() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + state.set_snapshot(Box::new(snapshot)); + + let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: Some(SemanticNotificationSound::Done), + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("tab_1".into()), + pane_id: Some("pane_1".into()), + position: None, + }, + std::time::Instant::now(), + ); + + assert!(effects.is_empty()); + assert!(!repaint); + assert!(state.visible_notification.is_none()); + } + + #[test] + fn finished_hint_waits_for_the_projected_completion_snapshot() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Terminal; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut snapshot = super::super::tests::snapshot(); + snapshot.agents.push(crate::protocol::ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("agent".into()), + display_agent: None, + agent: Some("agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: crate::api::schema::AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(snapshot.clone())); + let now = std::time::Instant::now(); + let event = SemanticNotification { + kind: SemanticNotificationKind::Finished, + title: "agent finished".into(), + body: None, + sound: None, + agent: Some("agent".into()), + workspace_id: Some("ws_1".into()), + tab_id: Some("background-tab".into()), + pane_id: Some("pane_1".into()), + position: None, + }; + + let (effects, _) = state.receive_notification(&ClientEndpointId::Local, event, now); + assert!(effects.is_empty()); + assert_eq!(state.pending_notifications.len(), 1); + + snapshot.revision = snapshot.revision.saturating_add(1); + snapshot.agents[0].agent_status = crate::api::schema::AgentStatus::Idle; + snapshot.agents[0].state_change_seq = 2; + state.set_snapshot(Box::new(snapshot)); + let (effects, _) = state.tick_notifications(now + COMPLETION_RECHECK_INTERVAL); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Terminal { title, .. }] if title == "agent finished" + )); + assert!(state.pending_notifications.is_empty()); + } + + #[test] + fn retiring_one_endpoint_preserves_other_endpoint_notifications() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut local = notification(); + local.event.title = "local".into(); + let remote_id = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + let mut remote = notification(); + remote.endpoint_id = remote_id.clone(); + remote.event.title = "remote".into(); + state.visible_notification = Some(local); + state.queued_notifications.push_back(remote); + + state.retire_endpoint_notifications(&ClientEndpointId::Local); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| (¬ification.endpoint_id, notification.event.title.as_str())), + Some((&remote_id, "remote")) + ); + } + + #[test] + fn concurrent_endpoint_notifications_are_queued_in_arrival_order() { + let mut config = Config::default(); + config.ui.toast.delivery = crate::config::ToastDelivery::Herdr; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let now = std::time::Instant::now(); + for title in ["first", "second"] { + state.receive_notification( + &ClientEndpointId::Local, + SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: title.into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: Some(title.into()), + position: None, + }, + now, + ); + } + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("first") + ); + assert_eq!(state.queued_notifications.len(), 1); + + state.tick_notifications(now + std::time::Duration::from_secs(5)); + + assert_eq!( + state + .visible_notification + .as_ref() + .map(|notification| notification.event.title.as_str()), + Some("second") + ); + assert!(state.queued_notifications.is_empty()); + } + + #[test] fn notification_rect_stays_inside_short_nonzero_area() { let palette = crate::app::client_palette_from_config(&Config::default()); for height in [1, 2] { diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 154b00b6..db089f4e 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -191,98 +191,123 @@ impl ClientShellState { } pub(super) fn open_navigator_overlay(&mut self) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let expanded_workspaces = snapshot - .workspaces - .iter() - .map(|workspace| workspace.workspace_id.clone()) - .collect(); + let expanded_workspaces = + super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints) + .flat_map(|endpoint| { + endpoint.snapshot.workspaces.iter().map(move |workspace| { + (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()) + }) + }) + .collect(); let mut navigator = ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces, }; - let rows = render::client_navigator_rows(snapshot, &navigator); - navigator.selected = rows.iter().position(|row| row.current).unwrap_or(0); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator); + navigator.selected = rows + .iter() + .find(|row| row.current) + .map(|row| row.target.clone()); self.overlay = Some(ClientShellOverlay::Navigator(navigator)); } pub(super) fn move_navigator_selection(&mut self, delta: isize) { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { return; }; - let rows = render::client_navigator_rows(snapshot, navigator); + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); if rows.is_empty() { - navigator.selected = 0; + navigator.selected = None; return; } - navigator.selected = (navigator.selected as isize + delta) - .clamp(0, rows.len().saturating_sub(1) as isize) as usize; + let selected = + super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0); + let next = + (selected as isize + delta).clamp(0, rows.len().saturating_sub(1) as isize) as usize; + navigator.selected = Some(rows[next].target.clone()); } pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { - let target = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .map(|row| row.target.clone()) - }; + let target = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator) + } + _ => None, + }); let Some(target) = target else { return; }; - self.overlay = None; - let method = match target { - ClientNavigatorTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { - workspace_id, - }) - } - ClientNavigatorTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }) + let activated = match target { + ClientNavigatorTarget::Machine { endpoint_id } => { + self.activate_endpoint(endpoint_id, outcome) } - ClientNavigatorTarget::Pane(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ), + ClientNavigatorTarget::Tab { + endpoint_id, + tab_id, + } => { + self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome) } + ClientNavigatorTarget::Pane { + endpoint_id, + pane_id, + } => self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ), }; - self.push_endpoint_method(method, outcome); + if activated { + self.overlay = None; + } outcome.repaint = true; } pub(super) fn toggle_selected_navigator_workspace(&mut self) { - let workspace_id = { - let Some(snapshot) = self.snapshot.as_deref() else { - return; - }; - let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_ref() else { - return; - }; - render::client_navigator_rows(snapshot, navigator) - .get(navigator.selected) - .and_then(|row| match &row.target { - ClientNavigatorTarget::Workspace(workspace_id) => Some(workspace_id.clone()), - _ => None, - }) - }; - if let (Some(workspace_id), Some(ClientShellOverlay::Navigator(navigator))) = - (workspace_id, self.overlay.as_mut()) + let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => { + let rows = render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ); + super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then( + |target| match target { + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } => Some((endpoint_id, workspace_id)), + _ => None, + }, + ) + } + _ => None, + }); + if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) = + (workspace_key, self.overlay.as_mut()) { - if !navigator.expanded_workspaces.remove(&workspace_id) { - navigator.expanded_workspaces.insert(workspace_id); + if !navigator.expanded_workspaces.remove(&workspace_key) { + navigator.expanded_workspaces.insert(workspace_key); } - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } } @@ -433,7 +458,7 @@ impl ClientShellState { Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => { navigator.query.push_str(text); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; true } _ => false, @@ -641,11 +666,11 @@ impl ClientShellState { if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if code == KeyCode::Backspace { navigator.query.pop(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } else if let KeyCode::Char(character) = code { if modifiers.difference(KeyModifiers::SHIFT).is_empty() { navigator.filter = None; @@ -654,7 +679,7 @@ impl ClientShellState { } else { navigator.query.push(character); } - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -664,7 +689,7 @@ impl ClientShellState { if code == KeyCode::Backspace && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { if navigator.filter.take().is_some() { - navigator.selected = 0; + navigator.selected = None; } } outcome.repaint = true; @@ -672,25 +697,23 @@ impl ClientShellState { } if code == KeyCode::Home && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = 0; + navigator.selected = None; navigator.scroll = 0; } outcome.repaint = true; return; } if matches!(code, KeyCode::End | KeyCode::Char('G')) && modifiers.is_empty() { - let last = self - .snapshot - .as_deref() - .zip(self.overlay.as_ref()) - .and_then(|(snapshot, overlay)| match overlay { - ClientShellOverlay::Navigator(navigator) => { - Some(render::client_navigator_rows(snapshot, navigator).len()) - } - _ => None, - }) - .unwrap_or(0) - .saturating_sub(1); + let last = self.overlay.as_ref().and_then(|overlay| match overlay { + ClientShellOverlay::Navigator(navigator) => render::client_navigator_rows( + &self.endpoints, + &self.active_endpoint_id, + navigator, + ) + .last() + .map(|row| row.target.clone()), + _ => None, + }); if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.selected = last; } @@ -735,7 +758,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = Some(filter); - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; @@ -744,7 +767,7 @@ impl ClientShellState { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.query.clear(); navigator.filter = None; - navigator.selected = 0; + navigator.selected = None; } outcome.repaint = true; return; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 30ac78b9..faf252a8 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -10,7 +10,7 @@ pub(crate) struct OverlayRender { pub(crate) cancel: Rect, pub(crate) navigator_popup: Rect, pub(crate) navigator_search: Rect, - pub(crate) navigator_rows: Vec<(Rect, usize)>, + pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(crate) worktree_search: Rect, pub(crate) worktree_rows: Vec<(Rect, usize)>, pub(crate) help_popup: Rect, @@ -33,6 +33,8 @@ pub(crate) fn render_client_overlay( b: &mut Buffer, o: &ClientShellOverlay, s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, k: &LiveKeybindConfig, p: &Palette, ) -> Option<OverlayRender> { @@ -58,7 +60,9 @@ pub(crate) fn render_client_overlay( ClientShellOverlay::Rename(v) => render_rename_overlay(b, v, p), ClientShellOverlay::ConfirmClose(v) => render_confirm_close_overlay(b, v, p), ClientShellOverlay::Help(v) => render_help_overlay(b, v, k, p), - ClientShellOverlay::Navigator(v) => render_navigator_overlay(b, v, s, p), + ClientShellOverlay::Navigator(v) => { + render_navigator_overlay(b, v, endpoints, active_endpoint_id, p) + } ClientShellOverlay::Settings(v) => { settings_overlay::render_settings_overlay(b, v, s.integration_updates_available, p) } @@ -669,91 +673,11 @@ fn render_rename_overlay( }) } -pub(crate) fn client_navigator_rows( - s: &ClientShellSnapshot, - n: &ClientNavigatorOverlay, -) -> Vec<ClientNavigatorRow> { - let q = n.query.trim().to_lowercase(); - let filter = |st| match n.filter { - Some(ClientNavigatorFilter::Blocked) => st == crate::api::schema::AgentStatus::Blocked, - Some(ClientNavigatorFilter::Working) => st == crate::api::schema::AgentStatus::Working, - Some(ClientNavigatorFilter::Idle) => st == crate::api::schema::AgentStatus::Idle, - Some(ClientNavigatorFilter::Done) => st == crate::api::schema::AgentStatus::Done, - None => true, - }; - let text = |v: &str| q.is_empty() || v.to_lowercase().contains(&q); - let filtering = n.filter.is_some() || !q.is_empty(); - let mut out = Vec::new(); - for w in &s.workspaces { - let mut children = Vec::new(); - for t in s.tabs.iter().filter(|t| t.workspace_id == w.workspace_id) { - let mut panes = Vec::new(); - for (ix, p) in s.panes.iter().filter(|p| p.tab_id == t.tab_id).enumerate() { - let a = s.agents.iter().find(|a| a.pane_id == p.pane_id); - let st = a - .map(|a| a.agent_status) - .unwrap_or(crate::api::schema::AgentStatus::Unknown); - let label = p - .label - .clone() - .or_else(|| a.and_then(|a| a.name.clone())) - .or_else(|| a.and_then(|a| a.display_agent.clone())) - .or_else(|| a.and_then(|a| a.title.clone())) - .unwrap_or_else(|| format!("pane {}", ix + 1)); - let meta = p - .foreground_cwd - .clone() - .or_else(|| p.cwd.clone()) - .unwrap_or_default(); - if !filtering || filter(st) && (text(&label) || text(&meta)) { - panes.push(ClientNavigatorRow { - depth: 2, - label, - meta, - status: st, - current: s.focused_pane_id.as_deref() == Some(&p.pane_id), - target: ClientNavigatorTarget::Pane(p.pane_id.clone()), - }) - } - } - if !filtering || filter(t.agent_status) && text(&t.label) || !panes.is_empty() { - children.push(ClientNavigatorRow { - depth: 1, - label: t.label.clone(), - meta: format!( - "{} panes", - s.panes.iter().filter(|p| p.tab_id == t.tab_id).count() - ), - status: t.agent_status, - current: s.focused_tab_id.as_deref() == Some(&t.tab_id), - target: ClientNavigatorTarget::Tab(t.tab_id.clone()), - }); - children.extend(panes) - } - } - let wm = - filter(w.agent_status) && (text(&w.label) || w.branch.as_deref().is_some_and(&text)); - if !filtering || wm || !children.is_empty() { - out.push(ClientNavigatorRow { - depth: 0, - label: w.label.clone(), - meta: w.branch.clone().unwrap_or_default(), - status: w.agent_status, - current: s.focused_workspace_id.as_deref() == Some(&w.workspace_id), - target: ClientNavigatorTarget::Workspace(w.workspace_id.clone()), - }); - if n.expanded_workspaces.contains(&w.workspace_id) || filtering { - out.extend(children) - } - } - } - out -} - fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, - s: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, p: &Palette, ) -> Option<OverlayRender> { let a = b.area; @@ -766,7 +690,7 @@ fn render_navigator_overlay( a.height.saturating_sub(my * 2).max(4), ); let i = panel(b, q, p.accent, p.panel_bg)?; - let rows = client_navigator_rows(s, n); + let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { format!(" / {}", n.query) } else if let Some(f) = n.filter { @@ -798,7 +722,12 @@ fn render_navigator_overlay( b, i, i.y, - &format!("{} panes", s.panes.len()), + &format!( + "{} panes", + rows.iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count() + ), Style::default().fg(p.overlay0).bg(p.panel_bg), ); put_text( @@ -810,14 +739,12 @@ fn render_navigator_overlay( Style::default().fg(p.surface1).bg(p.panel_bg), ); let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0); let max = rows.len().saturating_sub(body.height as usize); let scroll = n .scroll - .max( - n.selected - .saturating_sub(body.height.saturating_sub(1) as usize), - ) - .min(n.selected) + .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) + .min(selected) .min(max); let mut row_hits = Vec::new(); for (vis, (ix, r)) in rows @@ -828,8 +755,17 @@ fn render_navigator_overlay( .enumerate() { let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); - row_hits.push((rect, ix)); - let st = if ix == n.selected { + row_hits.push((rect, r.target.clone())); + let st = if r.stale { + Style::default() + .fg(p.overlay0) + .bg(if ix == selected { + p.surface0 + } else { + p.panel_bg + }) + .add_modifier(Modifier::DIM) + } else if ix == selected { Style::default() .fg(contrast(p)) .bg(p.accent) @@ -840,23 +776,92 @@ fn render_navigator_overlay( .bg(p.panel_bg) }; b.set_style(rect, st); - let tree = match r.depth { - 0 => match &r.target { - ClientNavigatorTarget::Workspace(id) if n.expanded_workspaces.contains(id) => "▾", - _ => "▸", - }, - 1 => "└──", - _ => " └──", + let tree = match &r.target { + ClientNavigatorTarget::Machine { .. } => "▾", + ClientNavigatorTarget::Workspace { + endpoint_id, + workspace_id, + } if n + .expanded_workspaces + .contains(&(endpoint_id.clone(), workspace_id.clone())) => + { + if r.depth == 0 { + "▾" + } else { + " ▾" + } + } + ClientNavigatorTarget::Workspace { .. } => { + if r.depth == 0 { + "▸" + } else { + " ▸" + } + } + ClientNavigatorTarget::Tab { .. } => { + if r.depth == 1 { + "└──" + } else { + " └──" + } + } + ClientNavigatorTarget::Pane { .. } => { + if r.depth == 2 { + " └──" + } else { + " └──" + } + } }; - let label = format!( - " {} {} {} {}", - if r.current { "◆" } else { " " }, - tree, - status_dot(r.status), - r.label - ); + let current = if r.current { "◆ " } else { "" }; + let status = r.status.map(status_dot).unwrap_or_default(); + let status_separator = if status.is_empty() { "" } else { " " }; + let label = format!(" {tree} {current}{status}{status_separator}{}", r.label); put_text(b, rect.x, rect.y, rect.width, &label, st); - if !r.meta.is_empty() { + if let Some(status) = r.status { + let prefix = format!(" {tree} {current}"); + let status_style = if r.stale || ix == selected { + st + } else { + Style::default().fg(status_color(status, p)).bg(p.panel_bg) + }; + put_text( + b, + rect.x.saturating_add(display_width(&prefix)), + rect.y, + display_width(status_dot(status)), + status_dot(status), + status_style, + ); + } + let machine_status = match &r.target { + ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .map(|endpoint| endpoint.status), + _ => None, + }; + if let Some(status) = machine_status { + let (glyph, state, color) = endpoint_status_presentation(status, p); + let signal = if status == ClientEndpointStatus::Online { + glyph.to_owned() + } else { + format!("{glyph} {state}") + }; + let signal_style = if ix == selected { + st + } else { + Style::default() + .fg(color) + .bg(p.panel_bg) + .add_modifier(if r.stale { + Modifier::DIM + } else { + Modifier::empty() + }) + }; + put_right_text(b, rect, rect.y, &signal, signal_style); + } else if !r.meta.is_empty() { let label_width = display_width(&label).min(rect.width); let meta = Rect::new( rect.x.saturating_add(label_width).saturating_add(1), @@ -868,7 +873,7 @@ fn render_navigator_overlay( } } let dy = i.bottom() - 2; - if let Some(r) = rows.get(n.selected) { + if let Some(r) = rows.get(selected) { put_text( b, i.x, diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 387695ec..ad9ac8b1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -3,16 +3,31 @@ use super::*; #[path = "../shell/overlays.rs"] mod overlays; #[path = "../shell/sidebar.rs"] -mod sidebar; +pub(in crate::client::shell) mod sidebar; #[path = "../shell/tabs.rs"] mod tabs; pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; -pub(super) use overlays::{ - client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu, -}; +pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows; +pub(super) use overlays::{render_client_overlay, render_context_menu, render_global_menu}; pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries}; pub(super) use tabs::{render_tab_bar, tab_bar_status_width}; + +pub(in crate::client::shell) fn render_sidebar_background( + buffer: &mut Buffer, + area: Rect, + palette: &Palette, +) { + buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); + let separator_x = area.right().saturating_sub(1); + for y in area.y..area.bottom() { + if let Some(cell) = buffer.cell_mut((separator_x, y)) { + cell.set_symbol("│"); + cell.set_style(Style::default().fg(palette.surface_dim)); + } + } +} + pub(super) fn render_mode_bar( buffer: &mut Buffer, pane_area: Rect, @@ -196,6 +211,9 @@ pub(super) fn render_mode_bar( } pub(super) struct ShellRenderState<'a> { + pub(super) endpoints: &'a [ClientShellEndpoint], + pub(super) active_endpoint_id: &'a ClientEndpointId, + pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>, pub(super) collapsed_groups: &'a HashSet<String>, pub(super) workspace_scroll: &'a mut usize, pub(super) agent_scroll: &'a mut usize, @@ -228,7 +246,26 @@ pub(super) fn render_shell( ); } if layout.sidebar.width > 0 { - if state.sidebar_collapsed { + if state.endpoints.len() > 1 { + if state.sidebar_collapsed { + super::endpoint_sidebar::render_collapsed( + buffer, + layout.sidebar, + config, + &mut state, + &mut hits, + ); + } else { + super::endpoint_sidebar::render_expanded( + buffer, + layout.sidebar, + Some(snapshot), + config, + &mut state, + &mut hits, + ); + } + } else if state.sidebar_collapsed { render_collapsed_sidebar( buffer, layout.sidebar, @@ -267,8 +304,10 @@ pub(super) fn render_shell( hits.agent_scrollbar = Rect::default(); hits.agent_sort_toggle = Rect::default(); hits.new_workspace = Rect::default(); + hits.machines.clear(); hits.workspaces.clear(); hits.agents.clear(); + hits.endpoint_agents.clear(); hits.tab_scroll_left = Rect::default(); hits.tab_scroll_right = Rect::default(); hits.new_tab = Rect::default(); @@ -277,7 +316,7 @@ pub(super) fn render_shell( hits } -fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { +pub(super) fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) { let width = display_width(text).min(area.width); put_text( buffer, diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs index d90d371f..200201c5 100644 --- a/src/client/shell/settings.rs +++ b/src/client/shell/settings.rs @@ -313,6 +313,9 @@ impl ClientShellState { (true, Vec::new()) } PendingEndpointKind::IntegrationInstall => { + let cancelled = result + .as_ref() + .is_err_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); self.pending_integration_installs = self.pending_integration_installs.saturating_sub(1); if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() { @@ -328,7 +331,8 @@ impl ClientShellState { } settings.installing_integrations = self.pending_integration_installs > 0; } - let actions = if self.pending_integration_installs == 0 + let actions = if !cancelled + && self.pending_integration_installs == 0 && matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { let mut deferred = ClientShellInput::default(); diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 2a71e297..0aaa0d51 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -4,7 +4,9 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; -fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) { +pub(in crate::client::shell) fn collapsed_sidebar_sections( + area: Rect, +) -> (Rect, Option<u16>, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); if content.is_empty() { return (Rect::default(), None, Rect::default()); @@ -85,6 +87,7 @@ pub(crate) fn render_collapsed_sidebar( ); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: false, group_toggle: None, @@ -308,6 +311,7 @@ pub(crate) fn render_sidebar( config.status_indicators, entry, rows, + true, selected, dragged, palette, @@ -330,6 +334,7 @@ pub(crate) fn render_sidebar( }); hits.workspaces.push(WorkspaceHit { rect, + endpoint_id: ClientEndpointId::Local, workspace_id: workspace.workspace_id.clone(), indented: entry.indented, group_toggle, @@ -440,17 +445,6 @@ pub(crate) fn render_sidebar( ); } -pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) { - buffer.set_style(area, Style::default().bg(palette.sidebar_bg)); - let separator_x = area.right().saturating_sub(1); - for y in area.y..area.bottom() { - if let Some(cell) = buffer.cell_mut((separator_x, y)) { - cell.set_symbol("│"); - cell.set_style(Style::default().fg(palette.surface_dim)); - } - } -} - pub(crate) fn workspace_entries( snapshot: &ClientShellSnapshot, collapsed_groups: &HashSet<String>, @@ -540,7 +534,7 @@ pub(crate) fn workspace_entries( entries } -fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { +pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> { let workspace = snapshot.workspaces.get(index)?; let worktree = workspace.worktree.as_ref()?; if worktree.is_linked_worktree { @@ -560,7 +554,7 @@ fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<Stri .then(|| worktree.key.clone()) } -fn displayed_workspace_status( +pub(super) fn displayed_workspace_status( snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace, collapsed_groups: &HashSet<String>, @@ -589,7 +583,7 @@ fn displayed_workspace_status( .unwrap_or(workspace.agent_status) } -fn workspace_rows( +pub(in crate::client::shell) fn workspace_rows( workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, indented: bool, @@ -618,7 +612,7 @@ fn workspace_rows( ) } -fn render_workspace_rows( +pub(in crate::client::shell) fn render_workspace_rows( buffer: &mut Buffer, area: Rect, workspace: &ClientShellWorkspace, @@ -626,6 +620,7 @@ fn render_workspace_rows( indicators: crate::config::StatusIndicatorStyle, entry: &WorkspaceEntry, rows: Vec<Vec<crate::ui::ResolvedToken>>, + endpoint_active: bool, selected: bool, dragged: bool, palette: &Palette, @@ -661,7 +656,7 @@ fn render_workspace_rows( } else { x = x.saturating_add(3); } - let highlighted = workspace.focused || dragged; + let highlighted = endpoint_active && workspace.focused || dragged; let workspace_style = Style::default() .fg(if highlighted { palette.text @@ -673,7 +668,7 @@ fn render_workspace_rows( } else { Modifier::empty() }); - let secondary_style = Style::default().fg(if workspace.focused { + let secondary_style = Style::default().fg(if endpoint_active && workspace.focused { palette.mauve } else { palette.overlay0 @@ -703,7 +698,7 @@ fn render_workspace_rows( Some(palette.selection_bg) } else if dragged { Some(palette.surface1) - } else if workspace.focused { + } else if endpoint_active && workspace.focused { Some(palette.active_row_bg) } else { None diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 60307f66..67bdff91 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -119,16 +119,27 @@ pub(super) struct ClientShellLayout { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum ClientMobileTarget { + Machine(ClientEndpointId), NewWorkspace, - Workspace(String), + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, NewTab, - Tab(String), - Agent(String), + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Agent { + endpoint_id: ClientEndpointId, + pane_id: String, + }, Menu(usize), } #[derive(Default)] pub(super) struct ShellHitMap { + pub(super) machines: Vec<MachineHit>, pub(super) workspaces: Vec<WorkspaceHit>, pub(super) workspace_body: Rect, pub(super) workspace_scrollbar: Rect, @@ -139,6 +150,7 @@ pub(super) struct ShellHitMap { pub(super) popup: Option<PaneHit>, pub(super) pane_splits: Vec<PaneSplitHit>, pub(super) agents: Vec<(Rect, String)>, + pub(super) endpoint_agents: Vec<(Rect, ClientEndpointId, String)>, pub(super) agent_body: Rect, pub(super) agent_scrollbar: Rect, pub(super) agent_scroll_metrics: Option<crate::pane::ScrollMetrics>, @@ -164,7 +176,7 @@ pub(super) struct ShellHitMap { pub(super) overlay_cancel: Rect, pub(super) navigator_popup: Rect, pub(super) navigator_search: Rect, - pub(super) navigator_rows: Vec<(Rect, usize)>, + pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, pub(super) worktree_search: Rect, pub(super) worktree_rows: Vec<(Rect, usize)>, pub(super) help_popup: Rect, @@ -215,6 +227,7 @@ pub(super) struct ClientPaneMouseGesture { } pub(super) struct ClientWorkspacePress { + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) start_column: u16, pub(super) start_row: u16, @@ -271,6 +284,7 @@ pub(super) enum ClientChromeDrag { pub(super) struct WorkspaceHit { pub(super) rect: Rect, + pub(super) endpoint_id: ClientEndpointId, pub(super) workspace_id: String, pub(super) indented: bool, pub(super) group_toggle: Option<(Rect, String)>, @@ -279,12 +293,17 @@ pub(super) struct WorkspaceHit { #[derive(Debug)] pub(crate) enum ClientShellAction { Endpoint { + endpoint_id: ClientEndpointId, boot_id: String, request: Box<crate::api::schema::Request>, }, ClipboardWrite(Vec<u8>), Request(ClientMessage), OpenSafeWebUrl(String), + ActivateEndpoint { + endpoint_id: ClientEndpointId, + target: Option<ClientEndpointFocusTarget>, + }, ReplayMouse(Vec<crossterm::event::MouseEvent>), Keybind(crate::input::KeybindAction), } @@ -366,11 +385,23 @@ pub(super) enum ClientNavigatorFilter { Done, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum ClientNavigatorTarget { - Workspace(String), - Tab(String), - Pane(String), + Machine { + endpoint_id: ClientEndpointId, + }, + Workspace { + endpoint_id: ClientEndpointId, + workspace_id: String, + }, + Tab { + endpoint_id: ClientEndpointId, + tab_id: String, + }, + Pane { + endpoint_id: ClientEndpointId, + pane_id: String, + }, } #[derive(Clone, Debug)] @@ -378,7 +409,8 @@ pub(super) struct ClientNavigatorRow { pub(super) depth: u8, pub(super) label: String, pub(super) meta: String, - pub(super) status: crate::api::schema::AgentStatus, + pub(super) status: Option<crate::api::schema::AgentStatus>, + pub(super) stale: bool, pub(super) current: bool, pub(super) target: ClientNavigatorTarget, } @@ -387,10 +419,10 @@ pub(super) struct ClientNavigatorRow { pub(super) struct ClientNavigatorOverlay { pub(super) query: String, pub(super) search_focused: bool, - pub(super) selected: usize, + pub(super) selected: Option<ClientNavigatorTarget>, pub(super) scroll: usize, pub(super) filter: Option<ClientNavigatorFilter>, - pub(super) expanded_workspaces: HashSet<String>, + pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>, } #[derive(Debug)] @@ -741,12 +773,15 @@ pub(crate) enum ClientShellNotificationEffect { } pub(super) struct ClientPendingNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, + pub(super) expires_at: std::time::Instant, pub(super) validate_state: bool, } pub(super) struct ClientVisibleNotification { + pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, pub(super) deadline: std::time::Instant, } @@ -861,6 +896,9 @@ pub(crate) struct ClientShellState { pub(super) config: ClientShellConfig, pub(super) snapshot: Option<Box<ClientShellSnapshot>>, pub(super) pane_surface: Option<PaneSurfaceFrame>, + /// A future projection surface waits here until its matching snapshot arrives. The visible + /// pane surface always remains an exact snapshot pair. + pub(super) pending_pane_surface: Option<PaneSurfaceFrame>, pub(super) graphics: crate::kitty_graphics::surface::ClientState, pub(super) graphics_cell_size: crate::kitty_graphics::HostCellSize, pub(super) popup_terminal_id: Option<String>, @@ -887,6 +925,9 @@ pub(crate) struct ClientShellState { pub(super) last_tab_bar_width: Option<u16>, pub(super) last_composed_size: Option<(u16, u16)>, pub(super) hits: ShellHitMap, + pub(super) endpoints: Vec<ClientShellEndpoint>, + pub(super) active_endpoint_id: ClientEndpointId, + pub(super) collapsed_endpoints: HashSet<ClientEndpointId>, pub(super) mode: ClientShellMode, pub(super) navigate_workspace_id: Option<String>, pub(super) overlay: Option<ClientShellOverlay>, @@ -917,14 +958,11 @@ pub(crate) struct ClientShellState { pub(super) popup_pending: bool, pub(super) popup_pending_deadline: Option<std::time::Instant>, pub(super) next_request_id: u64, - /// Methods advertised by this endpoint. `None` is used only by local tests - /// and legacy construction paths; negotiated endpoint connections always - /// install an explicit set. - pub(super) endpoint_methods: Option<HashSet<String>>, pub(super) pending_requests: HashMap<String, PendingEndpointRequest>, pub(super) pending_integration_installs: usize, pub(super) pending_notifications: Vec<ClientPendingNotification>, pub(super) visible_notification: Option<ClientVisibleNotification>, + pub(super) queued_notifications: VecDeque<ClientVisibleNotification>, pub(super) endpoint_notice_seen: HashSet<ClientEndpointNoticeKey>, pub(super) visible_endpoint_notice: Option<ClientVisibleEndpointNotice>, pub(super) outer_focused: Option<bool>, @@ -1000,6 +1038,7 @@ impl ClientShellState { config, snapshot: None, pane_surface: None, + pending_pane_surface: None, graphics: crate::kitty_graphics::surface::ClientState::default(), graphics_cell_size: crate::kitty_graphics::HostCellSize { width_px: 1, @@ -1029,6 +1068,9 @@ impl ClientShellState { last_tab_bar_width: None, last_composed_size: None, hits: ShellHitMap::default(), + endpoints: vec![local_endpoint()], + active_endpoint_id: ClientEndpointId::Local, + collapsed_endpoints: HashSet::new(), mode: ClientShellMode::Terminal, navigate_workspace_id: None, overlay, @@ -1059,11 +1101,11 @@ impl ClientShellState { popup_pending: false, popup_pending_deadline: None, next_request_id: 1, - endpoint_methods: None, pending_requests: HashMap::new(), pending_integration_installs: 0, pending_notifications: Vec::new(), visible_notification: None, + queued_notifications: VecDeque::new(), endpoint_notice_seen: HashSet::new(), visible_endpoint_notice: None, outer_focused: None, @@ -1078,29 +1120,6 @@ impl ClientShellState { } } - pub(crate) fn set_endpoint_methods(&mut self, methods: Option<Vec<String>>) { - self.endpoint_methods = methods.map(|methods| methods.into_iter().collect()); - } - - pub(super) fn supports_endpoint_method(&self, method: &crate::api::schema::Method) -> bool { - self.endpoint_methods - .as_ref() - .is_none_or(|methods| methods.contains(crate::api::api_method_name(method))) - } - - fn focused_tab_count(&self) -> usize { - let Some(snapshot) = self.snapshot.as_deref() else { - return 0; - }; - snapshot - .tabs - .iter() - .filter(|tab| { - Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref() - }) - .count() - } - pub(super) fn resume_mobile_switcher_if_ready(&mut self) -> bool { if !self.mobile_switcher_suspended || self.overlay.is_some() { return false; @@ -1173,16 +1192,80 @@ impl ClientShellState { } } - pub(crate) fn set_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) { + pub(super) fn reset_endpoint_projection(&mut self) { + self.hits = ShellHitMap::default(); + self.pane_surface = None; + self.pending_pane_surface = None; + self.input_leases = ClientInputLeases::default(); + self.popup_terminal_id = None; + self.chrome_drag = None; + self.workspace_press = None; + self.tab_press = None; + self.workspace_scroll = 0; + self.agent_scroll = 0; + self.tab_scroll = 0; + self.mobile_switcher_scroll = 0; + self.reveal_focused_workspace = true; + self.reveal_mobile_workspace = false; + self.mobile_switcher_suspended = false; + self.reveal_focused_tab = true; + self.last_tab_bar_width = None; + self.last_composed_size = None; + self.pending_requests.clear(); + self.pane_scroll_in_flight.clear(); + self.pane_scroll_queued.clear(); + self.pane_scroll_targets.clear(); + self.popup_pending = false; + self.popup_pending_deadline = None; + self.pending_integration_installs = 0; + self.endpoint_notice_seen.clear(); + self.visible_endpoint_notice = None; + self.endpoint_error = None; + self.navigate_workspace_id = None; + self.overlay = self + .config + .startup_onboarding + .then_some(ClientShellOverlay::Onboarding); + self.previous_pane_id = None; + self.pane_mouse_gesture = None; + self.url_click_consumes_until_up = false; + self.replaying_url_click = false; + self.selection = None; + self.last_pane_click = None; + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + self.copy_mode = None; + if self.mode == ClientShellMode::Copy { + self.mode = ClientShellMode::Terminal; + } + self.reset_copy_pipeline(); + self.copy_feedback = None; + self.copy_feedback_deadline = None; + self.host_mouse_pixels = None; + self.dismissed_product_announcement = None; + } + + pub(super) fn apply_active_snapshot(&mut self, mut snapshot: Box<ClientShellSnapshot>) { snapshot .commands .retain(|command| command.action != crate::protocol::ClientShellCommandAction::Unknown); - if self.snapshot.as_ref().is_some_and(|current| { - current.boot_id == snapshot.boot_id && snapshot.revision < current.revision - }) { + let graphics_scope = format!( + "{}:{}", + self.active_endpoint_id.storage_key(), + snapshot.boot_id + ); + let endpoint_boot_changed = + self.snapshot.is_some() && self.graphics.scope() != graphics_scope; + if !endpoint_boot_changed + && self.snapshot.as_ref().is_some_and(|current| { + current.boot_id == snapshot.boot_id && snapshot.revision < current.revision + }) + { return; } - self.graphics.set_scope(&snapshot.boot_id); + self.graphics.set_scope(&graphics_scope); let command_bindings_changed = self.snapshot.as_ref().is_none_or(|current| { current.commands.len() != snapshot.commands.len() || current @@ -1221,10 +1304,11 @@ impl ClientShellState { self.local_config_diagnostic.as_deref(), snapshot.config_diagnostic.as_deref(), ); - let boot_changed = self - .snapshot - .as_ref() - .is_some_and(|current| current.boot_id != snapshot.boot_id); + let boot_changed = endpoint_boot_changed + || self + .snapshot + .as_ref() + .is_some_and(|current| current.boot_id != snapshot.boot_id); if boot_changed || self .pane_surface @@ -1234,55 +1318,7 @@ impl ClientShellState { self.hits = ShellHitMap::default(); } if boot_changed { - self.pane_surface = None; - self.input_leases = ClientInputLeases::default(); - self.popup_terminal_id = None; - self.chrome_drag = None; - self.workspace_press = None; - self.tab_press = None; - self.workspace_scroll = 0; - self.agent_scroll = 0; - self.tab_scroll = 0; - self.mobile_switcher_scroll = 0; - self.reveal_focused_workspace = true; - self.reveal_mobile_workspace = false; - self.mobile_switcher_suspended = false; - self.reveal_focused_tab = true; - self.last_tab_bar_width = None; - self.last_composed_size = None; - self.pending_requests.clear(); - self.pane_scroll_in_flight.clear(); - self.pane_scroll_queued.clear(); - self.pane_scroll_targets.clear(); - self.popup_pending = false; - self.popup_pending_deadline = None; - self.pending_integration_installs = 0; - self.pending_notifications.clear(); - self.visible_notification = None; - self.endpoint_notice_seen.clear(); - self.visible_endpoint_notice = None; - self.endpoint_error = None; - self.navigate_workspace_id = None; - self.overlay = self - .config - .startup_onboarding - .then_some(ClientShellOverlay::Onboarding); - self.previous_pane_id = None; - self.pane_mouse_gesture = None; - self.url_click_consumes_until_up = false; - self.replaying_url_click = false; - self.selection = None; - self.last_pane_click = None; - self.selection_autoscroll = None; - self.selection_autoscroll_deadline = None; - self.selection_highlight_clear_deadline = None; - self.pending_word_selection = None; - self.copy_mode = None; - self.reset_copy_pipeline(); - self.copy_feedback = None; - self.copy_feedback_deadline = None; - self.host_mouse_pixels = None; - self.dismissed_product_announcement = None; + self.reset_endpoint_projection(); } else if let Some(previous) = self .snapshot .as_deref() @@ -1474,16 +1510,64 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + let pending_surface = self.pending_pane_surface.take(); + if let Some(surface) = pending_surface { + let matching = self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }); + if matching { + self.install_pane_surface(surface, false); + } else if self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision > snapshot.revision + }) { + self.pending_pane_surface = Some(surface); + } + } self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } - pub(crate) fn set_pane_surface(&mut self, mut surface: PaneSurfaceFrame) { + pub(crate) fn has_presented_surface(&self) -> bool { + self.pane_surface.is_some() + } + + pub(crate) fn set_pane_surface(&mut self, surface: PaneSurfaceFrame) { + let Some(snapshot) = self.snapshot.as_ref() else { + return; + }; + if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision { + return; + } + if self.pane_surface.as_ref().is_some_and(|current| { + current.boot_id == surface.boot_id + && (surface.projection_revision < current.projection_revision + || (surface.projection_revision == current.projection_revision + && surface.surface_revision < current.surface_revision)) + }) { + return; + } + if surface.projection_revision == snapshot.revision.saturating_add(1) { + // The next expected surface waits separately for its exact snapshot. Keeping the + // current pair avoids treating this speculative successor as presentation evidence. + self.pending_pane_surface = Some(surface); + self.hits = ShellHitMap::default(); + return; + } + // A surface that skips one or more revisions supersedes any retained pair, but is still + // not rendered until its matching snapshot arrives. Retain it monotonically so delayed + // intermediate surfaces cannot replace it. + self.install_pane_surface(surface, true); + } + + fn install_pane_surface(&mut self, mut surface: PaneSurfaceFrame, retain_future: bool) { let Some(snapshot) = self.snapshot.as_ref() else { return; }; if surface.boot_id != snapshot.boot_id || surface.projection_revision < snapshot.revision + || (!retain_future && surface.projection_revision != snapshot.revision) || self.pane_surface.as_ref().is_some_and(|current| { current.boot_id == surface.boot_id && (surface.projection_revision < current.projection_revision @@ -1493,13 +1577,12 @@ impl ClientShellState { { return; } - if self - .snapshot - .as_ref() - .is_none_or(|snapshot| snapshot.revision != surface.projection_revision) - { + // A retained future surface is not presentable yet. Clear hit targets immediately; the + // exact-pair compose guard prevents it from replacing the visible frame. + if surface.projection_revision != snapshot.revision { self.hits = ShellHitMap::default(); } + self.acknowledge_active_surface_agents(&surface); let previous_popup = self.popup_terminal_id.clone(); let next_popup = surface .popup @@ -1696,45 +1779,8 @@ impl ClientShellState { pub(crate) fn invalidate_pane_surface(&mut self) { self.pane_surface = None; + self.pending_pane_surface = None; self.hits = ShellHitMap::default(); self.host_mouse_pixels = None; } - - fn wants_ascii_input(&self) -> bool { - if let Some(overlay) = self.overlay.as_ref() { - return matches!( - overlay, - ClientShellOverlay::ConfirmClose(_) - | ClientShellOverlay::Help(_) - | ClientShellOverlay::Navigator(_) - | ClientShellOverlay::WorktreeRemove(_) - | ClientShellOverlay::ContextMenu(_) - | ClientShellOverlay::GlobalMenu(_) - ); - } - matches!( - self.mode, - ClientShellMode::Prefix - | ClientShellMode::Navigate - | ClientShellMode::Resize - | ClientShellMode::Copy - ) - } - - pub(crate) fn reconcile_input_source(&mut self) { - // Keep the platform restore token while another window has focus. Restoring - // through a global key injection is only safe after this client regains focus. - if self.outer_focused == Some(false) { - return; - } - let desired = self.config.switch_ascii_input_source_in_prefix && self.wants_ascii_input(); - if desired != self.ascii_input_source_active { - self.ascii_input_source_active = desired; - self.pending_input_source_changes.push(desired); - } - } - - pub(crate) fn take_input_source_changes(&mut self) -> Vec<bool> { - std::mem::take(&mut self.pending_input_source_changes) - } } diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 2ccfe77b..10f97118 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() { &state.overlay, Some(ClientShellOverlay::WorktreeCreate(create)) if create.checkout_path - == std::path::Path::new("/tmp/herdr-worktrees") - .join("repo") - .join("feature-client-shell").to_string_lossy() + == "/tmp/herdr-worktrees/repo/feature-client-shell" )); let submit = state.handle_input_bytes(b"\r"); let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { @@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { state.set_pane_surface(surface()); let now = std::time::Instant::now(); let (effects, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "codex needs attention".into(), @@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_none()); state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "first".into(), @@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { assert!(state.visible_notification.is_some()); state.config.toast_delay_seconds = 1; let (_, repaint) = state.receive_notification( + &ClientEndpointId::Local, SemanticNotification { kind: SemanticNotificationKind::NeedsAttention, title: "replacement".into(), diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index e8555db6..7d71a981 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { state.handle_input_bytes(b"\x1b"); state.handle_input_bytes(b"a"); state.compose(106, 30).expect("navigator rows"); - let pane_index = { - let snapshot = state.snapshot.as_deref().expect("snapshot"); + let pane_target = { let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") else { panic!("expected navigator"); }; - render::client_navigator_rows(snapshot, navigator) + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) .iter() - .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_))) + .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .map(|row| row.target.clone()) .expect("pane row") }; let pane_rect = state .hits .navigator_rows .iter() - .find(|(_, index)| *index == pane_index) + .find(|(_, target)| *target == pane_target) .map(|(rect, _)| *rect) .expect("visible pane row"); let select = diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs new file mode 100644 index 00000000..8149b530 --- /dev/null +++ b/src/client/shell/tests/endpoint_requests.rs @@ -0,0 +1,298 @@ +use super::*; +use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus}; + +fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let binding = crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("t"), + label: "prefix+t".into(), + command: "popup-command".into(), + action: crate::config::CustomCommandAction::Popup, + description: None, + width: None, + height: None, + }; + let mut projection = snapshot(); + projection + .commands + .push(crate::protocol::ClientShellCommand { + command_id: "cmd_popup".into(), + binding_label: binding.label.clone(), + binding_labels: binding.bindings.labels(), + action: crate::protocol::ClientShellCommandAction::Popup, + description: None, + }); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome); + assert!(state.popup_pending); + (state, outcome.actions) +} + +fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut outcome, + ); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected worktree preparation"); + }; + state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None))); + state.handle_input_bytes(b"feature/reconnect"); + let outcome = state.handle_input_bytes(b"\r"); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating + )); + (state, outcome.actions) +} + +fn request_id(actions: &[ClientShellAction]) -> &str { + let [ClientShellAction::Endpoint { request, .. }] = actions else { + panic!("expected one endpoint request"); + }; + &request.id +} + +#[test] +fn cancelling_popup_request_unblocks_input_and_ignores_late_success() { + let (mut state, actions) = pending_popup(); + let id = request_id(&actions); + assert!(state.cancel_endpoint_request(id)); + assert!(!state.popup_pending); + assert!(state.pending_requests.is_empty()); + assert!(state + .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {})) + .1 + .is_empty()); + assert!(!state.popup_pending); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +#[test] +fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() { + let (mut state, _) = pending_worktree(); + state.mark_endpoint_disconnected(&ClientEndpointId::Local); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.set_pane_surface(surface()); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))]); + assert!(state.overlay.is_none()); + assert!(!state.handle_input_bytes(b"x").requests.is_empty()); +} + +struct TestTransport { + fail: bool, +} + +impl crate::client::endpoint::EndpointTransport for TestTransport { + fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> { + if self.fail { + Err(std::io::ErrorKind::BrokenPipe.into()) + } else { + Ok(()) + } + } +} + +#[test] +fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + for fail_send in [false, true] { + let (mut state, actions) = pending_worktree(); + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: fail_send }, + 1, + EndpointNegotiation::default(), + ); + endpoints.set_surface_active(&ClientEndpointId::Local, fail_send); + let mut commands = EndpointCommands::default(); + let (tx, _rx) = tokio::sync::mpsc::channel(16); + let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions( + actions, + &mut commands, + &mut endpoints, + Some(&mut state), + &mut Vec::new(), + &tx, + ) + .unwrap(); + assert!(repaint); + assert!(replay.is_empty()); + assert!(state.pending_requests.is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating + )); + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| { notice.title == "Action interrupted" })); + assert!(commands.disconnect(&ClientEndpointId::Local).is_empty()); + } +} + +#[test] +fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + let (mut state, actions) = pending_popup(); + let stale_id = request_id(&actions).to_owned(); + let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into())); + let current_id = request_id(¤t).to_owned(); + let mut commands = EndpointCommands::default(); + for (generation, actions) in [(1, actions), (2, current)] { + for action in actions { + let ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } = action + else { + panic!("expected endpoint request"); + }; + commands.enqueue(endpoint_id, generation, boot_id, request); + } + } + let mut endpoints = EndpointRegistry::new( + TestTransport { fail: false }, + 2, + EndpointNegotiation::default(), + ); + let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints); + assert_eq!(cancelled, vec![stale_id.clone()]); + state.cancel_endpoint_request(&stale_id); + assert!(!state.popup_pending); + assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id)); + assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", ¤t_id)); + assert!(state.pending_requests.contains_key(¤t_id)); +} + +#[test] +fn cancelled_integration_install_does_not_queue_a_refresh() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.open_settings_overlay(); + let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else { + panic!("settings overlay"); + }; + settings.installing_integrations = true; + state.pending_integration_installs = 1; + let mut outcome = ClientShellInput::default(); + assert!(state.push_endpoint_method_with_kind( + crate::api::schema::Method::IntegrationInstall( + crate::api::schema::IntegrationInstallParams { + target: crate::api::schema::IntegrationTarget::Pi, + } + ), + PendingEndpointKind::IntegrationInstall, + &mut outcome, + )); + assert!(state.cancel_endpoint_request(request_id(&outcome.actions))); + assert!(state.pending_requests.is_empty()); + assert_eq!(state.pending_integration_installs, 0); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::Settings(settings)) + if !settings.installing_integrations && !settings.loading_integrations + )); +} + +#[test] +fn cancelled_selection_does_not_replay_its_fallback_key() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.selection = Some(crate::selection::Selection::absolute_range( + "pane_1".into(), + (0, 0), + (0, 2), + )); + let mut outcome = ClientShellInput::default(); + state.request_selection_copy_with_fallback( + &mut outcome, + Some(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + )), + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.pending_requests.is_empty()); +} + +#[test] +fn cancelled_link_activation_does_not_replay_mouse_input() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + let pane_id = state.hits.panes[0].pane_id.clone(); + let inner_rect = state.hits.panes[0].inner_rect; + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events: vec![MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: inner_rect.x, + row: inner_rect.y, + modifiers: KeyModifiers::NONE, + }], + }, + &mut outcome, + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + request_id(&outcome.actions), + Err(ClientShellEndpointError { + code: Some("endpoint_cancelled".into()), + message: "cancelled".into(), + }), + ); + assert!(actions.is_empty()); + assert!(state.url_click_consumes_until_up); +} + +#[test] +fn another_machine_disconnect_does_not_cancel_active_popup() { + let (mut state, _) = pending_popup(); + let remote = ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + state.mark_endpoint_disconnected(&remote); + assert!(state.popup_pending); + assert_eq!(state.pending_requests.len(), 1); +} diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs new file mode 100644 index 00000000..52c71923 --- /dev/null +++ b/src/client/shell/tests/endpoints.rs @@ -0,0 +1,1205 @@ +use super::*; +use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, +}; +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; + +fn remote_profile() -> SavedSshEndpoint { + SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "dev@build.example".into(), + session: "agents".into(), + enabled: true, + } +} + +fn agent( + name: &str, + status: crate::api::schema::AgentStatus, + state_change_seq: u64, +) -> ClientShellAgent { + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some(name.into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: status, + state_change_seq, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + } +} + +fn state_with_remote() -> (ClientShellState, ClientEndpointId) { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.workspaces[0].label = "remote-workspace".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + (state, endpoint_id) +} + +#[test] +fn switching_machines_from_copy_mode_restores_terminal_input() { + let (mut state, remote) = state_with_remote(); + let mut local_surface = surface(); + local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(local_surface); + state.compose(100, 28).unwrap(); + assert!(state.enter_copy_mode(&mut ClientShellInput::default())); + assert_eq!(state.mode, ClientShellMode::Copy); + + assert!(state.activate_endpoint_projection(&remote)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.compose(100, 28).unwrap(); + + assert!(state.copy_mode.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))]); + assert!(matches!( + input.requests.as_slice(), + [ClientMessage::ClientShellPaneInput { pane_id, events }] + if pane_id == "pane_1" && events.len() == 1 + )); +} + +#[test] +fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() { + let (mut state, remote) = state_with_remote(); + let mut profile = remote_profile(); + profile.label = "Renamed".into(); + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!(state.endpoint_label(&remote), "Renamed"); + assert!(state.endpoint_is_online(&remote)); + assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot")); + profile.enabled = false; + state.set_endpoint_catalog(&[profile.clone()]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Disabled) + ); + assert!(!state.endpoint_has_snapshot(&remote)); + profile.enabled = true; + state.set_endpoint_catalog(&[profile]); + assert_eq!( + state.endpoint_status(&remote), + Some(ClientEndpointStatus::Connecting) + ); + assert!(!state.endpoint_has_snapshot(&remote)); +} + +#[test] +fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() { + let (mut state, remote) = state_with_remote(); + assert!(state.activate_endpoint_projection(&remote)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Prefix; + state.overlay = Some(ClientShellOverlay::Onboarding); + state.select_unavailable_local(); + state.retire_endpoint(&remote); + state.set_endpoint_catalog(&[]); + assert!(state.endpoint_is_active(&ClientEndpointId::Local)); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + assert!(state.pending_pane_surface.is_none()); + assert!(state.overlay.is_none()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local)); + let frame = state.compose(100, 30).unwrap(); + let buffer = frame.to_ratatui_buffer().unwrap(); + let text = buffer + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::<String>(); + assert!(!text.contains("remote-workspace")); +} + +#[test] +fn machine_navigation_does_not_require_a_local_snapshot_or_surface() { + for (cols, rows) in [(100, 28), (36, 18)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = remote_profile(); + let remote = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting); + state.set_endpoint_status(&remote, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote, Box::new(snapshot())); + assert!(state.snapshot.is_none()); + assert!(state.pane_surface.is_none()); + let frame = state + .compose(cols, rows) + .expect("connection chrome without Local"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .unwrap() + .rect; + let buffer = frame.to_ratatui_buffer().unwrap(); + let local_row = (local.x..local.right()) + .map(|x| buffer[(x, local.y)].symbol()) + .collect::<String>(); + assert!(!local_row.contains("reconnecting")); + assert!( + !local_row.contains('◐'), + "Local never gets a connection badge" + ); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == remote) + .unwrap() + .rect; + let mut outcome = ClientShellInput::default(); + state.handle_mouse( + crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x + 5, + row: hit.y, + modifiers: KeyModifiers::NONE, + }, + &mut outcome, + ); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote) + ); + assert!( + state.snapshot.is_none(), + "selection is committed only by coherent activation" + ); + } +} + +#[test] +fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() { + let (mut state, _) = state_with_remote(); + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("Local")); + assert!(text.contains("Build")); + assert!(text.contains("remote-workspace")); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); + assert_eq!( + buffer[(remote.right() - 1, remote.y)].fg, + state.config.palette.green + ); + + state.sidebar_collapsed = true; + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let local = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("collapsed local machine row") + .rect; + let remote = state + .hits + .machines + .iter() + .find(|hit| !hit.endpoint_id.is_local()) + .expect("collapsed remote machine row") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●"); + assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●"); +} + +#[test] +fn active_workspace_is_the_only_highlight_when_machine_is_expanded() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_ne!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); + assert_eq!( + buffer[(workspace.x + 2, workspace.y)].bg, + state.config.palette.active_row_bg + ); + + state.collapsed_endpoints.insert(endpoint_id.clone()); + let frame = state.compose(100, 28).expect("collapsed endpoint frame"); + let machine = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote machine hit") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + assert_eq!( + buffer[(machine.x, machine.y)].bg, + state.config.palette.active_row_bg + ); +} + +#[test] +fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let mut config = Config::default(); + config.ui.status_indicators = StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let frame = state.compose(100, 28).expect("combined endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("○ Local · local agent"), "frame: {text}"); + assert!(text.contains("× Build · remote agent"), "frame: {text}"); + assert!(text.contains("grouped"), "frame: {text}"); + let toggle = state.hits.agent_sort_toggle; + assert!(!toggle.is_empty()); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + + let buffer = frame + .to_ratatui_buffer() + .expect("aggregate frame should reconstruct"); + assert!(buffer + .content() + .iter() + .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red)); +} + +#[test] +fn aggregate_priority_uses_client_observed_recency_across_machines() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.sidebar.agents.rows = + vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let profile = remote_profile(); + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 1)]; + state.set_snapshot(Box::new(local)); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + + let mut local = snapshot(); + local.agents = vec![agent("local agent", AgentStatus::Idle, 2)]; + state.set_snapshot(Box::new(local)); + let frame_text = |state: &mut ClientShellState| { + let frame = state.compose(100, 28).expect("combined endpoint frame"); + frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + }; + let text = frame_text(&mut state); + assert!( + text.find("Local · local agent").expect("local agent") + < text.find("Build · remote agent").expect("remote agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + let text = frame_text(&mut state); + assert!( + text.find("Build · remote agent").expect("remote agent") + < text.find("Local · local agent").expect("local agent") + ); + let mut outcome = ClientShellInput::default(); + assert!( + state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,) + ); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); +} + +#[test] +fn unselected_endpoint_completion_projects_done_client_side() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + remote.agents = vec![agent("background agent", AgentStatus::Working, 2)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone())); + remote.revision = 2; + remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)]; + + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let status = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.agents.first()) + .map(|agent| agent.agent_status); + assert_eq!(status, Some(AgentStatus::Done)); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn clicking_remote_endpoint_requests_activation_without_mutating_projection() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote endpoint hit") + .rect; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); + assert_eq!( + state + .snapshot + .as_deref() + .map(|snapshot| snapshot.boot_id.as_str()), + Some("boot-1") + ); +} + +#[test] +fn context_menu_lookup_ignores_inactive_endpoint_workspaces() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("combined endpoint frame"); + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + + assert_eq!( + state.active_endpoint_workspace_at((remote.x, remote.y)), + None + ); + assert_eq!( + state.active_endpoint_workspace_at((local.x, local.y)), + Some("ws_1".into()) + ); +} + +#[test] +fn future_surface_waits_for_its_exact_snapshot_revision() { + let (mut state, _) = state_with_remote(); + let mut future = surface(); + future.projection_revision = 2; + future.surface_revision = 2; + state.set_pane_surface(future); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(1) + ); + assert_eq!( + state + .pending_pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + + let mut next = snapshot(); + next.revision = 2; + state.set_snapshot(Box::new(next)); + assert_eq!( + state + .pane_surface + .as_ref() + .map(|surface| surface.projection_revision), + Some(2) + ); + assert!(state.pending_pane_surface.is_none()); +} + +#[test] +fn inactive_endpoint_snapshot_cache_never_regresses_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut newest = snapshot(); + newest.boot_id = "remote-boot".into(); + newest.revision = 3; + newest.workspaces[0].label = "newest".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(newest)); + let mut delayed = snapshot(); + delayed.boot_id = "remote-boot".into(); + delayed.revision = 2; + delayed.workspaces[0].label = "delayed".into(); + + state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed)); + + let label = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .and_then(|snapshot| snapshot.workspaces.first()) + .map(|workspace| workspace.label.as_str()); + assert_eq!(label, Some("newest")); +} + +#[test] +fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() { + let (mut state, endpoint_id) = state_with_remote(); + let mut previous = snapshot(); + previous.boot_id = "shared-server-boot".into(); + previous.revision = 9; + previous.workspaces[0].label = "old connection".into(); + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous)); + let mut reconnected = snapshot(); + reconnected.boot_id = "shared-server-boot".into(); + reconnected.revision = 1; + reconnected.workspaces[0].label = "new connection".into(); + + state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected)); + + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + assert_eq!(endpoint.snapshot_generation, Some(5)); + assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1); + assert_eq!( + endpoint.snapshot.as_ref().unwrap().workspaces[0].label, + "new connection" + ); +} + +#[test] +fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() { + let (mut state, endpoint_id) = state_with_remote(); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.mark_endpoint_disconnected(&endpoint_id); + let mut replacement = snapshot(); + replacement.boot_id = "replacement-boot".into(); + state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement)); + assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot"); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!(state.activate_endpoint_projection(&endpoint_id)); + assert_eq!( + state.snapshot.as_deref().unwrap().boot_id, + "replacement-boot" + ); +} + +#[test] +fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() { + use crate::api::schema::AgentStatus; + use crate::config::{AgentSidebarToken, StatusIndicatorStyle}; + + let (mut state, endpoint_id) = state_with_remote(); + state.config.status_indicators = StatusIndicatorStyle::Symbols; + state.config.agents.rows = vec![vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, + AgentSidebarToken::Agent, + ]]; + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint"); + endpoint.snapshot.as_mut().expect("remote snapshot").agents = + vec![agent("remote agent", AgentStatus::Blocked, 1)]; + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.pending_integration_installs = 2; + + state.mark_endpoint_disconnected(&endpoint_id); + let frame = state.compose(100, 28).expect("frozen endpoint frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + + assert_eq!(state.pending_integration_installs, 0); + assert_eq!( + state.endpoint_status(&endpoint_id), + Some(ClientEndpointStatus::Reconnecting) + ); + assert!(text.contains("◐ reconnecting"), "frame: {text}"); + assert!(text.contains("Build · remote agent"), "frame: {text}"); + assert!( + text.contains("LIVE"), + "frozen surface should remain: {text}" + ); + assert!(state.hits.panes.is_empty()); + assert!(frame.cursor.is_none()); + let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct"); + let stale_icon = buffer + .content() + .iter() + .find(|cell| cell.symbol() == "×") + .expect("stale blocked icon"); + assert_eq!(stale_icon.fg, state.config.palette.overlay0); +} + +#[cfg(unix)] +#[test] +fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + let local_scope = state.graphics_scope().to_owned(); + let mut remote = snapshot(); + remote.boot_id = "boot-1".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let remote_scope = state.graphics_scope(); + assert_ne!(local_scope, remote_scope); + assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:")); +} + +#[test] +fn navigator_uses_machine_parents_only_for_federated_clients() { + let (mut state, _) = state_with_remote(); + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machines = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. })) + .collect::<Vec<_>>(); + assert_eq!( + machines + .iter() + .map(|row| row.label.as_str()) + .collect::<Vec<_>>(), + vec!["Local", "Build"] + ); + assert!(rows.iter().all(|row| { + matches!(row.target, ClientNavigatorTarget::Machine { .. }) + || (!row.label.contains("Local ·") && !row.label.contains("Build ·")) + })); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(), + ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(), + ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(), + ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + })); + assert_eq!(rows.iter().filter(|row| row.current).count(), 1); + + let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + local.set_snapshot(Box::new(snapshot())); + local.set_pane_surface(surface()); + let frame = local.compose(100, 28).expect("local-only sidebar"); + assert!(local.hits.machines.is_empty()); + assert!(!frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n") + .contains(" machines")); + local.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator); + assert!(rows + .iter() + .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. }))); + assert!(rows.iter().all(|row| match row.target { + ClientNavigatorTarget::Workspace { .. } => row.depth == 0, + ClientNavigatorTarget::Tab { .. } => row.depth == 1, + ClientNavigatorTarget::Pane { .. } => row.depth == 2, + ClientNavigatorTarget::Machine { .. } => false, + })); +} + +#[test] +fn navigator_keeps_saved_machine_visible_before_metadata_arrives() { + let (mut state, endpoint_id) = state_with_remote(); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("saved remote endpoint"); + endpoint.snapshot = None; + endpoint.status = ClientEndpointStatus::Connecting; + state.open_navigator_overlay(); + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + + assert!(rows.iter().any(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) && row.label == "Build" + && row.stale + })); + assert!(!rows.iter().any(|row| match &row.target { + ClientNavigatorTarget::Machine { .. } => false, + ClientNavigatorTarget::Workspace { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Tab { + endpoint_id: target, + .. + } + | ClientNavigatorTarget::Pane { + endpoint_id: target, + .. + } => target == &endpoint_id, + })); +} + +#[test] +fn navigator_machine_selection_opens_its_remembered_view() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .into_iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .map(|row| row.target) + .expect("remote machine row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn navigator_foreign_pane_selection_activates_its_endpoint() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote pane row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut local = snapshot(); + let mut inserted = local.workspaces[0].clone(); + inserted.workspace_id = "ws_2".into(); + inserted.focused = false; + local.workspaces.push(inserted); + state.set_snapshot(Box::new(local)); + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + assert!(state.overlay.is_none()); +} + +#[test] +fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_agent = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Agent { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + .then_some(*rect) + }) + .expect("remote agent target"); + let click = |rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + let outcome = state.handle_raw_events(vec![click(remote_agent)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if activated == &endpoint_id && pane_id == "pane_1" + )); + + state.mode = ClientShellMode::Navigate; + state.compose(44, 30).expect("mobile switcher"); + let remote_workspace = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("remote workspace target"); + let outcome = state.handle_raw_events(vec![click(remote_workspace)]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)), + }] if activated == &endpoint_id && workspace_id == "ws_1" + )); +} + +#[test] +fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)]; + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let machine = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id + ) + }) + .expect("cached remote machine row"); + assert!(machine.stale); + let row = rows + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Pane { + endpoint_id: target_endpoint, + pane_id, + } if target_endpoint == &endpoint_id && pane_id == "pane_1" + ) + }) + .expect("cached remote pane row"); + assert!(row.stale); + assert!(!row.meta.contains("reconnecting")); + row.target.clone() + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + let mut navigator_outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut navigator_outcome); + assert!(navigator_outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(_)) + )); + + state.overlay = None; + state.mode = ClientShellMode::Navigate; + let frame = state.compose(44, 30).expect("offline mobile switcher"); + let mobile_target = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!( + target, + ClientMobileTarget::Workspace { + endpoint_id: target_endpoint, + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" + ) + .then_some(*rect) + }) + .expect("cached remote workspace target"); + let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer"); + assert_eq!( + buffer[(mobile_target.x, mobile_target.y)].fg, + state.config.palette.overlay0 + ); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: mobile_target.x, + row: mobile_target.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.active_endpoint_id, ClientEndpointId::Local); +} + +#[test] +fn focus_agent_index_uses_online_aggregate_rows() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .agents = vec![agent("remote agent", AgentStatus::Working, 2)]; + let focus_agent = + |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index)); + + assert!(state.indexed_navigation_target_exists(&focus_agent(0))); + assert!(!state.indexed_navigation_target_exists(&focus_agent(1))); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + assert!(!state.indexed_navigation_target_exists(&focus_agent(0))); +} + +#[test] +fn workspace_drag_rejects_foreign_endpoint_slots() { + let (mut state, endpoint_id) = state_with_remote(); + state.compose(100, 28).expect("aggregate sidebar"); + let local = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id.is_local()) + .expect("local workspace") + .rect; + let remote = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let mouse = |kind, rect: Rect| { + RawInputEvent::Mouse(MouseEvent { + kind, + column: rect.x.saturating_add(1), + row: rect.y, + modifiers: KeyModifiers::empty(), + }) + }; + + state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]); + state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]); + + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn collapsed_aggregate_workspace_status_uses_its_status_color() { + use crate::api::schema::AgentStatus; + + let (mut state, endpoint_id) = state_with_remote(); + state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .expect("remote endpoint") + .snapshot + .as_mut() + .expect("remote snapshot") + .workspaces[0] + .agent_status = AgentStatus::Blocked; + state.sidebar_collapsed = true; + + let frame = state.compose(100, 28).expect("collapsed aggregate sidebar"); + let workspace = state + .hits + .workspaces + .iter() + .find(|hit| hit.endpoint_id == endpoint_id) + .expect("remote workspace") + .rect; + let buffer = frame.to_ratatui_buffer().expect("frame buffer"); + assert_eq!( + buffer[(workspace.x.saturating_add(2), workspace.y)].fg, + state.config.palette.red + ); +} + +#[test] +fn navigator_foreign_tab_selection_keeps_the_tab_target() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + let selected = { + let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator") + else { + panic!("expected navigator"); + }; + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator) + .iter() + .find(|row| { + matches!( + &row.target, + ClientNavigatorTarget::Tab { + endpoint_id: target_endpoint, + tab_id, + .. + } if target_endpoint == &endpoint_id && tab_id == "tab_1" + ) + }) + .map(|row| row.target.clone()) + .expect("remote tab row") + }; + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.selected = Some(selected); + } + + let mut outcome = ClientShellInput::default(); + state.accept_navigator_selection(&mut outcome); + + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: Some(ClientEndpointFocusTarget::Tab(tab_id)), + }] if activated == &endpoint_id && tab_id == "tab_1" + )); +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index d20485b3..b8f17e57 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() { assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); let create = state.handle_input_bytes(b"c"); - let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else { + let [ClientShellAction::Endpoint { + boot_id, request, .. + }] = &create.actions[..] + else { panic!("expected one endpoint action: {:?}", create.actions); }; assert_eq!(boot_id, "boot-1"); diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs index 094b141f..b7cc2a47 100644 --- a/src/client/shell/tests/mobile.rs +++ b/src/client/shell/tests/mobile.rs @@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() { state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top; state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "bottom notification".into(), @@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() { } #[test] +fn mobile_switcher_can_activate_an_online_saved_machine() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let profile = crate::client::endpoint::SavedSshEndpoint { + id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Build".into(), + target: "build".into(), + session: "agents".into(), + enabled: true, + }; + let endpoint_id = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut remote = snapshot(); + remote.boot_id = "remote-boot".into(); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + state.mode = ClientShellMode::Navigate; + + let frame = state.compose(44, 30).expect("mobile machine switcher"); + let text = frame + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("machines")); + assert!(text.contains("Build")); + let machine = state + .hits + .mobile_targets + .iter() + .find_map(|(rect, target)| { + matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect) + }) + .expect("saved machine target"); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: machine.x, + row: machine.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id: activated, + target: None, + }] if activated == &endpoint_id + )); + + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!(state.activate_endpoint_projection(&endpoint_id)); + let mut remote_surface = surface(); + remote_surface.boot_id = "remote-boot".into(); + state.set_pane_surface(remote_surface); + state.mark_endpoint_disconnected(&endpoint_id); + state.receive_endpoint_unavailable("network lost".into()); + state.mode = ClientShellMode::Navigate; + let stale = state.compose(44, 30).expect("stale mobile switcher"); + let text = stale + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("reconnecting"), "frame: {text}"); + assert!(text.contains("network lost"), "frame: {text}"); +} + +#[test] fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() { let mut config = ClientShellConfig::from_config(&Config::default()); config.mouse_capture = false; @@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1" + ) + .then_some(*rect) }) .expect("workspace hit"); let focused = state.handle_raw_events(vec![click(workspace_hit)]); @@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1" + ) + .then_some(*rect) }) .expect("agent hit"); let focused = state.handle_raw_events(vec![click(agent_hit)]); @@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() { .mobile_targets .iter() .find_map(|(rect, target)| { - matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect) + matches!( + target, + ClientMobileTarget::Tab { + endpoint_id, + tab_id, + } if endpoint_id.is_local() && tab_id == "tab_1" + ) + .then_some(*rect) }) .expect("tab hit"); let focused = state.handle_raw_events(vec![click(tab_hit)]); @@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8")); assert!(state.mobile_switcher_scroll > 2); assert!(state.hits.mobile_targets.iter().any(|(_, target)| { - matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8") + matches!( + target, + ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8" + ) })); let close = state.hits.mobile_close; state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 4cf97152..497ce2c6 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -6,7 +6,7 @@ use crate::protocol::{ }; use crossterm::event::MouseEvent; -fn snapshot() -> ClientShellSnapshot { +pub(super) fn snapshot() -> ClientShellSnapshot { ClientShellSnapshot { boot_id: "boot-1".into(), revision: 1, @@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; mod copy; +mod endpoint_requests; +mod endpoints; #[path = "input.rs"] mod input_domain; mod keybindings_settings; diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 6ccdcf6e..044ef145 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay { query: String::new(), search_focused: false, - selected: 0, + selected: None, scroll: 0, filter: None, expanded_workspaces: HashSet::new(), diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 2e2901e8..7107aa71 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() { state.set_snapshot(Box::new(endpoint_snapshot)); state.set_pane_surface(surface()); state.visible_notification = Some(ClientVisibleNotification { + endpoint_id: ClientEndpointId::Local, event: SemanticNotification { kind: SemanticNotificationKind::Custom, title: "notification".into(), diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index bcadf1c8..ea2e8f24 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -1,10 +1,24 @@ use super::*; +fn checkout_path_preview(root: &str, repo: &str, branch: &str) -> String { + // This is an endpoint path, not a path on the machine drawing the dialog. + let separator = if !root.starts_with('/') && root.contains('\\') { + '\\' + } else { + '/' + }; + format!( + "{}{separator}{repo}{separator}{}", + root.trim_end_matches(separator), + crate::worktree::branch_to_path_slug(branch) + ) +} + impl ClientShellState { - fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> { + fn endpoint_worktree_directory(&self) -> Option<String> { self.snapshot .as_deref() - .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory)) + .map(|snapshot| snapshot.worktree_directory.clone()) } pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool { @@ -241,13 +255,8 @@ impl ClientShellState { let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else { return; }; - create.checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &create.repo_name, - &create.branch, - ) - .display() - .to_string(); + create.checkout_path = + checkout_path_preview(&worktree_directory, &create.repo_name, &create.branch); create.error = None; } @@ -270,9 +279,7 @@ impl ClientShellState { create.branch = branch.clone(); create.replace_on_type = false; create.checkout_path = - crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch) - .display() - .to_string(); + checkout_path_preview(&worktree_directory, &create.repo_name, &branch); create.creating = true; create.error = None; let workspace_id = create.source_workspace_id.clone(); @@ -399,13 +406,8 @@ impl ClientShellState { let Some(worktree_directory) = self.endpoint_worktree_directory() else { return false; }; - let checkout_path = crate::worktree::default_checkout_path( - &worktree_directory, - &source.repo_name, - &branch, - ) - .display() - .to_string(); + let checkout_path = + checkout_path_preview(&worktree_directory, &source.repo_name, &branch); self.overlay = Some(ClientShellOverlay::WorktreeCreate( ClientWorktreeCreateOverlay { source_workspace_id: workspace_id, @@ -549,3 +551,24 @@ impl ClientShellState { } } } + +#[cfg(test)] +mod path_tests { + use super::checkout_path_preview; + + #[test] + fn endpoint_path_style_is_independent_of_the_client_os() { + assert_eq!( + checkout_path_preview("/worktrees/", "repo", "feature/a"), + "/worktrees/repo/feature-a" + ); + assert_eq!( + checkout_path_preview(r"C:\worktrees\", "repo", "feature/a"), + r"C:\worktrees\repo\feature-a" + ); + assert_eq!( + checkout_path_preview(r"\\server\share", "repo", "feature/a"), + r"\\server\share\repo\feature-a" + ); + } +} diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs new file mode 100644 index 00000000..8a4af0b7 --- /dev/null +++ b/src/client/shell_runtime.rs @@ -0,0 +1,739 @@ +use super::*; + +pub(super) fn dispatch_client_shell_actions( + actions: Vec<shell::ClientShellAction>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + endpoints: &mut endpoint::EndpointRegistry, + mut shell: Option<&mut shell::ClientShellState>, + detached_process_children: &mut Vec<std::process::Child>, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(Vec<crossterm::event::MouseEvent>, bool), ClientError> { + let mut replay_mouse = Vec::new(); + let mut repaint = false; + for action in actions { + match action { + shell::ClientShellAction::Endpoint { + endpoint_id, + boot_id, + request, + } => { + if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| { + endpoints.active_id() == &endpoint_id && endpoints.active_surface_available() + }) { + endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request); + } else if let Some(shell) = shell.as_deref_mut() { + repaint |= shell.cancel_endpoint_request(&request.id); + } + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + if endpoints.active_surface_available() { + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + } + shell::ClientShellAction::ActivateEndpoint { + endpoint_id, + target, + } => { + let _ = event_tx.try_send(ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force: false, + }); + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + // A source-off-first handoff leaves the registry's committed identity pointing at a + // deliberately surface-inactive source. Do not drain its retained queue into a server that + // must reject it; completion below resumes the committed owner's lane. + if endpoints.active_surface_available() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = shell { + for request_id in cancelled { + repaint |= shell.cancel_endpoint_request(&request_id); + } + } + } + Ok((replay_mouse, repaint)) +} + +pub(super) fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +pub(super) fn sync_client_shell_keyboard_report_all( + state: &mut ClientState, +) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +pub(super) fn clear_endpoint_host_effects( + state: &mut ClientState, + host_mouse_capture_active: &std::sync::atomic::AtomicBool, + host_sgr_pixels_active: &std::sync::atomic::AtomicBool, +) { + state.endpoint_mouse_capture_requested = false; + state.endpoint_sgr_pixels_requested = false; + let enabled = if state.shell.is_some() { + state.shell_mouse_capture_preference + } else { + state.direct_mouse_capture_preference + }; + let sgr_pixels = super::effective_sgr_pixel_mouse(enabled, false, state.pixel_geometry_exact); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(std::sync::atomic::Ordering::Acquire) + { + let _ = super::set_mouse_capture(enabled, sgr_pixels); + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, std::sync::atomic::Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, std::sync::atomic::Ordering::Release); + + state.pane_keyboard_report_all = false; + let _ = sync_client_shell_keyboard_report_all(state); + let _ = crate::terminal_effects::write_window_title(&mut std::io::stdout(), None); +} + +pub(super) fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn install_pending_activation( + state: &mut ClientState, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + activation: endpoint::PendingEndpointActivation, +) { + let retired = activation + .source_command_lane() + .map(|source| endpoint_commands.retire_lane(source)) + .unwrap_or_default(); + if let Some(shell) = state.shell.as_mut() { + for request_id in retired { + shell.cancel_endpoint_request(&request_id); + } + } + *next_surface_serial = next_surface_serial.saturating_add(1); + state.freeze_presentation(); + *pending = Some(activation); +} + +pub(super) fn begin_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + pending: &mut Option<endpoint::PendingEndpointActivation>, + next_surface_serial: &mut u64, + endpoint_id: endpoint::ClientEndpointId, + target: Option<shell::ClientEndpointFocusTarget>, + force: bool, + now: std::time::Instant, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<(), ClientError> { + if let Some(activation) = pending.as_mut() { + if activation.can_retarget(&endpoint_id) { + let retarget_error = activation.retarget(target, endpoints).err(); + if let Some(error) = retarget_error { + rollback_endpoint_activation(state, endpoints, pending, error, false); + } + } else { + // Once rollback starts, even a request for the original target is a new intent. It + // replaces the retained successor instead of mutating the transaction being retired. + let outcome = activation.supersede(endpoint_id, target, endpoints); + if let endpoint::ActivationRollback::Unavailable(message) = outcome { + *pending = None; + present_handoff_unavailable(state, message); + } + } + return Ok(()); + } + let already_active = !force + && endpoints.active_id() == &endpoint_id + && endpoints + .connection(&endpoint_id) + .is_some_and(|connection| connection.surface_active); + if already_active { + if let (Some(shell), Some(target)) = (state.shell.as_mut(), target) { + let actions = shell.focus_endpoint_target(target); + let (_, repaint) = dispatch_client_shell_actions( + actions, + endpoint_commands, + endpoints, + Some(shell), + &mut state.detached_process_children, + event_tx, + )?; + if repaint { + if let Some(frame) = shell.compose(state.reported_size.0, state.reported_size.1) { + state.present_frame(frame); + } + } + } + return Ok(()); + } + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + match endpoint::PendingEndpointActivation::begin( + shell, + endpoints, + endpoint_id.clone(), + target, + resize, + *next_surface_serial, + now, + ) { + Ok(activation) => install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + activation, + ), + Err(endpoint::ActivationBeginError::Preflight(error)) => { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(format!( + "{}: {error}", + shell.endpoint_label(&endpoint_id) + )); + } + } + Err(endpoint::ActivationBeginError::Partial { activation, error }) => { + // A send error is not evidence that its peer did not observe the write. Freeze and + // retain the lifecycle object before rollback so no source or target output can be + // projected until one ownership path has been proved again. + install_pending_activation( + state, + endpoint_commands, + pending, + next_surface_serial, + *activation, + ); + rollback_endpoint_activation( + state, + endpoints, + pending, + format!( + "{}: {error}", + state + .shell + .as_ref() + .map(|shell| shell.endpoint_label(&endpoint_id).to_owned()) + .unwrap_or_else(|| format!("{endpoint_id:?}")) + ), + false, + ); + } + } + Ok(()) +} + +pub(super) fn complete_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, +) -> Result<Option<ClientLoopEvent>, ClientError> { + let sync_endpoint = pending + .as_ref() + .and_then(endpoint::PendingEndpointActivation::presentation_sync_endpoint) + .cloned(); + if let Some(endpoint_id) = sync_endpoint.as_ref() { + state.replay_host_theme(endpoints, endpoint_id); + } + let completion = { + let Some(activation) = pending.as_mut() else { + return Ok(None); + }; + let Some(shell) = state.shell.as_mut() else { + return Ok(None); + }; + match activation.complete(shell, endpoints) { + Ok(completion) => completion, + Err(error) => { + shell.receive_endpoint_unavailable(error); + return Ok(None); + } + } + }; + + if matches!( + completion, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + ) { + #[cfg(unix)] + if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } = + &completion + { + if previous != endpoint { + state.retire_endpoint_graphics(previous); + } + } + // The coherent target frame can replace the frozen source now, but the registry keeps + // pane input disabled until a second projection epoch has replayed host modes/effects. + state.unfreeze_presentation(); + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + return Ok(None); + } + if completion == endpoint::ActivationCompletion::AwaitingPresentationEffects { + return Ok(None); + } + + let _ = pending.take(); + endpoints.unfreeze_input(); + let successor = match completion { + endpoint::ActivationCompletion::RestoredSource { + error, + successor: next, + .. + } => { + if next.is_none() { + if let Some(shell) = state.shell.as_mut() { + shell.receive_endpoint_unavailable(error); + } + } + next + } + endpoint::ActivationCompletion::Activated => None, + endpoint::ActivationCompletion::AwaitingPresentationSync { .. } + | endpoint::ActivationCompletion::AwaitingPresentationEffects => unreachable!(), + }; + state.unfreeze_presentation(); + if successor.is_none() { + let active_endpoint = endpoints.active_id().clone(); + let cancelled = endpoint_commands.send_next(&active_endpoint, endpoints); + if let Some(shell) = state.shell.as_mut() { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + } + } + let (cleanup, frame) = { + let shell = state.shell.as_mut().expect("checked client shell"); + ( + shell.take_pending_graphics_cleanup(), + shell.compose(state.reported_size.0, state.reported_size.1), + ) + }; + state.present_graphics(&cleanup); + if let Some(frame) = frame { + state.present_frame(frame); + } + if let Some(intent) = successor { + return Ok(Some(ClientLoopEvent::ActivateEndpoint { + endpoint_id: intent.endpoint_id, + target: intent.target, + force: true, + })); + } + Ok(None) +} + +pub(super) fn present_handoff_unavailable(state: &mut ClientState, message: String) { + // An unavailable committed endpoint has no presentation lease. Keep all pane input and late + // source output blocked, while allowing this client-owned chrome frame through the freeze. + state.freeze_presentation(); + let frame = state.shell.as_mut().and_then(|shell| { + shell.receive_endpoint_unavailable(message); + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = frame { + state.present_frozen_chrome(frame); + } +} + +pub(super) fn rollback_endpoint_activation( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + pending: &mut Option<endpoint::PendingEndpointActivation>, + error: String, + source_release_rejected: bool, +) { + let Some(activation) = pending.as_mut() else { + return; + }; + match activation.rollback(endpoints, error.clone(), source_release_rejected) { + endpoint::ActivationRollback::Pending => state.freeze_presentation(), + endpoint::ActivationRollback::Unavailable(message) => { + *pending = None; + // No endpoint has been proven safe to present. Keep pane input frozen, but render + // the client-owned unavailable chrome rather than silently swallowing the error. + present_handoff_unavailable(state, message); + } + } +} + +pub(super) fn handle_endpoint_disconnect( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + notice: &str, +) -> bool { + supervisors.disconnected(endpoint_id, generation, now); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + format!("endpoint connection was lost while activating {notice}"), + ); + match outcome { + endpoint::ActivationRollback::Pending => {} + endpoint::ActivationRollback::Unavailable(error) => { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.mark_endpoint_disconnected(endpoint_id); + endpoint_was_active.then(|| format!("{} {notice}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn handle_endpoint_attention( + state: &mut ClientState, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + supervisors: &mut endpoint::EndpointSupervisors, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + now: std::time::Instant, + message: String, +) -> bool { + endpoints.disconnect(endpoint_id); + supervisors.record_status( + endpoint_id, + generation, + endpoint::ClientEndpointStatus::Attention, + now, + ); + #[cfg(unix)] + state.retire_endpoint_graphics(endpoint_id); + if pending_activation + .as_ref() + .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) + { + let outcome = pending_activation + .as_mut() + .expect("checked pending activation") + .endpoint_disconnected( + endpoints, + endpoint_id, + "endpoint reported attention while activating".into(), + ); + if let endpoint::ActivationRollback::Unavailable(error) = outcome { + *pending_activation = None; + present_handoff_unavailable(state, error); + } + } + let endpoint_was_active = endpoints.active_id() == endpoint_id; + let cancelled = endpoint_commands.disconnect(endpoint_id); + let unavailable = state.shell.as_mut().and_then(|shell| { + for request_id in cancelled { + shell.cancel_endpoint_request(&request_id); + } + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Attention); + endpoint_was_active.then(|| format!("{}: {message}", shell.endpoint_label(endpoint_id))) + }); + if let Some(message) = unavailable { + present_handoff_unavailable(state, message); + } else if let Some(frame) = state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + { + state.present_frame(frame); + } + endpoint_was_active +} + +pub(super) fn install_client_shell_snapshot( + state: &mut ClientState, + endpoint_id: &endpoint::ClientEndpointId, + snapshot: Box<crate::protocol::ClientShellSnapshot>, + projection_pending: bool, + endpoints: &mut endpoint::EndpointRegistry, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<(), ClientError> { + let Some(connection) = endpoints.connection(endpoint_id) else { + return Ok(()); + }; + let generation = connection.generation; + let project_snapshot = + !projection_pending && endpoints.active_id() == endpoint_id && connection.surface_active; + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { + let waits_for_selected_surface = projection_pending + || (endpoints.active_id() == endpoint_id + && !project_snapshot + && shell.has_presented_surface()); + let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + if !waits_for_selected_surface { + shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Online); + } + if project_snapshot { + shell.set_endpoint_snapshot_for_generation(endpoint_id, generation, snapshot); + } else { + shell.cache_endpoint_snapshot_inactive_for_generation( + endpoint_id, + generation, + snapshot, + ); + } + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }), + graphics_cleanup, + ) + } else { + (None, None, Vec::new()) + }; + apply_client_shell_input_source_changes(state, prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + endpoints.send_to(endpoint_id, &resize); + } + if let Some(frame) = composed { + if projection_pending { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(()) +} + +pub(super) fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option<FrameData>, + endpoints: &mut endpoint::EndpointRegistry, + pending_activation: &mut Option<endpoint::PendingEndpointActivation>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, +) -> Result<bool, ClientError> { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(endpoints, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_resize(resize, endpoints) { + rollback_endpoint_activation(state, endpoints, pending_activation, error, false); + } + } else { + let _ = write_to_server(endpoints, &resize); + } + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let (replay, dispatch_repaint) = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + endpoints, + state.shell.as_mut(), + &mut state.detached_process_children, + event_tx, + )?; + let frame = if dispatch_repaint { + state + .shell + .as_mut() + .and_then(|shell| shell.compose(state.reported_size.0, state.reported_size.1)) + } else { + frame + }; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + let active_endpoint_online = state + .shell + .as_ref() + .is_none_or(|shell| shell.endpoint_is_online(endpoints.active_id())) + && endpoints.active_surface_available(); + for request in outcome.requests { + if let ClientMessage::ClientShellHostTheme { update } = &request { + state.record_host_theme_update(update); + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_theme(update.clone(), endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + } + // Host focus belongs to a pending target even when the source has gone offline or has + // already had its surface revoked. Route it before the ordinary source-online gate. + if let ClientMessage::ClientShellFocus { focused } = request { + if let Some(activation) = pending_activation.as_mut() { + if let Err(error) = activation.update_host_focus(focused, endpoints) { + rollback_endpoint_activation( + state, + endpoints, + pending_activation, + error, + false, + ); + } + continue; + } + if active_endpoint_online { + write_to_server(endpoints, &ClientMessage::ClientShellFocus { focused }) + .map_err(ClientError::ConnectionLost)?; + } + continue; + } + if !active_endpoint_online { + continue; + } + if pending_activation.is_some() { + // Pane input and non-focus host effects do not cross the frozen handoff boundary. + continue; + } + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + if pending_activation.is_some() { + state.present_frame(frame); + } else { + state.present_frozen_chrome(frame); + } + } + Ok(false) +} diff --git a/src/client/startup.rs b/src/client/startup.rs new file mode 100644 index 00000000..40ee2cc8 --- /dev/null +++ b/src/client/startup.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Runs the thin client and enters the main event loop. +pub fn run_client() -> io::Result<()> { + run_client_with_mode(None, None, "connecting to server") +} + +#[cfg(unix)] +pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { + run_client_with_mode( + Some((terminal_id, takeover)), + Some(AttachEscapeState::default()), + "attaching to terminal", + ) +} + +#[cfg(windows)] +pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> { + debug_assert!(!crate::platform::capabilities().direct_terminal_attach); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "direct terminal attach is not supported on Windows yet", + )) +} diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 00000000..c26ca165 --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,231 @@ +use super::*; + +/// State tracking for the thin client. +pub(super) struct ClientState { + /// Stateful semantic-frame encoder used when the server sends FrameData. + pub(super) blit_encoder: render_ansi::BlitEncoder, + pub(super) mouse_capture_active: bool, + pub(super) endpoint_mouse_capture_requested: bool, + pub(super) endpoint_sgr_pixels_requested: bool, + /// Latest physical host theme observations, retained so an endpoint selected after the + /// observation receives the same client-owned baseline. + pub(super) host_theme_updates: Vec<crate::protocol::ClientHostThemeUpdate>, + pub(super) direct_mouse_capture_preference: bool, + pub(super) shell_mouse_capture_preference: bool, + pub(super) direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + pub(super) pane_keyboard_report_all: bool, + pub(super) keyboard_report_all_active: bool, + pub(super) reported_size: (u16, u16), + pub(super) reported_cell_size: (u32, u32), + pub(super) sound_config: crate::config::SoundConfig, + pub(super) kitty_graphics_enabled: bool, + pub(super) pixel_geometry_enabled: bool, + pub(super) pixel_geometry_exact: bool, + #[cfg(unix)] + pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, + #[cfg(unix)] + pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>, + #[cfg(unix)] + pub(super) pending_surface_graphics: + HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>, + pub(super) attach_escape: Option<AttachEscapeState>, + #[cfg(unix)] + pub(super) mouse_scroll_lines: usize, + pub(super) remote_image_paste_key: + Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(super) redraw_on_focus_gained: bool, + pub(super) repaint_pending: bool, + /// During a source-off-first handoff the currently blitted frame remains authoritative until + /// an acknowledged target snapshot/surface pair commits. + pub(super) presentation_frozen: bool, + pub(super) draw_host_cursor: bool, + pub(super) detached_process_children: Vec<std::process::Child>, + pub(super) shell: Option<shell::ClientShellState>, +} + +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); + } + } +} + +impl ClientState { + pub(super) fn request_repaint(&mut self) { + self.repaint_pending = true; + } + + pub(super) fn freeze_presentation(&mut self) { + self.presentation_frozen = true; + } + + pub(super) fn record_host_theme_update( + &mut self, + update: &crate::protocol::ClientHostThemeUpdate, + ) { + use crate::protocol::ClientHostThemeUpdate; + + match update { + ClientHostThemeUpdate::DefaultColor { kind, .. } => { + self.host_theme_updates.retain(|current| { + !matches!( + current, + ClientHostThemeUpdate::DefaultColor { + kind: current_kind, + .. + } if current_kind == kind + ) + }); + } + ClientHostThemeUpdate::PaletteColors(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::PaletteColors(_))), + ClientHostThemeUpdate::Appearance(_) => self + .host_theme_updates + .retain(|current| !matches!(current, ClientHostThemeUpdate::Appearance(_))), + } + self.host_theme_updates.push(update.clone()); + } + + /// Replay the retained physical-host baseline only after an endpoint owns the committed + /// presentation. The endpoint transport preserves this order ahead of the resync control. + pub(super) fn replay_host_theme( + &self, + endpoints: &mut endpoint::EndpointRegistry, + endpoint_id: &endpoint::ClientEndpointId, + ) { + for update in &self.host_theme_updates { + let _ = endpoints.send_to( + endpoint_id, + &crate::protocol::ClientMessage::ClientShellHostTheme { + update: update.clone(), + }, + ); + } + } + + pub(super) fn unfreeze_presentation(&mut self) { + self.presentation_frozen = false; + // A resize or metadata event may have happened while frozen. Force a full frame rather + // than attempting to patch the old source frame. + self.request_repaint(); + } + + /// Present a composed error/chrome frame while retaining the handoff input freeze. The pane + /// cells are still the last coherent surface; only client chrome (including the error) moves. + pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) { + let frozen = self.presentation_frozen; + self.presentation_frozen = false; + self.present_frame(frame_data); + self.presentation_frozen = frozen; + } + + pub(super) fn present_graphics(&mut self, graphics: &[u8]) { + if self.presentation_frozen || graphics.is_empty() || !self.kitty_graphics_enabled { + return; + } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); + } + + pub(super) fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result<bool> { + if self.presentation_frozen || self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); + } + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows + } else { + patch.rows + }; + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); + }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) + } + + #[cfg(unix)] + pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) { + let transfer_ids = self + .pending_surface_graphics + .keys() + .filter(|(owner, _, _)| owner == endpoint_id) + .map(|(_, transfer_id, _)| *transfer_id) + .collect::<Vec<_>>(); + self.pending_surface_graphics + .retain(|(owner, _, _), _| owner != endpoint_id); + if self + .retired_direct_graphics + .as_ref() + .is_some_and(|(owner, _, _)| owner == endpoint_id) + { + self.retired_direct_graphics = None; + } + if let Ok(mut matcher) = self.direct_graphics_response.lock() { + for transfer_id in transfer_ids { + matcher.retire(transfer_id); + } + } + } + + pub(super) fn present_frame(&mut self, frame_data: FrameData) { + if self.presentation_frozen { + return; + } + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; + } +} diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index a46af2b1..c7328efd 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -89,7 +89,18 @@ fn connect_terminal_session_stream( } }; - match do_handshake(&mut stream, cols, rows, 0, 0, false, None, false, false) { + match do_handshake( + &mut stream, + cols, + rows, + 0, + 0, + false, + None, + false, + false, + true, + ) { Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {} Ok(handshake) => { eprintln!( diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 5d789444..cca73232 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -156,6 +156,26 @@ fn host_cursor_policy_native_and_drawn_override_auto_detection() { )); } +#[test] +fn image_bridge_follows_the_selected_remote_endpoint() { + let remote = crate::client::endpoint::ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + + assert!(endpoint_accepts_local_images(false, &remote, true)); + assert!(endpoint_accepts_local_images( + true, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images( + false, + &crate::client::endpoint::ClientEndpointId::Local, + true, + )); + assert!(!endpoint_accepts_local_images(false, &remote, false)); +} + #[cfg(unix)] #[test] fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { @@ -181,6 +201,11 @@ fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { Some(ctrl_v) )); assert!(!should_bridge_clipboard_image_paste( + &[0x16], + false, + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste( b"\x1b[200~text\x1b[201~", true, Some(ctrl_v) diff --git a/src/client/transport.rs b/src/client/transport.rs new file mode 100644 index 00000000..4807c31d --- /dev/null +++ b/src/client/transport.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(super) fn start_endpoint_transport( + stream: LocalStream, + lifetime: impl Send + 'static, + event_tx: &tokio::sync::mpsc::Sender<ClientLoopEvent>, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + max_frame_size: usize, +) -> Result<endpoint::NativeEndpointTransport, ClientError> { + let reader = stream.try_clone().map_err(ClientError::ConnectionFailed)?; + let transport = endpoint::NativeEndpointTransport::with_lifetime(stream, lifetime) + .map_err(ClientError::ConnectionFailed)?; + let stopped = transport.stop_handle(); + let event_tx = event_tx.clone(); + std::thread::Builder::new() + .name("endpoint-reader".into()) + .spawn(move || { + server_reader_thread( + reader, + event_tx, + &stopped, + max_frame_size, + endpoint_id, + generation, + ); + }) + .map_err(ClientError::ConnectionFailed)?; + Ok(transport) +} + +/// Reads complete frames while retaining partial-read progress across nonblocking polls. +pub(super) fn server_reader_thread( + mut stream: LocalStream, + event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, + should_quit: &Arc<AtomicBool>, + max_frame_size: usize, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, +) { + if stream.set_nonblocking(true).is_err() { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id, + generation, + }); + return; + } + + let mut stream = EndpointReader { + stream: &mut stream, + stopped: should_quit, + }; + loop { + if should_quit.load(Ordering::Acquire) { + break; + } + + match protocol::read_message(&mut stream, max_frame_size) { + Ok(msg) => { + if event_tx + .blocking_send(ClientLoopEvent::ServerMessage { + endpoint_id: endpoint_id.clone(), + generation, + message: Box::new(msg), + }) + .is_err() + { + break; + } + } + Err(protocol::FramingError::UnexpectedEof) => { + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(err) => { + warn!(err = %err, "server read error"); + let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected { + endpoint_id: endpoint_id.clone(), + generation, + }); + break; + } + } + } +} + +struct EndpointReader<'a> { + stream: &'a mut LocalStream, + stopped: &'a AtomicBool, +} + +impl io::Read for EndpointReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> { + loop { + if self.stopped.load(Ordering::Acquire) { + return Ok(0); + } + match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? { + crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count), + crate::ipc::LocalStreamReadCount::Closed => return Ok(0), + crate::ipc::LocalStreamReadCount::Pending => { + crate::platform::wait_client_stream_readable(self.stream)?; + } + } + } + } +} + +pub(in crate::client) fn write_to_local_server( + stream: &mut LocalStream, + msg: &ClientMessage, +) -> io::Result<()> { + protocol::write_message(stream, msg).map_err(|error| io::Error::other(error.to_string())) +} + +pub(super) trait ClientMessageSink { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()>; +} + +impl ClientMessageSink for LocalStream { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + write_to_local_server(self, message) + } +} + +impl ClientMessageSink for endpoint::EndpointRegistry { + fn send_client_message(&mut self, message: &ClientMessage) -> io::Result<()> { + // The lifecycle loop consumes failures for every endpoint, including Local. A send + // failure must not bypass that transition or tear down unrelated connections. + self.send(message); + Ok(()) + } +} + +pub(super) fn write_to_server( + stream: &mut impl ClientMessageSink, + msg: &ClientMessage, +) -> io::Result<()> { + stream.send_client_message(msg) +} diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index f5715889..32838c21 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -104,6 +104,7 @@ pub struct SidebarTokenStyle { pub enum AgentSidebarToken { StateIcon, StateText, + Machine, Workspace, Tab, Pane, @@ -234,6 +235,7 @@ fn agent_token_name(token: &AgentSidebarToken) -> String { match token { AgentSidebarToken::StateIcon => "state_icon".into(), AgentSidebarToken::StateText => "state_text".into(), + AgentSidebarToken::Machine => "machine".into(), AgentSidebarToken::Workspace => "workspace".into(), AgentSidebarToken::Tab => "tab".into(), AgentSidebarToken::Pane => "pane".into(), @@ -288,6 +290,7 @@ impl<'de> Deserialize<'de> for AgentSidebarToken { &[ ("state_icon", Self::StateIcon), ("state_text", Self::StateText), + ("machine", Self::Machine), ("workspace", Self::Workspace), ("tab", Self::Tab), ("pane", Self::Pane), @@ -393,6 +396,7 @@ impl Default for AgentsSidebarConfig { rows: vec![ vec![ AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], @@ -443,6 +447,7 @@ mod tests { vec![ vec![ AgentSidebarToken::StateIcon, + AgentSidebarToken::Machine, AgentSidebarToken::Workspace, AgentSidebarToken::Tab, ], diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 77ac36da..cd774c6b 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1557,6 +1557,11 @@ impl Terminal { ) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> { + let generation = self.kitty_graphics_generation()?; + Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation)) + } + pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -3448,13 +3453,16 @@ mod tests { terminal.resize(10, 5, 8, 16).unwrap(); assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"); let transmitted = terminal.kitty_graphics_generation().unwrap(); assert_ne!(transmitted, 0); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted)); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); terminal.write(b"plain text"); assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted); @@ -3463,6 +3471,7 @@ mod tests { terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"); let placed = terminal.kitty_graphics_generation().unwrap(); assert_ne!(placed, transmitted); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); terminal.resize(10, 5, 12, 24).unwrap(); @@ -3473,14 +3482,17 @@ mod tests { assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_ne!(terminal.kitty_empty_generation.get(), Some(placed)); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); terminal.scroll_viewport_row(0); assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1); terminal.write(b"\x1b_Ga=d,d=A\x1b\\"); let deleted = terminal.kitty_graphics_generation().unwrap(); assert_ne!(deleted, placed); + assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted)); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); } #[test] @@ -131,13 +131,6 @@ pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool) } } -#[cfg(unix)] -pub(crate) fn shutdown_local_stream_write(stream: &LocalStream) -> io::Result<()> { - match stream { - LocalStream::UdSocket(stream) => stream.inner().shutdown(std::net::Shutdown::Write), - } -} - /// Binds a listener for private terminal traffic. Unix callers restrict the /// socket file after binding; Windows must set the named-pipe DACL at creation. pub(crate) fn bind_private_local_listener(path: &Path) -> io::Result<LocalListener> { diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index bf2482b6..e5f00ac2 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -50,6 +50,10 @@ pub(crate) struct ClientState { } impl ClientState { + pub(crate) fn scope(&self) -> &str { + &self.scope + } + pub(crate) fn set_scope(&mut self, scope: &str) { if self.scope == scope { return; diff --git a/src/main.rs b/src/main.rs index ee38e84b..4d7e2f88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -330,18 +330,18 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # distinct static glyphs for blocked, working, done, idle, and unknown states. # status_indicators = "dots" -# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent, -# terminal_title, and terminal_title_stripped. +# Expanded agent rows. Built-ins are state_icon, state_text, machine, workspace, tab, +# pane, agent, terminal_title, and terminal_title_stripped. # Custom values reported through pane metadata use a $name token. # A token occurrence may be styled with { token = "workspace", fg = "#89b4fa", bold = true, dim = false }. # Omitted style fields preserve the contextual default. # [ui.sidebar.agents] # Blank rows between agent entries. Set to 1 to restore the previous spacing. # row_gap = 0 -# rows = [["state_icon", "workspace", "tab"], ["agent"]] +# rows = [["state_icon", "machine", "workspace", "tab"], ["agent"]] # Optional canonical agent IDs replace the default rows for matching agents. # [ui.sidebar.agents.rows_by_agent] -# claude = [["state_icon", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]] +# claude = [["state_icon", "machine", "workspace", "tab"], ["terminal_title_stripped"], ["agent"]] # Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status. # Custom values reported through workspace metadata use a $name token, for example $jj_status. @@ -587,6 +587,7 @@ fn main() -> io::Result<()> { println!(" herdr completion zsh"); println!(" herdr update [--handoff]"); println!(" herdr channel set <stable|preview>"); + println!(" herdr machine <subcommand> ..."); println!(" herdr server stop"); println!(" herdr server reload-config"); println!(" herdr api <subcommand> ..."); @@ -631,6 +632,7 @@ fn main() -> io::Result<()> { "herdr channel <subcommand>", "Manage the stable or preview update channel", ), + ("herdr machine <subcommand>", "Manage saved SSH machines"), ( "herdr api <subcommand>", "Inspect socket API metadata and live runtime state", @@ -737,6 +739,7 @@ fn main() -> io::Result<()> { "status", "config", "channel", + "machine", "workspace", "worktree", "pane", @@ -764,7 +767,9 @@ fn main() -> io::Result<()> { let loaded_config = config::Config::load(); exit_if_nested_disabled(&loaded_config.config); - if let Err(err) = server::autodetect::auto_detect_launch() { + let saved_federation = + client::endpoint::EndpointCatalog::load().is_ok_and(|catalog| catalog.has_enabled_ssh()); + if let Err(err) = server::autodetect::auto_detect_launch(saved_federation) { eprintln!("herdr: {err}"); std::process::exit(1); } diff --git a/src/pane.rs b/src/pane.rs index 4f1ea0ad..83bad900 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3086,6 +3086,10 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.terminal.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index a804a7fb..1dc5a88d 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -538,6 +538,10 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.ghostty.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, @@ -2190,6 +2194,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.core + .lock() + .ok() + .and_then(|core| core.terminal.kitty_graphics_may_have_placements().ok()) + .unwrap_or(true) + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/platform/client_state.rs b/src/platform/client_state.rs new file mode 100644 index 00000000..d339bae8 --- /dev/null +++ b/src/platform/client_state.rs @@ -0,0 +1,32 @@ +use std::path::Path; + +#[cfg(not(windows))] +pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> { + super::create_remote_ssh_config_file(path) +} + +#[cfg(windows)] +pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result<std::fs::File> { + super::windows::create_remote_ssh_config_file(path) +} + +#[cfg(not(windows))] +pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::rename(source, destination) +} + +#[cfg(windows)] +pub(crate) fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + super::windows::replace_file(source, destination) +} + +#[cfg(not(windows))] +pub(crate) fn sync_parent_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +#[cfg(windows)] +pub(crate) fn sync_parent_directory(_path: &Path) -> std::io::Result<()> { + // replace_file uses MOVEFILE_WRITE_THROUGH on Windows. + Ok(()) +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 8efe7357..6c09667a 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - StatusCommandGuard, + wait_client_stream_readable, StatusCommandGuard, }; const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 948468ac..8840c7ed 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -17,7 +17,7 @@ pub(crate) use super::unix_common::{ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, - StatusCommandGuard, + wait_client_stream_readable, StatusCommandGuard, }; const PROC_PGRP_ONLY: u32 = 2; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 4b49b9f7..86a431aa 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -236,6 +236,9 @@ mod unix_common; #[cfg(unix)] pub(crate) use unix_common::{begin_cli_output, end_cli_output}; +mod client_state; +pub(crate) use client_state::{create_private_state_file, replace_file, sync_parent_directory}; + #[cfg(not(unix))] pub(crate) fn begin_cli_output() {} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index a7926c22..dff67357 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -1,5 +1,24 @@ use std::path::{Path, PathBuf}; +pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { + use std::os::fd::{AsFd as _, AsRawFd as _}; + let crate::ipc::LocalStream::UdSocket(stream) = stream; + let mut descriptor = libc::pollfd { + fd: stream.as_fd().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // Bound cancellation latency without polling idle connections hundreds of times per second. + let result = unsafe { libc::poll(&mut descriptor, 1, 100) }; + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } + Ok(()) +} + pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::window_size().map(|size| (size.columns, size.rows)) } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 1c5c4afb..ce0afedb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,10 +15,52 @@ use std::{ mod clipboard_image; +pub(crate) fn wait_client_stream_readable( + _stream: &crate::ipc::LocalStream, +) -> std::io::Result<()> { + // Sync named pipes have no read timeout. The caller peeks before each read and checks its + // cancellation flag between polls, including when a frame arrives in several fragments. + std::thread::sleep(Duration::from_millis(2)); + Ok(()) +} + pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::size() } +pub(crate) fn replace_file( + source: &std::path::Path, + destination: &std::path::Path, +) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::<Vec<_>>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::<Vec<_>>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + pub(crate) fn set_default_plugin_pane_pwd( _env: &mut Vec<(String, String)>, _cwd: &std::path::Path, diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index b2955ec8..a70ef568 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -20,6 +20,17 @@ pub const ENDPOINT_SNAPSHOT_KIND: &str = SNAPSHOT_CODEC_V1; pub const SURFACE_CODEC_V1: &str = "shell.surface.v1"; pub const INPUT_CODEC_V1: &str = "shell.input.semantic.v1"; pub const BLOB_CODEC_V1: &str = "shell.blob.v1"; +pub const SURFACE_INTEREST_CAPABILITY: &str = "surface_interest"; +pub const PRESENTATION_EFFECTS_FENCE_CAPABILITY: &str = "presentation_effects_fence"; +pub const PRESENTATION_EFFECTS_SYNC_KIND: &str = "endpoint.presentation.sync.v1"; +pub const PRESENTATION_EFFECTS_READY_KIND: &str = "endpoint.presentation.ready.v1"; +pub const HEALTH_CHECK_CAPABILITY: &str = "health_check"; +pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1"; +pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1"; + +fn default_true() -> bool { + true +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointClientHello { @@ -31,6 +42,8 @@ pub struct EndpointClientHello { pub direct_graphics: bool, pub endpoint_keybindings: bool, pub mouse_capture: bool, + #[serde(default = "default_true")] + pub surface_active: bool, #[serde(default)] pub snapshot_codecs: Vec<String>, #[serde(default)] @@ -57,6 +70,8 @@ pub struct EndpointServerWelcome { pub blob_codec: String, #[serde(default)] pub methods: Vec<String>, + #[serde(default)] + pub capabilities: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option<EndpointHandshakeError>, } @@ -95,6 +110,11 @@ impl EndpointServerWelcome { input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods, + capabilities: vec![ + SURFACE_INTEREST_CAPABILITY.into(), + PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + HEALTH_CHECK_CAPABILITY.into(), + ], error: None, } } @@ -108,6 +128,7 @@ impl EndpointServerWelcome { input_codec: INPUT_CODEC_V1.into(), blob_codec: BLOB_CODEC_V1.into(), methods: Vec::new(), + capabilities: Vec::new(), error: Some(EndpointHandshakeError { code: code.into(), message: message.into(), @@ -130,6 +151,7 @@ mod tests { direct_graphics: false, endpoint_keybindings: false, mouse_capture: true, + surface_active: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -192,6 +214,7 @@ mod tests { assert_eq!(welcome.surface_codec, SURFACE_CODEC_V1); assert_eq!(welcome.input_codec, INPUT_CODEC_V1); assert_eq!(welcome.blob_codec, BLOB_CODEC_V1); + assert!(welcome.capabilities.is_empty()); } #[test] @@ -245,6 +268,27 @@ mod tests { } #[test] + fn legacy_hello_defaults_to_an_active_surface() { + let mut value = serde_json::to_value(hello()).unwrap(); + value.as_object_mut().unwrap().remove("surface_active"); + let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); + assert!(decoded.surface_active); + } + + #[test] + fn compatible_server_advertises_endpoint_lifecycle_capabilities() { + let welcome = EndpointServerWelcome::compatible(Vec::new()); + assert_eq!( + welcome.capabilities, + vec![ + SURFACE_INTEREST_CAPABILITY.to_string(), + PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), + HEALTH_CHECK_CAPABILITY.to_string(), + ] + ); + } + + #[test] fn required_codecs_are_explicit() { let mut value = hello(); assert!(value.supports_required_codecs()); diff --git a/src/remote.rs b/src/remote.rs index 19904ef2..ba935ec4 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,10 +1,16 @@ +mod args; mod attach; #[cfg(unix)] mod host_unix; +mod process; +mod restart_policy; +mod saved; +pub(crate) use args::*; pub(crate) use attach::*; #[cfg(unix)] pub(crate) use host_unix::run_remote_client_bridge; +pub(crate) use saved::*; #[cfg(windows)] pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> { diff --git a/src/remote/args.rs b/src/remote/args.rs new file mode 100644 index 00000000..476acf70 --- /dev/null +++ b/src/remote/args.rs @@ -0,0 +1,126 @@ +pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; +pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteKeybindings { + Local, + Server, +} + +impl RemoteKeybindings { + pub(super) fn parse(value: &str) -> Result<Self, String> { + match value { + "local" => Ok(Self::Local), + "server" => Ok(Self::Server), + _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), + } + } + + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Server => "server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteLaunch { + pub(crate) target: String, + pub(crate) keybindings: RemoteKeybindings, + pub(crate) live_handoff: bool, +} + +pub(crate) fn extract_remote_args( + args: &[String], +) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut remote_target = None; + let mut keybindings = RemoteKeybindings::Local; + let mut keybindings_seen = false; + let mut live_handoff = false; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--" { + cleaned.extend_from_slice(&args[index..]); + break; + } + if arg == "--handoff" { + live_handoff = true; + index += 1; + continue; + } + if arg == "--remote" { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote".to_string()); + }; + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote=") { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 1; + continue; + } + if arg == "--remote-keybindings" { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote-keybindings".to_string()); + }; + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote-keybindings=") { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + let remote = remote_target.map(|target| RemoteLaunch { + target, + keybindings, + live_handoff, + }); + if remote.is_none() && keybindings_seen { + return Err("--remote-keybindings requires --remote".to_string()); + } + if remote.is_none() && live_handoff { + cleaned.push("--handoff".to_string()); + } + + Ok((cleaned, remote)) +} + +pub(crate) fn validate_remote_target(target: &str) -> Result<&str, String> { + if target.is_empty() { + return Err("missing value for --remote".to_string()); + } + if target.starts_with('-') { + return Err("--remote target must not start with '-'".to_string()); + } + Ok(target) +} diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 641aad00..81810411 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -1,15 +1,13 @@ //! Remote thin-client launcher over SSH command stdio. -use super::shell_quote; +use super::{args::*, process::wait_with_output_timeout, restart_policy::*, shell_quote}; use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{self, IsTerminal, Write as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; -use interprocess::local_socket::traits::Listener as _; -#[cfg(windows)] -use interprocess::local_socket::traits::Stream as _; +use interprocess::local_socket::traits::{Listener as _, Stream as _}; use interprocess::local_socket::ListenerNonblockingMode; use interprocess::TryClone as _; use serde::Deserialize; @@ -21,144 +19,16 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); -#[cfg(windows)] const BRIDGE_IO_POLL: Duration = Duration::from_millis(1); const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); +const NONINTERACTIVE_SSH_COMMAND_TIMEOUT: Duration = Duration::from_secs(15); const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; -pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; - -pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RemoteKeybindings { - Local, - Server, -} - -impl RemoteKeybindings { - fn parse(value: &str) -> Result<Self, String> { - match value { - "local" => Ok(Self::Local), - "server" => Ok(Self::Server), - _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Server => "server", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RemoteLaunch { - pub(crate) target: String, - pub(crate) keybindings: RemoteKeybindings, - pub(crate) live_handoff: bool, -} - -pub(crate) fn extract_remote_args( - args: &[String], -) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { - let mut cleaned = Vec::with_capacity(args.len()); - if let Some(program) = args.first() { - cleaned.push(program.clone()); - } - - let mut remote_target = None; - let mut keybindings = RemoteKeybindings::Local; - let mut keybindings_seen = false; - let mut live_handoff = false; - let mut index = 1; - while index < args.len() { - let arg = &args[index]; - if arg == "--" { - cleaned.extend_from_slice(&args[index..]); - break; - } - if arg == "--handoff" { - live_handoff = true; - index += 1; - continue; - } - if arg == "--remote" { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote".to_string()); - }; - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote=") { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 1; - continue; - } - if arg == "--remote-keybindings" { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote-keybindings".to_string()); - }; - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote-keybindings=") { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 1; - continue; - } - - cleaned.push(arg.clone()); - index += 1; - } - - let remote = remote_target.map(|target| RemoteLaunch { - target, - keybindings, - live_handoff, - }); - if remote.is_none() && keybindings_seen { - return Err("--remote-keybindings requires --remote".to_string()); - } - if remote.is_none() && live_handoff { - cleaned.push("--handoff".to_string()); - } - - Ok((cleaned, remote)) -} - -fn validate_remote_target(target: &str) -> Result<&str, String> { - if target.is_empty() { - return Err("missing value for --remote".to_string()); - } - if target.starts_with('-') { - return Err("--remote target must not start with '-'".to_string()); - } - Ok(target) -} - pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { let session_name = crate::session::active_name() .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); @@ -177,13 +47,22 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { .config .remote .manage_ssh_config; - let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config); - let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff)?; + let require_surface_interest = crate::client::endpoint::EndpointCatalog::load() + .map(|catalog| catalog.contains_enabled_target_session(&remote.target, &session_name)) + .unwrap_or(false); + let remote_ssh = RemoteSsh::new( + remote.target.clone(), + manage_ssh_config, + session_name.clone(), + ); + let prepared_remote = + prepare_remote_herdr(&remote_ssh, remote.live_handoff, require_surface_interest)?; ensure_remote_server_ready( &remote_ssh, &prepared_remote.remote_herdr, prepared_remote.stop_after_install_approved, remote.live_handoff, + require_surface_interest, )?; let _bridge = SshStdioBridge::start( @@ -192,11 +71,68 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { local_socket.clone(), session_name, remote_ssh.options(), + false, )?; run_client_process(&local_socket, &reattach_command, remote.keybindings) } +pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> { + super::validate_remote_target(target) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + crate::session::validate_name(session_name) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let manage_ssh_config = crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let ssh = RemoteSsh::new( + target.to_owned(), + manage_ssh_config, + session_name.to_owned(), + ); + let prepared = prepare_remote_herdr(&ssh, true, true)?; + ensure_remote_server_ready( + &ssh, + &prepared.remote_herdr, + prepared.stop_after_install_approved, + true, + true, + )?; + + // The bridge already owns daemon startup. EOF closes only this temporary attachment, + // leaving the named server running even when no local TUI is open yet. + let output = ssh.sh_output(&format!( + "{} </dev/null", + remote_bridge_command(&prepared.remote_herdr, session_name) + ))?; + if !output.status.success() { + return Err(command_failed("remote server startup failed", &output)); + } + match remote_server_status(&ssh, &prepared.remote_herdr, true)? { + RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + detached_server_daemon, + .. + } if remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + true, + surface_interest, + health_check, + ) + .is_none() => + { + Ok(()) + } + _ => Err(io::Error::other( + "remote server is not ready for saved machines", + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -244,7 +180,7 @@ impl RemotePlatform { } #[derive(Debug, Clone)] -struct RemoteHerdr { +pub(super) struct RemoteHerdr { install_suffix: String, shell_path: String, platform: RemotePlatform, @@ -392,13 +328,13 @@ struct RemoteReleaseAsset { sha256: Option<String>, } -struct PreparedRemoteHerdr { - remote_herdr: RemoteHerdr, +pub(super) struct PreparedRemoteHerdr { + pub(super) remote_herdr: RemoteHerdr, stop_after_install_approved: bool, } #[derive(Clone)] -struct ManagedSshOptions { +pub(super) struct ManagedSshOptions { config_path: PathBuf, control_path: Option<PathBuf>, } @@ -415,13 +351,15 @@ impl Drop for ManagedSshConfig { } } -struct RemoteSsh { +pub(super) struct RemoteSsh { target: String, + session_name: String, managed_config: Option<ManagedSshConfig>, + noninteractive: bool, } impl RemoteSsh { - fn new(target: String, manage_ssh_config: bool) -> Self { + fn new(target: String, manage_ssh_config: bool, session_name: String) -> Self { let managed_config = if manage_ssh_config { write_managed_ssh_config() .inspect_err(|err| { @@ -434,7 +372,18 @@ impl RemoteSsh { Self { target, + session_name, managed_config, + noninteractive: false, + } + } + + pub(super) fn new_noninteractive(target: String) -> Self { + Self { + target, + session_name: crate::session::DEFAULT_SESSION_NAME.into(), + managed_config: None, + noninteractive: true, } } @@ -442,12 +391,19 @@ impl RemoteSsh { &self.target } - fn options(&self) -> Option<&ManagedSshOptions> { + fn destination(&self) -> String { + format!("{} (session {})", self.target, self.session_name) + } + + pub(super) fn options(&self) -> Option<&ManagedSshOptions> { self.managed_config.as_ref().map(|config| &config.options) } fn command(&self) -> Command { let mut command = self.base_command(); + if self.noninteractive { + apply_noninteractive_ssh_options(&mut command); + } command.arg("-T").arg(&self.target); command } @@ -475,13 +431,26 @@ impl RemoteSsh { "ssh bootstrap stdin missing", )) }; - let output = child.wait_with_output()?; + let output = if self.noninteractive { + wait_with_output_timeout(child, NONINTERACTIVE_SSH_COMMAND_TIMEOUT)? + } else { + child.wait_with_output()? + }; write_result?; Ok(output) } - fn user_shell_output(&self, command: &str) -> io::Result<Output> { - self.command().arg(command).output() + fn user_shell_output(&self, remote_command: &str) -> io::Result<Output> { + let mut command = self.command(); + command + .arg(remote_command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if self.noninteractive { + wait_with_output_timeout(command.spawn()?, NONINTERACTIVE_SSH_COMMAND_TIMEOUT) + } else { + command.output() + } } fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> { @@ -601,6 +570,24 @@ impl Drop for RemoteSsh { } } +fn apply_noninteractive_ssh_options(command: &mut Command) { + command + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("NumberOfPasswordPrompts=0") + .arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg("-o") + .arg("ConnectionAttempts=1") + .arg("-o") + .arg("ServerAliveInterval=15") + .arg("-o") + .arg("ServerAliveCountMax=4"); +} + fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) { let Some(options) = options else { return; @@ -640,9 +627,10 @@ impl InstallSource { } } -fn prepare_remote_herdr( +pub(super) fn prepare_remote_herdr( ssh: &RemoteSsh, live_handoff_enabled: bool, + require_surface_interest: bool, ) -> io::Result<PreparedRemoteHerdr> { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); @@ -651,14 +639,20 @@ fn prepare_remote_herdr( if override_binary.is_none() { for candidate in &remote_binary_candidates { - if remote_binary_supports_endpoint(ssh, candidate).unwrap_or(false) { + if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest) + .unwrap_or(false) + { return Ok(PreparedRemoteHerdr { remote_herdr: candidate.clone(), stop_after_install_approved: false, }); } } - if remote_binary_supports_endpoint(ssh, &remote_herdr)? { + if remote_binary_supports_endpoint_requirement( + ssh, + &remote_herdr, + require_surface_interest, + )? { return Ok(PreparedRemoteHerdr { remote_herdr, stop_after_install_approved: false, @@ -676,10 +670,11 @@ fn prepare_remote_herdr( ssh, status_probe_herdr, live_handoff_enabled, + require_surface_interest, )?; } confirm_remote_install( - ssh.target(), + &ssh.destination(), &remote_herdr, &install_source_description(&remote_herdr.platform, override_binary.as_deref()), )?; @@ -688,11 +683,10 @@ fn prepare_remote_herdr( source.cleanup(); install_result?; - if !remote_binary_supports_endpoint(ssh, &remote_herdr)? { + if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? { return Err(io::Error::other(format!( - "installed remote herdr at {}, but it does not support endpoint generation {}", - remote_herdr.shell_path, - crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + "installed remote herdr at {}, but it does not support saved SSH endpoint federation", + remote_herdr.shell_path ))); } warn_if_remote_bin_not_on_path(ssh)?; @@ -703,6 +697,25 @@ fn prepare_remote_herdr( }) } +pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> { + let platform = detect_remote_platform(ssh)?; + let remote_herdr = RemoteHerdr::for_platform(platform); + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; + for candidate in candidates { + if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? { + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "matching Herdr is not ready on {}; run `herdr --remote {}` interactively to install or update it", + ssh.target(), + ssh.target() + ), + )) +} + fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result<RemotePlatform> { let output = ssh.sh_output("uname -s\nuname -m\n")?; if !output.status.success() { @@ -869,6 +882,9 @@ fn remote_client_status( ); let output = ssh.sh_output(&command)?; if !output.status.success() { + if output.status.code() == Some(255) { + return Err(command_failed("remote SSH connection failed", &output)); + } return Ok(None); } Ok(parse_client_status_json(&String::from_utf8_lossy( @@ -876,13 +892,13 @@ fn remote_client_status( ))) } -fn remote_binary_supports_endpoint( +fn remote_binary_supports_endpoint_requirement( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, + require_surface_interest: bool, ) -> io::Result<bool> { Ok(remote_client_status(ssh, remote_herdr)? - .and_then(|status| status.endpoint_protocol_generation) - == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)) + .is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest))) } fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> { @@ -986,23 +1002,30 @@ enum RemoteServerStatus { Running { version: Option<String>, endpoint_protocol_generation: Option<u32>, + surface_interest: bool, + health_check: bool, live_handoff: bool, detached_server_daemon: bool, }, NotRunning, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteServerRestartReason { - EndpointProtocolMissing, - DaemonDetachMissing, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteInstallRunningServerPlan { - KeepRunning, - LiveHandoff, - StopRequired(RemoteServerRestartReason), +impl RemoteServerStatus { + fn with_endpoint_negotiation( + mut self, + negotiation: &crate::client::endpoint::EndpointNegotiation, + ) -> Self { + if let Self::Running { + surface_interest, + health_check, + .. + } = &mut self + { + *surface_interest = negotiation.supports_surface_interest(); + *health_check = negotiation.supports_health_check(); + } + self + } } fn ensure_remote_server_ready( @@ -1010,11 +1033,14 @@ fn ensure_remote_server_ready( remote_herdr: &RemoteHerdr, stop_after_install_approved: bool, live_handoff_enabled: bool, + require_surface_interest: bool, ) -> io::Result<()> { - let status = remote_server_status(ssh, remote_herdr)?; + let status = remote_server_status(ssh, remote_herdr, require_surface_interest)?; let RemoteServerStatus::Running { version, endpoint_protocol_generation, + surface_interest, + health_check, live_handoff, detached_server_daemon, } = status @@ -1022,9 +1048,13 @@ fn ensure_remote_server_ready( return Ok(()); }; - let Some(reason) = - remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon) - else { + let Some(reason) = remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + require_surface_interest, + surface_interest, + health_check, + ) else { return Ok(()); }; @@ -1043,33 +1073,20 @@ fn ensure_remote_server_ready( return Ok(()); } - if confirm_remote_server_stop(ssh.target(), version.as_deref(), reason)? { + if confirm_remote_server_stop(&ssh.destination(), version.as_deref(), reason)? { stop_remote_server(ssh, remote_herdr)?; } Ok(()) } -fn remote_server_restart_reason( - endpoint_protocol_generation: Option<u32>, - detached_server_daemon: bool, -) -> Option<RemoteServerRestartReason> { - if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) - { - return Some(RemoteServerRestartReason::EndpointProtocolMissing); - } - if !detached_server_daemon { - return Some(RemoteServerRestartReason::DaemonDetachMissing); - } - None -} - fn confirm_remote_install_with_running_server( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, live_handoff_enabled: bool, + require_surface_interest: bool, ) -> io::Result<bool> { - let target = ssh.target(); - let status = match remote_server_status(ssh, remote_herdr) { + let target = ssh.destination(); + let status = match remote_server_status(ssh, remote_herdr, require_surface_interest) { Ok(status) => status, Err(err) => { if !io::stdin().is_terminal() { @@ -1098,6 +1115,8 @@ fn confirm_remote_install_with_running_server( let RemoteServerStatus::Running { version, endpoint_protocol_generation, + surface_interest, + health_check, live_handoff, detached_server_daemon, } = &status @@ -1107,8 +1126,11 @@ fn confirm_remote_install_with_running_server( let plan = remote_install_running_server_plan( *endpoint_protocol_generation, *detached_server_daemon, + *surface_interest, + *health_check, *live_handoff, live_handoff_enabled, + require_surface_interest, ); if plan == RemoteInstallRunningServerPlan::KeepRunning { @@ -1172,37 +1194,57 @@ fn confirm_remote_install_with_running_server( Ok(true) } -fn remote_install_running_server_plan( - endpoint_protocol_generation: Option<u32>, - detached_server_daemon: bool, - live_handoff: bool, - live_handoff_enabled: bool, -) -> RemoteInstallRunningServerPlan { - let Some(reason) = - remote_server_restart_reason(endpoint_protocol_generation, detached_server_daemon) - else { - return RemoteInstallRunningServerPlan::KeepRunning; - }; - - if live_handoff_enabled && live_handoff { - return RemoteInstallRunningServerPlan::LiveHandoff; - } - - RemoteInstallRunningServerPlan::StopRequired(reason) -} - fn remote_server_status( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, + require_surface_interest: bool, ) -> io::Result<RemoteServerStatus> { - let command = format!("{} status server --json", remote_herdr.shell_path); + let command = remote_session_command(remote_herdr, &ssh.session_name, "status server --json"); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server status failed", &output)); } let stdout = String::from_utf8_lossy(&output.stdout); - parse_remote_server_status_json(stdout.trim()) + let status = parse_remote_server_status_json(stdout.trim())?; + if require_surface_interest + && matches!( + status, + RemoteServerStatus::Running { + endpoint_protocol_generation: Some( + crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION + ), + surface_interest: true, + health_check: true, + .. + } + ) + { + // Older status helpers omit newer capabilities. Ask the live endpoint rather than + // assuming that the installed binary and the running daemon support the same features. + let negotiation = probe_remote_endpoint(ssh, remote_herdr)?; + return Ok(status.with_endpoint_negotiation(&negotiation)); + } + Ok(status) +} + +fn probe_remote_endpoint( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, +) -> io::Result<crate::client::endpoint::EndpointNegotiation> { + let path = local_forward_socket_path(ssh.target(), &ssh.session_name); + let _bridge = SshStdioBridge::start( + ssh.target.clone(), + remote_herdr.clone(), + path.clone(), + ssh.session_name.clone(), + None, + true, + )?; + let mut stream = crate::ipc::connect_local_stream(&path)?; + // Use the saved client's noninteractive path. This metadata-only attachment never + // acquires a surface or sends pane input. + crate::client::probe_endpoint_negotiation(&mut stream) } #[derive(Debug, Deserialize)] @@ -1213,6 +1255,27 @@ struct RemoteClientStatusJson { protocol: Option<u32>, #[serde(default)] endpoint_protocol_generation: Option<u32>, + #[serde(default)] + endpoint_capabilities: Vec<String>, +} + +impl RemoteClientStatusJson { + fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool { + self.endpoint_protocol_generation + == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!require_surface_interest + || [ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY, + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY, + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY, + ] + .iter() + .all(|required| { + self.endpoint_capabilities + .iter() + .any(|capability| capability == required) + })) + } } #[derive(Debug, Deserialize)] @@ -1229,6 +1292,10 @@ struct RemoteServerCapabilitiesJson { detached_server_daemon: bool, #[serde(default)] endpoint_protocol_generation: Option<u32>, + #[serde(default)] + surface_interest: bool, + #[serde(default)] + health_check: bool, } fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { @@ -1241,6 +1308,7 @@ fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { status.version.is_some() || status.protocol.is_some() || status.endpoint_protocol_generation.is_some() + || !status.endpoint_capabilities.is_empty() }) } @@ -1261,6 +1329,12 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu endpoint_protocol_generation: capabilities .as_ref() .and_then(|capabilities| capabilities.endpoint_protocol_generation), + surface_interest: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.surface_interest), + health_check: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.health_check), live_handoff: capabilities .as_ref() .is_some_and(|capabilities| capabilities.live_handoff), @@ -1275,8 +1349,14 @@ fn confirm_remote_server_stop( version: Option<&str>, reason: RemoteServerRestartReason, ) -> io::Result<bool> { + let required_upgrade = matches!( + reason, + RemoteServerRestartReason::EndpointProtocol + | RemoteServerRestartReason::SurfaceInterest + | RemoteServerRestartReason::HealthCheck + ); if !io::stdin().is_terminal() { - if reason == RemoteServerRestartReason::EndpointProtocolMissing { + if required_upgrade { return Err(io::Error::other(format!( "remote herdr server on {target} needs one final update before this client can attach; run from an interactive terminal to approve updating it" ))); @@ -1296,19 +1376,27 @@ fn confirm_remote_server_stop( eprintln!(); match reason { - RemoteServerRestartReason::EndpointProtocolMissing => { + RemoteServerRestartReason::EndpointProtocol => { eprintln!( "the remote server predates Herdr's stable endpoint protocol and must update before this client can attach." ); } - RemoteServerRestartReason::DaemonDetachMissing => { + RemoteServerRestartReason::SurfaceInterest => { + eprintln!( + "the remote server must restart before it can join saved SSH endpoint federation." + ); + } + RemoteServerRestartReason::HealthCheck => { + eprintln!("the remote server must restart to enable saved SSH endpoint health checks."); + } + RemoteServerRestartReason::DaemonDetach => { eprintln!( "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client." ); } } - let prompt = if reason == RemoteServerRestartReason::EndpointProtocolMissing { + let prompt = if required_upgrade { "update the remote server and continue attaching? [Y/n] " } else { "restart the remote server now? [y/N] " @@ -1316,16 +1404,10 @@ fn confirm_remote_server_stop( eprint!("{prompt}"); io::stderr().flush()?; - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "y" || answer == "yes" { + if read_remote_confirmation(&mut io::stdin().lock(), required_upgrade)? { return Ok(true); } - if answer.is_empty() && reason == RemoteServerRestartReason::EndpointProtocolMissing { - return Ok(true); - } - if reason == RemoteServerRestartReason::EndpointProtocolMissing { + if required_upgrade { return Err(io::Error::new( io::ErrorKind::Interrupted, "remote herdr server stop cancelled", @@ -1335,10 +1417,19 @@ fn confirm_remote_server_stop( Ok(false) } -fn remote_live_handoff_command(remote_herdr: &RemoteHerdr, protocol: u32, version: &str) -> String { - format!( - "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", - remote_herdr.shell_path, remote_herdr.shell_path, protocol, version +fn remote_live_handoff_command( + remote_herdr: &RemoteHerdr, + session_name: &str, + protocol: u32, + version: &str, +) -> String { + remote_session_command( + remote_herdr, + session_name, + &format!( + "server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", + remote_herdr.shell_path, protocol, version + ), ) } @@ -1353,7 +1444,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io .version .filter(|version| !version.is_empty()) .ok_or_else(|| io::Error::other("prepared remote herdr did not report its version"))?; - let command = remote_live_handoff_command(remote_herdr, protocol, &version); + let command = remote_live_handoff_command(remote_herdr, &ssh.session_name, protocol, &version); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server live handoff failed", &output)); @@ -1367,7 +1458,7 @@ fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io } fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!("{} server stop", remote_herdr.shell_path); + let command = remote_session_command(remote_herdr, &ssh.session_name, "server stop"); let output = ssh.sh_output(&command)?; if !output.status.success() { return Err(command_failed("remote server stop failed", &output)); @@ -1384,7 +1475,7 @@ fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; loop { - if remote_server_status(ssh, remote_herdr)? == RemoteServerStatus::NotRunning { + if remote_server_status(ssh, remote_herdr, false)? == RemoteServerStatus::NotRunning { return Ok(()); } if Instant::now() >= deadline { @@ -1577,6 +1668,25 @@ fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> { )) } +fn read_remote_confirmation(reader: &mut impl io::BufRead, default: bool) -> io::Result<bool> { + let mut answer = String::new(); + if reader.read_line(&mut answer)? == 0 { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote setup cancelled", + )); + } + match answer.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + "" => Ok(default), + _ => Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote setup cancelled: expected yes or no", + )), + } +} + fn confirm_remote_install( target: &str, remote_herdr: &RemoteHerdr, @@ -1601,10 +1711,7 @@ fn confirm_remote_install( ); io::stderr().flush()?; - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "n" || answer == "no" { + if !read_remote_confirmation(&mut io::stdin().lock(), true)? { return Err(io::Error::new( io::ErrorKind::Interrupted, "remote herdr installation cancelled", @@ -1614,16 +1721,24 @@ fn confirm_remote_install( Ok(()) } -fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { - let mut command = format!("exec {}", remote_herdr.shell_path); +fn remote_session_command(remote_herdr: &RemoteHerdr, session_name: &str, args: &str) -> String { + let mut command = remote_herdr.shell_path.clone(); if session_name != crate::session::DEFAULT_SESSION_NAME { command.push_str(" --session "); command.push_str(&shell_quote(session_name)); } - command.push_str(" remote-client-bridge"); + command.push(' '); + command.push_str(args); command } +fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { + format!( + "exec {}", + remote_session_command(remote_herdr, session_name, "remote-client-bridge") + ) +} + fn reattach_command( program: &str, target: &str, @@ -1658,7 +1773,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error { } } -struct SshStdioBridge { +pub(super) struct SshStdioBridge { local_socket: PathBuf, socket_identity: crate::ipc::SocketFileIdentity, should_stop: Arc<AtomicBool>, @@ -1666,12 +1781,13 @@ struct SshStdioBridge { } impl SshStdioBridge { - fn start( + pub(super) fn start( target: String, remote_herdr: RemoteHerdr, local_socket: PathBuf, session_name: String, ssh_options: Option<&ManagedSshOptions>, + noninteractive: bool, ) -> io::Result<Self> { crate::ipc::prepare_socket_path(&local_socket, |path| { format!("remote bridge is already listening at {}", path.display()) @@ -1712,16 +1828,25 @@ impl SshStdioBridge { &remote_herdr, &session_name, thread_ssh_options.as_ref(), + noninteractive, &thread_stop, ) { - eprintln!("herdr: remote bridge failed: {err}"); + if noninteractive { + tracing::warn!(error = %err, "saved SSH endpoint bridge failed"); + } else { + eprintln!("herdr: remote bridge failed: {err}"); + } } } Err(err) if err.kind() == io::ErrorKind::WouldBlock => { thread::sleep(BRIDGE_ACCEPT_POLL); } Err(err) => { - eprintln!("herdr: remote bridge listener failed: {err}"); + if noninteractive { + tracing::warn!(error = %err, "saved SSH endpoint listener failed"); + } else { + eprintln!("herdr: remote bridge listener failed: {err}"); + } break; } } @@ -1813,79 +1938,31 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { }) } -#[cfg(unix)] -fn bridge_connection( - stream: crate::ipc::LocalStream, - target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, - ssh_options: Option<&ManagedSshOptions>, - _bridge_stop: &Arc<AtomicBool>, -) -> io::Result<()> { - let mut command = Command::new("ssh"); - apply_managed_ssh_options(&mut command, ssh_options); - command - .arg("-T") - .arg(target) - .arg(remote_bridge_command(remote_herdr, session_name)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - - let mut child = command - .spawn() - .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; - let mut child_stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; - let mut child_stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; - let mut stream_to_child = stream.try_clone()?; - let mut child_to_stream = stream; - - let upload = thread::spawn(move || { - let _ = copy_flush(&mut stream_to_child, &mut child_stdin); - }); - let download = thread::spawn(move || { - let _ = copy_flush(&mut child_stdout, &mut child_to_stream); - let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream); - }); - - let status = child.wait()?; - let _ = upload.join(); - let _ = download.join(); - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - format!("ssh bridge exited with {status}"), - )) - } -} - -#[cfg(windows)] fn bridge_connection( stream: crate::ipc::LocalStream, target: &str, remote_herdr: &RemoteHerdr, session_name: &str, ssh_options: Option<&ManagedSshOptions>, + noninteractive: bool, bridge_stop: &Arc<AtomicBool>, ) -> io::Result<()> { let mut command = Command::new("ssh"); apply_managed_ssh_options(&mut command, ssh_options); + if noninteractive { + apply_noninteractive_ssh_options(&mut command); + } command .arg("-T") .arg(target) .arg(remote_bridge_command(remote_herdr, session_name)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(if noninteractive { + Stdio::null() + } else { + Stdio::inherit() + }); let mut child = command .spawn() @@ -2018,33 +2095,12 @@ fn bridge_connection( } } -#[cfg(unix)] -fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { - let mut buffer = [0_u8; 16 * 1024]; - let mut total = 0; - - loop { - let bytes_read = match reader.read(&mut buffer) { - Ok(0) => return Ok(total), - Ok(bytes_read) => bytes_read, - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err), - }; - - writer.write_all(&buffer[..bytes_read])?; - writer.flush()?; - total += bytes_read as u64; - } -} - -#[cfg(windows)] fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> { let _ = child.kill(); let _ = child.wait(); Err(io::Error::new(io::ErrorKind::BrokenPipe, message)) } -#[cfg(windows)] fn copy_reader_to_local_stream<R: io::Read>( reader: &mut R, stream: &mut crate::ipc::LocalStream, @@ -2082,7 +2138,6 @@ fn copy_reader_to_local_stream<R: io::Read>( } } -#[cfg(windows)] fn copy_local_stream_to_writer<W: io::Write>( mut stream: crate::ipc::LocalStream, writer: &mut W, @@ -2207,6 +2262,7 @@ mod tests { socket.clone(), "default".to_string(), None, + false, ) .expect("start bridge listener"); @@ -2253,9 +2309,8 @@ mod tests { let _ = std::fs::remove_file(socket); } - #[cfg(windows)] #[test] - fn windows_bridge_drop_while_waiting_for_client_is_bounded() { + fn bridge_drop_while_waiting_for_client_is_bounded() { let socket = local_forward_socket_path("drop-test", "default"); let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { os: "linux", @@ -2267,6 +2322,7 @@ mod tests { socket.clone(), "default".to_string(), None, + false, ) .expect("start bridge listener"); let started = Instant::now(); @@ -2362,7 +2418,9 @@ mod tests { .expect("Unix managed config has a control path"); let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: Some(managed_config), + noninteractive: false, }; let command = ssh.command(); @@ -2400,7 +2458,9 @@ mod tests { let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: Some(managed_config), + noninteractive: false, }; let args = ssh .command() @@ -2428,10 +2488,106 @@ mod tests { } #[test] + fn noninteractive_ssh_command_cannot_prompt_or_accept_unknown_hosts() { + let ssh = RemoteSsh::new_noninteractive("example".into()); + let args = ssh + .command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(); + for required in [ + "BatchMode=yes", + "NumberOfPasswordPrompts=0", + "StrictHostKeyChecking=yes", + "ConnectTimeout=10", + "ConnectionAttempts=1", + "ServerAliveInterval=15", + "ServerAliveCountMax=4", + ] { + assert!(args.iter().any(|arg| arg == required), "missing {required}"); + } + assert!(!args.iter().any(|arg| arg == "-F")); + assert!(ssh.options().is_none()); + } + + #[test] + fn remote_setup_approval_requires_input_and_rejects_unrecognized_answers() { + for default in [false, true] { + for input in ["", "maybe\n"] { + assert_eq!( + read_remote_confirmation(&mut input.as_bytes(), default) + .unwrap_err() + .kind(), + io::ErrorKind::Interrupted + ); + } + assert_eq!( + read_remote_confirmation(&mut "\n".as_bytes(), default).unwrap(), + default + ); + assert!(read_remote_confirmation(&mut "YES\n".as_bytes(), default).unwrap()); + assert!(!read_remote_confirmation(&mut "no\n".as_bytes(), default).unwrap()); + } + } + + #[test] + fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() { + let mut status = RemoteClientStatusJson { + version: Some("0.1.0".into()), + protocol: Some(1), + endpoint_protocol_generation: Some( + crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, + ), + endpoint_capabilities: vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(), + ], + }; + assert!(status.supports_endpoint_requirement(true)); + for index in 0..status.endpoint_capabilities.len() { + let removed = status.endpoint_capabilities.remove(index); + assert!(!status.supports_endpoint_requirement(true)); + assert!(status.supports_endpoint_requirement(false)); + status.endpoint_capabilities.insert(index, removed); + } + status.endpoint_protocol_generation = None; + assert!(!status.supports_endpoint_requirement(true)); + } + + #[test] + fn saved_machine_server_commands_are_scoped_to_the_explicit_session() { + let herdr = + RemoteHerdr::for_platform(RemotePlatform::from_uname("Linux", "x86_64").unwrap()); + for command in [ + "status server --json", + "server stop", + "remote-client-bridge", + ] { + assert_eq!( + remote_session_command(&herdr, "agents", command), + format!("{} --session agents {command}", herdr.shell_path) + ); + assert_eq!( + remote_session_command(&herdr, crate::session::DEFAULT_SESSION_NAME, command), + format!("{} {command}", herdr.shell_path) + ); + } + assert!( + remote_live_handoff_command(&herdr, "agents", 19, "0.7.9").starts_with(&format!( + "{} --session agents server live-handoff", + herdr.shell_path + )) + ); + } + + #[test] fn remote_ssh_command_is_plain_without_managed_config() { let ssh = RemoteSsh { target: "example".to_string(), + session_name: crate::session::DEFAULT_SESSION_NAME.into(), managed_config: None, + noninteractive: false, }; let command = ssh.command(); @@ -2898,27 +3054,91 @@ mod tests { #[test] fn parse_client_status_json_reads_last_json_record() { let status = parse_client_status_json( - "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1}\n{\"wrapper\":true}\n", + "wrapper output\n{\"version\":\"0.8.0\",\"protocol\":20,\"endpoint_protocol_generation\":1,\"endpoint_capabilities\":[\"surface_interest\",\"health_check\"]}\n{\"wrapper\":true}\n", ) .unwrap(); assert_eq!(status.version.as_deref(), Some("0.8.0")); assert_eq!(status.protocol, Some(20)); assert_eq!(status.endpoint_protocol_generation, Some(1)); + assert_eq!( + status.endpoint_capabilities, + vec!["surface_interest", "health_check"] + ); assert!( parse_client_status_json(r#"{"endpoint_protocol_generation":"unknown"}"#).is_none() ); } #[test] + fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() { + // Captured from Rohan after installing a new binary while the old daemon stayed alive. + let installed = parse_client_status_json( + r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, + ) + .unwrap(); + let running_binary = parse_client_status_json( + r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, + ) + .unwrap(); + assert!(installed.supports_endpoint_requirement(true)); + assert!(!running_binary.supports_endpoint_requirement(true)); + for (live_capabilities, expected) in [ + ( + running_binary.endpoint_capabilities, + RemoteInstallRunningServerPlan::LiveHandoff, + ), + ( + installed.endpoint_capabilities, + RemoteInstallRunningServerPlan::KeepRunning, + ), + ] { + let live_negotiation = crate::client::endpoint::EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + live_capabilities, + ); + let RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + live_handoff, + detached_server_daemon, + .. + } = parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.8.2","protocol":22,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#, + ) + .unwrap() + .with_endpoint_negotiation(&live_negotiation) else { + panic!("captured server must be running"); + }; + + assert_eq!( + remote_install_running_server_plan( + endpoint_protocol_generation, + detached_server_daemon, + surface_interest, + health_check, + live_handoff, + true, + true, + ), + expected, + "setup must follow the running server's negotiated capabilities", + ); + } + } + + #[test] fn parse_remote_server_status_json_reads_running_server() { assert_eq!( parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1}}"# + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"# ) .unwrap(), RemoteServerStatus::Running { version: Some("0.6.0".into()), endpoint_protocol_generation: Some(1), + surface_interest: true, + health_check: true, live_handoff: true, detached_server_daemon: true } @@ -2935,6 +3155,8 @@ mod tests { RemoteServerStatus::Running { version: Some("0.6.0".into()), endpoint_protocol_generation: None, + surface_interest: false, + health_check: false, live_handoff: false, detached_server_daemon: false } @@ -3109,88 +3331,17 @@ mod tests { } #[test] - fn remote_server_restart_reason_requires_one_update_for_pre_floor_server() { - assert_eq!( - remote_server_restart_reason(None, true), - Some(RemoteServerRestartReason::EndpointProtocolMissing) - ); - } - - #[test] - fn remote_server_restart_reason_allows_compatible_server() { - assert_eq!( - remote_server_restart_reason( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - true - ), - None - ); - } - - #[test] - fn remote_server_restart_reason_requires_restart_for_old_daemon() { - assert_eq!( - remote_server_restart_reason( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - false - ), - Some(RemoteServerRestartReason::DaemonDetachMissing) - ); - } - - #[test] - fn remote_install_plan_keeps_compatible_running_server() { - assert_eq!( - remote_install_running_server_plan( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - true, - false, - false - ), - RemoteInstallRunningServerPlan::KeepRunning - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_old_daemon() { - assert_eq!( - remote_install_running_server_plan( - Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), - false, - false, - false - ), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::DaemonDetachMissing - ) - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_pre_floor_server() { - assert_eq!( - remote_install_running_server_plan(None, true, false, false), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::EndpointProtocolMissing - ) - ); - } - - #[test] - fn remote_install_plan_uses_live_handoff_for_pre_floor_server() { - assert_eq!( - remote_install_running_server_plan(None, true, true, true), - RemoteInstallRunningServerPlan::LiveHandoff - ); - } - - #[test] fn remote_live_handoff_uses_prepared_binary_identity() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { os: "linux", arch: "x86_64", }); - let command = remote_live_handoff_command(&remote_herdr, 19, "0.7.9"); + let command = remote_live_handoff_command( + &remote_herdr, + crate::session::DEFAULT_SESSION_NAME, + 19, + "0.7.9", + ); assert!(command.contains("--expected-protocol 19")); assert!(command.contains("--expected-version 0.7.9")); assert!(!command.contains(&format!( diff --git a/src/remote/process.rs b/src/remote/process.rs new file mode 100644 index 00000000..d3db5c9f --- /dev/null +++ b/src/remote/process.rs @@ -0,0 +1,87 @@ +use std::io::{self, Read as _}; +use std::process::Output; +use std::thread; +use std::time::{Duration, Instant}; + +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +pub(super) fn wait_with_output_timeout( + mut child: std::process::Child, + timeout: Duration, +) -> io::Result<Output> { + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("SSH command stdout was not captured"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("SSH command stderr was not captured"))?; + let stdout = thread::spawn(move || { + let mut stdout = stdout; + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let stderr = thread::spawn(move || { + let mut stderr = stderr; + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(error); + } + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "noninteractive SSH command timed out", + )); + } + thread::sleep(POLL_INTERVAL); + }; + let stdout = stdout + .join() + .map_err(|_| io::Error::other("SSH stdout reader panicked"))??; + let stderr = stderr + .join() + .map_err(|_| io::Error::other("SSH stderr reader panicked"))??; + Ok(Output { + status, + stdout, + stderr, + }) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + + #[test] + fn timeout_kills_the_child() { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("exec sleep 10") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let started = Instant::now(); + let error = wait_with_output_timeout(command.spawn().unwrap(), Duration::from_millis(25)) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} diff --git a/src/remote/restart_policy.rs b/src/remote/restart_policy.rs new file mode 100644 index 00000000..757efcc5 --- /dev/null +++ b/src/remote/restart_policy.rs @@ -0,0 +1,161 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RemoteServerRestartReason { + EndpointProtocol, + SurfaceInterest, + HealthCheck, + DaemonDetach, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RemoteInstallRunningServerPlan { + KeepRunning, + LiveHandoff, + StopRequired(RemoteServerRestartReason), +} + +pub(super) fn remote_server_restart_reason( + endpoint_protocol_generation: Option<u32>, + detached_server_daemon: bool, + require_surface_interest: bool, + surface_interest: bool, + health_check: bool, +) -> Option<RemoteServerRestartReason> { + if endpoint_protocol_generation != Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + { + return Some(RemoteServerRestartReason::EndpointProtocol); + } + if require_surface_interest && !surface_interest { + return Some(RemoteServerRestartReason::SurfaceInterest); + } + if require_surface_interest && !health_check { + return Some(RemoteServerRestartReason::HealthCheck); + } + if !detached_server_daemon { + return Some(RemoteServerRestartReason::DaemonDetach); + } + None +} + +pub(super) fn remote_install_running_server_plan( + endpoint_protocol_generation: Option<u32>, + detached_server_daemon: bool, + surface_interest: bool, + health_check: bool, + live_handoff: bool, + live_handoff_enabled: bool, + require_surface_interest: bool, +) -> RemoteInstallRunningServerPlan { + let Some(reason) = remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + require_surface_interest, + surface_interest, + health_check, + ) else { + return RemoteInstallRunningServerPlan::KeepRunning; + }; + + if live_handoff_enabled && live_handoff { + return RemoteInstallRunningServerPlan::LiveHandoff; + } + RemoteInstallRunningServerPlan::StopRequired(reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pre_floor_server_requires_update() { + assert_eq!( + remote_server_restart_reason(None, true, false, false, false), + Some(RemoteServerRestartReason::EndpointProtocol) + ); + } + + #[test] + fn compatible_server_can_keep_running() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + false, + false, + false + ), + None + ); + } + + #[test] + fn saved_endpoint_requires_surface_interest() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + false, + false + ), + Some(RemoteServerRestartReason::SurfaceInterest) + ); + } + + #[test] + fn saved_endpoint_requires_health_checks() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + true, + false + ), + Some(RemoteServerRestartReason::HealthCheck) + ); + } + + #[test] + fn saved_endpoint_accepts_complete_lifecycle_support() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + true, + true, + true + ), + None + ); + } + + #[test] + fn old_daemon_requires_restart() { + assert_eq!( + remote_server_restart_reason( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + false, + false, + false, + false + ), + Some(RemoteServerRestartReason::DaemonDetach) + ); + } + + #[test] + fn saved_endpoint_can_explicitly_live_handoff_for_surface_support() { + assert_eq!( + remote_install_running_server_plan( + Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), + true, + false, + false, + true, + true, + true + ), + RemoteInstallRunningServerPlan::LiveHandoff + ); + } +} diff --git a/src/remote/saved.rs b/src/remote/saved.rs new file mode 100644 index 00000000..47684758 --- /dev/null +++ b/src/remote/saved.rs @@ -0,0 +1,136 @@ +use std::io; +use std::path::PathBuf; + +use super::attach::{find_installed_remote_herdr, RemoteSsh, SshStdioBridge}; + +pub(crate) struct SavedSshBridge { + _bridge: SshStdioBridge, +} + +pub(crate) struct SavedSshStream { + pub(crate) stream: crate::ipc::LocalStream, + pub(crate) bridge: SavedSshBridge, +} + +pub(crate) fn connect_saved_ssh( + profile_id: &str, + target: &str, + session: &str, +) -> io::Result<SavedSshStream> { + validate_profile_path_id(profile_id)?; + crate::session::validate_name(session) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let ssh = RemoteSsh::new_noninteractive(target.to_owned()); + let remote_herdr = find_installed_remote_herdr(&ssh)?; + let path = saved_bridge_path(profile_id); + let bridge = SshStdioBridge::start( + target.to_owned(), + remote_herdr, + path.clone(), + session.to_owned(), + ssh.options(), + true, + )?; + let stream = crate::ipc::connect_local_stream(&path)?; + Ok(SavedSshStream { + stream, + bridge: SavedSshBridge { _bridge: bridge }, + }) +} + +pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String { + format!( + "herdr --remote {} --session {} --handoff", + super::shell_quote(target), + super::shell_quote(session) + ) +} + +pub(crate) fn saved_ssh_failure_needs_attention(error: &io::Error) -> bool { + if matches!( + error.kind(), + io::ErrorKind::InvalidInput + | io::ErrorKind::InvalidData + | io::ErrorKind::NotFound + | io::ErrorKind::PermissionDenied + | io::ErrorKind::Unsupported + ) { + return true; + } + let message = error.to_string().to_ascii_lowercase(); + [ + "permission denied", + "host key verification failed", + "remote host identification has changed", + "no matching host key", + "unsupported remote platform", + "not ready", + "install or update", + "protocol", + "handshake", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +fn saved_bridge_path(profile_id: &str) -> PathBuf { + let pid = std::process::id(); + let readable = format!("herdr-ssh-{pid}-{profile_id}.sock"); + let short = format!("herdr-s-{pid}-{}.sock", &profile_id[..16]); + crate::platform::remote_bridge_endpoint_path(&readable, &short) +} + +fn validate_profile_path_id(profile_id: &str) -> io::Result<()> { + if profile_id.len() == 32 + && profile_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid SSH endpoint profile id", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_paths_use_profile_identity_not_target_or_session() { + let first = saved_bridge_path("0123456789abcdef0123456789abcdef"); + let second = saved_bridge_path("fedcba9876543210fedcba9876543210"); + assert_ne!(first, second); + assert!(!first.to_string_lossy().contains("example.com")); + assert!(!first.to_string_lossy().contains("default")); + } + + #[test] + fn bootstrap_command_preserves_the_explicit_remote_session() { + assert_eq!( + saved_ssh_bootstrap_command("build host", "agent work"), + "herdr --remote 'build host' --session 'agent work' --handoff" + ); + } + + #[test] + fn prompt_and_compatibility_failures_require_attention() { + for message in [ + "Permission denied (publickey)", + "Host key verification failed", + "matching Herdr is not ready; install or update", + "handshake rejected", + ] { + assert!(saved_ssh_failure_needs_attention(&io::Error::other( + message + ))); + } + assert!(!saved_ssh_failure_needs_attention(&io::Error::new( + io::ErrorKind::TimedOut, + "network timed out" + ))); + } +} diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index bd364a70..e6fdaaa4 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -142,7 +142,7 @@ fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> { } } -fn validate_running_server_compatibility() -> io::Result<()> { +fn validate_running_server_compatibility(saved_federation: bool) -> io::Result<()> { let Some(status) = read_server_status()? else { return Err(io::Error::other(format!( "a herdr server is listening, but its status API is unavailable.\n\n{}\nIf that fails, stop the old server process manually.", @@ -150,16 +150,23 @@ fn validate_running_server_compatibility() -> io::Result<()> { ))); }; - let endpoint_generation = status - .capabilities - .as_ref() - .and_then(|capabilities| capabilities.endpoint_protocol_generation); - if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) { + let capabilities = status.capabilities.as_ref(); + let endpoint_generation = + capabilities.and_then(|capabilities| capabilities.endpoint_protocol_generation); + let surface_interest = capabilities.is_some_and(|capabilities| capabilities.surface_interest); + if endpoint_generation == Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION) + && (!saved_federation || surface_interest) + { return Ok(()); } + let requirement = if saved_federation && !surface_interest { + "saved SSH machines require surface lifecycle support" + } else { + "the stable endpoint generation is incompatible" + }; Err(io::Error::other(format!( - "This session predates Herdr's stable endpoint protocol and needs one final server update.\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}", + "This session needs one final server update before Herdr can attach ({requirement}).\n\nserver: v{} endpoint generation {}\nclient: v{} endpoint generation {}\n\n{}", status.version.as_deref().unwrap_or("unknown"), endpoint_generation .map(|value| value.to_string()) @@ -285,18 +292,27 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu /// 1. Check if a server is listening on the client socket /// 2. If no server → spawn server daemon → wait for socket readiness /// 3. Run the thin client (which connects to the server) -pub fn auto_detect_launch() -> io::Result<()> { +pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> { let socket_path = client_socket_path(); info!(path = %socket_path.display(), "auto-detect launch starting"); - if is_server_listening_at(&socket_path) { - validate_running_server_compatibility()?; + let startup = if is_server_listening_at(&socket_path) { info!("server already running, attaching as client"); + if saved_federation { + Ok(()) + } else { + validate_running_server_compatibility(false) + } } else { info!("no server running, spawning server daemon"); - spawn_server_daemon()?; - wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?; - info!("server ready, attaching as client"); + spawn_server_daemon() + .and_then(|_| wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)) + }; + if let Err(error) = startup { + if !saved_federation { + return Err(error); + } + tracing::warn!(%error, "Local startup failed; keeping saved machines available"); } // Now attach as a thin client. @@ -522,7 +538,7 @@ test "$sid" = "$$" let path = dir.join("api.sock"); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path); - let err = validate_running_server_compatibility().unwrap_err(); + let err = validate_running_server_compatibility(false).unwrap_err(); assert!( err.to_string().contains("status API is unavailable"), @@ -558,7 +574,7 @@ test "$sid" = "$$" stream.flush().unwrap(); }); - let err = validate_running_server_compatibility().unwrap_err(); + let err = validate_running_server_compatibility(false).unwrap_err(); let message = err.to_string(); let _ = handle.join(); diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 34be0519..0af1fad1 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -13,6 +13,7 @@ pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128; const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024; const CLIENT_SHELL_METHODS: &[&str] = &[ + "client_shell.surface.set", "command.invoke", "integration.install", "integration.list", @@ -77,6 +78,30 @@ pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) }) } +pub(crate) fn success_message_with_result( + boot_id: String, + request_id: String, + result: crate::api::schema::ResponseResult, +) -> crate::protocol::ServerMessage { + let response = serde_json::to_string(&crate::api::schema::SuccessResponse { + id: request_id.clone(), + result, + }) + .unwrap_or_else(|_| { + error_response( + request_id.clone(), + "serialization_error", + "failed to serialize endpoint response", + ) + }); + crate::protocol::ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk: true, + data: response.into_bytes(), + } +} + pub(crate) fn error_message( boot_id: String, request_id: String, @@ -312,6 +337,11 @@ mod tests { #[test] fn client_shell_lane_excludes_api_front_door_and_lifecycle_methods() { + assert!(supports_client_shell_method( + &Method::ClientShellSurfaceSet(crate::api::schema::ClientShellSurfaceSetParams { + active: false, + }) + )); assert!(supports_client_shell_method(&Method::ServerReloadConfig( crate::api::schema::EmptyParams::default(), ))); diff --git a/src/server/client_endpoint_control.rs b/src/server/client_endpoint_control.rs new file mode 100644 index 00000000..09970004 --- /dev/null +++ b/src/server/client_endpoint_control.rs @@ -0,0 +1,24 @@ +pub(crate) fn response(kind: &str, data: String) -> Option<crate::protocol::ServerMessage> { + (kind == crate::protocol::endpoint::HEALTH_PING_KIND).then(|| { + crate::protocol::ServerMessage::EndpointControl { + kind: crate::protocol::endpoint::HEALTH_PONG_KIND.into(), + data, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_ping_echoes_a_pong_without_entering_server_state() { + let pong = response(crate::protocol::endpoint::HEALTH_PING_KIND, "probe".into()).unwrap(); + assert!(matches!( + pong, + crate::protocol::ServerMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::HEALTH_PONG_KIND && data == "probe" + )); + assert!(response("future.control", String::new()).is_none()); + } +} diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs index 578350b2..00355230 100644 --- a/src/server/client_shell_graphics.rs +++ b/src/server/client_shell_graphics.rs @@ -1,6 +1,48 @@ use crate::kitty_graphics::surface::DeliveryCache; use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; +pub(crate) fn collect_retained( + app: &crate::app::App, + surface: &crate::protocol::PaneSurfaceFrame, + target: crate::ui::TabSurfaceTarget, + cell_size: crate::kitty_graphics::HostCellSize, + delivered: &DeliveryCache, + client_id: u64, +) -> Option<(SurfaceGraphicsScene, DeliveryCache)> { + let rect = |rect: crate::protocol::SurfaceRect| { + ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height) + }; + let pane_infos = surface + .panes + .iter() + .map(|pane| { + let (workspace_index, id) = app.parse_pane_id(&pane.pane_id)?; + if workspace_index != target.workspace_index { + return None; + } + Some(crate::layout::PaneInfo { + id, + rect: rect(pane.rect), + inner_rect: rect(pane.inner_rect), + scrollbar_rect: pane.scrollbar_rect.map(rect), + borders: ratatui::widgets::Borders::NONE, + is_focused: pane.focused, + }) + }) + .collect::<Option<Vec<_>>>()?; + // Image clipping uses the retained content rectangles, not borders or split handles. + Some(collect( + app, + &pane_infos, + &[], + None, + Some(target), + cell_size, + delivered, + client_id, + )) +} + pub(crate) fn collect( app: &crate::app::App, pane_infos: &[crate::layout::PaneInfo], diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 25a963e7..c57d0f86 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -124,6 +124,11 @@ pub(crate) struct ClientWriter { } impl ClientWriter { + /// Drops render-lane work that has not yet been claimed by the writer. + pub(crate) fn discard_pending_render(&self) { + self.render.queue.discard_pending_render(); + } + #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec<u8>) { self.render.try_send(data).unwrap(); @@ -304,6 +309,13 @@ impl ClientWriterQueue { Ok(()) } + fn discard_pending_render(&self) { + let mut state = self.lock_state(); + state.render = None; + state.ordered.clear(); + self.ready.notify_all(); + } + fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> { let mut state = self.lock_state(); if !state.writer_alive { @@ -382,6 +394,7 @@ pub(crate) enum ServerEvent { direct_graphics: bool, endpoint_keybindings: bool, mouse_capture: bool, + surface_active: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -483,6 +496,8 @@ pub(crate) enum ServerEvent { ClientShellFocus { client_id: u64, focused: bool }, /// A client-owned shell updated its local mouse-capture preference. ClientShellMouseCapture { client_id: u64, enabled: bool }, + /// The committed shell asks the server to replay presentation effects before input resumes. + ClientShellPresentationSync { client_id: u64, token: String }, /// A client-owned shell invoked one endpoint operation through this connection. ClientShellEndpointRequest { client_id: u64, @@ -744,6 +759,7 @@ pub(crate) fn handle_client_handshake( hello.direct_graphics, hello.endpoint_keybindings, hello.mouse_capture, + hello.surface_active, )), ) } @@ -831,33 +847,39 @@ pub(crate) fn handle_client_handshake( } // Notify the main loop about the new client. - let connected = - if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) = - shell_options - { - ServerEvent::ClientShellConnected { - client_id, - surface_cols: client_cols, - surface_rows: client_rows, - cell_width_px, - cell_height_px, - pixel_mouse, - direct_graphics, - endpoint_keybindings, - mouse_capture, - writer, - } - } else { - ServerEvent::ClientConnected { - client_id, - cols: client_cols, - rows: client_rows, - cell_width_px, - cell_height_px, - pixel_mouse: terminal_pixel_mouse, - writer, - } - }; + let endpoint_control_writer = shell_options.as_ref().map(|_| writer.control.clone()); + let connected = if let Some(( + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + surface_active, + )) = shell_options + { + ServerEvent::ClientShellConnected { + client_id, + surface_cols: client_cols, + surface_rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse, + direct_graphics, + endpoint_keybindings, + mouse_capture, + surface_active, + writer, + } + } else { + ServerEvent::ClientConnected { + client_id, + cols: client_cols, + rows: client_rows, + cell_width_px, + cell_height_px, + pixel_mouse: terminal_pixel_mouse, + writer, + } + }; if let Err(err) = server_event_tx.blocking_send(connected) { match err.0 { ServerEvent::ClientConnected { writer, .. } @@ -869,7 +891,13 @@ pub(crate) fn handle_client_handshake( } // Enter read loop — read client messages and forward to main loop. - client_read_loop(stream, client_id, server_event_tx, should_quit) + client_read_loop_with_endpoint_controls( + stream, + client_id, + server_event_tx, + should_quit, + endpoint_control_writer.as_ref(), + ) } fn send_shutdown_to_unregistered_client(writer: &ClientWriter) { @@ -924,11 +952,22 @@ fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool { } /// The client read loop — reads messages from the client and forwards to the server event channel. +#[cfg(test)] fn client_read_loop( + stream: LocalStream, + client_id: u64, + server_event_tx: &mpsc::Sender<ServerEvent>, + should_quit: &Arc<AtomicBool>, +) -> io::Result<()> { + client_read_loop_with_endpoint_controls(stream, client_id, server_event_tx, should_quit, None) +} + +fn client_read_loop_with_endpoint_controls( mut stream: LocalStream, client_id: u64, server_event_tx: &mpsc::Sender<ServerEvent>, should_quit: &Arc<AtomicBool>, + endpoint_control_writer: Option<&ClientControlWriter>, ) -> io::Result<()> { while !should_quit.load(Ordering::Acquire) { let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) @@ -1242,8 +1281,29 @@ fn client_read_loop( }, } } - ClientMessage::EndpointControl { kind, .. } => { - debug!(client_id, %kind, "ignoring unknown endpoint control message"); + ClientMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND => + { + ServerEvent::ClientShellPresentationSync { + client_id, + token: data, + } + } + ClientMessage::EndpointControl { kind, data } => { + let Some(response) = crate::server::client_endpoint_control::response(&kind, data) + else { + debug!(client_id, %kind, "ignoring unknown endpoint control message"); + continue; + }; + let Some(writer) = endpoint_control_writer else { + continue; + }; + let mut framed = Vec::new(); + if protocol::write_message(&mut framed, &response).is_err() + || writer.send(framed).is_err() + { + break; + } continue; } ClientMessage::Detach => { @@ -1356,6 +1416,7 @@ mod tests { direct_graphics: true, endpoint_keybindings: true, mouse_capture: true, + surface_active: true, snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()], input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()], @@ -1767,6 +1828,7 @@ mod tests { direct_graphics, endpoint_keybindings, mouse_capture, + surface_active, writer, } => { assert_eq!(client_id, 43); @@ -1776,6 +1838,7 @@ mod tests { assert!(direct_graphics); assert!(endpoint_keybindings); assert!(mouse_capture); + assert!(surface_active); drop(writer); } other => panic!("expected ClientShellConnected, got {other:?}"), diff --git a/src/server/clients.rs b/src/server/clients.rs index 4f1baadb..972342ac 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -159,6 +159,8 @@ pub(crate) struct ClientConnection { pub(crate) host_keyboard_report_all_active: Option<bool>, /// Whether an ordinary render was skipped because the render channel was full. pub(crate) render_pending: bool, + /// Whether this connection receives pane surfaces and may affect presentation state. + pub(crate) shell_surface_active: bool, /// Whether this shell wants host mouse capture without pane demand. pub(crate) shell_mouse_capture: bool, /// Last host mouse capture mode sent to this client. @@ -179,6 +181,9 @@ pub(crate) struct ClientConnection { pub(crate) shell_projection_revision: u64, /// Whether this shell is waiting for one ordered endpoint command response. pub(crate) shell_endpoint_command_in_flight: bool, + /// Surface projection epoch that owned the in-flight command. Deferred navigation may run + /// only if this exact presentation lease is still active when its response arrives. + pub(crate) shell_endpoint_command_surface_revision: Option<u64>, /// Request id and buffered response for a deferred worktree-created navigation. pub(crate) shell_deferred_navigation_request_id: Option<String>, pub(crate) shell_deferred_navigation_response: Option<Vec<u8>>, @@ -230,6 +235,7 @@ impl ClientConnection { outer_terminal_focus: None, host_keyboard_report_all_active: None, render_pending: false, + shell_surface_active: true, shell_mouse_capture: false, host_mouse_capture_active: None, host_sgr_pixels_active: None, @@ -240,6 +246,7 @@ impl ClientConnection { shell_snapshot: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, + shell_endpoint_command_surface_revision: None, shell_deferred_navigation_request_id: None, shell_deferred_navigation_response: None, shell_uses_endpoint_keybindings: false, @@ -451,12 +458,16 @@ impl ClientConnection { pub(crate) fn is_shell_client(&self) -> bool { matches!(self.mode, ClientConnectionMode::ClientShell) } + + pub(crate) fn is_active_shell_client(&self) -> bool { + self.is_shell_client() && self.shell_surface_active + } } pub(crate) fn latest_shell_client(clients: &HashMap<u64, ClientConnection>) -> Option<u64> { clients .iter() - .filter(|(_, client)| client.is_shell_client()) + .filter(|(_, client)| client.is_active_shell_client()) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id) } diff --git a/src/server/headless.rs b/src/server/headless.rs index 04a1b17b..abaca0aa 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -73,11 +73,13 @@ use crate::server::terminal_attach::paste_payload_for_runtime; mod bootstrap; mod client_views; +mod endpoint_requests; mod lifecycle; mod notifications; mod pane_graphics; mod render; mod retained_surface; +mod surface_interest; pub use bootstrap::run_server; use lifecycle::wait_for_live_handoff_response_write; @@ -910,6 +912,9 @@ impl HeadlessServer { let Some(client) = self.clients.get_mut(&client_id) else { return false; }; + if !client.shell_surface_active { + return false; + } client.last_activity = stamp; let changed = self.foreground_client_id != Some(client_id); @@ -929,13 +934,13 @@ impl HeadlessServer { fn app_client_count(&self) -> usize { self.clients .values() - .filter(|client| client.is_shell_client() && client.writer.is_some()) + .filter(|client| client.is_active_shell_client() && client.writer.is_some()) .count() } fn client_supports_direct_graphics(&self, client_id: u64) -> bool { self.clients.get(&client_id).is_some_and(|client| { - client.is_shell_client() + client.is_active_shell_client() && client.writer.is_some() && client.direct_graphics && client.pixel_mouse @@ -992,12 +997,14 @@ impl HeadlessServer { let disconnected_focus = self .clients .get(&client_id) - .filter(|client| client.is_shell_client() && client.outer_terminal_focus == Some(true)) + .filter(|client| { + client.is_active_shell_client() && client.outer_terminal_focus == Some(true) + }) .and_then(|_| self.shell_focus_target(client_id)); let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| { !self.clients.iter().any(|(&other_id, client)| { other_id != client_id - && client.is_shell_client() + && client.is_active_shell_client() && client.outer_terminal_focus == Some(true) && self.shell_tab_id_for_client(other_id).as_deref() == Some(target.tab_id.as_str()) @@ -1008,7 +1015,8 @@ impl HeadlessServer { self.tab_geometry_controllers .retain(|_, controller_id| *controller_id != client_id); if let Some(mut removed) = removed { - self.release_client_shell_inputs(client_id, &mut removed); + let held_inputs = removed.drain_shell_held_inputs(); + self.release_client_shell_inputs(client_id, held_inputs); crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { self.terminal_attach_owners.remove(&terminal_id); @@ -1033,8 +1041,12 @@ impl HeadlessServer { } } - fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) { - for held in client.drain_shell_held_inputs() { + fn release_client_shell_inputs( + &mut self, + client_id: u64, + held_inputs: Vec<crate::server::clients::ClientShellHeldInput>, + ) { + for held in held_inputs { let result = match held.target { ClientShellInputTarget::Pane(pane_id) => { let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id) @@ -1181,16 +1193,18 @@ impl HeadlessServer { protocol::ClientClipboardImageTarget::Pane(pane_id) => { !self.handoff_in_progress && self.app.state.popup_pane.is_none() - && self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + && self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) && self.app.parse_pane_id(pane_id).is_some() } protocol::ClientClipboardImageTarget::Popup(terminal_id) => { !self.handoff_in_progress - && self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + && self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) && self .app .state @@ -1237,9 +1251,10 @@ impl HeadlessServer { } protocol::ClientClipboardImageTarget::Pane(pane_id) => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -1273,9 +1288,10 @@ impl HeadlessServer { } protocol::ClientClipboardImageTarget::Popup(terminal_id) => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -1929,6 +1945,7 @@ impl HeadlessServer { direct_graphics, endpoint_keybindings, mouse_capture, + surface_active, writer, } => { if self.handoff_in_progress { @@ -1950,6 +1967,7 @@ impl HeadlessServer { rows = surface_rows, cell_width_px, cell_height_px, + surface_active, render_encoding = ?protocol::RenderEncoding::SemanticFrame, "client connected" ); @@ -1972,6 +1990,7 @@ impl HeadlessServer { connection.direct_graphics = direct_graphics; connection.shell_uses_endpoint_keybindings = endpoint_keybindings; connection.shell_mouse_capture = mouse_capture; + connection.shell_surface_active = surface_active; connection.shell_projection_revision = 1; let config_diagnostic = if endpoint_keybindings { self.server_config_diagnostic.as_deref() @@ -2002,7 +2021,9 @@ impl HeadlessServer { self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id); } self.send_to_client(client_id, snapshot_message); - self.foreground_client_id = Some(client_id); + if surface_active { + self.foreground_client_id = Some(client_id); + } if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } @@ -2224,6 +2245,9 @@ impl HeadlessServer { client.cell_size = observed; } client.pixel_mouse = pixel_mouse && observed.is_known(); + if !client.shell_surface_active { + return false; + } client.request_repaint(); self.promote_client_to_foreground(client_id); self.resize_shell_tab_if_controller(client_id, true); @@ -2239,7 +2263,7 @@ impl HeadlessServer { if !client.update_host_theme(&update) { return false; } - if self.foreground_client_id != Some(client_id) { + if !client.shell_surface_active || self.foreground_client_id != Some(client_id) { return false; } let mut changed = self.app.set_host_terminal_appearance_state( @@ -2256,15 +2280,14 @@ impl HeadlessServer { let Some(client) = self.clients.get(&client_id) else { return false; }; - if !matches!(client.mode, ClientConnectionMode::ClientShell) - || client.outer_terminal_focus == Some(focused) + if !client.is_active_shell_client() || client.outer_terminal_focus == Some(focused) { return false; } let tab_id = self.shell_tab_id_for_client(client_id); let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| { other_id != client_id - && client.is_shell_client() + && client.is_active_shell_client() && client.outer_terminal_focus == Some(true) && self.shell_tab_id_for_client(other_id) == tab_id }); @@ -2308,15 +2331,38 @@ impl HeadlessServer { client.host_mouse_capture_active = None; true } + ServerEvent::ClientShellPresentationSync { client_id, token } => { + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + if !client.is_active_shell_client() { + return false; + } + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + client.host_keyboard_report_all_active = None; + self.sent_window_title = None; + self.stream_host_mouse_capture_mode(); + self.stream_direct_terminal_keyboard_mode(); + self.sync_window_title(); + self.send_to_client( + client_id, + ServerMessage::EndpointControl { + kind: crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND.into(), + data: token, + }, + ) + } ServerEvent::ClientShellPaneInput { client_id, pane_id, events, } => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -2400,9 +2446,10 @@ impl HeadlessServer { events, } => { if self.handoff_in_progress - || !self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) - }) + || !self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_active_shell_client) { return false; } @@ -2494,96 +2541,8 @@ impl HeadlessServer { ServerEvent::ClientShellEndpointRequest { client_id, boot_id, - mut request, - } => { - let Some(client) = self.clients.get(&client_id) else { - return false; - }; - if !matches!(client.mode, ClientConnectionMode::ClientShell) { - self.remove_client_and_resize_if_needed(client_id); - return true; - } - let request_id = request.id.clone(); - if !crate::server::client_commands::supports_client_shell_method(&request.method) { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "unsupported_endpoint_command", - "this method is not available through the client shell command lane", - ); - self.send_to_client(client_id, message); - return false; - } - if boot_id != self.client_shell_boot_id { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "stale_boot", - "endpoint command targeted an earlier server boot", - ); - self.send_to_client(client_id, message); - return false; - } - if client.shell_endpoint_command_in_flight { - self.send_to_client( - client_id, - ServerMessage::ServerShutdown { - reason: Some("client sent concurrent endpoint commands".into()), - }, - ); - self.remove_client_and_resize_if_needed(client_id); - return true; - } - - let api_request_id = format!( - "endpoint:{}:{client_id}:{request_id}", - self.client_shell_boot_id - ); - request.id = api_request_id.clone(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if let Err(err) = crate::server::client_commands::spawn_response_waiter( - client_id, - boot_id.clone(), - request_id.clone(), - response_rx, - self.server_event_tx.clone(), - ) { - let message = crate::server::client_commands::error_message( - boot_id, - request_id, - "server_unavailable", - format!("failed to start endpoint response bridge: {err}"), - ); - self.send_to_client(client_id, message); - return false; - } - if let Some(client) = self.clients.get_mut(&client_id) { - client.shell_endpoint_command_in_flight = true; - let deferred_worktree = matches!( - &request.method, - api::schema::Method::WorktreeCreate(_) - | api::schema::Method::WorktreeRemove(_) - ); - let deferred_navigation = matches!( - &request.method, - api::schema::Method::WorktreeCreate(params) if params.focus - ); - client.shell_deferred_navigation_request_id = - deferred_worktree.then(|| api_request_id.clone()); - client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); - } - let foreground_changed = self.promote_client_to_foreground(client_id); - foreground_changed - | self.handle_client_shell_api_request( - client_id, - api::ApiRequestMessage { - request: *request, - respond_to, - response_write_complete: None, - stream_active: None, - }, - ) - } + request, + } => self.handle_client_shell_endpoint_request(client_id, boot_id, request), ServerEvent::ClientShellEndpointResponseChunkReady { client_id, boot_id, @@ -2591,14 +2550,16 @@ impl HeadlessServer { final_chunk, data, } => { - let valid = self.clients.get(&client_id).is_some_and(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell) + let command_surface_revision = self.clients.get(&client_id).and_then(|client| { + (matches!(client.mode, ClientConnectionMode::ClientShell) && client.shell_endpoint_command_in_flight - && boot_id == self.client_shell_boot_id + && boot_id == self.client_shell_boot_id) + .then_some(client.shell_endpoint_command_surface_revision) + .flatten() }); - if !valid { + let Some(command_surface_revision) = command_surface_revision else { return false; - } + }; let completed_deferred_response = self.clients.get_mut(&client_id).and_then(|client| { let response = client.shell_deferred_navigation_response.as_mut()?; @@ -2610,6 +2571,7 @@ impl HeadlessServer { if final_chunk { if let Some(client) = self.clients.get_mut(&client_id) { client.shell_endpoint_command_in_flight = false; + client.shell_endpoint_command_surface_revision = None; client.shell_deferred_navigation_request_id = None; } } @@ -2618,7 +2580,10 @@ impl HeadlessServer { .and_then(Self::deferred_endpoint_navigation_tab_id); let focus_before = self.shell_focus_target(client_id); let focused_tabs_before = self.focused_shell_tabs(); - let navigation_changed = deferred_tab_id + let navigation_changed = self.clients.get(&client_id).is_some_and(|client| { + client.is_active_shell_client() + && client.shell_projection_revision == command_surface_revision + }) && deferred_tab_id .as_deref() .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id)); let geometry_changed = diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 2d49a50a..2257a05e 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -388,7 +388,7 @@ impl HeadlessServer { self.clients .iter() .filter(|(_, client)| { - client.is_shell_client() && client.outer_terminal_focus == Some(true) + client.is_active_shell_client() && client.outer_terminal_focus == Some(true) }) .filter_map(|(&client_id, _)| self.shell_tab_id_for_client(client_id)) .collect() @@ -397,7 +397,7 @@ impl HeadlessServer { pub(super) fn shell_focus_targets(&self) -> Vec<(u64, Option<ShellFocusTarget>)> { self.clients .iter() - .filter(|(_, client)| client.is_shell_client()) + .filter(|(_, client)| client.is_active_shell_client()) .map(|(&client_id, _)| (client_id, self.shell_focus_target(client_id))) .collect() } @@ -608,11 +608,16 @@ impl HeadlessServer { &mut self, start_pending_agent_resumes: bool, ) -> bool { - if self.app_client_count() != 1 { + let active_shell_count = self + .clients + .values() + .filter(|client| client.is_active_shell_client() && client.writer.is_some()) + .count(); + if active_shell_count != 1 { return false; } let Some(client_id) = self.clients.iter().find_map(|(&client_id, client)| { - (client.is_shell_client() && client.writer.is_some()).then_some(client_id) + (client.is_active_shell_client() && client.writer.is_some()).then_some(client_id) }) else { return false; }; @@ -625,7 +630,7 @@ impl HeadlessServer { ) -> bool { let mut viewed_tabs = HashMap::<String, Vec<u64>>::new(); for (&client_id, client) in &self.clients { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { continue; } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { @@ -684,6 +689,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; @@ -698,6 +710,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; @@ -713,6 +732,13 @@ impl HeadlessServer { client_id: u64, start_pending_agent_resumes: bool, ) -> bool { + if !self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + return false; + } let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { return false; }; diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs new file mode 100644 index 00000000..4f02b676 --- /dev/null +++ b/src/server/headless/endpoint_requests.rs @@ -0,0 +1,130 @@ +use super::*; + +impl HeadlessServer { + pub(super) fn handle_client_shell_endpoint_request( + &mut self, + client_id: u64, + boot_id: String, + mut request: Box<api::schema::Request>, + ) -> bool { + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + if !matches!(client.mode, ClientConnectionMode::ClientShell) { + self.remove_client_and_resize_if_needed(client_id); + return true; + } + let request_id = request.id.clone(); + if !crate::server::client_commands::supports_client_shell_method(&request.method) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "unsupported_endpoint_command", + "this method is not available through the client shell command lane", + ); + self.send_to_client(client_id, message); + return false; + } + if boot_id != self.client_shell_boot_id { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "stale_boot", + "endpoint command targeted an earlier server boot", + ); + self.send_to_client(client_id, message); + return false; + } + let surface_active = client.shell_surface_active; + if let api::schema::Method::ClientShellSurfaceSet(params) = &request.method { + let Some((changed, projection_revision)) = + self.set_client_shell_surface_active(client_id, params.active) + else { + return false; + }; + self.send_to_client( + client_id, + crate::server::client_commands::success_message_with_result( + boot_id, + request_id, + api::schema::ResponseResult::ClientShellSurfaceSet { + active: params.active, + projection_revision, + }, + ), + ); + return changed; + } + if client.shell_endpoint_command_in_flight { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "endpoint_busy", + "this endpoint is still processing another command", + ); + self.send_to_client(client_id, message); + return false; + } + if !surface_active { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "surface_inactive", + "this method requires an active client shell surface", + ); + self.send_to_client(client_id, message); + return false; + } + + let api_request_id = format!( + "endpoint:{}:{client_id}:{request_id}", + self.client_shell_boot_id + ); + request.id = api_request_id.clone(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if let Err(err) = crate::server::client_commands::spawn_response_waiter( + client_id, + boot_id.clone(), + request_id.clone(), + response_rx, + self.server_event_tx.clone(), + ) { + let message = crate::server::client_commands::error_message( + boot_id, + request_id, + "server_unavailable", + format!("failed to start endpoint response bridge: {err}"), + ); + self.send_to_client(client_id, message); + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.shell_endpoint_command_in_flight = true; + // A later source restore has a new projection revision. Keep this request's lease + // so a delayed worktree response cannot focus a pane after endpoint switching. + client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision); + let deferred_worktree = matches!( + &request.method, + api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) + ); + let deferred_navigation = matches!( + &request.method, + api::schema::Method::WorktreeCreate(params) if params.focus + ); + client.shell_deferred_navigation_request_id = + deferred_worktree.then(|| api_request_id.clone()); + client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); + } + let foreground_changed = self.promote_client_to_foreground(client_id); + foreground_changed + | self.handle_client_shell_api_request( + client_id, + api::ApiRequestMessage { + request: *request, + respond_to, + response_write_complete: None, + stream_active: None, + }, + ) + } +} diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 991d1861..5e36be01 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -39,7 +39,10 @@ impl HeadlessServer { .iter() .filter_map(|(&client_id, client)| match &client.mode { ClientConnectionMode::ClientShell => { - let focused = self.shell_focused_runtime(client_id); + let focused = client + .shell_surface_active + .then(|| self.shell_focused_runtime(client_id)) + .flatten(); let child_requests_mouse = focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled()); let sgr_pixels = client.pixel_mouse @@ -49,8 +52,9 @@ impl HeadlessServer { }); Some(( client_id, - client.shell_mouse_capture || child_requests_mouse, - sgr_pixels, + client.shell_surface_active + && (client.shell_mouse_capture || child_requests_mouse), + client.shell_surface_active && sgr_pixels, )) } ClientConnectionMode::TerminalAttach { terminal_id } => { @@ -113,9 +117,10 @@ impl HeadlessServer { .clients .iter() .filter(|(_, client)| client.is_shell_client()) - .map(|(&client_id, _)| { - let report_all = - self.shell_focused_runtime(client_id) + .map(|(&client_id, client)| { + let report_all = client.shell_surface_active + && self + .shell_focused_runtime(client_id) .is_some_and(|(runtime, _)| { let protocol = runtime.keyboard_protocol(); protocol.reports_all_keys() @@ -224,7 +229,7 @@ impl HeadlessServer { let mut pane_ids = HashSet::new(); if has_app_target { for (&client_id, client) in &self.clients { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { continue; } let Some(target) = self.shell_target_for_client(client_id) else { @@ -279,7 +284,10 @@ impl HeadlessServer { .filter(|client| client.writer.is_some()) { match &client.mode { - ClientConnectionMode::ClientShell => has_app_target = true, + ClientConnectionMode::ClientShell if client.shell_surface_active => { + has_app_target = true; + } + ClientConnectionMode::ClientShell => {} ClientConnectionMode::TerminalAttach { terminal_id } | ClientConnectionMode::TerminalObserve { terminal_id } => { direct_terminal_targets.insert(terminal_id.as_str()); @@ -339,7 +347,7 @@ impl HeadlessServer { fn any_shell_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { self.clients.iter().any(|(&client_id, client)| { - if !client.is_shell_client() || client.writer.is_none() { + if !client.is_active_shell_client() || client.writer.is_none() { return false; } if self @@ -456,6 +464,10 @@ impl HeadlessServer { client.shell_snapshot = Some(candidate); } shell_projection_revision = client.shell_projection_revision; + if !client.shell_surface_active { + client.clear_deferred_render(); + continue; + } } let shell_graphics_delivery = self .clients diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index 230704a6..a62a43f6 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -199,12 +199,14 @@ struct CollectedPanePatch { mouse_reporting: bool, sgr_pixel_mouse: bool, alternate_screen_active: bool, + graphics_may_have_placements: bool, } struct RetainedRecipientUpdate { client_id: u64, surface: protocol::PaneSurfaceFrame, patch: protocol::PaneSurfacePatch, + graphics_delivery: Option<crate::kitty_graphics::surface::DeliveryCache>, } impl HeadlessServer { @@ -239,11 +241,20 @@ impl HeadlessServer { { fallback!("unsafe_state"); } - let targets = render_targets(&self.clients, self.foreground_client_id); - if targets.is_empty() - || targets - .iter() - .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) + let mut targets = render_targets(&self.clients, self.foreground_client_id); + targets.retain(|(client_id, _, _, _, mode)| { + !matches!(mode, ClientConnectionMode::ClientShell) + || self + .clients + .get(client_id) + .is_some_and(|client| client.shell_surface_active) + }); + if targets.is_empty() { + success!("no_active_surface"); + } + if targets + .iter() + .any(|target| !matches!(target.4, ClientConnectionMode::ClientShell)) { fallback!("non_shell_target"); } @@ -266,8 +277,6 @@ impl HeadlessServer { || surface.frame.height != *rows || surface.popup.is_some() || !surface.graphics.assets.is_empty() - || !surface.graphics.placements.is_empty() - || !surface.graphics.retained_assets.is_empty() || !surface.frame.graphics.is_empty() { fallback!("baseline_mismatch"); @@ -325,6 +334,8 @@ impl HeadlessServer { fallback!("terminal_patch"); } }; + let graphics_may_have_placements = + crate::kitty_graphics::is_enabled() && runtime.kitty_graphics_may_have_placements(); let revision = runtime.content_seq(); if revision != revision_before || !revision.is_multiple_of(2) { fallback!("content_changed"); @@ -337,6 +348,7 @@ impl HeadlessServer { mouse_reporting: runtime.mouse_reporting_enabled(), sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), alternate_screen_active: runtime.alternate_screen_active(), + graphics_may_have_placements, }); } @@ -349,6 +361,8 @@ impl HeadlessServer { let mut changed_panes = Vec::with_capacity(collected.len()); let mut patch_rows = Vec::new(); let mut metadata_changed = false; + let mut refresh_graphics = !surface.graphics.placements.is_empty() + || !surface.graphics.retained_assets.is_empty(); for collected_pane in &collected { let Some(pane) = surface .panes @@ -370,6 +384,7 @@ impl HeadlessServer { ) { fallback!("hyperlink"); } + refresh_graphics |= collected_pane.graphics_may_have_placements; let previous_pane = pane.clone(); let Some(rows) = apply_rows(&mut surface.frame, pane.inner_rect, &collected_pane.patch) @@ -405,7 +420,31 @@ impl HeadlessServer { let cursor = retained_cursor(&self.app, &surface.panes); let cursor_changed = cursor != surface.frame.cursor; surface.frame.cursor = cursor.clone(); - if patch_rows.is_empty() && !cursor_changed && !metadata_changed { + let mut graphics_changed = false; + let graphics_delivery = if refresh_graphics { + let Some(target) = self.shell_target_for_client(client_id) else { + fallback!("graphics_target"); + }; + let client = &self.clients[&client_id]; + let Some((graphics, delivery)) = + crate::server::client_shell_graphics::collect_retained( + &self.app, + &surface, + target, + client.cell_size, + &client.shell_graphics_delivery, + client_id, + ) + else { + fallback!("graphics_geometry"); + }; + graphics_changed = graphics != surface.graphics; + surface.graphics = graphics; + Some(delivery) + } else { + None + }; + if patch_rows.is_empty() && !cursor_changed && !metadata_changed && !graphics_changed { continue; } let patch = protocol::PaneSurfacePatch { @@ -421,6 +460,7 @@ impl HeadlessServer { client_id, surface, patch, + graphics_delivery, }); } if updates.is_empty() { @@ -435,6 +475,7 @@ impl HeadlessServer { client_id, surface, patch, + graphics_delivery, } = update; let Some(client) = self.clients.get_mut(&client_id) else { continue; @@ -444,31 +485,53 @@ impl HeadlessServer { deferred += 1; continue; }; - let Some(prepared) = client - .render_state - .prepare_pane_surface_patch(patch, surface) - else { + // The published row patch cannot carry images. Reuse the retained text/layout + // in a graphics-capable surface message rather than invoking the full renderer. + let prepared = if graphics_delivery.is_some() { + client.render_state.prepare_pane_surface(surface) + } else { + client + .render_state + .prepare_pane_surface_patch(patch, surface) + }; + let Some(prepared) = prepared else { client.defer_full_render(); deferred += 1; continue; }; - let serialized = match Self::frame_server_message(prepared.message()) { - Ok(serialized) => serialized, - Err(error) => { - warn!( - client_id, - %error, - "failed to serialize retained pane surface patch" - ); - client.defer_full_render(); - deferred += 1; - continue; - } + let max_frame_size = if graphics_delivery.is_some() { + MAX_GRAPHICS_FRAME_SIZE + } else { + protocol::MAX_FRAME_SIZE }; + let serialized = + match Self::frame_server_message_with_max(prepared.message(), max_frame_size) { + Ok(serialized) => serialized, + Err(error) => { + warn!( + client_id, + %error, + "failed to serialize retained pane surface patch" + ); + client.defer_full_render(); + deferred += 1; + continue; + } + }; crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64); match writer.render.try_send(serialized) { Ok(()) => { - client.clear_deferred_render(); + let graphics_pending = graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + if let Some(delivery) = graphics_delivery { + client.shell_graphics_delivery = delivery; + } + if graphics_pending { + client.defer_full_render(); + } else { + client.clear_deferred_render(); + } client.render_state.commit_sent_frame(prepared); sent += 1; } diff --git a/src/server/headless/surface_interest.rs b/src/server/headless/surface_interest.rs new file mode 100644 index 00000000..7270be0d --- /dev/null +++ b/src/server/headless/surface_interest.rs @@ -0,0 +1,86 @@ +use super::*; + +impl HeadlessServer { + /// Apply a client-shell surface lease and return the resulting projection floor. + /// + /// Every `active: true` request advances the floor, even if the server already considered + /// the connection active. That makes a new client activation epoch distinguishable from a + /// delayed same-boot PaneSurface that was prepared for an earlier epoch. + pub(super) fn set_client_shell_surface_active( + &mut self, + client_id: u64, + active: bool, + ) -> Option<(bool, u64)> { + let focus_before = self.shell_focus_targets(); + let focused_tabs_before = self.focused_shell_tabs(); + let (changed, projection_revision) = { + let client = self.clients.get_mut(&client_id)?; + if !client.is_shell_client() { + return None; + } + let changed = client.shell_surface_active != active; + if active { + client.shell_projection_revision = + client.shell_projection_revision.saturating_add(1); + // Force the next control snapshot to carry this new floor instead of reusing a + // same-boot cached snapshot from the prior surface epoch. + client.shell_snapshot = None; + } + if !changed && !active { + return Some((false, client.shell_projection_revision)); + } + client.shell_surface_active = active; + client.request_repaint(); + client.shell_graphics_delivery = Default::default(); + // The client drops target effects while its old source frame is frozen. Reset the + // dedupe state whenever a viewer is (re)activated so the post-commit replay can + // produce mouse/keyboard modes and graphics even when runtime demand is unchanged. + if active { + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + client.host_keyboard_report_all_active = None; + } + client.clear_deferred_render(); + if !active { + if let Some(writer) = &client.writer { + writer.discard_pending_render(); + } + } + (changed, client.shell_projection_revision) + }; + let held_inputs = (!active && changed).then(|| { + self.clients + .get_mut(&client_id) + .expect("checked client") + .drain_shell_held_inputs() + }); + + if let Some(held_inputs) = held_inputs { + self.release_client_shell_inputs(client_id, held_inputs); + self.retire_direct_graphics_for_client(client_id); + } + if changed { + self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); + } + + if active { + self.promote_client_to_foreground(client_id); + // Do not emit/cache a title during a frozen target activation. A committed client + // explicitly requests the bounded replay after its coherent frame is visible. + self.sent_window_title = None; + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + self.claim_shell_tab_geometry(client_id, true); + } else { + self.tab_geometry_controllers + .retain(|_, controller_id| *controller_id != client_id); + if self.foreground_client_id == Some(client_id) { + self.promote_latest_remaining_client(); + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + } else { + self.sync_foreground_client_state(); + } + self.reapply_controlled_shell_tab_geometry(true); + } + Some((changed || active, projection_revision)) + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index e7e73e8b..5e0e5d01 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -2,6 +2,8 @@ use super::*; #[path = "pane_graphics.rs"] mod pane_graphics_tests; +#[path = "surface_interest.rs"] +mod surface_interest_tests; fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> { let ServerMessage::EndpointControl { kind, data } = message else { @@ -607,6 +609,7 @@ async fn client_shell_attach_seeds_workspace() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -636,6 +639,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -654,6 +658,26 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { ); assert!(server.clients[&client_id].shell_endpoint_command_in_flight); + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "client-shell:busy".into(), + method: api::schema::Method::IntegrationList(api::schema::EmptyParams::default()), + }), + }) + ); + assert!(server.clients.contains_key(&client_id)); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("busy endpoint response")) + else { + panic!("expected busy endpoint response"); + }; + let response = + serde_json::from_slice::<api::schema::ErrorResponse>(&data).expect("typed busy response"); + assert_eq!(response.error.code, "endpoint_busy"); + let response_ready = server .server_event_rx .recv() @@ -751,6 +775,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -894,6 +919,7 @@ fn connect_test_shell( direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -1219,6 +1245,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer: local_writer, }) ); @@ -1242,6 +1269,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { direct_graphics: false, endpoint_keybindings: true, mouse_capture: false, + surface_active: true, writer: endpoint_writer, }) ); @@ -1351,6 +1379,12 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .get_mut(&51) .unwrap() .shell_endpoint_command_in_flight = true; + let source_surface_revision = server.clients[&51].shell_projection_revision; + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); server .clients .get_mut(&51) @@ -1385,6 +1419,11 @@ async fn deferred_worktree_response_moves_only_its_source_client() { ); assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); + // A source command begun in the old presentation epoch may finish after source-off and + // source-on rollback. Its response remains endpoint-local, but it must not apply deferred + // client navigation to the restored source. + assert!(server.set_client_shell_surface_active(51, false).is_some()); + assert!(server.set_client_shell_surface_active(51, true).is_some()); server .clients .get_mut(&51) @@ -1394,7 +1433,17 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .clients .get_mut(&51) .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); + server + .clients + .get_mut(&51) + .unwrap() .shell_deferred_navigation_request_id = Some("background-worktree".into()); + server + .clients + .get_mut(&51) + .unwrap() + .shell_deferred_navigation_response = Some(Vec::new()); assert!( !server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { client_id: 51, @@ -1919,6 +1968,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -2168,6 +2218,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { direct_graphics: false, endpoint_keybindings: false, mouse_capture: false, + surface_active: true, writer, }) ); @@ -2664,6 +2715,19 @@ fn retained_test_server( std::sync::mpsc::Receiver<Vec<u8>>, crate::layout::PaneId, ) { + let (server, _control_rx, render_rx, pane_id) = + retained_test_server_with_control(initial_screen); + (server, render_rx, pane_id) +} + +fn retained_test_server_with_control( + initial_screen: &[u8], +) -> ( + HeadlessServer, + std::sync::mpsc::Receiver<Vec<u8>>, + std::sync::mpsc::Receiver<Vec<u8>>, + crate::layout::PaneId, +) { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("test"); let pane_id = workspace.focused_pane_id().expect("focused pane"); @@ -2676,7 +2740,7 @@ fn retained_test_server( server.app.state.selected = 0; server.app.state.mode = crate::app::Mode::Terminal; - let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + let (client_tx, client_control_rx, client_rx) = test_client_writer(); server.clients.insert( 1, ClientConnection::new( @@ -2691,7 +2755,7 @@ fn retained_test_server( server.sync_foreground_client_state(); assert!(server.claim_unowned_shell_tab_geometry(1, true)); - (server, client_rx, pane_id) + (server, client_control_rx, client_rx, pane_id) } #[test] diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index d310003c..f8637e8f 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -6,7 +6,8 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati #[tokio::test] async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -38,6 +39,19 @@ async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once }; assert_eq!(second.graphics.placements, first.graphics.placements); assert!(second.graphics.assets.is_empty()); + + // A post-commit typed surface.set(true) resets only this viewer's delivery cache, so the + // already selected target receives the asset again without relying on graphics that may have + // arrived during the frozen source frame. + assert!(server.set_client_shell_surface_active(1, true).is_some()); + server.render_and_stream(); + let replay = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(replay) = replay else { + panic!("expected post-commit graphics replay"); + }; + assert_eq!(replay.graphics.placements, first.graphics.placements); + assert_eq!(replay.graphics.assets.len(), 1); + assert_eq!(replay.graphics.assets[0].data, vec![1, 2, 3, 4]); } #[tokio::test] @@ -77,6 +91,237 @@ async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { } #[tokio::test] +async fn first_kitty_image_updates_retained_surface_without_full_redraw() { + let (mut server, client_rx, pane_id) = retained_test_server(b"text before image"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + server.render_and_stream(); + let ServerMessage::PaneSurface(initial) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected text-only baseline"); + }; + assert!(initial.graphics.placements.is_empty()); + + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(repainted) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected image update without a full redraw"); + }; + assert_eq!(repainted.graphics.placements.len(), 1); + assert_eq!(repainted.graphics.assets[0].data, [255, 0, 0, 255]); + assert_eq!(repainted.panes[0].inner_rect, initial.panes[0].inner_rect); + + // Text changes while an image is visible must reuse its uploaded pixels. + write_shared_test_pane(&mut server, pane_id, b"\rupdated text"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(text_update) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected retained text and image scene"); + }; + assert_eq!( + text_update.graphics.placements, + repainted.graphics.placements + ); + assert!(text_update.graphics.assets.is_empty()); + assert!(frame_text(&text_update.frame).contains("updated text")); + + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=A\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(deleted) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected image removal"); + }; + assert!(deleted.graphics.placements.is_empty()); + assert!(deleted.graphics.assets.is_empty()); + + write_shared_test_pane(&mut server, pane_id, b"\rtext only again"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(matches!( + read_server_message(receive_render(&client_rx, Duration::from_millis(100))), + ServerMessage::PaneSurfacePatch(_) + )); + + // A full output queue must not mark unsent pixels as delivered. + fill_render_lane(&server); + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + assert!(server.clients[&1] + .render_state + .last_pane_surface() + .unwrap() + .graphics + .placements + .is_empty()); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + server.render_and_stream(); + let ServerMessage::PaneSurface(recovered) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected deferred graphics recovery"); + }; + assert_eq!(recovered.graphics.assets[0].data, [255, 0, 0, 255]); +} + +#[tokio::test] +async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { + let (mut server, client_rx, pane_id) = retained_test_server(b"\x1b[?1049h"); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + let sources = HashSet::from([pane_id]); + // Yazi-style virtual placement: uploading the image and drawing its Unicode cell + // can happen in separate PTY reads, with no text dirty rows when upload completes. + for bytes in [ + b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;/wAA/w".as_slice(), + b"==\x1b\\", + b"\x1b_Ga=p,U=1,i=1193046,c=1,r=1,q=2\x1b\\", + ] { + write_shared_test_pane(&mut server, pane_id, bytes); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + for frame in client_rx.try_iter() { + if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { + assert!(surface.graphics.placements.is_empty()); + } + } + } + write_shared_test_pane( + &mut server, + pane_id, + "\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes(), + ); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(surface) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("virtual image must arrive without a tab switch"); + }; + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]); + // Replacing pixels under the same image ID must invalidate the delivered asset. + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;AP8A/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(replaced) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("updated image pixels must arrive"); + }; + assert_eq!(replaced.graphics.assets[0].data, [0, 255, 0, 255]); + assert_ne!( + surface.graphics.assets[0].key, + replaced.graphics.assets[0].key + ); +} + +#[tokio::test] +#[ignore = "manual retained text/image scaling profile"] +async fn render_scale_profile_retained_graphics() { + use ratatui::layout::Direction; + for retained in [false, true] { + for with_image in [false, true] { + for count in [1, 15] { + let (mut server, client_rx, root) = retained_test_server(b"populated terminal\r\n"); + let mut pane_ids = vec![root]; + for index in 1..count { + let workspace = &mut server.app.state.workspaces[0]; + workspace.tabs[0] + .layout + .focus_pane(pane_ids[(index - 1) / 2]); + let id = workspace.test_split(if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }); + workspace.insert_test_runtime( + id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 80, + 24, + b"populated terminal\r\n", + ), + ); + pane_ids.push(id); + } + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + if with_image { + write_shared_test_pane( + &mut server, + root, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + } + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + let sources = pane_ids.iter().copied().collect(); + let mut samples = Vec::new(); + for sample in 0..110 { + for id in &pane_ids { + write_shared_test_pane( + &mut server, + *id, + format!("\x1b[H{sample:03}").as_bytes(), + ); + } + let started = Instant::now(); + if retained { + assert!(server.render_retained_pane_surface_and_stream(&sources)); + } else { + server.render_and_stream(); + } + let elapsed = started.elapsed(); + for frame in client_rx.try_iter() { + if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { + assert!(surface.graphics.assets.is_empty()); + } + } + if sample >= 10 { + samples.push(elapsed); + } + } + samples.sort_unstable(); + println!( + "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}", + samples[50].as_micros(), + samples[94].as_micros() + ); + } + } + } +} + +#[tokio::test] async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { let (mut server, client_rx, _pane_id) = retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs new file mode 100644 index 00000000..591f6a8f --- /dev/null +++ b/src/server/headless/tests/surface_interest.rs @@ -0,0 +1,725 @@ +use super::*; + +use std::sync::{Arc, Mutex}; + +use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, EndpointNegotiation, EndpointRegistry, + EndpointTransport, ProfileId, SavedSshEndpoint, +}; + +#[derive(Clone)] +struct CapturingEndpointTransport(Arc<Mutex<Vec<crate::protocol::ClientMessage>>>); + +impl EndpointTransport for CapturingEndpointTransport { + fn send(&mut self, message: &crate::protocol::ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } +} + +fn lifecycle_negotiation() -> EndpointNegotiation { + EndpointNegotiation::new( + vec!["client_shell.surface.set".into()], + vec![ + crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY.into(), + crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), + ], + ) +} + +fn lifecycle_resize() -> crate::protocol::ClientMessage { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px: 8, + cell_height_px: 16, + surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }, + pixel_mouse: false, + } +} + +#[tokio::test] +async fn metadata_only_shell_is_isolated_until_surface_activation() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b""); + let pane_id = server.app.session_snapshot().focused_pane_id.unwrap(); + let workspace_id = server.app.session_snapshot().focused_workspace_id.unwrap(); + let original_size = server.effective_size; + let (writer, control_rx, render_rx) = test_client_writer(); + let client_id = 52; + + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 101, + surface_rows: 37, + cell_width_px: 9, + cell_height_px: 18, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: false, + writer, + }) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("metadata snapshot")), + ServerMessage::EndpointControl { .. } + )); + assert_eq!(server.foreground_client_id, None); + assert_eq!(server.effective_size, original_size); + + server.render_and_stream(); + assert!(render_rx.try_recv().is_err()); + assert!(server.clients[&client_id] + .render_state + .last_pane_surface() + .is_none()); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id, + pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::Paste( + "blocked".into() + )], + }) + ); + assert!(input_rx.try_recv().is_err()); + + let boot_id = server.client_shell_boot_id.clone(); + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "inactive-mutation".into(), + method: api::schema::Method::WorkspaceFocus(api::schema::WorkspaceTarget { + workspace_id, + }), + }), + }) + ); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("inactive mutation response")) + else { + panic!("expected endpoint response"); + }; + let error = serde_json::from_slice::<api::schema::ErrorResponse>(&data).unwrap(); + assert_eq!(error.error.code, "surface_inactive"); + + assert!( + server.send_to_client_shells(ServerMessage::SemanticNotification( + crate::protocol::SemanticNotification { + kind: crate::protocol::SemanticNotificationKind::Custom, + title: "metadata event".into(), + body: None, + sound: None, + agent: None, + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }, + )) + ); + assert!(matches!( + read_server_message(control_rx.recv().expect("metadata notification")), + ServerMessage::SemanticNotification(_) + )); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "activate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let ServerMessage::ClientShellEndpointResponseChunk { data, .. } = + read_server_message(control_rx.recv().expect("surface activation response")) + else { + panic!("expected typed surface activation response"); + }; + let activation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap(); + let api::schema::ResponseResult::ClientShellSurfaceSet { + active: true, + projection_revision: activation_floor, + } = activation_ack.result + else { + panic!("expected typed surface activation result"); + }; + assert_eq!(server.foreground_client_id, Some(client_id)); + assert_eq!(server.effective_size, (101, 37)); + + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("activated surface")) + else { + panic!("expected pane surface"); + }; + assert_eq!((surface.frame.width, surface.frame.height), (101, 37)); + assert!(surface.projection_revision >= activation_floor); + assert_eq!(surface.surface_revision, 1); + server + .clients + .get_mut(&client_id) + .expect("surface client") + .shell_endpoint_command_in_flight = true; + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id: boot_id.clone(), + request: Box::new(api::schema::Request { + id: "deactivate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: false }, + ), + }), + }) + ); + let _ = control_rx.recv().expect("surface deactivation response"); + assert!(server.clients.contains_key(&client_id)); + let (_, runtime_pane_id) = server.app.parse_pane_id(&surface.panes[0].pane_id).unwrap(); + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, runtime_pane_id) + .unwrap() + .test_process_pty_bytes(b"REACTIVATED"); + assert!( + server.render_retained_pane_surface_and_stream(&std::collections::HashSet::from([ + runtime_pane_id + ])) + ); + assert!(render_rx.try_recv().is_err()); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(api::schema::Request { + id: "reactivate-surface".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let data = loop { + let message = + read_server_message(control_rx.recv().expect("surface reactivation response")); + match message { + ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } if request_id == "reactivate-surface" => break data, + ServerMessage::EndpointControl { .. } + | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue, + other => panic!("unexpected surface reactivation message: {other:?}"), + } + }; + let reactivation_ack = serde_json::from_slice::<api::schema::SuccessResponse>(&data).unwrap(); + let api::schema::ResponseResult::ClientShellSurfaceSet { + active: true, + projection_revision: reactivation_floor, + } = reactivation_ack.result + else { + panic!("expected typed surface reactivation result"); + }; + assert!(reactivation_floor > activation_floor); + server.render_and_stream(); + let ServerMessage::PaneSurface(surface) = + read_server_message(render_rx.recv().expect("reactivated surface")) + else { + panic!("expected replacement pane surface"); + }; + assert!(frame_text(&surface.frame).contains("REACTIVATED")); + assert!(surface.projection_revision >= reactivation_floor); + assert_eq!(surface.surface_revision, 2); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn presentation_sync_epoch_replays_modes_and_title() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client_id = 63; + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: true, + writer, + }) + ); + let _ = control_rx.recv().expect("initial snapshot"); + server.api_window_title = Some("target title".into()); + { + let client = server.clients.get_mut(&client_id).unwrap(); + client.host_mouse_capture_active = Some(false); + client.host_sgr_pixels_active = Some(false); + client.host_keyboard_report_all_active = Some(false); + } + + let boot_id = server.client_shell_boot_id.clone(); + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(api::schema::Request { + id: "post-commit-reassert".into(), + method: api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: true }, + ), + }), + }) + ); + let _ = control_rx + .recv() + .expect("typed surface reassertion acknowledgement"); + server.stream_host_mouse_capture_mode(); + server.stream_direct_terminal_keyboard_mode(); + server.sync_window_title(); + assert_eq!( + server.clients[&client_id].host_mouse_capture_active, + Some(true), + "the target mode is sent after, not during, the frozen handoff" + ); + assert_eq!( + server.clients[&client_id].host_keyboard_report_all_active, + Some(false) + ); + let messages = (0..3) + .map(|_| read_server_message(control_rx.recv().expect("reassertion effect"))) + .collect::<Vec<_>>(); + assert!(messages + .iter() + .any(|message| matches!(message, ServerMessage::MouseCapture { enabled: true, .. }))); + assert!(messages.iter().any(|message| matches!( + message, + ServerMessage::ClientShellKeyboardReportAll { enabled: false } + ))); + assert!(messages.iter().any(|message| matches!( + message, + ServerMessage::WindowTitle { title: Some(title) } if title == "target title" + ))); + shutdown_test_runtimes(&mut server); +} + +fn dispatch_lifecycle_messages( + server: &mut HeadlessServer, + client_id: u64, + messages: Vec<crate::protocol::ClientMessage>, +) { + for message in messages { + let event = match message { + crate::protocol::ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse, + } => ServerEvent::ClientShellResize { + client_id, + cell_width_px, + cell_height_px, + surface_cols: surface_size.cols, + surface_rows: surface_size.rows, + pixel_mouse, + }, + crate::protocol::ClientMessage::ClientShellFocus { focused } => { + ServerEvent::ClientShellFocus { client_id, focused } + } + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + ServerEvent::ClientShellEndpointRequest { + client_id, + boot_id, + request: Box::new(serde_json::from_str(&request).unwrap()), + } + } + other => panic!("unhandled lifecycle message: {other:?}"), + }; + server.handle_server_event(event); + } +} + +/// A real two-server/client lifecycle harness. Both source-off and target-on traverse the +/// production HeadlessServer endpoint request path; the client test only routes its emitted wire +/// messages and never authors an acknowledgement, snapshot, or surface response. +#[tokio::test] +async fn two_headless_servers_drive_atomic_endpoint_handoff() { + let mut source_server = test_headless_server(); + let _source_input = install_focused_test_runtime(&mut source_server, b"local source"); + let (source_writer, source_control, _source_render) = test_client_writer(); + let source_client_id = 78; + assert!( + source_server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: source_client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: true, + writer: source_writer, + }) + ); + let source_snapshot = client_shell_snapshot(read_server_message( + source_control + .recv() + .expect("real source metadata snapshot"), + )); + + let mut target_server = test_headless_server(); + let _target_input = install_focused_test_runtime(&mut target_server, b"remote target"); + let (target_writer, target_control, target_render) = test_client_writer(); + let target_client_id = 79; + assert!( + target_server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: target_client_id, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 8, + cell_height_px: 16, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: true, + mouse_capture: true, + surface_active: false, + writer: target_writer, + }) + ); + let remote_snapshot = client_shell_snapshot(read_server_message( + target_control + .recv() + .expect("real target metadata snapshot"), + )); + + let profile = SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Remote".into(), + target: "dev@example.com".into(), + session: "main".into(), + enabled: true, + }; + let target_id = ClientEndpointId::Ssh(profile.id.clone()); + let mut shell = crate::client::ClientShellState::new( + crate::client::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + shell.set_endpoint_catalog(&[profile]); + shell.set_snapshot(source_snapshot); + shell.set_endpoint_status(&target_id, ClientEndpointStatus::Online); + shell.set_endpoint_snapshot(&target_id, remote_snapshot); + + let source_sent = Arc::new(Mutex::new(Vec::new())); + let target_sent = Arc::new(Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::new( + CapturingEndpointTransport(source_sent.clone()), + 1, + lifecycle_negotiation(), + ); + endpoints.insert( + target_id.clone(), + CapturingEndpointTransport(target_sent.clone()), + 7, + lifecycle_negotiation(), + false, + ); + let mut activation = crate::client::endpoint::PendingEndpointActivation::begin( + &shell, + &mut endpoints, + target_id.clone(), + None, + lifecycle_resize(), + 41, + std::time::Instant::now(), + ) + .unwrap(); + + // Route the source-off-first client messages through a second real HeadlessServer. Its + // typed response is the only source acknowledgement supplied to the activation state. + let mut source_release_request_id = None; + for message in std::mem::take(&mut *source_sent.lock().unwrap()) { + match message { + crate::protocol::ClientMessage::ClientShellFocus { focused } => { + assert!( + source_server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: source_client_id, + focused, + }) + ); + } + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + let request = serde_json::from_str::<api::schema::Request>(&request).unwrap(); + if matches!( + request.method, + api::schema::Method::ClientShellSurfaceSet( + api::schema::ClientShellSurfaceSetParams { active: false } + ) + ) { + source_release_request_id = Some(request.id.clone()); + } + assert!(source_server.handle_server_event( + ServerEvent::ClientShellEndpointRequest { + client_id: source_client_id, + boot_id, + request: Box::new(request), + } + )); + } + other => panic!("unexpected source lifecycle message: {other:?}"), + } + } + let source_release_request_id = source_release_request_id.expect("client source-off request"); + let source_release_data = loop { + let message = read_server_message(source_control.recv().expect("source typed release ack")); + match message { + ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } if request_id == source_release_request_id => break data, + ServerMessage::EndpointControl { .. } + | ServerMessage::MouseCapture { .. } + | ServerMessage::ClientShellKeyboardReportAll { .. } + | ServerMessage::WindowTitle { .. } + | ServerMessage::ClientShellEndpointResponseChunk { .. } => continue, + other => panic!("unexpected source release message: {other:?}"), + } + }; + assert_eq!( + activation.receive_response( + &ClientEndpointId::Local, + 1, + &source_release_request_id, + &source_release_data, + &mut endpoints, + ), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + + dispatch_lifecycle_messages( + &mut target_server, + target_client_id, + std::mem::take(&mut *target_sent.lock().unwrap()), + ); + assert_eq!( + target_server.clients[&target_client_id].outer_terminal_focus, + Some(true) + ); + assert_eq!( + source_server.clients[&source_client_id].outer_terminal_focus, + Some(false) + ); + let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = read_server_message(target_control.recv().expect("target typed activation ack")) + else { + panic!("expected target activation acknowledgement"); + }; + assert_eq!( + activation.receive_response(&target_id, 7, &request_id, &data, &mut endpoints), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + + target_server.render_and_stream(); + let coherent_snapshot = client_shell_snapshot(read_server_message( + target_control.recv().expect("target replacement snapshot"), + )); + let snapshot_progress = activation.receive_snapshot(&target_id, 7, &coherent_snapshot); + shell.set_endpoint_snapshot(&target_id, coherent_snapshot); + assert_eq!( + snapshot_progress, + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + let ServerMessage::PaneSurface(coherent_surface) = + read_server_message(target_render.recv().expect("target replacement surface")) + else { + panic!("expected target pane surface"); + }; + assert_eq!( + activation.receive_surface(&target_id, 7, coherent_surface), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + + assert!(matches!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationSync { + endpoint, + .. + }) if endpoint == target_id + )); + assert!( + !endpoints.active_surface_available(), + "target input remains fenced while presentation effects resynchronize" + ); + + let sync_request = target_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::ClientShellEndpointRequest { boot_id, request } => { + let request = serde_json::from_str::<api::schema::Request>(request).ok()?; + request + .id + .ends_with(":presentation-sync") + .then(|| (boot_id.clone(), Box::new(request))) + } + _ => None, + }) + .expect("client presentation synchronization request"); + assert!( + target_server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id: target_client_id, + boot_id: sync_request.0, + request: sync_request.1, + }) + ); + let (sync_request_id, sync_data) = loop { + let message = read_server_message(target_control.recv().expect("presentation sync ack")); + if let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = message + { + if request_id.ends_with(":presentation-sync") { + break (request_id, data); + } + } + }; + assert_eq!( + activation.receive_response(&target_id, 7, &sync_request_id, &sync_data, &mut endpoints,), + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + target_server.render_and_stream(); + let sync_snapshot = loop { + let message = + read_server_message(target_control.recv().expect("presentation sync snapshot")); + if let ServerMessage::EndpointControl { kind, data } = message { + if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { + break serde_json::from_str::<crate::protocol::ClientShellSnapshot>(&data).unwrap(); + } + } + }; + let sync_progress = activation.receive_snapshot(&target_id, 7, &sync_snapshot); + shell.set_endpoint_snapshot_for_generation(&target_id, 7, Box::new(sync_snapshot)); + assert_eq!( + sync_progress, + crate::client::endpoint::SurfaceActivationProgress::Pending + ); + let ServerMessage::PaneSurface(sync_surface) = + read_server_message(target_render.recv().expect("presentation sync surface")) + else { + panic!("expected synchronized target surface"); + }; + assert_eq!( + activation.receive_surface(&target_id, 7, sync_surface), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::AwaitingPresentationEffects) + ); + let effects_token = target_sent + .lock() + .unwrap() + .iter() + .find_map(|message| match message { + crate::protocol::ClientMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_SYNC_KIND => + { + Some(data.clone()) + } + _ => None, + }) + .expect("client presentation effects fence"); + assert!( + target_server.handle_server_event(ServerEvent::ClientShellPresentationSync { + client_id: target_client_id, + token: effects_token.clone(), + }) + ); + let mut replayed_mouse = false; + let mut replayed_keyboard = false; + loop { + match read_server_message(target_control.recv().expect("presentation effect or fence")) { + ServerMessage::MouseCapture { .. } => replayed_mouse = true, + ServerMessage::ClientShellKeyboardReportAll { .. } => replayed_keyboard = true, + ServerMessage::EndpointControl { kind, data } + if kind == crate::protocol::endpoint::PRESENTATION_EFFECTS_READY_KIND => + { + assert_eq!(data, effects_token); + assert_eq!( + activation.receive_presentation_effects_ready(&target_id, 7, &data), + crate::client::endpoint::SurfaceActivationProgress::Ready + ); + break; + } + ServerMessage::WindowTitle { .. } => {} + other => panic!("unexpected presentation fence message: {other:?}"), + } + } + assert!(replayed_mouse); + assert!(replayed_keyboard); + assert_eq!( + activation.complete(&mut shell, &mut endpoints), + Ok(crate::client::endpoint::ActivationCompletion::Activated) + ); + endpoints.unfreeze_input(); + assert_eq!(endpoints.active_id(), &target_id); + assert!(endpoints.active_surface_available()); + assert!(shell.endpoint_is_active(&target_id)); + + target_sent.lock().unwrap().clear(); + let mut returning = crate::client::endpoint::PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + lifecycle_resize(), + 42, + std::time::Instant::now(), + ) + .unwrap(); + dispatch_lifecycle_messages( + &mut target_server, + target_client_id, + std::mem::take(&mut *target_sent.lock().unwrap()), + ); + loop { + if let ServerMessage::ClientShellEndpointResponseChunk { + request_id, data, .. + } = read_server_message(target_control.recv().unwrap()) + { + returning.receive_response(&target_id, 7, &request_id, &data, &mut endpoints); + break; + } + } + dispatch_lifecycle_messages( + &mut source_server, + source_client_id, + std::mem::take(&mut *source_sent.lock().unwrap()), + ); + assert_eq!( + source_server.clients[&source_client_id].outer_terminal_focus, + Some(true), + "returning to Local must restore focus without a host focus event" + ); + assert_eq!( + target_server.clients[&target_client_id].outer_terminal_focus, + Some(false) + ); + shutdown_test_runtimes(&mut source_server); + shutdown_test_runtimes(&mut target_server); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 8b155df1..5d249587 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3,6 +3,7 @@ pub mod autodetect; #[cfg(unix)] pub(crate) mod client_accept; pub(crate) mod client_commands; +mod client_endpoint_control; pub(crate) mod client_shell; pub(crate) mod client_shell_graphics; pub(crate) mod client_transport; diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 4fa80326..57d45b92 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -423,6 +423,10 @@ impl TerminalRuntime { self.0.visible_hyperlinks(area) } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { + self.0.kitty_graphics_may_have_placements() + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 3da1e9f9..178bdb1f 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -129,6 +129,7 @@ pub(crate) fn resolved_token_spans( .iter() .map(|token| match &token.kind { ResolvedTokenKind::StateText(text) + | ResolvedTokenKind::Machine(text) | ResolvedTokenKind::Workspace(text) | ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) @@ -236,7 +237,8 @@ pub(crate) fn resolved_token_spans( truncate_end(text, budgets[index]), apply_token_style(workspace_style, token.style), )), - ResolvedTokenKind::Tab(text) + ResolvedTokenKind::Machine(text) + | ResolvedTokenKind::Tab(text) | ResolvedTokenKind::Pane(text) | ResolvedTokenKind::Agent(text) | ResolvedTokenKind::Branch(text) => spans.push(Span::styled( diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index eaec154b..911c87f9 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -13,6 +13,7 @@ pub(crate) struct ResolvedToken { pub(crate) enum ResolvedTokenKind { StateIcon, StateText(String), + Machine(String), Workspace(String), Tab(String), Pane(String), @@ -35,6 +36,7 @@ impl ResolvedToken { } pub(crate) struct AgentTokenContext<'a> { + pub(crate) machine: Option<&'a str>, pub(crate) workspace: &'a str, pub(crate) tab: Option<&'a str>, pub(crate) pane: Option<&'a str>, @@ -63,6 +65,9 @@ pub(crate) fn agent_rows( AgentSidebarToken::StateText => { Some(ResolvedTokenKind::StateText(state_text.to_string())) } + AgentSidebarToken::Machine => context + .machine + .map(|value| ResolvedTokenKind::Machine(value.to_string())), AgentSidebarToken::Workspace => { Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) } @@ -190,6 +195,7 @@ mod tests { fn context(entry: &Entry) -> AgentTokenContext<'_> { AgentTokenContext { + machine: None, workspace: &entry.workspace, tab: entry.tab.as_deref(), pane: entry.pane.as_deref(), @@ -232,6 +238,35 @@ mod tests { } #[test] + fn machine_token_only_resolves_for_multi_machine_rows() { + let entry = entry(); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::Machine, + AgentSidebarToken::Workspace, + ]], + ..Default::default() + }; + + assert_eq!( + agent_rows(&config, context(&entry), "working"), + vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( + "repo".into() + ))]] + ); + + let mut remote_context = context(&entry); + remote_context.machine = Some("Build"); + assert_eq!( + agent_rows(&config, remote_context, "working"), + vec![vec![ + ResolvedToken::unstyled(ResolvedTokenKind::Machine("Build".into())), + ResolvedToken::unstyled(ResolvedTokenKind::Workspace("repo".into())), + ]] + ); + } + + #[test] fn state_text_and_arbitrary_values_are_independent_tokens() { let mut entry = entry(); entry diff --git a/src/update.rs b/src/update.rs index ae307bc7..f836fb80 100644 --- a/src/update.rs +++ b/src/update.rs @@ -2852,6 +2852,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }; let missing_baseline = crate::api::RuntimeStatus { @@ -2924,6 +2926,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }, }; @@ -3180,6 +3184,8 @@ mod tests { endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), + surface_interest: true, + health_check: true, }), }, }; diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 351c5fa2..06bd1123 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -125,6 +125,7 @@ fn spawn_client_process_with_args_and_env( let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.args(args); cmd.env("HERDR_DISABLE_SOUND", "1"); + cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); @@ -492,6 +493,7 @@ fn server_unreachable_shows_clear_error() { .env("HERDR_DISABLE_SOUND", "1") .env("XDG_CONFIG_HOME", &config_home) .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("XDG_STATE_HOME", runtime_dir.join("state")) .env("HERDR_SOCKET_PATH", &api_socket) .env_remove("HERDR_CLIENT_SOCKET_PATH") .env_remove("HERDR_ENV") @@ -753,6 +755,271 @@ fn attach_thin_client_with_config( } #[test] +fn federated_launch_opens_local_directly_while_saved_ssh_is_unavailable() { + use std::os::unix::fs::PermissionsExt; + + let _lock = test_lock(); + for select_remote in [false, true] { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); + fs::write( + config_home.join(app_dir_name()).join("config.toml"), + "onboarding = false\n", + ) + .unwrap(); + let catalog_dir = runtime_dir + .join("state") + .join(app_dir_name()) + .join("client"); + fs::create_dir_all(&catalog_dir).unwrap(); + let profile = "0123456789abcdef0123456789abcdef"; + fs::write(catalog_dir.join("endpoints.json"), serde_json::json!({ + "version": 1, "selected_profile": select_remote.then_some(profile), + "ssh": [{"id": profile, "label": "Unavailable remote", "target": "test-only", "session": "default", "enabled": true}], + }).to_string()).unwrap(); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("ssh"), "#!/bin/sh\nexit 255\n").unwrap(); + fs::set_permissions(bin.join("ssh"), fs::Permissions::from_mode(0o700)).unwrap(); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + + // Exercise both auto-start and a subsequent attach to the healthy Local server. + for args in [&[][..], &["client"][..]] { + let client = spawn_client_process_with_args_and_env( + &config_home, + &runtime_dir, + &api_socket, + args, + &[("PATH", &path)], + ); + let output = + spawn_pty_drain(client._master.as_ref().unwrap().try_clone_reader().unwrap()); + wait_for_socket(&api_socket, Duration::from_secs(10)); + assert!(wait_until( + Duration::from_secs(10), + Duration::from_millis(20), + || { read_output(&output).contains("Local") } + )); + let mut input = client._master.as_ref().unwrap().take_writer().unwrap(); + input + .write_all(b"printf 'LOCAL_%s\\n' DIRECT_READY\r") + .unwrap(); + assert!(wait_until(Duration::from_secs(10), Duration::from_millis(20), || { + read_output(&output).contains("LOCAL_DIRECT_READY") + }), "Local must accept input without waiting for SSH (remote selected: {select_remote}): {}", read_output(&output)); + let text = read_output(&output); + assert!(!text.contains("Local: connecting"), "{text}"); + assert!(!text.contains("Local: reconnecting"), "{text}"); + drop(input); + drop(client); + } + let _ = send_json_request( + &api_socket, + r#"{"id":"stop","method":"server.stop","params":{}}"#, + ); + cleanup_test_base(&base); + } +} + +#[test] +fn federated_client_starts_without_local_and_survives_its_restart() { + use std::os::unix::fs::PermissionsExt; + + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let remote_config = base.join("remote-config"); + let remote_runtime = base.join("remote-runtime"); + let remote_api = remote_runtime.join("herdr.sock"); + let remote_client = remote_runtime.join("herdr-client.sock"); + let mut remote_server = + spawn_server(&remote_config, &remote_runtime, &remote_api, &remote_client); + wait_for_socket(&remote_api, Duration::from_secs(10)); + wait_for_socket(&remote_client, Duration::from_secs(10)); + let created = send_json_request( + &remote_api, + &serde_json::json!({ + "id": "remote-workspace", "method": "workspace.create", + "params": {"cwd": base, "focus": true, "label": "remote-ready"}, + }) + .to_string(), + ); + let remote_pane = created["result"]["root_pane"]["pane_id"].as_str().unwrap(); + send_pane_shell_command(&remote_api, remote_pane, "printf 'REMOTE_INITIAL_FRAME\\n'"); + + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); + fs::write( + config_home.join(app_dir_name()).join("config.toml"), + "onboarding = false\n", + ) + .unwrap(); + let catalog_dir = runtime_dir + .join("state") + .join(app_dir_name()) + .join("client"); + fs::create_dir_all(&catalog_dir).unwrap(); + let profile = "0123456789abcdef0123456789abcdef"; + fs::write(catalog_dir.join("endpoints.json"), serde_json::json!({ + "version": 1, "selected_profile": profile, + "ssh": [{"id": profile, "label": "Test remote", "target": "test-only", "session": "default", "enabled": true}], + }).to_string()).unwrap(); + + // The SSH executable is private to this client. Discovery and the stdio bridge run the real + // binary against a second disposable local server, never the developer's saved hosts. + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(base.join("home")).unwrap(); + std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_herdr"), bin.join("herdr")).unwrap(); + let quote = + |path: &std::path::Path| format!("'{}'", path.display().to_string().replace('\'', "'\\''")); + fs::write(bin.join("ssh"), format!( + "#!/bin/sh\nexport HOME={} XDG_CONFIG_HOME={} XDG_RUNTIME_DIR={} HERDR_SOCKET_PATH={}\nunset HERDR_CLIENT_SOCKET_PATH HERDR_SESSION\nfor arg do last=\"$arg\"; done\nexec /bin/sh -c \"$last\"\n", + quote(&base.join("home")), quote(&remote_config), quote(&remote_runtime), quote(&remote_api), + )).unwrap(); + fs::set_permissions(bin.join("ssh"), fs::Permissions::from_mode(0o700)).unwrap(); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut client = spawn_client_process_with_args_and_env( + &config_home, + &runtime_dir, + &api_socket, + &["client"], + &[("PATH", &path)], + ); + let output = spawn_pty_drain(client._master.as_ref().unwrap().try_clone_reader().unwrap()); + assert!( + wait_until(Duration::from_secs(12), Duration::from_millis(20), || { + read_output(&output).contains("REMOTE_INITIAL_FRAME") + }), + "remote must be usable before Local exists: {}", + read_output(&output) + ); + + let mut local = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "local-workspace", "method": "workspace.create", + "params": {"cwd": base, "focus": true, "label": "local-online"}, + }) + .to_string(), + ); + assert_eq!(created["result"]["type"], "workspace_created"); + assert!(wait_until( + Duration::from_secs(10), + Duration::from_millis(20), + || read_output(&output).contains("local-online") + )); + + local.child.kill().unwrap(); + local.close_master(); + drop(local); + let watermark = output_len(&output); + let mut input = client._master.as_ref().unwrap().take_writer().unwrap(); + input + .write_all(b"printf 'REMOTE_%s\\n' SURVIVED\r") + .unwrap(); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + read_output(&output)[watermark..].contains("REMOTE_SURVIVED") + }), + "Local loss must not interrupt remote input or output" + ); + assert!(client.child.try_wait().unwrap().is_none()); + + let restarted = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + let created = send_json_request( + &api_socket, + &serde_json::json!({ + "id": "local-returned", "method": "workspace.create", + "params": {"cwd": base, "focus": true, "label": "local-returned"}, + }) + .to_string(), + ); + assert_eq!(created["result"]["type"], "workspace_created"); + assert!( + wait_until(Duration::from_secs(12), Duration::from_millis(20), || { + read_output(&output).contains("local-returned") + }), + "Local must reconnect with fresh metadata" + ); + let watermark = output_len(&output); + input + .write_all(b"printf 'REMOTE_%s\\n' STILL_SELECTED\r") + .unwrap(); + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(20), || { + read_output(&output)[watermark..].contains("REMOTE_STILL_SELECTED") + }), + "Local recovery must not steal selection" + ); + let watermark = output_len(&output); + remote_server.child.kill().unwrap(); + assert!( + wait_until(Duration::from_secs(10), Duration::from_millis(20), || { + read_output(&output)[watermark..].contains("reconnecting") + }), + "the selected remote must be marked disconnected" + ); + let text = read_output(&output); + assert!( + text.rfind("\x1b[?1000h") > text.rfind("\x1b[?1000l"), + "losing the selected remote must keep host mouse reporting enabled" + ); + + let local_pane = created["result"]["root_pane"]["pane_id"].as_str().unwrap(); + send_pane_shell_command( + &api_socket, + local_pane, + "printf 'LOCAL_RECOVERED_SURFACE\\n'", + ); + let watermark = output_len(&output); + // Select the fresh workspace below Local's restored workspace. + input.write_all(b"\x1b[<0;7;5M\x1b[<0;7;5m").unwrap(); + assert!( + wait_until(Duration::from_secs(10), Duration::from_millis(20), || { + read_output(&output)[watermark..].contains("LOCAL_RECOVERED_SURFACE") + }), + "recovered Local must be selectable: {}", + read_output(&output) + ); + // A coherent frame precedes the final host-effects fence; input stays gated until then. + assert!( + wait_until(Duration::from_secs(8), Duration::from_millis(100), || { + if read_output(&output)[watermark..].contains("LOCAL_INPUT_RECOVERED") { + return true; + } + input + .write_all(b"printf 'LOCAL_%s\\n' INPUT_RECOVERED\r") + .unwrap(); + false + }), + "recovered Local must accept input: {}", + read_output(&output) + ); + drop(input); + drop(client); + drop(restarted); + drop(remote_server); + cleanup_test_base(&base); +} + +#[test] fn client_shell_detaches_restores_and_freshly_reattaches_to_current_state() { let _lock = test_lock(); let base = unique_test_dir(); diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 01c15c68..3e933c95 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -119,6 +119,7 @@ fn spawn_server_with_path( let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("server"); + cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); @@ -157,6 +158,7 @@ fn spawn_client_process( let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); + cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); diff --git a/tests/fixtures/endpoint-method-shapes-v1.json b/tests/fixtures/endpoint-method-shapes-v1.json index b473869f..f8ea6444 100644 --- a/tests/fixtures/endpoint-method-shapes-v1.json +++ b/tests/fixtures/endpoint-method-shapes-v1.json @@ -1,4 +1,5 @@ { + "client_shell.surface.set": "919df2ba99fe7d5df003a051dbbaf9929998387c53ccb652b6262bd8e4650330", "command.invoke": "b16817652442b1ea2405e60a0443826a55baca461e938f524e79b16a675c5899", "integration.install": "913055b838166650fee6713332c1cc436efd4f8fae9a2064d8f9b6616a7a66f8", "integration.list": "60c57189adf9c281958062f0e11680bda0ece83307c506fa18bff8b01ebfb4e8", |
