1use std::future::{Future, IntoFuture};
2use std::pin::Pin;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use rmux_proto::{
6 CreateWebShareRequest, Request, Response, WebShareRequest, WebShareResponse, WebShareScope,
7 WebShareUrlOptions, WebTerminalPalette, WebTerminalTheme,
8};
9
10use crate::handles::{Pane, Session};
11use crate::transport::TransportClient;
12use crate::{Result, RmuxError};
13
14use super::{require_web_share, unexpected_response, WebShareHandle};
15
16pub struct WebShareBuilder<'a> {
18 transport: &'a TransportClient,
19 scope: WebShareScope,
20 frontend_url: Option<String>,
21 public_base_url: Option<String>,
22 tunnel_provider: Option<String>,
23 ttl_seconds: Option<u64>,
24 expires_at_unix: Option<u64>,
25 max_operators: Option<u16>,
26 max_spectators: Option<u16>,
27 url_options: WebShareUrlOptions,
28 require_pin: bool,
29 operator_pin: Option<String>,
30 spectator_pin: Option<String>,
31 terminal_theme: Option<WebTerminalTheme>,
32 terminal_palette: Option<WebTerminalPalette>,
33 operator: bool,
34 spectator: bool,
35 kill_session_on_expire: bool,
36}
37
38impl<'a> WebShareBuilder<'a> {
39 pub(crate) fn new(transport: &'a TransportClient, scope: WebShareScope) -> Self {
40 Self {
41 transport,
42 scope,
43 frontend_url: None,
44 public_base_url: None,
45 tunnel_provider: None,
46 ttl_seconds: None,
47 expires_at_unix: None,
48 max_operators: None,
49 max_spectators: None,
50 url_options: WebShareUrlOptions::default(),
51 require_pin: true,
52 operator_pin: None,
53 spectator_pin: None,
54 terminal_theme: None,
55 terminal_palette: None,
56 operator: true,
57 spectator: true,
58 kill_session_on_expire: false,
59 }
60 }
61
62 #[must_use]
64 pub fn ttl(mut self, duration: Duration) -> Self {
65 self.ttl_seconds = Some(whole_seconds_ceil(duration));
66 self.expires_at_unix = None;
67 self
68 }
69
70 pub fn expires_at(mut self, deadline: SystemTime) -> Result<Self> {
72 self.expires_at_unix = Some(system_time_to_unix(deadline)?);
73 self.ttl_seconds = None;
74 Ok(self)
75 }
76
77 #[must_use]
79 pub const fn max_spectators(mut self, max_spectators: u16) -> Self {
80 self.max_spectators = Some(max_spectators);
81 self
82 }
83
84 #[must_use]
86 pub const fn max_operators(mut self, max_operators: u16) -> Self {
87 self.max_operators = Some(max_operators);
88 self
89 }
90
91 #[must_use]
93 pub fn frontend_url(mut self, url: impl Into<String>) -> Self {
94 self.frontend_url = Some(url.into());
95 self
96 }
97
98 #[must_use]
100 pub fn tunnel_url(mut self, url: impl Into<String>) -> Self {
101 self.public_base_url = Some(url.into());
102 self.tunnel_provider = None;
103 self
104 }
105
106 #[must_use]
108 pub fn tunnel_provider(mut self, provider: impl Into<String>) -> Self {
109 self.tunnel_provider = Some(provider.into());
110 self.public_base_url = None;
111 self
112 }
113
114 #[must_use]
116 pub const fn no_navbar(mut self) -> Self {
117 self.url_options.no_navbar = true;
118 self
119 }
120
121 #[must_use]
123 pub const fn no_disclaimer(mut self) -> Self {
124 self.url_options.no_disclaimer = true;
125 self
126 }
127
128 #[must_use]
130 pub const fn hide_viewers(mut self) -> Self {
131 self.url_options.show_viewers = false;
132 self
133 }
134
135 #[must_use]
137 pub const fn show_viewers(mut self) -> Self {
138 self.url_options.show_viewers = true;
139 self
140 }
141
142 #[must_use]
144 pub const fn show_viewer_count(self) -> Self {
145 self.show_viewers()
146 }
147
148 #[must_use]
150 pub const fn no_pin(mut self) -> Self {
151 self.require_pin = false;
152 self
153 }
154
155 #[must_use]
157 pub const fn pin(mut self) -> Self {
158 self.require_pin = true;
159 self
160 }
161
162 #[must_use]
164 pub const fn pairing_code(self) -> Self {
165 self.pin()
166 }
167
168 #[must_use]
170 pub fn operator_pin(mut self, pin: impl Into<String>) -> Self {
171 self.operator_pin = Some(pin.into());
172 self
173 }
174
175 #[must_use]
177 pub fn spectator_pin(mut self, pin: impl Into<String>) -> Self {
178 self.spectator_pin = Some(pin.into());
179 self
180 }
181
182 #[must_use]
184 pub const fn theme(mut self, theme: WebTerminalTheme) -> Self {
185 self.terminal_theme = Some(theme);
186 self
187 }
188
189 #[must_use]
191 pub const fn terminal_theme(self, theme: WebTerminalTheme) -> Self {
192 self.theme(theme)
193 }
194
195 #[must_use]
197 pub const fn user_theme(self) -> Self {
198 self.theme(WebTerminalTheme::User)
199 }
200
201 #[must_use]
203 pub const fn light_theme(self) -> Self {
204 self.theme(WebTerminalTheme::Light)
205 }
206
207 #[must_use]
209 pub const fn dark_theme(self) -> Self {
210 self.theme(WebTerminalTheme::Dark)
211 }
212
213 #[must_use]
215 pub fn terminal_palette(mut self, palette: WebTerminalPalette) -> Self {
216 self.terminal_palette = Some(palette);
217 self
218 }
219
220 #[must_use]
222 pub const fn operator_only(mut self) -> Self {
223 self.operator = true;
224 self.spectator = false;
225 self
226 }
227
228 #[must_use]
230 pub const fn spectator_only(mut self) -> Self {
231 self.operator = false;
232 self.spectator = true;
233 self
234 }
235
236 #[must_use]
240 pub const fn kill_session_on_expire(mut self, enabled: bool) -> Self {
241 self.kill_session_on_expire = enabled;
242 self
243 }
244
245 async fn run(self) -> Result<WebShareHandle> {
246 require_web_share(self.transport).await?;
247 let controls = self.operator && matches!(&self.scope, WebShareScope::Session(_));
248 let response = self
249 .transport
250 .request(Request::WebShare(WebShareRequest::Create(
251 CreateWebShareRequest {
252 scope: self.scope,
253 public_base_url: self.public_base_url,
254 tunnel_provider: self.tunnel_provider,
255 frontend_url: self.frontend_url,
256 ttl_seconds: self.ttl_seconds,
257 expires_at_unix: self.expires_at_unix,
258 max_spectators: self.max_spectators,
259 max_operators: self.max_operators,
260 url_options: WebShareUrlOptions {
261 terminal_theme: self.terminal_theme,
262 ..self.url_options
263 },
264 require_pin: self.require_pin,
265 operator_pin: self.operator_pin,
266 spectator_pin: self.spectator_pin,
267 terminal_palette: self.terminal_palette.map(Box::new),
268 operator: self.operator,
269 spectator: self.spectator,
270 controls,
271 kill_session_on_expire: self.kill_session_on_expire,
272 },
273 )))
274 .await?;
275 match response {
276 Response::WebShare(WebShareResponse::Created(created)) => {
277 Ok(WebShareHandle::new(self.transport.clone(), created))
278 }
279 Response::Error(error) => Err(error.into()),
280 response => Err(unexpected_response("web-share create", response)),
281 }
282 }
283}
284
285impl<'a> IntoFuture for WebShareBuilder<'a> {
286 type Output = Result<WebShareHandle>;
287 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'a>>;
288
289 fn into_future(self) -> Self::IntoFuture {
290 Box::pin(self.run())
291 }
292}
293
294impl Session {
295 #[must_use]
297 pub fn share(&self) -> WebShareBuilder<'_> {
298 WebShareBuilder::new(
299 self.transport(),
300 WebShareScope::Session(self.name().clone()),
301 )
302 }
303}
304
305impl Pane {
306 #[must_use]
308 pub fn share(&self) -> WebShareBuilder<'_> {
309 WebShareBuilder::new(
310 self.transport(),
311 WebShareScope::Pane(self.proto_target_ref()),
312 )
313 }
314}
315
316fn whole_seconds_ceil(duration: Duration) -> u64 {
317 if duration.is_zero() {
318 0
319 } else {
320 duration
321 .as_secs()
322 .saturating_add(u64::from(duration.subsec_nanos() > 0))
323 }
324}
325
326fn system_time_to_unix(value: SystemTime) -> Result<u64> {
327 value
328 .duration_since(UNIX_EPOCH)
329 .map(|duration| duration.as_secs())
330 .map_err(|_| {
331 RmuxError::protocol(rmux_proto::RmuxError::Server(
332 "web-share expiration must not be before the Unix epoch".to_owned(),
333 ))
334 })
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{system_time_to_unix, whole_seconds_ceil, WebShareBuilder};
340 use crate::transport::TransportClient;
341 use rmux_proto::{SessionName, WebShareScope};
342 use std::time::{Duration, UNIX_EPOCH};
343
344 #[test]
345 fn ttl_ceil_rejects_only_explicit_zero_later() {
346 assert_eq!(whole_seconds_ceil(Duration::ZERO), 0);
347 assert_eq!(whole_seconds_ceil(Duration::from_millis(1)), 1);
348 assert_eq!(whole_seconds_ceil(Duration::from_secs(3)), 3);
349 assert_eq!(whole_seconds_ceil(Duration::new(3, 1)), 4);
350 }
351
352 #[test]
353 fn system_time_to_unix_returns_seconds() {
354 assert_eq!(
355 system_time_to_unix(UNIX_EPOCH + Duration::from_secs(42)).expect("valid deadline"),
356 42
357 );
358 }
359
360 #[test]
361 fn system_time_to_unix_rejects_pre_epoch_deadlines() {
362 let error = system_time_to_unix(UNIX_EPOCH - Duration::from_secs(1))
363 .expect_err("pre-epoch deadline must be rejected locally");
364 assert!(error
365 .to_string()
366 .contains("web-share expiration must not be before the Unix epoch"));
367 }
368
369 #[tokio::test]
370 async fn positive_compat_aliases_restore_default_web_share_choices() {
371 let (client, _server) = tokio::io::duplex(64);
372 let transport = TransportClient::spawn(client);
373 let scope = WebShareScope::Session(SessionName::new("alpha").expect("valid session"));
374 let builder = WebShareBuilder::new(&transport, scope)
375 .hide_viewers()
376 .show_viewer_count()
377 .no_pin()
378 .pairing_code();
379
380 assert!(builder.url_options.show_viewers);
381 assert!(builder.require_pin);
382 }
383}