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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
riptide/screens/join.py
ADDED
|
@@ -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
|
riptide/screens/menu.py
ADDED
|
@@ -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()
|