返回 CodeWhale
remote_control.rs
根目录 / crates / tui / src / commands / groups / session / remote_control.rs
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
288 lines RUST