riptide-transfer 1.3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ """Join Server screen: the dialler, redesigned for clarity, usability, and clean keyboard workflow.
2
+
3
+
4
+ - Omit unnecessary text; highlight important headers and active selection.
5
+ - Saved devices rendered as an unordered list instead of buttons.
6
+ - Default selection: first saved device (index 0).
7
+ - Up/Down arrow keys navigate between saved devices.
8
+ - Grey font color by default (#71717A), red for the focused device (#FF6B6B).
9
+ - Hostname input positioned at 3/4 vertically and centered horizontally.
10
+ - Tab cycles between saved devices and the hostname input field.
11
+ - Enter connects with the selected device / entered hostname.
12
+ - Saved devices are unhighlighted (all grey) whenever the hostname input is focused.
13
+ - Modern docked footer with clear shortcut keys.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import socket
18
+ import threading
19
+ from pathlib import Path
20
+
21
+ from textual import events
22
+ from textual.app import ComposeResult
23
+ from textual.containers import Horizontal, Vertical
24
+ from textual.screen import Screen
25
+ from textual.widgets import Button, Input, Label, Static
26
+
27
+ from ..core.config import Peer, add_peer, load_config
28
+ from ..core.focus import focus_first
29
+ from ..core.identity import fingerprint_code
30
+ from ..core.session import join_session, parse_host_input
31
+ from ..theme import BANNER_JOIN
32
+
33
+
34
+ def join_fingerprint_body(fingerprint: str) -> str:
35
+ """Body text for the Join fingerprint approval popup.
36
+
37
+ Shows the 6-digit host code prominently first, then a short
38
+ out-of-band comparison note, then the full fingerprint.
39
+ The code is not a secret and does not replace the full
40
+ fingerprint for sensitive transfers, and that is said in
41
+ the popup itself, not just in the docs.
42
+ """
43
+ code = fingerprint_code(fingerprint) if fingerprint else "------"
44
+ first, rest = fingerprint[:32], fingerprint[32:]
45
+ if fingerprint:
46
+ fp_text = f"Fingerprint:\n[bold #FFFFFF]{first}\n{rest}[/]"
47
+ else:
48
+ fp_text = "[bold #FF6B6B]Fingerprint: missing, cannot authenticate[/]"
49
+ return (
50
+ f"Host code: [bold #FFD166]{code}[/]\n\n"
51
+ f"[dim #A1A1AA]Compare this code with the host out of band, "
52
+ f"by call or message, before Allow. The code is a short form "
53
+ f"of the fingerprint below, not a secret.[/]\n\n"
54
+ f"{fp_text}\n\n"
55
+ f"[dim #A1A1AA]Allow to trust this host?[/]"
56
+ )
57
+
58
+
59
+ def friendly_dial_error(exc: Exception, port: int) -> str:
60
+ """Translate a dial failure into a cause the user can act on.
61
+
62
+ A raw WinError 10060 says nothing about why the connection
63
+ timed out. On a firewall drop, no packet ever reaches the
64
+ host, so the host shows no error at all. The dialler is
65
+ the only place that can warn, and the usual cause is the
66
+ host firewall or router forward for the session port.
67
+ """
68
+ text = str(exc)
69
+ lower = text.lower()
70
+ if "10060" in text or "timed out" in lower or isinstance(exc, TimeoutError):
71
+ return (
72
+ f"Connection timed out (10060). The host did not answer on port {port}. "
73
+ f"On the host PC, check Create Server is open, the firewall rule for port {port} is on, "
74
+ f"and the router forwards TCP {port} to that PC. "
75
+ f"Ask the host to click Create Server again, so Riptide can re-check and open the firewall."
76
+ )
77
+ if "10061" in text or "refused" in lower or isinstance(exc, ConnectionRefusedError):
78
+ return (
79
+ f"Connection refused. The host is not listening on port {port}, "
80
+ f"or Create Server is not open on that PC."
81
+ )
82
+ return text
83
+
84
+
85
+ class SavedDevicesList(Vertical):
86
+ """Focusable unordered list of saved peers."""
87
+
88
+ can_focus = True
89
+
90
+ def __init__(self, peers: list[Peer], **kwargs) -> None:
91
+ super().__init__(**kwargs)
92
+ self.peers = peers
93
+ self.selected_index = 0 if peers else -1
94
+
95
+ def compose(self) -> ComposeResult:
96
+ for i, peer in enumerate(self.peers):
97
+ text = peer.name if peer.name and peer.name != peer.host else peer.host
98
+ yield Static(f"• {text}", id=f"device_item_{i}", classes="device-item")
99
+
100
+ @property
101
+ def selected_device(self) -> Peer | None:
102
+ if 0 <= self.selected_index < len(self.peers):
103
+ return self.peers[self.selected_index]
104
+ return None
105
+
106
+ def _on_focus(self, event: events.Focus) -> None:
107
+ super()._on_focus(event)
108
+ self.refresh_items()
109
+
110
+ def _on_blur(self, event: events.Blur) -> None:
111
+ super()._on_blur(event)
112
+ self.refresh_items()
113
+
114
+ def refresh_items(self) -> None:
115
+ is_focused = self.has_focus
116
+ for i, peer in enumerate(self.peers):
117
+ try:
118
+ st = self.query_one(f"#device_item_{i}", Static)
119
+ text = peer.name if peer.name and peer.name != peer.host else peer.host
120
+ if is_focused and i == self.selected_index:
121
+ st.update(f"[bold #FF6B6B]• {text}[/]")
122
+ else:
123
+ st.update(f"[#D7D7D7]• {text}[/]")
124
+ except Exception:
125
+ pass
126
+
127
+ def move_up(self) -> None:
128
+ if self.peers and self.selected_index > 0:
129
+ self.selected_index -= 1
130
+ self.refresh_items()
131
+ self._scroll_to_selected()
132
+
133
+ def move_down(self) -> None:
134
+ if self.peers and self.selected_index < len(self.peers) - 1:
135
+ self.selected_index += 1
136
+ self.refresh_items()
137
+ self._scroll_to_selected()
138
+
139
+ def _scroll_to_selected(self) -> None:
140
+ try:
141
+ item = self.query_one(f"#device_item_{self.selected_index}", Static)
142
+ item.scroll_visible()
143
+ except Exception:
144
+ pass
145
+
146
+ def on_click(self, event: events.Click) -> None:
147
+ self.focus()
148
+ for i in range(len(self.peers)):
149
+ try:
150
+ w = self.query_one(f"#device_item_{i}", Static)
151
+ if w.region.contains(event.screen_x, event.screen_y):
152
+ self.selected_index = i
153
+ self.refresh_items()
154
+ break
155
+ except Exception:
156
+ pass
157
+
158
+
159
+ class JoinScreen(Screen):
160
+ def __init__(self) -> None:
161
+ super().__init__()
162
+ self.cwd = Path.cwd()
163
+ self.config = load_config()
164
+ self._peers: list[Peer] = [p for p in self.config.peers if p.host]
165
+ self._approve_event = threading.Event()
166
+ self._approved = False
167
+ self._awaiting_approval = False
168
+ self._allow_btn: Button | None = None
169
+ self._deny_btn: Button | None = None
170
+
171
+ def compose(self) -> ComposeResult:
172
+ yield Horizontal(
173
+ Static(BANNER_JOIN, classes="session-header-text", id="session_host_title"),
174
+ Vertical(
175
+ Static(f"Port: {self.config.session_port}", id="peer_client_label"),
176
+ Static("[#E8E8E8]Ready[/] [bold #78DC8C]○[/]", id="peer_status_label"),
177
+ id="session_peer_info",
178
+ ),
179
+ id="session_header_row",
180
+ )
181
+ yield Vertical(
182
+ Vertical(
183
+ Static("[bold #E8E8E8]SAVED DEVICES[/]", classes="create-section-title"),
184
+ SavedDevicesList(self._peers, id="saved_devices")
185
+ if self._peers
186
+ else Static("[dim]No saved devices found[/]", id="empty_devices"),
187
+ id="join_top_section",
188
+ ),
189
+ Vertical(
190
+ Static("HOSTNAME · host address", classes="composer-label"),
191
+ Input(placeholder="Host address: hostname, IPv4, or IPv6", id="host"),
192
+ Static("Enter the address of the device waiting on Create Server", classes="composer-hint"),
193
+ id="join_input_section",
194
+ ),
195
+ Vertical(
196
+ Static("Status: idle", id="status"),
197
+ Button("Join Server", id="join", classes="primary"),
198
+ id="join_bottom_section",
199
+ ),
200
+ id="join_body",
201
+ )
202
+ yield Horizontal(
203
+ Label(r"[bold #E8E8E8]\[↑/↓][/] [dim #FFFFFF]Select[/]", classes="keymap-item keymap-left"),
204
+ Label(r"[bold #E8E8E8]\[Tab][/] [dim #FFFFFF]Switch[/]", classes="keymap-item"),
205
+ Label(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Connect / Allow[/]", classes="keymap-item"),
206
+ Label(r"[bold #E8E8E8]\[Backspace][/] [dim #FFFFFF]Deny[/]", classes="keymap-item"),
207
+ Label(r"[bold #E8E8E8]\[Esc][/] [dim #FFFFFF]Back[/]", classes="keymap-item keymap-right"),
208
+ id="footer_bar",
209
+ )
210
+ # Fingerprint approval as a centered popup overlay, like the
211
+ # download approval request on the session page. The old
212
+ # inline prompt sat in the bottom section and wrapped out
213
+ # of the visible area on narrow screens.
214
+ yield Vertical(
215
+ Vertical(
216
+ Static("🔑 FINGERPRINT", id="fingerprint_modal_title"),
217
+ Static("", id="fingerprint_modal_body"),
218
+ Horizontal(
219
+ Button("Allow", id="allow", classes="allow"),
220
+ Button("Deny", id="deny", classes="deny"),
221
+ id="fingerprint_modal_actions",
222
+ ),
223
+ Static(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Allow[/] [bold #E8E8E8]\[Backspace][/] [dim #FFFFFF]Deny[/]", id="fingerprint_modal_footer"),
224
+ id="fingerprint_modal_card",
225
+ ),
226
+ id="fingerprint_modal_overlay",
227
+ )
228
+
229
+ def _set_allow_visible(self, visible: bool) -> None:
230
+ try:
231
+ overlay = self.query_one("#fingerprint_modal_overlay", Vertical)
232
+ if visible:
233
+ overlay.add_class("active")
234
+ overlay.styles.display = "block"
235
+ if self._allow_btn:
236
+ self._allow_btn.focus()
237
+ else:
238
+ overlay.remove_class("active")
239
+ overlay.styles.display = "none"
240
+ except Exception:
241
+ pass
242
+ try:
243
+ footer = self.query_one("#footer_bar")
244
+ footer.styles.display = "none" if visible else "block"
245
+ except Exception:
246
+ pass
247
+ try:
248
+ self.show_vertical_scrollbar = not visible
249
+ self.styles.overflow_y = "hidden" if visible else "auto"
250
+ except Exception:
251
+ pass
252
+
253
+ for btn in (self._allow_btn, self._deny_btn):
254
+ if btn is not None:
255
+ try:
256
+ btn.styles.display = "block" if visible else "none"
257
+ except Exception:
258
+ pass
259
+
260
+ def _set_status(self, text: str) -> None:
261
+ try:
262
+ self.query_one("#status", Static).update(f"Status: {text}")
263
+ lower = text.lower()
264
+ if "dial" in lower:
265
+ self.query_one("#peer_status_label", Static).update("[#E8E8E8]Dialling[/] [bold #FFD166]○[/]")
266
+ elif "live" in lower or "connected" in lower:
267
+ self.query_one("#peer_status_label", Static).update("[#E8E8E8]Connected[/] [bold #78DC8C]●[/]")
268
+ elif "fail" in lower:
269
+ self.query_one("#peer_status_label", Static).update("[#FF6B6B]Failed[/] [bold #FF6B6B]⊗[/]")
270
+ else:
271
+ self.query_one("#peer_status_label", Static).update("[#E8E8E8]Ready[/] [bold #78DC8C]○[/]")
272
+ except Exception:
273
+ pass
274
+
275
+ def _approve(self, fingerprint: str) -> bool:
276
+ self._approved = False
277
+ self._awaiting_approval = True
278
+ self._approve_event.clear()
279
+ # Show the full fingerprint, split across two lines so it
280
+ # fits the 60-wide modal card instead of wrapping out of
281
+ # view. The first line alone is not enough to trust on.
282
+ # Since v1.3, the 6-digit host code is shown first, so
283
+ # the two people can compare it out of band.
284
+ body = join_fingerprint_body(fingerprint)
285
+ try:
286
+ self.app.call_from_thread(
287
+ self.query_one("#fingerprint_modal_body", Static).update, body
288
+ )
289
+ self.app.call_from_thread(self._set_allow_visible, True)
290
+ except Exception:
291
+ pass
292
+ self._approve_event.wait(timeout=120)
293
+ self._awaiting_approval = False
294
+ try:
295
+ self.app.call_from_thread(self.query_one("#fingerprint_modal_body", Static).update, "")
296
+ self.app.call_from_thread(self._set_allow_visible, False)
297
+ except Exception:
298
+ pass
299
+ return self._approved
300
+
301
+ def on_input_submitted(self, event: Input.Submitted) -> None:
302
+ if event.input.id == "host":
303
+ self._start()
304
+
305
+ def _handle_button_id(self, bid: str) -> bool:
306
+ if bid.startswith("peer_"):
307
+ try:
308
+ idx = int(bid.split("_", 1)[1])
309
+ host = self.config.peers[idx].host
310
+ self.query_one("#host", Input).value = host
311
+ self._set_status(f"Selected past host: {host}")
312
+ except Exception:
313
+ pass
314
+ return True
315
+ if bid == "join":
316
+ self._start()
317
+ return True
318
+ if bid == "allow":
319
+ self._approved = True
320
+ self._approve_event.set()
321
+ self._set_allow_visible(False)
322
+ return True
323
+ if bid == "deny":
324
+ self._approved = False
325
+ self._approve_event.set()
326
+ self._set_allow_visible(False)
327
+ return True
328
+ if bid == "back":
329
+ self.app.pop_screen()
330
+ return True
331
+ return False
332
+
333
+ def on_button_pressed(self, event: Button.Pressed) -> None:
334
+ self._handle_button_id(event.button.id or "")
335
+
336
+ def _start(self, host_override: str | None = None) -> None:
337
+ if host_override:
338
+ text = host_override.strip()
339
+ elif isinstance(self.focused, SavedDevicesList):
340
+ dev = self.query_one(SavedDevicesList).selected_device
341
+ text = dev.host.strip() if dev and dev.host else ""
342
+ else:
343
+ try:
344
+ text = self.query_one("#host", Input).value.strip()
345
+ except Exception:
346
+ text = ""
347
+ if not text and self._peers:
348
+ try:
349
+ dev = self.query_one(SavedDevicesList).selected_device
350
+ text = dev.host.strip() if dev and dev.host else ""
351
+ except Exception:
352
+ pass
353
+ if not text:
354
+ self._set_status("Failed: enter the host's address first.")
355
+ return
356
+ try:
357
+ host = parse_host_input(text)
358
+ except Exception as exc:
359
+ self._set_status(f"Failed: {exc}")
360
+ return
361
+ try:
362
+ self.query_one("#host", Input).value = host
363
+ except Exception:
364
+ pass
365
+ self._set_status(f"Dialling {host}...")
366
+ threading.Thread(target=self._worker, args=(host,), daemon=True).start()
367
+
368
+ def _worker(self, host: str) -> None:
369
+ try:
370
+ known = self.config.peer_for(host)
371
+ known_fp = known.fingerprint if known else ""
372
+ session = join_session(
373
+ host,
374
+ self.config.session_port,
375
+ self.cwd,
376
+ device=socket.gethostname(),
377
+ known_fingerprint=known_fp,
378
+ approve_fingerprint=None if known_fp else self._approve,
379
+ )
380
+ if known is None and getattr(session, "peer_fingerprint", ""):
381
+ add_peer(self.config, Peer(name=host, host=host, fingerprint=session.peer_fingerprint))
382
+ self.app.call_from_thread(self._set_status, "Session live, opening...")
383
+ from .session_screen import SessionScreen
384
+ self.app.call_from_thread(self.app.push_screen, SessionScreen(session, "client"))
385
+ except Exception as exc:
386
+ self.app.call_from_thread(
387
+ self._set_status, f"Failed: {friendly_dial_error(exc, self.config.session_port)}"
388
+ )
389
+
390
+ def on_mount(self) -> None:
391
+ try:
392
+ self._allow_btn = self.query_one("#allow", Button)
393
+ self._deny_btn = self.query_one("#deny", Button)
394
+ except Exception:
395
+ pass
396
+ self._set_allow_visible(False)
397
+ if self._peers:
398
+ try:
399
+ self.query_one(SavedDevicesList).focus()
400
+ except Exception:
401
+ focus_first(self)
402
+ else:
403
+ try:
404
+ self.query_one("#host", Input).focus()
405
+ except Exception:
406
+ focus_first(self)
407
+
408
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
409
+ try:
410
+ if self._peers:
411
+ self.query_one(SavedDevicesList).refresh_items()
412
+ except Exception:
413
+ pass
414
+
415
+ def on_descendant_blur(self, event: events.DescendantBlur) -> None:
416
+ try:
417
+ if self._peers:
418
+ self.query_one(SavedDevicesList).refresh_items()
419
+ except Exception:
420
+ pass
421
+
422
+ async def on_key(self, event) -> None:
423
+ if event.key == "escape":
424
+ self.app.pop_screen()
425
+ return
426
+ focused = self.focused
427
+
428
+ # Tab cycling between SavedDevicesList and Input
429
+ if event.key in ("tab", "back_tab", "shift+tab"):
430
+ if isinstance(focused, Input):
431
+ if self._peers:
432
+ event.stop()
433
+ self.query_one(SavedDevicesList).focus()
434
+ return
435
+ elif isinstance(focused, SavedDevicesList):
436
+ event.stop()
437
+ self.query_one("#host", Input).focus()
438
+ return
439
+
440
+ # Up/Down navigation between saved devices
441
+ if event.key == "up":
442
+ if isinstance(focused, SavedDevicesList):
443
+ event.stop()
444
+ focused.move_up()
445
+ return
446
+ if isinstance(focused, Input):
447
+ event.stop()
448
+ return
449
+ elif event.key == "down":
450
+ if isinstance(focused, SavedDevicesList):
451
+ event.stop()
452
+ focused.move_down()
453
+ return
454
+ if isinstance(focused, Input):
455
+ event.stop()
456
+ return
457
+
458
+ # Backspace: deny when awaiting approval and host input is not focused
459
+ if event.key == "backspace" and self._awaiting_approval and not isinstance(focused, Input):
460
+ event.stop()
461
+ self._handle_button_id("deny")
462
+ return
463
+
464
+ # Enter: allow when awaiting approval; else connect with selected device or entered hostname
465
+ if event.key == "enter":
466
+ if self._awaiting_approval and not isinstance(focused, Button):
467
+ event.stop()
468
+ self._handle_button_id("allow")
469
+ return
470
+ if isinstance(focused, SavedDevicesList):
471
+ event.stop()
472
+ dev = focused.selected_device
473
+ if dev and dev.host:
474
+ self._start(dev.host)
475
+ return
476
+ if isinstance(focused, Input):
477
+ event.stop()
478
+ self._start()
479
+ return
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.screen import Screen
6
+ from textual.widgets import Label, OptionList, Static
7
+ from textual.widgets.option_list import Option
8
+
9
+ from .. import __version__
10
+ from ..core.focus import focus_first, handle_arrow_key
11
+ from ..theme import HEADER_BANNER, TAGLINES
12
+
13
+
14
+ class MenuScreen(Screen):
15
+ def compose(self) -> ComposeResult:
16
+ # Create Server listens, Join Server dials. Create stays on top.
17
+ options = [
18
+ Option("❯ Create Server Wait for incoming session (Requires Port Forwarding)", id="create"),
19
+ Option(" Join Server Dial out to peer (Required for CGNAT)", id="join"),
20
+ ]
21
+ yield Vertical(
22
+ Static(HEADER_BANNER, classes="header"),
23
+ Static(f"v{__version__}", classes="version-tag"),
24
+ Static(TAGLINES["home"], classes="subheader"),
25
+ OptionList(*options, id="menu_list"),
26
+ )
27
+ yield Horizontal(
28
+ Label(r"[bold #E8E8E8]\[↑/↓][/] [dim #FFFFFF]Navigate[/]", classes="keymap-item keymap-left"),
29
+ Label(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Select[/]", classes="keymap-item"),
30
+ Label(r"[bold #E8E8E8]\[Esc][/] [dim #FFFFFF]Quit[/]", classes="keymap-item keymap-right"),
31
+ id="footer_bar",
32
+ )
33
+
34
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
35
+ from .create import CreateScreen
36
+ from .join import JoinScreen
37
+ from ..core.config import load_config, save_config
38
+
39
+ opt_id = event.option_id
40
+ if opt_id == "create":
41
+ config = load_config()
42
+ port = config.session_port
43
+ # On clicking Create Server, check the OS firewall for the
44
+ # session port and open it automatically if it is closed.
45
+ # This is the only place that may prompt for elevation:
46
+ # UAC on Windows, pkexec on Linux. The plain open runs
47
+ # first (admin on Windows, passwordless sudo on Linux),
48
+ # so no prompt appears when it is not needed. The
49
+ # 20 second watch loop on the Create page never prompts.
50
+ # The Create page then reports opened, cancelled, or
51
+ # failed, with the manual command when opening failed.
52
+ from ..core.firewall import ensure_firewall_port_with_elevation
53
+
54
+ was_open, opened, is_open_now, cancelled = ensure_firewall_port_with_elevation(port)
55
+ config.firewall_port_open = is_open_now
56
+ save_config(config)
57
+
58
+ self.app.push_screen(
59
+ CreateScreen(
60
+ port_was_closed=not was_open,
61
+ firewall_opened=opened,
62
+ firewall_cancelled=cancelled,
63
+ )
64
+ )
65
+ elif opt_id == "join":
66
+ self.app.push_screen(JoinScreen())
67
+
68
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
69
+ try:
70
+ opt_list = self.query_one("#menu_list", OptionList)
71
+ prompts = [
72
+ ("create", "Create Server", "Wait for incoming session (Requires Port Forwarding)"),
73
+ ("join", "Join Server", "Dial out to peer (Required for CGNAT)"),
74
+ ]
75
+ for idx, (oid, title, desc) in enumerate(prompts):
76
+ prefix = "❯ " if idx == event.option_index else " "
77
+ opt_list.replace_option_prompt_at_index(idx, f"{prefix}{title:<18} {desc}")
78
+ except Exception:
79
+ pass
80
+
81
+ def on_mount(self) -> None:
82
+ try:
83
+ self.query_one("#menu_list", OptionList).focus()
84
+ except Exception:
85
+ focus_first(self)
86
+
87
+ async def on_key(self, event) -> None:
88
+ if event.key in ("escape", "q"):
89
+ self.app.exit()
90
+ event.stop()
91
+ return
92
+ if event.key in ("down", "up") and not isinstance(self.focused, OptionList):
93
+ if handle_arrow_key(self, event.key):
94
+ event.stop()