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 d67a78691..decc379d5 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -50,6 +50,8 @@ herdr --remote workbox herdr --remote ssh://you@server:2222 ``` +サイドバーの幅、セクションの分割位置、折りたたんだグループ、エージェントの並び順は、接続先とセッションごとにローカルへ保存されます。同じ接続先とセッションに再接続すると復元されます。SSH エイリアスやセッションが異なる場合は別の設定になります。 + このモードでは、リモートサーバーがペインを動かし、ターミナル内容とセッション状態を SSH 越しに送信します。ローカルの Herdr がサイドバー、メニュー、テーマを含む UI を描画します。クライアントがローカルで動くため、Herdr は画像クリップボードの貼り付けのようなローカルデスクトップ機能をリモートセッションにブリッジできます。画像をリモートの一時ファイルにコピーし、そのパスを貼り付けます。 デフォルトでは、`herdr --remote` はそのアタッチにローカルの Herdr キーバインドを使います。リモートサーバーの設定が異なっていても、ローカルの操作感覚を維持できます。ローカルキーバインドを編集したら、UI の `reload config` でデタッチせずに反映できます。リモートサーバーの設定を使いたい場合は `--remote-keybindings server` を使います。ローカルのカスタムコマンドキーバインドは送信されません。それらのコマンドはリモートホスト上で実行されてしまうからです。 diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index 53ce53875..aaa1db96c 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -50,6 +50,8 @@ herdr --remote workbox herdr --remote ssh://you@server:2222 ``` +Sidebar width, section split, collapsed groups, and agent sorting are saved locally for each remote target and session. Reattaching to the same target and session restores these preferences. Different SSH aliases or sessions have separate preferences. + In this mode, the remote server owns the running panes and sends their terminal content and session state over SSH. Your local Herdr draws the UI, including its sidebar, menus, and theme. Because the client runs locally, Herdr can bridge local desktop features such as image clipboard paste into the remote session by copying the image to a remote temp file and pasting that path. By default, `herdr --remote` uses your local Herdr keybindings for that attach. This keeps local muscle memory even when the remote server has different config. After editing local keybindings, use the UI's `reload config` action to apply them without detaching. Use `--remote-keybindings server` when you want the remote server config instead. Local custom command keybindings are not sent, because those commands would run on the remote host. 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 90ce9ea96..c4c09dbbb 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 @@ -50,6 +50,8 @@ herdr --remote workbox herdr --remote ssh://you@server:2222 ``` +侧栏宽度、分区比例、折叠分组和代理排序会按远程目标与会话保存在本地。重新连接同一目标和会话时会恢复这些偏好;不同 SSH 别名或会话的偏好相互独立。 + 这种模式下,远程服务器保持窗格运行,并通过 SSH 发送终端内容和会话状态。本地 Herdr 负责绘制 UI,包括侧边栏、菜单和主题。因为客户端在本地运行,Herdr 可以把图像剪贴板粘贴等本地桌面功能桥接到远程会话: 把图像复制到远程临时文件,再粘贴该路径。 默认情况下,`herdr --remote` 在这次连接中使用你本地的 Herdr 按键绑定。即使远程服务器的配置不同,也能保持本地的肌肉记忆。编辑本地按键绑定后,可通过 UI 中的 `reload config` 应用更改,无需分离重连。想改用远程服务器配置时,使用 `--remote-keybindings server`。本地的自定义命令按键绑定不会被发送,因为那些命令会在远程主机上执行。 diff --git a/src/client/mod.rs b/src/client/mod.rs index 2668fd49e..6a754e1fb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -182,7 +182,7 @@ fn run_client_with_mode( .with_startup_config_diagnostic(startup_config_diagnostic) .with_startup_onboarding(loaded_config.config.should_show_onboarding()) .with_keybinding_source(keybinding_source) - .with_local_endpoint(&socket_path) + .with_process_endpoint_preferences(&socket_path) }); let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index bfa1fb2f7..04a3ddb56 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -194,6 +194,31 @@ impl ClientShellConfig { self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) } + pub(crate) fn with_process_endpoint_preferences(self, socket_path: &std::path::Path) -> Self { + self.with_endpoint_preferences( + socket_path, + std::env::var(crate::remote::REMOTE_PREFERENCES_ENV_VAR) + .ok() + .as_deref(), + ) + } + + pub(crate) fn with_endpoint_preferences( + self, + socket_path: &std::path::Path, + remote_identity: Option<&str>, + ) -> Self { + let identity = remote_identity + .and_then(|value| serde_json::from_str::<(String, String)>(value).ok()) + .filter(|(target, session)| !target.is_empty() && !session.is_empty()); + match identity { + Some((target, session)) => { + self.with_preferences_path(preferences::path_for_remote_endpoint(&target, &session)) + } + None => self.with_local_endpoint(socket_path), + } + } + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { self.preferences = preferences::load(&path).unwrap_or_default(); self.preferences_path = Some(path); diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs index fcfe1440d..77cf90cae 100644 --- a/src/client/shell/preferences.rs +++ b/src/client/shell/preferences.rs @@ -41,6 +41,20 @@ pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf { .join(format!("local-{hash:016x}.json")) } +/// SSH preferences belong to the target/session, not its per-process bridge socket. +pub(super) fn path_for_remote_endpoint(target: &str, session: &str) -> PathBuf { + use sha2::{Digest as _, Sha256}; + + let mut hash = Sha256::new(); + for part in [target, session] { + hash.update((part.len() as u64).to_le_bytes()); + hash.update(part.as_bytes()); + } + crate::config::state_dir() + .join("client-shell") + .join(format!("remote-{:x}.json", hash.finalize())) +} + pub(super) fn load(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() @@ -82,6 +96,22 @@ mod tests { assert_ne!(first, second); } + #[test] + fn remote_preferences_distinguish_targets_sessions_and_component_boundaries() { + let original = path_for_remote_endpoint("dev@build", "agents"); + assert_eq!(original, path_for_remote_endpoint("dev@build", "agents")); + assert_ne!(original, path_for_remote_endpoint("other@build", "agents")); + assert_ne!(original, path_for_remote_endpoint("dev@build", "other")); + assert_ne!( + path_for_remote_endpoint("ab", "c"), + path_for_remote_endpoint("a", "bc") + ); + assert_ne!( + original, + path_for_local_endpoint(Path::new("dev@build/agents")) + ); + } + #[test] fn legacy_preferences_default_remote_collapses() { let preferences: ClientChromePreferences = diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index dd60fe118..a00e79c0a 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -44,6 +44,83 @@ fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { )); } +#[test] +fn remote_client_preferences_keep_the_same_identity_across_bridge_processes() { + let first = ClientShellConfig::from_config(&Config::default()).with_endpoint_preferences( + std::path::Path::new("/tmp/herdr-remote-100-dev-agents.sock"), + Some(r#"["dev", "agents"]"#), + ); + let next = ClientShellConfig::from_config(&Config::default()).with_endpoint_preferences( + std::path::Path::new("/tmp/herdr-remote-200-dev-agents.sock"), + Some(r#"["dev", "agents"]"#), + ); + assert_eq!(first.preferences_path, next.preferences_path); + let root = + std::env::temp_dir().join(format!("herdr-remote-preferences-{}", std::process::id())); + let first_path = root.join( + first + .preferences_path + .as_ref() + .unwrap() + .file_name() + .unwrap(), + ); + let next_path = root.join(next.preferences_path.as_ref().unwrap().file_name().unwrap()); + let mut state = ClientShellState::new(first.with_preferences_path(first_path)); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.persist_chrome_preferences(&mut ClientShellInput::default()); + let restored = ClientShellState::new(next.with_preferences_path(next_path)); + assert_eq!(restored.sidebar_width, 31); + assert!(restored.sidebar_collapsed); + std::fs::remove_dir_all(root).unwrap(); + + let local_socket = std::path::Path::new("/tmp/local.sock"); + for identity in [ + None, + Some("invalid"), + Some(r#"["dev", ""]"#), + Some(r#"["", "agents"]"#), + ] { + let config = ClientShellConfig::from_config(&Config::default()) + .with_endpoint_preferences(local_socket, identity); + assert_eq!( + config.preferences_path, + Some(super::super::preferences::path_for_local_endpoint( + local_socket + )) + ); + } +} + +#[test] +fn remote_client_preferences_process_child() { + if std::env::var("HERDR_TEST_PREFERENCES_CHILD").as_deref() != Ok("1") { + return; + } + let expected_path = super::super::preferences::path_for_remote_endpoint("dev", "agents"); + if !expected_path.exists() { + super::super::preferences::store( + expected_path.as_path(), + super::super::preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..Default::default() + }, + ) + .unwrap(); + } + let socket = crate::server::socket_paths::client_socket_path(); + let config = ClientShellConfig::from_config(&Config::default()) + .with_process_endpoint_preferences(&socket); + assert_eq!(config.preferences_path, Some(expected_path)); + let restored = ClientShellState::new(config); + assert_eq!(restored.sidebar_width, 31); + assert!(restored.sidebar_collapsed); +} + #[test] fn manual_client_chrome_preferences_round_trip_per_endpoint() { let path = std::env::temp_dir().join(format!( diff --git a/src/remote/args.rs b/src/remote/args.rs index 476acf702..dce8ba55e 100644 --- a/src/remote/args.rs +++ b/src/remote/args.rs @@ -1,3 +1,4 @@ +pub(crate) const REMOTE_PREFERENCES_ENV_VAR: &str = "HERDR_REMOTE_PREFERENCES_IDENTITY"; pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 695fcfd90..da0e120a5 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -75,15 +75,21 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { )?; let _bridge = SshStdioBridge::start( - remote.target, + remote.target.clone(), prepared_remote.remote_herdr, local_socket.clone(), - session_name, + session_name.clone(), remote_ssh.options(), false, )?; - run_client_process(&local_socket, &reattach_command, remote.keybindings) + run_client_process( + &local_socket, + &reattach_command, + remote.keybindings, + &remote.target, + &session_name, + ) } pub(crate) fn check_saved_ssh(target: &str, session: &str) -> io::Result<()> { @@ -3351,17 +3357,21 @@ fn run_client_process( local_socket: &Path, reattach_command: &str, keybindings: RemoteKeybindings, + target: &str, + session: &str, ) -> io::Result<()> { let exe = std::env::current_exe()?; - let status = Command::new(exe) - .arg("client") - .env( - crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, - local_socket, - ) - .env(REATTACH_COMMAND_ENV_VAR, reattach_command) - .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) - .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + let mut command = Command::new(exe); + command.arg("client"); + configure_remote_client_environment( + &mut command, + local_socket, + reattach_command, + keybindings, + target, + session, + )?; + let status = command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -3377,6 +3387,28 @@ fn run_client_process( } } +fn configure_remote_client_environment( + command: &mut Command, + local_socket: &Path, + reattach_command: &str, + keybindings: RemoteKeybindings, + target: &str, + session: &str, +) -> io::Result<()> { + let preference_identity = + serde_json::to_string(&(target, session)).map_err(io::Error::other)?; + command + .env( + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, + local_socket, + ) + .env(REMOTE_PREFERENCES_ENV_VAR, preference_identity) + .env(REATTACH_COMMAND_ENV_VAR, reattach_command) + .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) + .env_remove(crate::api::SOCKET_PATH_ENV_VAR); + Ok(()) +} + fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { let pid = std::process::id(); let target_clean = sanitize_path_component(target); @@ -3424,6 +3456,44 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + #[test] + fn remote_client_preferences_cross_process_handoff() { + let root = + std::env::temp_dir().join(format!("herdr-preference-handoff-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for bridge in ["bridge-100.sock", "bridge-200.sock"] { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args(["--exact", "client::shell::tests::keybindings_settings::remote_client_preferences_process_child", "--nocapture"]); + command + .env("HERDR_TEST_PREFERENCES_CHILD", "1") + .env("XDG_STATE_HOME", &root); + // A stale inherited identity must be replaced by this launch's identity. + command.env(REMOTE_PREFERENCES_ENV_VAR, r#"["wrong", "session"]"#); + configure_remote_client_environment( + &mut command, + &root.join(bridge), + "herdr --remote dev", + RemoteKeybindings::Local, + "dev", + "agents", + ) + .unwrap(); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child test did not run: {}", + String::from_utf8_lossy(&output.stdout) + ); + } + fs::remove_dir_all(root).unwrap(); + } + fn decode_windows_command(command: &str) -> String { let encoded = command .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ")