1use std::num::NonZeroU32;
2
3use des::{
4 Des,
5 cipher::{BlockCipherEncrypt, KeyInit},
6};
7use serde::{Deserialize, Serialize};
8
9pub mod act;
10mod desktop;
11mod files;
12mod shell;
13mod transfer;
14
15pub use act::{
16 ActReply, ActRequest, Action, ActionError, Button, Direction, Kind, Point, RawAction,
17};
18pub use desktop::{LaunchAppRequest, OpenPathRequest};
19pub use files::{
20 FileEntry, FileKind, ImageType, ListFilesReply, ListFilesRequest, MAX_IMAGE_BYTES,
21 MAX_WRITE_BYTES, ReadFileReply, ReadFileRequest, WriteFileReply, WriteFileRequest,
22};
23pub use shell::{
24 DEFAULT_SHELL_TIMEOUT_MAX_SECS, DEFAULT_SHELL_TIMEOUT_SECS, LONGEST_SHELL_TIMEOUT_SECS,
25 SetCwdReply, SetCwdRequest, ShellOutcome, ShellReply, ShellRequest, ShellTimeouts,
26 ShellTimeoutsError,
27};
28pub use transfer::{
29 DownloadRequest, MAX_LISTED_SKIPS, SKIP_REPORT_ENTRY, SkipList, Skipped, TransferReply,
30 UploadCheck, UploadQuery,
31};
32
33pub const PROTOCOL_VERSION: u32 = 11;
35
36pub const RELEASE_VERSION: Option<&str> = match option_env!("COMPUTER_USE_MCP_VERSION") {
37 Some(version) if !version.is_empty() => Some(version),
38 _ => None,
39};
40
41pub const VERSION: &str = match RELEASE_VERSION {
42 Some(version) => version,
43 None => concat!(env!("CARGO_PKG_VERSION"), "-dev"),
44};
45
46pub const API_PORT: u16 = 7070;
48
49pub const TOKEN_ENV: &str = "COMPUTERD_TOKEN";
51
52pub const HOST_PORT_BASE_ENV: &str = "COMPUTERD_HOST_PORT_BASE";
54
55pub const DEFAULT_PORT_BASE: u16 = 20900;
57
58pub const VIEWER_PORT: u16 = 20900;
60
61pub const SCREEN_COUNT: u8 = 16;
63
64#[must_use]
66pub fn vnc_port(screen: u8) -> u16 {
67 VIEWER_PORT + u16::from(screen)
68}
69
70#[must_use]
74pub fn viewer_link(host_port: u16, key: &str) -> String {
75 format!("http://127.0.0.1:{host_port}/#key={key}")
76}
77
78#[must_use]
80pub fn viewer_screen_link(host_port: u16, key: &str, screen: u8) -> String {
81 format!("http://127.0.0.1:{host_port}/#key={key}&screen={screen}")
82}
83
84const VNC_FILE_KEY: [u8; 8] = [23, 82, 107, 6, 35, 78, 88, 7];
86
87#[must_use]
91pub fn vnc_password_file(key: &str) -> [u8; 8] {
92 let mut block = [0u8; 8];
93 for (slot, byte) in block.iter_mut().zip(key.bytes()) {
94 *slot = byte;
95 }
96 let cipher = Des::new(&VNC_FILE_KEY.map(u8::reverse_bits).into());
97 let mut out = block.into();
98 cipher.encrypt_block(&mut out);
99 out.into()
100}
101
102pub const MAX_TITLE_CHARS: usize = 80;
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Health {
107 pub protocol_version: u32,
108 pub version: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ViewerInfo {
114 pub key: String,
116 pub pages: usize,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
122pub enum TitleError {
123 #[error("title must not be empty")]
124 Empty,
125 #[error("title is {0} characters long, the limit is {MAX_TITLE_CHARS}")]
126 TooLong(usize),
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(try_from = "String", into = "String")]
132pub struct SessionTitle(String);
133
134impl SessionTitle {
135 pub fn parse(text: &str) -> Result<Self, TitleError> {
141 let text = text.trim();
142 let len = text.chars().count();
143 if len == 0 {
144 Err(TitleError::Empty)
145 } else if len > MAX_TITLE_CHARS {
146 Err(TitleError::TooLong(len))
147 } else {
148 Ok(Self(text.to_owned()))
149 }
150 }
151
152 #[must_use]
153 pub fn as_str(&self) -> &str {
154 &self.0
155 }
156}
157
158impl TryFrom<String> for SessionTitle {
159 type Error = TitleError;
160
161 fn try_from(text: String) -> Result<Self, Self::Error> {
162 Self::parse(&text)
163 }
164}
165
166impl From<SessionTitle> for String {
167 fn from(title: SessionTitle) -> Self {
168 title.0
169 }
170}
171
172pub const MIN_SCREEN_SIDE: u16 = 320;
174
175pub const MAX_SCREEN_SIDE: u16 = 7680;
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(try_from = "String", into = "String")]
181pub struct ScreenSize {
182 width: u16,
183 height: u16,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
188#[error(
189 "screen size must look like 1280x800, with each side from {MIN_SCREEN_SIDE} to {MAX_SCREEN_SIDE}"
190)]
191pub struct ScreenSizeError;
192
193impl ScreenSize {
194 pub const DEFAULT: Self = Self {
195 width: 1280,
196 height: 800,
197 };
198
199 pub fn parse(text: &str) -> Result<Self, ScreenSizeError> {
205 let (width, height) = text.trim().split_once(['x', 'X']).ok_or(ScreenSizeError)?;
206 let side = |text: &str| {
207 text.parse::<u16>()
208 .ok()
209 .filter(|side| (MIN_SCREEN_SIDE..=MAX_SCREEN_SIDE).contains(side))
210 .ok_or(ScreenSizeError)
211 };
212 Ok(Self {
213 width: side(width)?,
214 height: side(height)?,
215 })
216 }
217
218 #[must_use]
219 pub fn width(self) -> u16 {
220 self.width
221 }
222
223 #[must_use]
224 pub fn height(self) -> u16 {
225 self.height
226 }
227}
228
229impl Default for ScreenSize {
230 fn default() -> Self {
231 Self::DEFAULT
232 }
233}
234
235impl std::fmt::Display for ScreenSize {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 write!(f, "{}x{}", self.width, self.height)
238 }
239}
240
241impl TryFrom<String> for ScreenSize {
242 type Error = ScreenSizeError;
243
244 fn try_from(text: String) -> Result<Self, Self::Error> {
245 Self::parse(&text)
246 }
247}
248
249impl From<ScreenSize> for String {
250 fn from(size: ScreenSize) -> Self {
251 size.to_string()
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct CreateSession {
258 pub title: SessionTitle,
259 pub screen_size: ScreenSize,
261 pub shell_timeouts: ShellTimeouts,
263 pub owner: OwnerId,
265 pub idle_secs: NonZeroU32,
267}
268
269pub const HEARTBEAT_INTERVAL_SECS: u64 = 10;
271
272pub const OWNER_TIMEOUT_SECS: u64 = 30;
274
275pub const DEFAULT_IDLE_SECS: u32 = 3600;
277
278pub const OWNER_ID_LEN: usize = 32;
280
281#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
283#[serde(try_from = "String", into = "String")]
284pub struct OwnerId(String);
285
286#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
288#[error("not an owner id")]
289pub struct OwnerIdError;
290
291impl OwnerId {
292 pub fn parse(text: &str) -> Result<Self, OwnerIdError> {
298 let valid = text.len() == OWNER_ID_LEN
299 && text
300 .bytes()
301 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
302 if valid {
303 Ok(Self(text.to_owned()))
304 } else {
305 Err(OwnerIdError)
306 }
307 }
308
309 #[must_use]
310 pub fn as_str(&self) -> &str {
311 &self.0
312 }
313}
314
315impl TryFrom<String> for OwnerId {
316 type Error = OwnerIdError;
317
318 fn try_from(text: String) -> Result<Self, Self::Error> {
319 Self::parse(&text)
320 }
321}
322
323impl From<OwnerId> for String {
324 fn from(id: OwnerId) -> Self {
325 id.0
326 }
327}
328
329impl std::fmt::Display for OwnerId {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 f.write_str(&self.0)
332 }
333}
334
335pub const SESSION_ID_LEN: usize = 32;
337
338#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
340#[serde(try_from = "String", into = "String")]
341pub struct SessionId(String);
342
343#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
345#[error("not a session id")]
346pub struct SessionIdError;
347
348impl SessionId {
349 pub fn parse(text: &str) -> Result<Self, SessionIdError> {
355 let valid = text.len() == SESSION_ID_LEN
356 && text
357 .bytes()
358 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
359 if valid {
360 Ok(Self(text.to_owned()))
361 } else {
362 Err(SessionIdError)
363 }
364 }
365
366 #[must_use]
367 pub fn as_str(&self) -> &str {
368 &self.0
369 }
370}
371
372impl TryFrom<String> for SessionId {
373 type Error = SessionIdError;
374
375 fn try_from(text: String) -> Result<Self, Self::Error> {
376 Self::parse(&text)
377 }
378}
379
380impl From<SessionId> for String {
381 fn from(id: SessionId) -> Self {
382 id.0
383 }
384}
385
386impl std::fmt::Display for SessionId {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 f.write_str(&self.0)
389 }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct SessionCreated {
395 pub session: SessionId,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Cursor {
401 pub x: i16,
402 pub y: i16,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct Observation {
408 pub frame_id: u64,
410 pub captured_at: String,
412 pub width: u16,
413 pub height: u16,
414 pub cursor: Cursor,
415 pub active_window: String,
417 pub png_base64: Option<String>,
419 #[serde(default)]
421 pub opened_screen: Option<u8>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub struct ApiError {
427 pub message: String,
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn the_password_file_is_the_des_obfuscation_xvnc_reads() {
436 assert_eq!(
437 vnc_password_file("abcd2345"),
438 [255, 232, 190, 74, 23, 18, 52, 125],
439 "bytes from the same encoding that Xvnc accepted in the Docker test"
440 );
441 }
442
443 #[test]
444 fn title_limits_count_characters_after_trimming() {
445 let longest = "é".repeat(MAX_TITLE_CHARS);
446 assert_eq!(
447 SessionTitle::parse(&format!(" {longest} "))
448 .unwrap()
449 .as_str(),
450 longest
451 );
452 assert_eq!(
453 SessionTitle::parse(&format!("{longest}x")),
454 Err(TitleError::TooLong(MAX_TITLE_CHARS + 1))
455 );
456 assert_eq!(SessionTitle::parse(" \t"), Err(TitleError::Empty));
457 }
458
459 #[test]
460 fn session_ids_must_be_exactly_32_lowercase_hex_digits() {
461 let good = "0123456789abcdef0123456789abcdef";
462 assert_eq!(SessionId::parse(good).unwrap().as_str(), good);
463 for bad in [
464 "",
465 "../health",
466 "0123456789abcdef0123456789abcde",
467 "0123456789abcdef0123456789abcdef0",
468 "0123456789ABCDEF0123456789abcdef",
469 "0123456789abcdef0123456789abcde/",
470 ] {
471 assert_eq!(SessionId::parse(bad), Err(SessionIdError), "{bad}");
472 }
473 }
474
475 #[test]
476 fn screen_size_parses_width_by_height_within_limits() {
477 let size = ScreenSize::parse(" 1920x1080 ").unwrap();
478 assert_eq!((size.width(), size.height()), (1920, 1080));
479 assert_eq!(size.to_string(), "1920x1080");
480 for bad in [
481 "",
482 "1280",
483 "1280x",
484 "x800",
485 "1280x800x2",
486 "319x800",
487 "1280x7681",
488 "-1x800",
489 "axb",
490 ] {
491 assert_eq!(ScreenSize::parse(bad), Err(ScreenSizeError), "{bad}");
492 }
493 }
494
495 #[test]
496 fn request_body_with_a_bad_title_is_refused() {
497 let body = serde_json::json!({ "title": "", "screen_size": "1280x800", "shell_timeouts": { "default_secs": 120, "max_secs": 600 } });
498 assert!(serde_json::from_value::<CreateSession>(body).is_err());
499 }
500}