Skip to main content

rmux_sdk/web_share/
builder.rs

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
16/// Builder for creating one browser-visible pane or session share.
17pub 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    /// Sets the maximum lifetime for the share.
63    #[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    /// Sets an absolute expiration time for the share.
71    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    /// Sets the maximum number of concurrent spectator clients.
78    #[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    /// Sets the maximum number of concurrent operator clients.
85    #[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    /// Sets the browser frontend URL used for this share.
92    #[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    /// Sets the public tunnel origin used by the frontend.
99    #[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    /// Spawns a named daemon-side tunnel preset for this share.
107    #[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    /// Hides the browser navigation bar in generated share URLs.
115    #[must_use]
116    pub const fn no_navbar(mut self) -> Self {
117        self.url_options.no_navbar = true;
118        self
119    }
120
121    /// Suppresses the client-side privacy/disclaimer toast in generated share URLs.
122    #[must_use]
123    pub const fn no_disclaimer(mut self) -> Self {
124        self.url_options.no_disclaimer = true;
125        self
126    }
127
128    /// Hides the live connected browser count in generated share URLs.
129    #[must_use]
130    pub const fn hide_viewers(mut self) -> Self {
131        self.url_options.show_viewers = false;
132        self
133    }
134
135    /// Shows the live connected browser count in generated share URLs.
136    #[must_use]
137    pub const fn show_viewers(mut self) -> Self {
138        self.url_options.show_viewers = true;
139        self
140    }
141
142    /// Alias for [`Self::show_viewers`].
143    #[must_use]
144    pub const fn show_viewer_count(self) -> Self {
145        self.show_viewers()
146    }
147
148    /// Disables the out-of-band pairing code.
149    #[must_use]
150    pub const fn no_pin(mut self) -> Self {
151        self.require_pin = false;
152        self
153    }
154
155    /// Requires the out-of-band pairing code.
156    #[must_use]
157    pub const fn pin(mut self) -> Self {
158        self.require_pin = true;
159        self
160    }
161
162    /// Alias for [`Self::pin`].
163    #[must_use]
164    pub const fn pairing_code(self) -> Self {
165        self.pin()
166    }
167
168    /// Supplies the 6-digit operator pairing PIN instead of generating one.
169    #[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    /// Supplies the 6-digit spectator pairing PIN instead of generating one.
176    #[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    /// Sets the initial browser terminal theme for generated share URLs.
183    #[must_use]
184    pub const fn theme(mut self, theme: WebTerminalTheme) -> Self {
185        self.terminal_theme = Some(theme);
186        self
187    }
188
189    /// Alias for [`Self::theme`].
190    #[must_use]
191    pub const fn terminal_theme(self, theme: WebTerminalTheme) -> Self {
192        self.theme(theme)
193    }
194
195    /// Uses the owner's captured terminal palette when available.
196    #[must_use]
197    pub const fn user_theme(self) -> Self {
198        self.theme(WebTerminalTheme::User)
199    }
200
201    /// Uses the bundled light browser terminal palette.
202    #[must_use]
203    pub const fn light_theme(self) -> Self {
204        self.theme(WebTerminalTheme::Light)
205    }
206
207    /// Uses the bundled dark browser terminal palette.
208    #[must_use]
209    pub const fn dark_theme(self) -> Self {
210        self.theme(WebTerminalTheme::Dark)
211    }
212
213    /// Supplies a captured terminal palette for the browser "User" theme.
214    #[must_use]
215    pub fn terminal_palette(mut self, palette: WebTerminalPalette) -> Self {
216        self.terminal_palette = Some(palette);
217        self
218    }
219
220    /// Mints only the operator URL.
221    #[must_use]
222    pub const fn operator_only(mut self) -> Self {
223        self.operator = true;
224        self.spectator = false;
225        self
226    }
227
228    /// Mints only the spectator URL.
229    #[must_use]
230    pub const fn spectator_only(mut self) -> Self {
231        self.operator = false;
232        self.spectator = true;
233        self
234    }
235
236    /// Kills the target session when this share expires.
237    ///
238    /// The daemon rejects this option for pane shares.
239    #[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    /// Starts a web-share builder for this session.
296    #[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    /// Starts a web-share builder for this pane.
307    #[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}