Skip to content

Commit 0d32562

Browse files
CopilotibigbugCodexClaude
authored
feat: add stream subcommand for live terminal session broadcasting (#278)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ibigbug <543405+ibigbug@users.noreply.github.com> Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com> Co-authored-by: Yuwei Ba <dev@watfaq.com> Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
1 parent 941d32a commit 0d32562

8 files changed

Lines changed: 585 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 128 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ uuid = { version = "1.22.0", features = [
2828
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
2929
] }
3030

31-
reqwest = { version = "0.13", features = ["blocking", "multipart"] }
31+
reqwest = { version = "0.13", features = ["blocking", "multipart", "json"] }
32+
33+
tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
3234

3335
rustc_version_runtime = "0.3.0"
3436
os_info = "3"

src/commands/api/asciinema.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::ApiService;
1+
use super::{ApiService, StreamInfo};
22

33
use base64::Engine;
44
use base64::prelude::BASE64_STANDARD;
@@ -193,11 +193,73 @@ impl ApiService for Asciinema {
193193
None
194194
}
195195
}
196+
197+
fn create_stream(&self, cols: u16, rows: u16) -> Option<StreamInfo> {
198+
#[derive(Deserialize)]
199+
struct CreateStreamResponse {
200+
id: String,
201+
url: String,
202+
ws_producer_url: String,
203+
}
204+
205+
let stream_url = format!("{}/api/streams", &self.config.api_server);
206+
let body = serde_json::json!({ "cols": cols, "rows": rows });
207+
let res = match self
208+
.http_client
209+
.post(stream_url)
210+
.json(&body)
211+
.send()
212+
{
213+
Ok(r) => r,
214+
Err(e) => {
215+
println!("Failed to reach stream server: {}", e);
216+
return None;
217+
}
218+
};
219+
220+
if res.status().is_success() {
221+
match res.json::<CreateStreamResponse>() {
222+
Ok(resp) => Some(StreamInfo {
223+
id: resp.id,
224+
url: resp.url,
225+
ws_producer_url: resp.ws_producer_url,
226+
}),
227+
Err(e) => {
228+
println!("Failed to parse stream response: {}", e);
229+
None
230+
}
231+
}
232+
} else {
233+
println!("Failed to create stream:");
234+
println!("{}", res.text().unwrap_or_default());
235+
None
236+
}
237+
}
238+
239+
fn get_stream_ws_url(&self, stream_id: &str) -> String {
240+
// Derive the producer WebSocket URL from the server base URL and stream ID.
241+
// The path follows the asciinema server convention: /ws/S/<stream_id>
242+
let base = self
243+
.config
244+
.api_server
245+
.trim_end_matches('/')
246+
.replace("https://", "wss://")
247+
.replace("http://", "ws://");
248+
format!("{}/ws/S/{}", base, stream_id)
249+
}
250+
251+
fn get_auth_header(&self) -> String {
252+
let cred = format!("user:{}", self.config.install_id);
253+
format!("Basic {}", BASE64_STANDARD.encode(&cred))
254+
}
196255
}
197256

198257
#[cfg(test)]
199258
mod tests {
259+
use base64::prelude::BASE64_STANDARD;
260+
use base64::Engine;
200261
use crate::commands::api::asciinema::Config;
262+
use crate::commands::api::ApiService;
201263

202264
use uuid::{Uuid, Version};
203265

@@ -207,4 +269,50 @@ mod tests {
207269
let uuid = Uuid::parse_str(&c.install_id);
208270
assert_eq!(uuid.unwrap().get_version(), Some(Version::Random)); // uuid4
209271
}
272+
273+
#[test]
274+
fn test_get_stream_ws_url_https_and_http() {
275+
let client = reqwest::blocking::Client::new();
276+
277+
let asc_https = super::Asciinema {
278+
config: Config {
279+
install_id: "install".to_string(),
280+
api_server: "https://demo.asciinema.org/".to_string(),
281+
location: String::new(),
282+
},
283+
http_client: client.clone(),
284+
};
285+
assert_eq!(
286+
asc_https.get_stream_ws_url("abc123"),
287+
"wss://demo.asciinema.org/ws/S/abc123"
288+
);
289+
290+
let asc_http = super::Asciinema {
291+
config: Config {
292+
install_id: "install".to_string(),
293+
api_server: "http://asciinema.test".to_string(),
294+
location: String::new(),
295+
},
296+
http_client: client,
297+
};
298+
assert_eq!(
299+
asc_http.get_stream_ws_url("xyz"),
300+
"ws://asciinema.test/ws/S/xyz"
301+
);
302+
}
303+
304+
#[test]
305+
fn test_get_auth_header_format() {
306+
let asc = super::Asciinema {
307+
config: Config {
308+
install_id: "token-123".to_string(),
309+
api_server: "https://example".to_string(),
310+
location: String::new(),
311+
},
312+
http_client: reqwest::blocking::Client::new(),
313+
};
314+
315+
let expected = format!("Basic {}", BASE64_STANDARD.encode("user:token-123"));
316+
assert_eq!(asc.get_auth_header(), expected);
317+
}
210318
}

src/commands/api/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
mod asciinema;
22

3+
pub struct StreamInfo {
4+
pub id: String,
5+
pub url: String,
6+
pub ws_producer_url: String,
7+
}
8+
39
pub trait ApiService {
410
fn auth(&self);
511
fn upload(&self, filepath: &str) -> Option<String>;
12+
/// Create a new live stream on the server. Returns `StreamInfo` with the public viewer
13+
/// URL and the WebSocket producer URL to push events to.
14+
fn create_stream(&self, cols: u16, rows: u16) -> Option<StreamInfo>;
15+
/// Build the WebSocket producer URL for an *existing* stream identified by `stream_id`.
16+
fn get_stream_ws_url(&self, stream_id: &str) -> String;
17+
/// Return the `Authorization` header value to authenticate WebSocket connections.
18+
fn get_auth_header(&self) -> String;
619
}
720

821
pub use asciinema::Asciinema;

0 commit comments

Comments
 (0)