1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
//! Handing a terminal to someone else: the shared link and the served page.
//!
//! Two different things that read as one to the user and share the same
//! spinner: `web-share` puts a live pane on a URL, and `cctop serve` publishes
//! the dashboard. Both are slow enough to be started on another thread and
//! reported back through [`Opening`], and both produce a link that goes to the
//! clipboard rather than to the screen — a status line is read over your
//! shoulder and survives into a screenshot.
use super::*;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
/// A tunnel registration in flight, and when it started.
///
/// The instant is what the spinner is drawn from — a frame counter would have
/// to be advanced by whoever happens to redraw, and the loop redraws on events
/// that have nothing to do with this.
pub struct Opening {
pub(super) rx: Receiver<Result<crate::serve::Serving, String>>,
pub(super) since: Instant,
}
impl Opening {
/// The spinner's current frame.
pub fn frame(&self) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = self.since.elapsed().as_millis() / 100;
FRAMES[i as usize % FRAMES.len()]
}
}
impl App {
/// Open the selected agent's terminal in a browser, via the multiplexer.
///
/// Only reaches agents cctop handed to the multiplexer, which is the same
/// limit `a` has and for the same reason: an agent on cctop's own pty is on
/// no terminal a second viewer can be pointed at, so there is nothing for
/// `web-share -t` to name.
///
/// The operator link goes to the clipboard and never to the screen. It
/// grants input to a live coding agent, and a status line is read by
/// whoever is behind you and survives into a screenshot; the clipboard is
/// where the user was going to put it anyway. The pairing code is shown,
/// since it is worth nothing without the link.
pub(super) fn share_selected(&mut self) {
let Some(session) = self.selected_session() else {
return;
};
let label = session.display_label().to_string();
let Some(pid) = session.root_pid() else {
self.set_status("Selected session has no local process");
return;
};
let Some(name) = crate::rmux::holding(pid) else {
self.set_status("Only an agent cctop put in a multiplexer can be shared");
return;
};
// Tunnelled where the machine can be reached and this machine only
// where it cannot; the status line below says which came back. Pressing
// `W` twice reuses the first share rather than minting a second.
let mut reachable = false;
let share = crate::rmux::share_link(&name, false).map(|(share, tunnelled)| {
reachable = tunnelled;
share
});
match share {
Ok(share) => {
// `web_share` refuses a share with no operator link, so this is
// the link or the error above it — never the spectator one.
let Some(operator) = share.operator.as_deref() else {
self.set_status(format!("Could not share {label}: no operator link"));
return;
};
render::copy_to_clipboard(operator);
let pin = match &share.pin {
Some(pin) => format!(" · pin {pin}"),
None => String::new(),
};
// Where the link reaches from is the one thing about a share
// that is not on the link: an operator URL looks the same
// whether its endpoint is a tunnel or this machine's loopback,
// and sending someone a link that cannot leave the building is
// a failure they discover instead of being told about.
let reach = match reachable {
true => "",
false => " · this machine only",
};
self.set_status(format!(
"Sharing {label} — operator link copied{pin}{reach}"
));
}
Err(error) => self.set_status(format!("Could not share {label}: {error}")),
}
}
/// Start serving the table to a browser, or say why not.
///
/// `tunnel` is the difference between a link that works on this machine and
/// one that works from a phone. It is asked for per start rather than
/// toggled on a running server: registering with the edge is what mints the
/// hostname, so turning it on means a new link either way, and a flag that
/// silently invalidated the link somebody was holding would be worse than a
/// stop and a start they can see.
/// A tunnel is registered off the UI thread; a loopback listener is not.
/// Binding a socket is instant, and waiting a millisecond for it costs less
/// than a state the rest of the code has to know about. The edge is the slow
/// half — see [`Opening`].
pub(super) fn start_serving(&mut self, tunnel: bool) {
self.serve_error = None;
let options = crate::serve::Options {
tunnel,
plan: self.plan,
// Fed from the rows this dashboard already has. Two loaders in one
// process would walk the same disk twice and, worse, could disagree
// — a page saying one thing while the table beside it says another
// is the bug nobody thinks to look for.
scan: false,
..Default::default()
};
if tunnel {
// One at a time. A second click while the first is still dialling
// would register a second tunnel and throw the first away.
if self.share_opening.is_some() {
return;
}
let (tx, rx) = channel();
std::thread::spawn(move || {
// The receiver is gone if cctop quit while this was dialling,
// and the tunnel then drops here — which unregisters it, which
// is the right ending for a link nobody is holding.
let _ = tx.send(crate::serve::start(options).map_err(|e| format!("{e}")));
});
self.share_opening = Some(Opening {
rx,
since: Instant::now(),
});
self.set_status("Opening a tunnel to trycloudflare…");
return;
}
match crate::serve::start(options) {
Ok(serving) => {
// Something to look at immediately: the page's first request
// would otherwise find the empty snapshot it was built with and
// report a machine with no sessions on it.
serving.publish(&self.sessions);
let where_to = match serving.public.is_some() {
true => "on the internet",
false => "on this machine",
};
self.set_status(format!("Serving {where_to} — B for the link"));
self.serving = Some(serving);
}
Err(error) => {
let error = format!("{error}");
self.set_status(format!("Could not serve: {error}"));
self.serve_error = Some(error);
}
}
}
/// Take the tunnel from the thread opening one, if it has finished.
///
/// Returns whether the screen has changed — which, while one is in flight,
/// is every tick: the spinner is the thing saying cctop has not hung.
pub(super) fn tick_share(&mut self) -> bool {
let Some(opening) = &self.share_opening else {
return false;
};
let done = match opening.rx.try_recv() {
Err(TryRecvError::Empty) => return true,
Ok(done) => done,
// The thread went without answering, which it has no path to do.
// Reported rather than left spinning for ever.
Err(TryRecvError::Disconnected) => Err("the tunnel gave no answer".to_string()),
};
self.share_opening = None;
match done {
Ok(serving) => {
// Something to look at immediately: the page's first request
// would otherwise find the empty snapshot it was built with.
serving.publish(&self.sessions);
self.set_status("On the internet — click the link, or B to copy it");
self.serving = Some(serving);
}
Err(error) => {
self.set_status(format!("Could not open a tunnel: {error}"));
self.serve_error = Some(error);
}
}
true
}
/// Stop serving, which un-mints every link handed out.
pub(super) fn stop_serving(&mut self) {
if self.serving.take().is_some() {
self.set_status("Stopped serving — the links no longer answer");
}
}
/// Show the page whatever the table is showing.
///
/// Called wherever the rows change rather than on a timer of its own: the
/// page's event stream wakes on a new version, so this is also what makes a
/// browser update when the table does.
pub(super) fn feed_serving(&self) {
if let Some(serving) = &self.serving {
serving.publish(&self.sessions);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::test_app;
/// Both halves matter. A panel that cannot say where the page is has not
/// answered the question it was opened to answer; a panel that prints the
/// token puts a credential into every screenshot of it. The link is drawn
/// as its origin and the token lives in the clipboard and the escape.
#[test]
fn the_serve_panel_names_the_page_without_naming_its_token() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut app = test_app();
let serving = crate::serve::start(crate::serve::Options {
// A port nobody asked for, so a busy one is stepped past rather
// than failing a test on whatever else is running here.
port_given: false,
..Default::default()
})
.expect("a loopback server");
let token = serving
.local
.split_once("?t=")
.map(|(_, token)| token.to_string())
.expect("a tokenised link");
app.serving = Some(serving);
app.mode = Mode::Serve;
let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("backend");
let mut layout = render::Layout::default();
terminal
.draw(|frame| layout = render::draw(frame, &mut app))
.expect("draw");
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(
screen.contains("http://127.0.0.1:"),
"the panel never said where the page is:\n{screen}"
);
assert!(
!screen.contains(&token),
"the token was drawn on screen:\n{screen}"
);
}
}