返回 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 codewhale_command_contract::outcome::SessionAction::RemoteControl(
69 codewhale_command_contract::outcome::SessionRemoteControlAction::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 codewhale_command_contract::outcome::SessionAction::RemoteControl(
104 codewhale_command_contract::outcome::SessionRemoteControlAction::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(
145 codewhale_command_contract::outcome::SessionAction::RemoteControl(
146 codewhale_command_contract::outcome::SessionRemoteControlAction::Start
147 )
148 )
149 ));
150 }
151 fake.start_info = Some(RemoteStartInfo { connecting: false });
152 let result = remote_control_portable(&mut fake, None);
153 assert!(
154 result
155 .message
156 .as_deref()
157 .is_some_and(|m| m == "Starting account-owned web remote control…")
158 );
159 assert_eq!(
160 fake.calls.borrow().as_slice(),
161 [
162 "remote_start_info",
163 "remote_start_info",
164 "remote_start_info"
165 ]
166 );
167 }
168
169 #[test]
170 fn rc_status_link_and_no_link_messages_are_exact() {
171 let mut fake = fake_with_defaults();
172 let status = remote_control_portable(&mut fake, Some("status"));
173 assert_eq!(message(&status), "Remote control: off");
174
175 let no_link = remote_control_portable(&mut fake, Some("link"));
176 assert!(no_link.is_error);
177 assert!(
178 no_link
179 .message
180 .as_deref()
181 .unwrap()
182 .contains("no live session link")
183 );
184
185 fake.remote_link = Some(Some(RemoteLink {
186 url: "https://remote.example/s".to_string(),
187 computer_url: Some("https://remote.example/c".to_string()),
188 }));
189 let link = remote_control_portable(&mut fake, Some("link"));
190 assert!(!link.is_error);
191 assert_eq!(
192 message(&link),
193 "Remote control session: https://remote.example/s\nManage this computer: https://remote.example/c"
194 );
195 assert_eq!(
196 fake.calls.borrow().as_slice(),
197 ["remote_status", "remote_link", "remote_link"]
198 );
199 }
200
201 #[test]
202 fn rc_open_is_synchronous_and_never_emits_external_url_action() {
203 let mut fake = fake_with_defaults();
204 let no_link = remote_control_portable(&mut fake, Some("open"));
205 assert!(no_link.is_error);
206 assert!(no_link.action.is_none());
207 assert!(
208 no_link
209 .message
210 .as_deref()
211 .unwrap()
212 .contains("no live session link")
213 );
214
215 fake.browser_open = Some(RemoteOpenOutcome::Opened {
216 url: "https://remote.example/s".to_string(),
217 });
218 let opened = remote_control_portable(&mut fake, Some("open"));
219 assert!(!opened.is_error);
220 assert_eq!(
221 message(&opened),
222 "Opening https://remote.example/s in your browser…"
223 );
224 assert!(opened.action.is_none());
225
226 fake.browser_open = Some(RemoteOpenOutcome::LaunchFailed {
227 url: "https://remote.example/s".to_string(),
228 });
229 let failed = remote_control_portable(&mut fake, Some("open"));
230 assert!(failed.is_error);
231 assert_eq!(
232 message(&failed),
233 "Could not launch a browser; open https://remote.example/s manually."
234 );
235 assert!(failed.action.is_none());
236 assert_eq!(
237 fake.calls.borrow().as_slice(),
238 [
239 "remote_browser_open",
240 "remote_browser_open",
241 "remote_browser_open"
242 ]
243 );
244 }
245
246 #[test]
247 fn rc_stop_refuses_active_turns_and_unknown_ops_show_usage() {
248 let mut fake = fake_with_defaults();
249 fake.stop_refusal = Some(Some(
250 "stop refused while a remote turn is active".to_string(),
251 ));
252 let refused = remote_control_portable(&mut fake, Some("stop"));
253 assert!(refused.is_error);
254 assert_eq!(
255 message(&refused),
256 "stop refused while a remote turn is active"
257 );
258 assert!(refused.action.is_none());
259
260 fake.stop_refusal = Some(None);
261 let stopped = remote_control_portable(&mut fake, Some("stop"));
262 assert!(!stopped.is_error);
263 assert!(matches!(
264 stopped.action,
265 Some(
266 codewhale_command_contract::outcome::SessionAction::RemoteControl(
267 codewhale_command_contract::outcome::SessionRemoteControlAction::Stop
268 )
269 )
270 ));
271
272 let unknown = remote_control_portable(&mut fake, Some("frobnicate"));
273 assert!(unknown.is_error);
274 assert_eq!(message(&unknown), "Usage: /rc [status|link|open|stop]");
275 assert_eq!(
276 fake.calls.borrow().as_slice(),
277 ["remote_stop_refusal", "remote_stop_refusal"]
278 );
279 }
280
281 #[test]
282 fn rc_missing_control_authority_fails_safely() {
283 let contexts = codewhale_command_contract::handler::CommandContexts::empty();
284 let result = remote_control_contextual(contexts, None);
285 assert!(result.is_error);
286 assert_eq!(
287 message(&result),
288 "Command capability unavailable: session_control"
289 );
290 }
291 }
292
292 lines RUST