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/core/transfer.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Transfer progress for a whole transfer batch.
|
|
2
|
+
|
|
3
|
+
Speed is an exponential moving average over properly spaced
|
|
4
|
+
updates, so a burst of callbacks cannot inflate it.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_bytes(n: int) -> str:
|
|
14
|
+
n = float(n)
|
|
15
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
16
|
+
if n < 1024 or unit == "TB":
|
|
17
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
|
18
|
+
n /= 1024
|
|
19
|
+
return f"{n} B"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_eta(seconds: float | None) -> str:
|
|
23
|
+
if seconds is None or seconds != seconds or seconds == float("inf"):
|
|
24
|
+
return "--"
|
|
25
|
+
seconds = int(seconds)
|
|
26
|
+
if seconds < 60:
|
|
27
|
+
return f"{seconds}s"
|
|
28
|
+
minutes, secs = divmod(seconds, 60)
|
|
29
|
+
if minutes < 60:
|
|
30
|
+
return f"{minutes}m {secs:02d}s"
|
|
31
|
+
hours, minutes = divmod(minutes, 60)
|
|
32
|
+
return f"{hours}h {minutes:02d}m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TransferProgress:
|
|
36
|
+
"""EMA speed, remaining size, and ETA for one whole transfer."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, total: int):
|
|
39
|
+
self.total = int(total)
|
|
40
|
+
self.sent = 0
|
|
41
|
+
self.started = time.monotonic()
|
|
42
|
+
self._last_time = self.started
|
|
43
|
+
self._last_sent = 0
|
|
44
|
+
self.speed_ema = 0.0
|
|
45
|
+
|
|
46
|
+
def update(self, sent: int) -> None:
|
|
47
|
+
now = time.monotonic()
|
|
48
|
+
self.sent = int(sent)
|
|
49
|
+
dt = now - self._last_time
|
|
50
|
+
# Guard against burst callbacks: if a caller floods updates
|
|
51
|
+
# faster than the UI can process them, dt collapses to
|
|
52
|
+
# microseconds while sent still jumps a full chunk, which
|
|
53
|
+
# inflates speed by several times. Below 10ms, keep the
|
|
54
|
+
# sent count for the progress bar, but do not feed the
|
|
55
|
+
# speed EMA; the next, properly spaced update will then
|
|
56
|
+
# average over the whole interval instead.
|
|
57
|
+
if dt < 0.01:
|
|
58
|
+
return
|
|
59
|
+
instant = (self.sent - self._last_sent) / dt
|
|
60
|
+
if self.speed_ema == 0:
|
|
61
|
+
self.speed_ema = instant
|
|
62
|
+
else:
|
|
63
|
+
self.speed_ema = 0.7 * self.speed_ema + 0.3 * instant
|
|
64
|
+
self._last_time = now
|
|
65
|
+
self._last_sent = self.sent
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def remaining(self) -> int:
|
|
69
|
+
return max(0, self.total - self.sent)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def fraction(self) -> float:
|
|
73
|
+
if self.total <= 0:
|
|
74
|
+
return 1.0
|
|
75
|
+
return min(1.0, self.sent / self.total)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def eta_seconds(self) -> float | None:
|
|
79
|
+
if self.speed_ema <= 0:
|
|
80
|
+
return None
|
|
81
|
+
return self.remaining / self.speed_ema
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual screens for riptide."""
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Create Server page: the listener.
|
|
2
|
+
|
|
3
|
+
Waits for an incoming connection from the client.
|
|
4
|
+
- Omits unnecessary/redundant text.
|
|
5
|
+
- Prominently highlights the incoming connection request in a dedicated card.
|
|
6
|
+
- Enter key to accept the connection, Backspace to deny.
|
|
7
|
+
- Blocks up/down arrow keys.
|
|
8
|
+
- Buttons (Accept/Deny) are equal sized with green/red backgrounds.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.containers import Horizontal, Vertical
|
|
18
|
+
from textual.screen import Screen
|
|
19
|
+
from textual.widgets import Button, Label, Static
|
|
20
|
+
|
|
21
|
+
from ..core.config import Peer, add_peer, load_config
|
|
22
|
+
from ..core.identity import cert_der_from_pem, cert_fingerprint, ensure_identity, fingerprint_code
|
|
23
|
+
from ..core.nat import public_ip, public_ipv6
|
|
24
|
+
from ..core.session import HostListener
|
|
25
|
+
from ..theme import BANNER_CREATE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CreateScreen(Screen):
|
|
29
|
+
def __init__(self, port_was_closed: bool = False, firewall_opened: bool = False, firewall_cancelled: bool = False) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.port_was_closed = port_was_closed
|
|
32
|
+
self.firewall_opened = firewall_opened
|
|
33
|
+
self.firewall_cancelled = firewall_cancelled
|
|
34
|
+
# Watchdog state: a failed open is not retried every 20 s,
|
|
35
|
+
# it is retried only after the port becomes open again and
|
|
36
|
+
# then closes again. The banner kind tracks whether a
|
|
37
|
+
# failure banner is currently shown, so it can be cleared
|
|
38
|
+
# when the port opens again.
|
|
39
|
+
self._firewall_banner_kind: str | None = (
|
|
40
|
+
"success" if port_was_closed and firewall_opened
|
|
41
|
+
else "cancelled" if port_was_closed and firewall_cancelled
|
|
42
|
+
else "failed" if port_was_closed
|
|
43
|
+
else None
|
|
44
|
+
)
|
|
45
|
+
self._firewall_watch_failed = port_was_closed and not firewall_opened
|
|
46
|
+
self.cwd = Path.cwd()
|
|
47
|
+
self.config = load_config()
|
|
48
|
+
self._pending: tuple[str, str, str, str] | None = None
|
|
49
|
+
self._approve_event = threading.Event()
|
|
50
|
+
self._approved = False
|
|
51
|
+
self._awaiting_approval = False
|
|
52
|
+
self._listener: HostListener | None = None
|
|
53
|
+
self._stopped = threading.Event()
|
|
54
|
+
self._allow_btn: Button | None = None
|
|
55
|
+
self._deny_btn: Button | None = None
|
|
56
|
+
|
|
57
|
+
def compose(self) -> ComposeResult:
|
|
58
|
+
yield Static("", id="router_banner")
|
|
59
|
+
# Blank row in place of the banner's old bottom margin.
|
|
60
|
+
# A margin row belongs to no widget, so the menu's
|
|
61
|
+
# RIPTIDE logo underneath showed through it. This blank
|
|
62
|
+
# widget writes spaces into that row, covering the logo,
|
|
63
|
+
# while staying transparent itself. It is shown only
|
|
64
|
+
# together with the banner, so the no-banner layout
|
|
65
|
+
# does not gain an extra blank row.
|
|
66
|
+
yield Static("", id="router_banner_spacer")
|
|
67
|
+
# Plain Vertical, not VerticalScroll: this page is only a
|
|
68
|
+
# header and a waiting card, it never needs scrolling, and
|
|
69
|
+
# the scroll container was showing a vertical scrollbar
|
|
70
|
+
# even when the terminal was tall enough for the content.
|
|
71
|
+
yield Vertical(
|
|
72
|
+
Horizontal(
|
|
73
|
+
Static(BANNER_CREATE, classes="session-header-text", id="session_host_title"),
|
|
74
|
+
Vertical(
|
|
75
|
+
Static(f"Port: {self.config.session_port}", id="peer_client_label"),
|
|
76
|
+
Static("[#E8E8E8]Listening[/] [bold #FFD166]○[/]", id="peer_status_label"),
|
|
77
|
+
id="session_peer_info",
|
|
78
|
+
),
|
|
79
|
+
id="session_header_row",
|
|
80
|
+
),
|
|
81
|
+
Vertical(
|
|
82
|
+
Static("Your public address: checking…", id="public_ip"),
|
|
83
|
+
Static("Your IPv6 address: checking…", id="public_ipv6"),
|
|
84
|
+
Static("My code: checking…", id="my_code"),
|
|
85
|
+
Static(
|
|
86
|
+
f"[dim]○[/] [bold #E8E8E8]Waiting for client on port {self.config.session_port}...[/]\n\n"
|
|
87
|
+
f"[dim]Share your IP or hostname with the client to initiate connection.[/]",
|
|
88
|
+
id="incoming",
|
|
89
|
+
),
|
|
90
|
+
Horizontal(
|
|
91
|
+
Button("Accept", id="allow", classes="allow"),
|
|
92
|
+
Button("Deny", id="deny", classes="deny"),
|
|
93
|
+
id="create_actions",
|
|
94
|
+
),
|
|
95
|
+
id="incoming_card",
|
|
96
|
+
),
|
|
97
|
+
id="create_content",
|
|
98
|
+
)
|
|
99
|
+
yield Horizontal(
|
|
100
|
+
Label(r"[bold #E8E8E8]\[Enter][/] [dim #FFFFFF]Accept[/]", classes="keymap-item keymap-left"),
|
|
101
|
+
Label(r"[bold #E8E8E8]\[Backspace][/] [dim #FFFFFF]Deny[/]", classes="keymap-item"),
|
|
102
|
+
Label(r"[bold #E8E8E8]\[Space][/] [dim #FFFFFF]Select[/]", classes="keymap-item"),
|
|
103
|
+
Label(r"[bold #E8E8E8]\[Esc][/] [dim #FFFFFF]Back[/]", classes="keymap-item keymap-right"),
|
|
104
|
+
id="footer_bar",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _show_router_banner(self) -> None:
|
|
108
|
+
try:
|
|
109
|
+
from ..core.firewall import manual_firewall_command, windows_block_rule_exists
|
|
110
|
+
|
|
111
|
+
banner = self.query_one("#router_banner", Static)
|
|
112
|
+
port = self.config.session_port
|
|
113
|
+
if self.firewall_opened:
|
|
114
|
+
self._firewall_banner_kind = "success"
|
|
115
|
+
banner.update(
|
|
116
|
+
f"Firewall: port {port} opened automatically. "
|
|
117
|
+
f"Please also forward port {port} (TCP) in your router settings."
|
|
118
|
+
)
|
|
119
|
+
elif windows_block_rule_exists(port):
|
|
120
|
+
self._firewall_banner_kind = "failed"
|
|
121
|
+
banner.update(
|
|
122
|
+
f"Firewall: port {port} is blocked by a block rule. "
|
|
123
|
+
f"An allow rule alone will not open it. "
|
|
124
|
+
f"Remove or disable the block rule for port {port} in Windows Defender Firewall, Advanced Settings, Inbound Rules. "
|
|
125
|
+
f"Then also forward port {port} (TCP) in your router settings."
|
|
126
|
+
)
|
|
127
|
+
elif self.firewall_cancelled:
|
|
128
|
+
self._firewall_banner_kind = "cancelled"
|
|
129
|
+
command = manual_firewall_command(port)
|
|
130
|
+
banner.update(
|
|
131
|
+
f"Firewall: port {port} was not opened because you cancelled the admin prompt. "
|
|
132
|
+
f"Click Create Server again to try again, or run as administrator, or with sudo: {command} "
|
|
133
|
+
f"Then also forward port {port} (TCP) in your router settings."
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
self._firewall_banner_kind = "failed"
|
|
137
|
+
command = manual_firewall_command(port)
|
|
138
|
+
banner.update(
|
|
139
|
+
f"Firewall: port {port} was closed and could not be opened automatically. "
|
|
140
|
+
f"Run as administrator, or with sudo: {command} "
|
|
141
|
+
f"Then also forward port {port} (TCP) in your router settings."
|
|
142
|
+
)
|
|
143
|
+
banner.styles.display = "block"
|
|
144
|
+
try:
|
|
145
|
+
self.query_one("#router_banner_spacer", Static).styles.display = "block"
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def _set_firewall_banner(self, opened: bool) -> None:
|
|
152
|
+
self.port_was_closed = True
|
|
153
|
+
self.firewall_opened = opened
|
|
154
|
+
self.firewall_cancelled = False
|
|
155
|
+
self._show_router_banner()
|
|
156
|
+
|
|
157
|
+
def _clear_firewall_banner(self) -> None:
|
|
158
|
+
try:
|
|
159
|
+
banner = self.query_one("#router_banner", Static)
|
|
160
|
+
banner.update("")
|
|
161
|
+
banner.styles.display = "none"
|
|
162
|
+
self.query_one("#router_banner_spacer", Static).styles.display = "none"
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
self._firewall_banner_kind = None
|
|
166
|
+
self.port_was_closed = False
|
|
167
|
+
self.firewall_cancelled = False
|
|
168
|
+
|
|
169
|
+
def _firewall_watch(self) -> None:
|
|
170
|
+
"""Re-check the firewall while Create Server waits.
|
|
171
|
+
|
|
172
|
+
The menu check only runs when Create Server is clicked.
|
|
173
|
+
If the rule is turned off after that, the host hears
|
|
174
|
+
nothing, because a firewall drop never reaches the app,
|
|
175
|
+
while the client times out after 30 s. This loop checks
|
|
176
|
+
every 20 s, tries to open a newly closed port, and warns
|
|
177
|
+
on the Create page when opening fails. It never prompts
|
|
178
|
+
for elevation: it uses the plain ensure_firewall_port,
|
|
179
|
+
not the menu elevation path, so a cancelled prompt is
|
|
180
|
+
never re-prompted from here.
|
|
181
|
+
"""
|
|
182
|
+
from ..core.firewall import check_firewall_port, ensure_firewall_port
|
|
183
|
+
|
|
184
|
+
while not self._stopped.wait(timeout=20):
|
|
185
|
+
try:
|
|
186
|
+
port = self.config.session_port
|
|
187
|
+
if check_firewall_port(port):
|
|
188
|
+
self._firewall_watch_failed = False
|
|
189
|
+
if self._firewall_banner_kind in ("failed", "cancelled"):
|
|
190
|
+
try:
|
|
191
|
+
self.app.call_from_thread(self._clear_firewall_banner)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
continue
|
|
195
|
+
if self._firewall_watch_failed:
|
|
196
|
+
continue
|
|
197
|
+
_was_open, opened, is_open_now = ensure_firewall_port(port)
|
|
198
|
+
if is_open_now:
|
|
199
|
+
try:
|
|
200
|
+
self.app.call_from_thread(self._set_firewall_banner, True)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
else:
|
|
204
|
+
self._firewall_watch_failed = True
|
|
205
|
+
try:
|
|
206
|
+
self.app.call_from_thread(self._set_firewall_banner, False)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _update_public_ip(self, ip: str | None) -> None:
|
|
214
|
+
try:
|
|
215
|
+
if ip:
|
|
216
|
+
self.query_one("#public_ip", Static).update(
|
|
217
|
+
f"Your public address: [bold #FF6B6B]{ip}[/]"
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
self.query_one("#public_ip", Static).update(
|
|
221
|
+
"[dim]Your public address: could not be determined (check internet)[/]"
|
|
222
|
+
)
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def _update_public_ipv6(self, ip: str | None) -> None:
|
|
227
|
+
# Shown only when the host has a public IPv6 address; with
|
|
228
|
+
# none, the row hides instead of showing a dead line.
|
|
229
|
+
try:
|
|
230
|
+
widget = self.query_one("#public_ipv6", Static)
|
|
231
|
+
if ip:
|
|
232
|
+
widget.styles.display = "block"
|
|
233
|
+
widget.update(f"Your IPv6 address: [bold #FF6B6B]{ip}[/]")
|
|
234
|
+
else:
|
|
235
|
+
widget.styles.display = "none"
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
def _fetch_public_ip(self) -> None:
|
|
240
|
+
try:
|
|
241
|
+
ip = public_ip()
|
|
242
|
+
except Exception:
|
|
243
|
+
ip = None
|
|
244
|
+
try:
|
|
245
|
+
ip6 = public_ipv6()
|
|
246
|
+
except Exception:
|
|
247
|
+
ip6 = None
|
|
248
|
+
try:
|
|
249
|
+
self.app.call_from_thread(self._update_public_ip, ip)
|
|
250
|
+
self.app.call_from_thread(self._update_public_ipv6, ip6)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def _update_my_code(self, code: str | None) -> None:
|
|
255
|
+
try:
|
|
256
|
+
if code:
|
|
257
|
+
self.query_one("#my_code", Static).update(
|
|
258
|
+
f"My code: [bold #FFD166]{code}[/]"
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
self.query_one("#my_code", Static).update(
|
|
262
|
+
"[dim]My code: could not be determined[/]"
|
|
263
|
+
)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def _fetch_my_code(self) -> None:
|
|
268
|
+
# Local identity only, no network. Shown on the Create
|
|
269
|
+
# page at all times, so the host can answer when the
|
|
270
|
+
# dialler asks, is your code XXX-XXX, without needing
|
|
271
|
+
# a connection first.
|
|
272
|
+
try:
|
|
273
|
+
_key, cert_path = ensure_identity()
|
|
274
|
+
code = fingerprint_code(cert_fingerprint(cert_der_from_pem(cert_path)))
|
|
275
|
+
except Exception:
|
|
276
|
+
code = None
|
|
277
|
+
try:
|
|
278
|
+
self.app.call_from_thread(self._update_my_code, code)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
def _set_allow_visible(self, visible: bool) -> None:
|
|
283
|
+
try:
|
|
284
|
+
actions = self.query_one("#create_actions", Horizontal)
|
|
285
|
+
actions.styles.display = "block" if visible else "none"
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
for btn in (self._allow_btn, self._deny_btn):
|
|
289
|
+
if btn is not None:
|
|
290
|
+
try:
|
|
291
|
+
btn.styles.display = "block" if visible else "none"
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def _set_status(self, text: str) -> None:
|
|
296
|
+
# Never clobber an active incoming-connection card with a
|
|
297
|
+
# background status message. A queued worker failure from a
|
|
298
|
+
# stale listener must not overwrite a decision the user is
|
|
299
|
+
# looking at.
|
|
300
|
+
try:
|
|
301
|
+
card = self.query_one("#incoming_card", Vertical)
|
|
302
|
+
if "active" in card.classes:
|
|
303
|
+
return
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
try:
|
|
307
|
+
self.query_one("#incoming", Static).update(text)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def _show_incoming_connection(self, device: str, fingerprint: str, status: str = "new", old_fingerprint: str = "") -> None:
|
|
312
|
+
try:
|
|
313
|
+
card = self.query_one("#incoming_card", Vertical)
|
|
314
|
+
card.add_class("active")
|
|
315
|
+
if fingerprint:
|
|
316
|
+
fp_text = f"\n[dim]Fingerprint: {fingerprint[:32]}...[/]"
|
|
317
|
+
code_text = f"\n[bold #FFD166]Peer code: {fingerprint_code(fingerprint)}[/]"
|
|
318
|
+
else:
|
|
319
|
+
# This should not happen in v1.1, because an empty
|
|
320
|
+
# client fingerprint aborts before approval. Show
|
|
321
|
+
# it explicitly if it ever does, instead of hiding it.
|
|
322
|
+
fp_text = "\n[bold #FF6B6B]Fingerprint: missing, cannot authenticate[/]"
|
|
323
|
+
code_text = "\n[bold #FF6B6B]Peer code: ------[/]"
|
|
324
|
+
if status == "known":
|
|
325
|
+
status_text = "\n[bold #78DC8C]Known peer: fingerprint matches saved[/]"
|
|
326
|
+
question = "Accept connection from this known peer?"
|
|
327
|
+
elif status == "changed":
|
|
328
|
+
old_text = f"\n[dim]Old fingerprint: {old_fingerprint[:32]}...[/]" if old_fingerprint else ""
|
|
329
|
+
old_code_text = f"\n[dim]Old peer code: {fingerprint_code(old_fingerprint)}[/]" if old_fingerprint else ""
|
|
330
|
+
status_text = (
|
|
331
|
+
"\n[bold #FF6B6B]WARNING: Fingerprint changed for this device[/]"
|
|
332
|
+
f"{old_text}{old_code_text}"
|
|
333
|
+
"\n[#E8E8E8]Accept only if this peer reinstalled Riptide or deleted its identity. Accepting here replaces the saved fingerprint.[/]"
|
|
334
|
+
)
|
|
335
|
+
question = "Replace saved fingerprint and accept?"
|
|
336
|
+
else:
|
|
337
|
+
status_text = "\n[bold #FFD166]New peer: first time, compare peer code with the peer out of band before Accept[/]"
|
|
338
|
+
question = "Accept connection from this peer?"
|
|
339
|
+
msg = (
|
|
340
|
+
f"[bold #78DC8C]● INCOMING CONNECTION REQUEST[/]\n\n"
|
|
341
|
+
f"Peer Device: [bold #FFFFFF]{device}[/]{fp_text}{code_text}{status_text}\n\n"
|
|
342
|
+
f"[#E8E8E8]{question}[/]"
|
|
343
|
+
)
|
|
344
|
+
self.query_one("#incoming", Static).update(msg)
|
|
345
|
+
self._set_allow_visible(True)
|
|
346
|
+
if self._allow_btn is not None:
|
|
347
|
+
self._allow_btn.focus()
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
def _reset_incoming_card(self) -> None:
|
|
352
|
+
try:
|
|
353
|
+
card = self.query_one("#incoming_card", Vertical)
|
|
354
|
+
card.remove_class("active")
|
|
355
|
+
self._set_allow_visible(False)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def _approve(self, device: str, fingerprint: str, status: str = "new", old_fingerprint: str = "") -> bool:
|
|
360
|
+
self._pending = (device, fingerprint, status, old_fingerprint)
|
|
361
|
+
self._approved = False
|
|
362
|
+
self._awaiting_approval = True
|
|
363
|
+
self._approve_event.clear()
|
|
364
|
+
try:
|
|
365
|
+
self.app.call_from_thread(self._show_incoming_connection, device, fingerprint, status, old_fingerprint)
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
self._approve_event.wait(timeout=300)
|
|
369
|
+
self._awaiting_approval = False
|
|
370
|
+
try:
|
|
371
|
+
self.app.call_from_thread(self._reset_incoming_card)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
return self._approved
|
|
375
|
+
|
|
376
|
+
def _handle_button_id(self, bid: str) -> bool:
|
|
377
|
+
if bid == "allow":
|
|
378
|
+
self._approved = True
|
|
379
|
+
self._approve_event.set()
|
|
380
|
+
self._reset_incoming_card()
|
|
381
|
+
return True
|
|
382
|
+
if bid == "deny":
|
|
383
|
+
self._approved = False
|
|
384
|
+
self._approve_event.set()
|
|
385
|
+
self._reset_incoming_card()
|
|
386
|
+
return True
|
|
387
|
+
if bid == "back":
|
|
388
|
+
if self._listener is not None:
|
|
389
|
+
self._listener.stop()
|
|
390
|
+
self.app.pop_screen()
|
|
391
|
+
return True
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
395
|
+
self._handle_button_id(event.button.id or "")
|
|
396
|
+
|
|
397
|
+
def on_mount(self) -> None:
|
|
398
|
+
try:
|
|
399
|
+
self._allow_btn = self.query_one("#allow", Button)
|
|
400
|
+
self._deny_btn = self.query_one("#deny", Button)
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
self._set_allow_visible(False)
|
|
404
|
+
# Public IP, same timing, so the client has something to dial.
|
|
405
|
+
threading.Thread(target=self._fetch_public_ip, daemon=True).start()
|
|
406
|
+
threading.Thread(target=self._fetch_my_code, daemon=True).start()
|
|
407
|
+
threading.Thread(target=self._worker, daemon=True).start()
|
|
408
|
+
if self.port_was_closed:
|
|
409
|
+
self._show_router_banner()
|
|
410
|
+
# Re-check while waiting, so turning the rule off after
|
|
411
|
+
# clicking Create Server still warns and tries to open it.
|
|
412
|
+
threading.Thread(target=self._firewall_watch, daemon=True).start()
|
|
413
|
+
|
|
414
|
+
def on_unmount(self) -> None:
|
|
415
|
+
# Free the listener port when leaving, otherwise a later
|
|
416
|
+
# Create Server page in the same process cannot bind it.
|
|
417
|
+
# Set the flag first, so a worker that has not bound yet
|
|
418
|
+
# aborts instead of leaking a listener after this screen is gone.
|
|
419
|
+
self._stopped.set()
|
|
420
|
+
if self._listener is not None:
|
|
421
|
+
try:
|
|
422
|
+
self._listener.stop()
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
def _worker(self) -> None:
|
|
427
|
+
# Listen for clients until the screen is left. After each
|
|
428
|
+
# session ends, listen again, so a peer can rejoin without
|
|
429
|
+
# restarting Create Server. A denied peer still stops the
|
|
430
|
+
# listener, as before: denying is a deliberate stop, while
|
|
431
|
+
# a session ending is not.
|
|
432
|
+
while not self._stopped.is_set():
|
|
433
|
+
try:
|
|
434
|
+
self._pending = None
|
|
435
|
+
self._listener = HostListener(
|
|
436
|
+
self.config.session_port, self.cwd, device=socket.gethostname(),
|
|
437
|
+
approve=self._approve,
|
|
438
|
+
)
|
|
439
|
+
if self._stopped.is_set():
|
|
440
|
+
return
|
|
441
|
+
session = self._listener.wait_for_client()
|
|
442
|
+
if self._pending is not None:
|
|
443
|
+
# _pending is (device, fingerprint, status, old_fp).
|
|
444
|
+
# Keep the dial-back peer save separate from the
|
|
445
|
+
# host trusted client save, which HostListener
|
|
446
|
+
# already did after approval.
|
|
447
|
+
try:
|
|
448
|
+
device, fp, _status, _old_fp = self._pending
|
|
449
|
+
except Exception:
|
|
450
|
+
device, fp = self._pending[0], self._pending[1]
|
|
451
|
+
if self.config.peer_for(device) is None:
|
|
452
|
+
add_peer(self.config, Peer(name=device, host=device, fingerprint=fp))
|
|
453
|
+
self.app.call_from_thread(self._set_status, "[bold #78DC8C]Connection accepted! Opening session...[/]")
|
|
454
|
+
from .session_screen import SessionScreen
|
|
455
|
+
self.app.call_from_thread(self.app.push_screen, SessionScreen(session, "host"))
|
|
456
|
+
# The session runs on its own screen. Wait here until
|
|
457
|
+
# it ends, then listen again.
|
|
458
|
+
while not session.wait_ended(timeout=0.5):
|
|
459
|
+
if self._stopped.is_set():
|
|
460
|
+
return
|
|
461
|
+
if self._stopped.is_set():
|
|
462
|
+
return
|
|
463
|
+
self.app.call_from_thread(
|
|
464
|
+
self._set_status,
|
|
465
|
+
f"[dim]Peer disconnected.[/] [bold #E8E8E8]Waiting for client on port {self.config.session_port}...[/]",
|
|
466
|
+
)
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
# Do not overwrite an incoming-connection card that is
|
|
469
|
+
# already on screen; a worker failure cannot happen while
|
|
470
|
+
# an approval is pending in the real flow.
|
|
471
|
+
if self._awaiting_approval:
|
|
472
|
+
return
|
|
473
|
+
if self._stopped.is_set():
|
|
474
|
+
return
|
|
475
|
+
try:
|
|
476
|
+
self.app.call_from_thread(self._set_status, f"[bold #FF6B6B]Failed: {exc}[/]")
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
async def on_key(self, event) -> None:
|
|
482
|
+
if event.key == "escape":
|
|
483
|
+
self._stopped.set()
|
|
484
|
+
if self._listener is not None:
|
|
485
|
+
self._listener.stop()
|
|
486
|
+
self.app.pop_screen()
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
# Up/down arrow keys are blocked
|
|
490
|
+
if event.key in ("up", "down"):
|
|
491
|
+
event.stop()
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# Backspace: deny connection
|
|
495
|
+
if event.key == "backspace" and self._awaiting_approval:
|
|
496
|
+
event.stop()
|
|
497
|
+
self._handle_button_id("deny")
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# Enter: accept connection
|
|
501
|
+
if event.key == "enter" and self._awaiting_approval:
|
|
502
|
+
event.stop()
|
|
503
|
+
self._handle_button_id("allow")
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Space: activate focused button, or accept if awaiting
|
|
507
|
+
if event.key == "space":
|
|
508
|
+
focused = self.focused
|
|
509
|
+
if isinstance(focused, Button):
|
|
510
|
+
event.stop()
|
|
511
|
+
self._handle_button_id(focused.id or "")
|
|
512
|
+
return
|
|
513
|
+
if self._awaiting_approval:
|
|
514
|
+
event.stop()
|
|
515
|
+
self._handle_button_id("allow")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Left / Right / Tab to switch focus between Accept and Deny buttons
|
|
519
|
+
if event.key in ("left", "right", "tab"):
|
|
520
|
+
event.stop()
|
|
521
|
+
if self._awaiting_approval:
|
|
522
|
+
if self.focused == self._deny_btn and self._allow_btn is not None:
|
|
523
|
+
self._allow_btn.focus()
|
|
524
|
+
elif self._allow_btn is not None and self._deny_btn is not None:
|
|
525
|
+
if self.focused == self._allow_btn:
|
|
526
|
+
self._deny_btn.focus()
|
|
527
|
+
else:
|
|
528
|
+
self._allow_btn.focus()
|
|
529
|
+
return
|