| 1 | //! `/rc` command — account-owned web remote control (portable handler). |
| 2 | |
| 3 | use super::CommandResult; |
| 4 | use codewhale_command_contract::facets::{CommandSessionControlContext, RemoteOpenOutcome}; |
| 5 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 6 | use codewhale_command_contract::metadata::{ |
| 7 | CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, |
| 8 | }; |
| 9 | |
| 10 | /// Shown by `/rc link` and `/rc open` before the control plane has advertised |
| 11 | /// a session link (not connected yet, or an older control plane). |
| 12 | const NO_LINK_MESSAGE: &str = |
| 13 | "Remote control has no live session link yet; run /rc to hand this session to the web first."; |
| 14 | |
| 15 | pub(in crate::commands) struct RemoteControlCmd; |
| 16 | |
| 17 | // --------------------------------------------------------------------------- |
| 18 | // FEAT-024 Phase 4 (D6/D7): portable contextual registration and handler. |
| 19 | // Start/status/link/open/stop routing, active-turn wording, no-link guidance, |
| 20 | // stop-refusal safety, and the bounded RemoteControl action payload stay |
| 21 | // handler-owned; all remote-service state stays behind the control facet. |
| 22 | // `/rc open` remains synchronous with no deferred external-URL action. |
| 23 | // --------------------------------------------------------------------------- |
| 24 | |
| 25 | pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { |
| 26 | name: "rc", |
| 27 | aliases: &["remote-control"], |
| 28 | usage: "/rc [status|link|open|stop]", |
| 29 | description_key: "cmd_remote_control_description", |
| 30 | }; |
| 31 | |
| 32 | impl ContractRegisterCommand<CommandResult> for RemoteControlCmd { |
| 33 | fn info() -> &'static ContractInfo { |
| 34 | &CONTRACT_INFO |
| 35 | } |
| 36 | fn handler() -> CommandHandler<CommandResult> { |
| 37 | CommandHandler::Contextual { |
| 38 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL, |
| 39 | handler: remote_control_contextual, |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | pub(in crate::commands) fn remote_control_contextual( |
| 45 | contexts: CommandContexts<'_>, |
| 46 | arg: Option<&str>, |
| 47 | ) -> CommandResult { |
| 48 | let mut parts = contexts.into_parts(); |
| 49 | let Some(control) = parts.control.as_deref_mut() else { |
| 50 | return CommandResult::error("Command capability unavailable: session_control".to_string()); |
| 51 | }; |
| 52 | remote_control_portable(control, arg) |
| 53 | } |
| 54 | |
| 55 | pub(in crate::commands) fn remote_control_portable( |
| 56 | control: &mut dyn CommandSessionControlContext, |
| 57 | arg: Option<&str>, |
| 58 | ) -> CommandResult { |
| 59 | match arg.map(str::trim).filter(|value| !value.is_empty()) { |
| 60 | None | Some("start") => { |
| 61 | let connecting = control.remote_start_info().connecting; |
| 62 | CommandResult::with_message_and_action( |
| 63 | if connecting { |
| 64 | "Connecting web remote control to the active turn…" |
| 65 | } else { |
| 66 | "Starting account-owned web remote control…" |
| 67 | }, |
| 68 | crate::tui::app::AppAction::RemoteControl( |
| 69 | crate::remote_control::RemoteControlAction::Start, |
| 70 | ), |
| 71 | ) |
| 72 | } |
| 73 | Some("status") => CommandResult::message(control.remote_status()), |
| 74 | Some("link") => match control.remote_link() { |
| 75 | Some(link) => { |
| 76 | let mut message = format!("Remote control session: {}", link.url); |
| 77 | if let Some(computer_url) = link.computer_url { |
| 78 | message.push_str(&format!("\nManage this computer: {computer_url}")); |
| 79 | } |
| 80 | CommandResult::message(message) |
| 81 | } |
| 82 | None => CommandResult::error(NO_LINK_MESSAGE), |
| 83 | }, |
| 84 | Some("open") => match control.remote_browser_open() { |
| 85 | RemoteOpenOutcome::Opened { url } => { |
| 86 | CommandResult::message(format!("Opening {url} in your browser…")) |
| 87 | } |
| 88 | RemoteOpenOutcome::LaunchFailed { url } => { |
| 89 | CommandResult::error(format!("Could not launch a browser; open {url} manually.")) |
| 90 | } |
| 91 | RemoteOpenOutcome::NoLink => CommandResult::error(NO_LINK_MESSAGE), |
| 92 | }, |
| 93 | Some("stop") => { |
| 94 | // Stop is refused while a remote turn is active or while any |
| 95 | // terminal/approval/integrity envelope is still awaiting the |
| 96 | // server-confirmed cursor; releasing the session earlier could |
| 97 | // strand account-side truth or create a second owner. |
| 98 | if let Some(reason) = control.remote_stop_refusal() { |
| 99 | return CommandResult::error(reason); |
| 100 | } |
| 101 | CommandResult::with_message_and_action( |
| 102 | "Stopping web remote control…", |
| 103 | crate::tui::app::AppAction::RemoteControl( |
| 104 | crate::remote_control::RemoteControlAction::Stop, |
| 105 | ), |
| 106 | ) |
| 107 | } |
| 108 | Some(_) => CommandResult::error("Usage: /rc [status|link|open|stop]"), |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | mod tests { |
| 114 | use super::super::control_test_support::message; |
| 115 | use super::*; |
| 116 | use codewhale_command_contract::facets::{RemoteLink, RemoteOpenOutcome, RemoteStartInfo}; |
| 117 | |
| 118 | fn fake_with_defaults() -> super::super::control_test_support::FakeControl { |
| 119 | super::super::control_test_support::FakeControl { |
| 120 | remote_status: Some("Remote control: off".to_string()), |
| 121 | remote_link: Some(None), |
| 122 | browser_open: Some(RemoteOpenOutcome::NoLink), |
| 123 | start_info: Some(RemoteStartInfo { connecting: false }), |
| 124 | stop_refusal: Some(None), |
| 125 | ..super::super::control_test_support::FakeControl::default() |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[test] |
| 130 | fn rc_start_uses_active_turn_copy_when_connecting() { |
| 131 | let mut fake = fake_with_defaults(); |
| 132 | fake.start_info = Some(RemoteStartInfo { connecting: true }); |
| 133 | for arg in [None, Some("start")] { |
| 134 | let result = remote_control_portable(&mut fake, arg); |
| 135 | assert!(!result.is_error); |
| 136 | assert!( |
| 137 | result |
| 138 | .message |
| 139 | .as_deref() |
| 140 | .is_some_and(|m| m == "Connecting web remote control to the active turn…") |
| 141 | ); |
| 142 | assert!(matches!( |
| 143 | result.action, |
| 144 | Some(crate::tui::app::AppAction::RemoteControl( |
| 145 | crate::remote_control::RemoteControlAction::Start |
| 146 | )) |
| 147 | )); |
| 148 | } |
| 149 | fake.start_info = Some(RemoteStartInfo { connecting: false }); |
| 150 | let result = remote_control_portable(&mut fake, None); |
| 151 | assert!( |
| 152 | result |
| 153 | .message |
| 154 | .as_deref() |
| 155 | .is_some_and(|m| m == "Starting account-owned web remote control…") |
| 156 | ); |
| 157 | assert_eq!( |
| 158 | fake.calls.borrow().as_slice(), |
| 159 | [ |
| 160 | "remote_start_info", |
| 161 | "remote_start_info", |
| 162 | "remote_start_info" |
| 163 | ] |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn rc_status_link_and_no_link_messages_are_exact() { |
| 169 | let mut fake = fake_with_defaults(); |
| 170 | let status = remote_control_portable(&mut fake, Some("status")); |
| 171 | assert_eq!(message(&status), "Remote control: off"); |
| 172 | |
| 173 | let no_link = remote_control_portable(&mut fake, Some("link")); |
| 174 | assert!(no_link.is_error); |
| 175 | assert!( |
| 176 | no_link |
| 177 | .message |
| 178 | .as_deref() |
| 179 | .unwrap() |
| 180 | .contains("no live session link") |
| 181 | ); |
| 182 | |
| 183 | fake.remote_link = Some(Some(RemoteLink { |
| 184 | url: "https://remote.example/s".to_string(), |
| 185 | computer_url: Some("https://remote.example/c".to_string()), |
| 186 | })); |
| 187 | let link = remote_control_portable(&mut fake, Some("link")); |
| 188 | assert!(!link.is_error); |
| 189 | assert_eq!( |
| 190 | message(&link), |
| 191 | "Remote control session: https://remote.example/s\nManage this computer: https://remote.example/c" |
| 192 | ); |
| 193 | assert_eq!( |
| 194 | fake.calls.borrow().as_slice(), |
| 195 | ["remote_status", "remote_link", "remote_link"] |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn rc_open_is_synchronous_and_never_emits_external_url_action() { |
| 201 | let mut fake = fake_with_defaults(); |
| 202 | let no_link = remote_control_portable(&mut fake, Some("open")); |
| 203 | assert!(no_link.is_error); |
| 204 | assert!(no_link.action.is_none()); |
| 205 | assert!( |
| 206 | no_link |
| 207 | .message |
| 208 | .as_deref() |
| 209 | .unwrap() |
| 210 | .contains("no live session link") |
| 211 | ); |
| 212 | |
| 213 | fake.browser_open = Some(RemoteOpenOutcome::Opened { |
| 214 | url: "https://remote.example/s".to_string(), |
| 215 | }); |
| 216 | let opened = remote_control_portable(&mut fake, Some("open")); |
| 217 | assert!(!opened.is_error); |
| 218 | assert_eq!( |
| 219 | message(&opened), |
| 220 | "Opening https://remote.example/s in your browser…" |
| 221 | ); |
| 222 | assert!(opened.action.is_none()); |
| 223 | |
| 224 | fake.browser_open = Some(RemoteOpenOutcome::LaunchFailed { |
| 225 | url: "https://remote.example/s".to_string(), |
| 226 | }); |
| 227 | let failed = remote_control_portable(&mut fake, Some("open")); |
| 228 | assert!(failed.is_error); |
| 229 | assert_eq!( |
| 230 | message(&failed), |
| 231 | "Could not launch a browser; open https://remote.example/s manually." |
| 232 | ); |
| 233 | assert!(failed.action.is_none()); |
| 234 | assert_eq!( |
| 235 | fake.calls.borrow().as_slice(), |
| 236 | [ |
| 237 | "remote_browser_open", |
| 238 | "remote_browser_open", |
| 239 | "remote_browser_open" |
| 240 | ] |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn rc_stop_refuses_active_turns_and_unknown_ops_show_usage() { |
| 246 | let mut fake = fake_with_defaults(); |
| 247 | fake.stop_refusal = Some(Some( |
| 248 | "stop refused while a remote turn is active".to_string(), |
| 249 | )); |
| 250 | let refused = remote_control_portable(&mut fake, Some("stop")); |
| 251 | assert!(refused.is_error); |
| 252 | assert_eq!( |
| 253 | message(&refused), |
| 254 | "stop refused while a remote turn is active" |
| 255 | ); |
| 256 | assert!(refused.action.is_none()); |
| 257 | |
| 258 | fake.stop_refusal = Some(None); |
| 259 | let stopped = remote_control_portable(&mut fake, Some("stop")); |
| 260 | assert!(!stopped.is_error); |
| 261 | assert!(matches!( |
| 262 | stopped.action, |
| 263 | Some(crate::tui::app::AppAction::RemoteControl( |
| 264 | crate::remote_control::RemoteControlAction::Stop |
| 265 | )) |
| 266 | )); |
| 267 | |
| 268 | let unknown = remote_control_portable(&mut fake, Some("frobnicate")); |
| 269 | assert!(unknown.is_error); |
| 270 | assert_eq!(message(&unknown), "Usage: /rc [status|link|open|stop]"); |
| 271 | assert_eq!( |
| 272 | fake.calls.borrow().as_slice(), |
| 273 | ["remote_stop_refusal", "remote_stop_refusal"] |
| 274 | ); |
| 275 | } |
| 276 | |
| 277 | #[test] |
| 278 | fn rc_missing_control_authority_fails_safely() { |
| 279 | let contexts = codewhale_command_contract::handler::CommandContexts::empty(); |
| 280 | let result = remote_control_contextual(contexts, None); |
| 281 | assert!(result.is_error); |
| 282 | assert_eq!( |
| 283 | message(&result), |
| 284 | "Command capability unavailable: session_control" |
| 285 | ); |
| 286 | } |
| 287 | } |
| 288 |