1use std::io;
4
5use mobius::{Error, Result};
6use mobius_gateway::config::{CloudflareConfig, MAX_CLOUDFLARE_TOKEN_BYTES as MAX_TOKEN_BYTES};
7use ratatui::Terminal;
8use ratatui::backend::CrosstermBackend;
9use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
12use tokio::time::MissedTickBehavior;
13
14use super::terminal::{INPUT_POLL, MAX_INPUT_BATCH, TerminalGuard, poll_event};
15use super::terminal_text;
16use super::theme::{Role, current};
17
18const MAX_HOSTNAME_BYTES: usize = 253;
19
20pub enum CloudflareInit {
22 Quick,
23 Named { hostname: String, token: String },
24}
25
26#[derive(Clone, Copy, PartialEq, Eq)]
27enum Field {
28 Quick,
29 Hostname,
30 Token,
31 Connect,
32}
33
34struct State {
35 field: Field,
36 hostname: String,
37 token: String,
38 error: Option<String>,
39}
40
41impl State {
42 fn new() -> Self {
43 Self {
44 field: Field::Quick,
45 hostname: String::new(),
46 token: String::new(),
47 error: None,
48 }
49 }
50
51 fn move_field(&mut self, delta: isize) {
52 let current = match self.field {
53 Field::Quick => 0,
54 Field::Hostname => 1,
55 Field::Token => 2,
56 Field::Connect => 3,
57 };
58 self.field = match (current + delta).rem_euclid(4) {
59 0 => Field::Quick,
60 1 => Field::Hostname,
61 2 => Field::Token,
62 _ => Field::Connect,
63 };
64 self.error = None;
65 }
66
67 fn push(&mut self, text: &str) {
68 let (target, limit) = match self.field {
69 Field::Quick => return,
70 Field::Hostname => (&mut self.hostname, MAX_HOSTNAME_BYTES),
71 Field::Token => (&mut self.token, MAX_TOKEN_BYTES),
72 Field::Connect => return,
73 };
74 for character in text.chars().filter(|character| !character.is_control()) {
75 if target.len() + character.len_utf8() > limit {
76 self.error = Some(format!("input is limited to {limit} bytes"));
77 return;
78 }
79 target.push(character);
80 }
81 self.error = None;
82 }
83
84 fn backspace(&mut self) {
85 match self.field {
86 Field::Quick => return,
87 Field::Hostname => {
88 self.hostname.pop();
89 }
90 Field::Token => {
91 self.token.pop();
92 }
93 Field::Connect => return,
94 }
95 self.error = None;
96 }
97
98 fn finish(&mut self) -> Result<CloudflareInit> {
99 if self.field == Field::Quick {
100 return Ok(CloudflareInit::Quick);
101 }
102 let cloudflare = CloudflareConfig::named(&self.hostname).map_err(configuration_error)?;
103 CloudflareConfig::validate_token(&self.token).map_err(configuration_error)?;
104 let hostname = cloudflare
105 .hostname()
106 .ok_or_else(|| Error::Config("named Cloudflare hostname is missing".into()))?;
107 Ok(CloudflareInit::Named {
108 hostname: hostname.to_owned(),
109 token: std::mem::take(&mut self.token).trim().to_owned(),
110 })
111 }
112}
113
114fn configuration_error(error: mobius_gateway::Error) -> Error {
115 Error::Config(error.to_string())
116}
117
118pub async fn run() -> Result<Option<CloudflareInit>> {
120 let mut guard = TerminalGuard::alternate()?;
121 guard.set_mouse_capture(false)?;
122 let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
123 let mut state = State::new();
124 terminal.clear()?;
125 let mut tick = tokio::time::interval(INPUT_POLL);
126 tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
127 let mut dirty = true;
128 loop {
129 if dirty {
130 terminal.draw(|frame| render(frame, &state))?;
131 dirty = false;
132 }
133 tick.tick().await;
134 for _ in 0..MAX_INPUT_BATCH {
135 let Some(event) = poll_event()? else {
136 break;
137 };
138 dirty = true;
139 match event {
140 Event::Key(key) => match handle_key(&mut state, key) {
141 Action::Continue => {}
142 Action::Cancel => return Ok(None),
143 Action::Finish => match state.finish() {
144 Ok(config) => return Ok(Some(config)),
145 Err(error) => state.error = Some(error.to_string()),
146 },
147 },
148 Event::Paste(text) => state.push(text.trim()),
149 Event::Resize(_, _) | Event::FocusGained | Event::FocusLost | Event::Mouse(_) => {}
150 }
151 }
152 }
153}
154
155enum Action {
156 Continue,
157 Cancel,
158 Finish,
159}
160
161fn handle_key(state: &mut State, key: KeyEvent) -> Action {
162 if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
163 return Action::Continue;
164 }
165 if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'd'))
166 {
167 return Action::Cancel;
168 }
169 match key.code {
170 KeyCode::Esc => Action::Cancel,
171 KeyCode::Up | KeyCode::BackTab => {
172 state.move_field(-1);
173 Action::Continue
174 }
175 KeyCode::Down | KeyCode::Tab => {
176 state.move_field(1);
177 Action::Continue
178 }
179 KeyCode::Enter if matches!(state.field, Field::Quick | Field::Connect) => Action::Finish,
180 KeyCode::Enter => {
181 state.move_field(1);
182 Action::Continue
183 }
184 KeyCode::Backspace => {
185 state.backspace();
186 Action::Continue
187 }
188 KeyCode::Char(character)
189 if !key
190 .modifiers
191 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
192 {
193 state.push(&character.to_string());
194 Action::Continue
195 }
196 _ => Action::Continue,
197 }
198}
199
200fn render(frame: &mut ratatui::Frame<'_>, state: &State) {
201 let theme = current();
202 let hostname = if state.hostname.is_empty() {
203 "mobius.example.com".into()
204 } else {
205 terminal_text(&state.hostname)
206 };
207 let token = if state.token.is_empty() {
208 "paste the tunnel token".into()
209 } else {
210 masked_token(&state.token)
211 };
212 let selected = |field| {
213 if state.field == field {
214 theme.style(Role::Selection)
215 } else {
216 theme.style(Role::Text)
217 }
218 };
219 let mut lines = vec![
220 Line::from(""),
221 Line::from(Span::styled(" Quick Connect", selected(Field::Quick))),
222 Line::styled(
223 " No Cloudflare account or route. The address changes when the gateway restarts.",
224 theme.style(Role::Muted),
225 ),
226 Line::from(""),
227 Line::styled(" Stable hostname (advanced)", theme.style(Role::Muted)),
228 Line::styled(
229 " Start the connector here, then publish the hostname to http://127.0.0.1:8741.",
230 theme.style(Role::Muted),
231 ),
232 Line::from(""),
233 Line::styled(" Public hostname", theme.style(Role::Muted)),
234 Line::from(Span::styled(
235 format!(" {hostname}"),
236 selected(Field::Hostname),
237 )),
238 Line::from(""),
239 Line::styled(" Tunnel token", theme.style(Role::Muted)),
240 Line::from(Span::styled(format!(" {token}"), selected(Field::Token))),
241 Line::from(""),
242 Line::from(Span::styled(
243 " Connect stable tunnel",
244 selected(Field::Connect),
245 )),
246 Line::from(""),
247 ];
248 if let Some(error) = &state.error {
249 lines.push(Line::styled(
250 format!(" {}", terminal_text(error)),
251 theme.style(Role::Error),
252 ));
253 lines.push(Line::from(""));
254 }
255 lines.push(Line::styled(
256 " tab/↑↓ select · enter continue · esc cancel",
257 theme.style(Role::Muted),
258 ));
259 frame.render_widget(
260 Paragraph::new(lines)
261 .block(
262 Block::default()
263 .borders(Borders::ALL)
264 .title(" Cloudflare Tunnel "),
265 )
266 .style(theme.style(Role::Canvas))
267 .wrap(Wrap { trim: false }),
268 frame.area(),
269 );
270}
271
272fn masked_token(token: &str) -> String {
273 let count = token.chars().count();
274 let mut masked = "•".repeat(count.min(24));
275 if count > 24 {
276 masked.push('…');
277 }
278 masked
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn tunnel_token_is_never_rendered() {
287 let token = "secret-tunnel-token";
288
289 assert!(!masked_token(token).contains(token));
290 }
291
292 #[test]
293 fn finish_normalizes_the_hostname_and_moves_the_token() {
294 let mut state = State {
295 field: Field::Connect,
296 hostname: " mobius.example.com ".into(),
297 token: " secret-tunnel-token ".into(),
298 error: None,
299 };
300
301 let CloudflareInit::Named { hostname, token } = state.finish().expect("valid setup") else {
302 panic!("expected named tunnel");
303 };
304
305 assert_eq!(
306 (hostname.as_str(), token.as_str()),
307 ("mobius.example.com", "secret-tunnel-token")
308 );
309 }
310
311 #[test]
312 fn quick_connect_is_the_default() {
313 let mut state = State::new();
314
315 let config = state.finish().expect("quick setup");
316
317 assert!(matches!(config, CloudflareInit::Quick));
318 }
319}