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,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