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,552 @@
1
+ """Firewall checks and port configuration across Windows, Linux, and macOS."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+
10
+
11
+ def get_os() -> str:
12
+ """Return lowercase operating system name ('windows', 'linux', 'darwin', etc.)."""
13
+ return platform.system().lower()
14
+
15
+
16
+ def _port_in_localport(localport: str, port: int) -> bool:
17
+ """True if a netsh LocalPort value explicitly covers the port.
18
+
19
+ Handles single ports, comma lists, and ranges like 49990-50010.
20
+ A value of Any is deliberately NOT counted: Riptide only
21
+ cares about rules for port 50000, and Any-port rules are
22
+ program or service rules, not port rules. Counting Any made
23
+ a normal Windows machine, with 35 Any-port rules and 2 Any
24
+ block rules, always look blocked and always skip the UAC
25
+ prompt, even though traffic worked.
26
+ """
27
+ for token in localport.split(","):
28
+ token = token.strip().lower()
29
+ if not token:
30
+ continue
31
+ if token == "any":
32
+ continue
33
+ if "-" in token:
34
+ try:
35
+ low, high = token.split("-", 1)
36
+ if int(low) <= port <= int(high):
37
+ return True
38
+ except ValueError:
39
+ continue
40
+ elif token == str(port):
41
+ return True
42
+ return False
43
+
44
+
45
+ def _parse_netsh_rules(stdout: str) -> list[dict[str, str]]:
46
+ """Parse netsh show rule output into one dict per rule.
47
+
48
+ Each rule starts with a Rule Name line, followed by a dash
49
+ line, then its fields. The dash line is inside a rule, not
50
+ between rules, so splitting on it used to put the rule name
51
+ in a separate chunk from its fields, and the matcher chunk
52
+ had no name. Dash lines are now ignored, and a rule ends
53
+ only when the next Rule Name line appears after an Action.
54
+ """
55
+ rules: list[dict[str, str]] = []
56
+ props: dict[str, str] = {}
57
+ for line in stdout.splitlines():
58
+ stripped = line.strip()
59
+ if stripped and set(stripped) == {"-"}:
60
+ continue
61
+ if ":" not in line:
62
+ continue
63
+ k, v = line.split(":", 1)
64
+ k = k.strip().lower()
65
+ if k == "rule name" and props and "action" in props:
66
+ rules.append(props)
67
+ props = {}
68
+ props[k] = v.strip().lower()
69
+ if props:
70
+ rules.append(props)
71
+ return rules
72
+
73
+
74
+ def _windows_rule_matches(rule: dict[str, str], port: int) -> bool:
75
+ """True if a netsh rule covers inbound TCP traffic on the port."""
76
+ if rule.get("enabled") != "yes":
77
+ return False
78
+ if rule.get("direction") and rule.get("direction") != "in":
79
+ return False
80
+ if rule.get("protocol") and rule.get("protocol") not in ("tcp", "any"):
81
+ return False
82
+ return _port_in_localport(rule.get("localport", ""), port)
83
+
84
+
85
+ def windows_block_rule_exists(port: int) -> bool:
86
+ """True if an enabled inbound block rule covers the port on Windows.
87
+
88
+ A block rule wins over an allow rule in Windows Firewall, so an
89
+ allow rule alone does not open the port in that case.
90
+ """
91
+ if get_os() != "windows":
92
+ return False
93
+ try:
94
+ res = subprocess.run(
95
+ ["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=in"],
96
+ capture_output=True, text=True, timeout=10,
97
+ )
98
+ if res.returncode != 0 or not isinstance(res.stdout, str):
99
+ return False
100
+ return any(
101
+ rule.get("action") == "block" and _windows_rule_matches(rule, port)
102
+ for rule in _parse_netsh_rules(res.stdout)
103
+ )
104
+ except Exception:
105
+ return False
106
+
107
+
108
+ def check_firewall_port(port: int) -> bool:
109
+ """Check if the given port is open in the OS firewall.
110
+
111
+ Checks the operating system first and uses the native OS firewall tools.
112
+ Returns True if the port is open/allowed, False if closed.
113
+ """
114
+ os_name = get_os()
115
+
116
+ if os_name == "windows":
117
+ try:
118
+ # Query inbound rules using netsh for fast execution (~50ms)
119
+ cmd = ["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=in"]
120
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
121
+ if res.returncode != 0 or not isinstance(res.stdout, str):
122
+ return False
123
+
124
+ rules = _parse_netsh_rules(res.stdout)
125
+ allow = any(
126
+ rule.get("action") == "allow" and _windows_rule_matches(rule, port)
127
+ for rule in rules
128
+ )
129
+ block = any(
130
+ rule.get("action") == "block" and _windows_rule_matches(rule, port)
131
+ for rule in rules
132
+ )
133
+ # A block rule wins over an allow rule, so allow plus
134
+ # block is still closed. Reporting it as open was the
135
+ # silent failure behind a dialler timeout.
136
+ return allow and not block
137
+ except Exception:
138
+ return False
139
+
140
+ elif os_name == "linux":
141
+ try:
142
+ # 1. Check UFW if installed
143
+ if shutil.which("ufw"):
144
+ res = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
145
+ if "Status: active" in res.stdout:
146
+ for line in res.stdout.splitlines():
147
+ if str(port) in line and "ALLOW" in line.upper():
148
+ return True
149
+ return False
150
+
151
+ # 2. Check Firewalld if running
152
+ if shutil.which("firewall-cmd"):
153
+ state = subprocess.run(["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5)
154
+ if state.returncode == 0 and "running" in state.stdout:
155
+ query = subprocess.run(
156
+ ["firewall-cmd", f"--query-port={port}/tcp"],
157
+ capture_output=True, text=True, timeout=5,
158
+ )
159
+ return query.returncode == 0
160
+
161
+ # 3. Check iptables if default policy drops
162
+ if shutil.which("iptables"):
163
+ res = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, text=True, timeout=5)
164
+ if res.returncode == 0:
165
+ lines = res.stdout.splitlines()
166
+ if lines and any(p in lines[0] for p in ("policy DROP", "policy REJECT")):
167
+ return f"dpt:{port}" in res.stdout
168
+
169
+ # 4. Check nftables if it has a drop policy for input
170
+ if shutil.which("nft"):
171
+ res = subprocess.run(["nft", "list", "ruleset"], capture_output=True, text=True, timeout=5)
172
+ if res.returncode == 0 and "policy drop" in res.stdout.lower():
173
+ return f"dport {port}" in res.stdout
174
+
175
+ # If no firewall daemon is active/blocking, OS firewall allows inbound connections
176
+ return True
177
+ except Exception:
178
+ return True
179
+
180
+ elif os_name == "darwin":
181
+ try:
182
+ fw = "/usr/libexec/ApplicationFirewall/socketfilterfw"
183
+ if shutil.which(fw):
184
+ res = subprocess.run([fw, "--getglobalstate"], capture_output=True, text=True, timeout=5)
185
+ if "disabled" in res.stdout.lower():
186
+ return True
187
+ return True
188
+ except Exception:
189
+ return True
190
+
191
+ return True
192
+
193
+
194
+ def open_firewall_port(port: int, rule_name: str = "Riptide") -> bool:
195
+ """Attempt to open the given port in the OS firewall.
196
+
197
+ Checks the operating system first and runs the corresponding rule addition.
198
+ Returns True if the command succeeded, False otherwise.
199
+ """
200
+ os_name = get_os()
201
+
202
+ if os_name == "windows":
203
+ try:
204
+ # Single-word rule name: a name with spaces was fragile
205
+ # in netsh argument parsing.
206
+ cmd = [
207
+ "netsh", "advfirewall", "firewall", "add", "rule",
208
+ f"name={rule_name}",
209
+ "dir=in",
210
+ "action=allow",
211
+ "protocol=TCP",
212
+ f"localport={port}",
213
+ ]
214
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
215
+ if res.returncode == 0:
216
+ return True
217
+ # Add fails when a rule with that name already exists,
218
+ # which is exactly the case when the user turned the
219
+ # rule off instead of deleting it. Enable it instead.
220
+ for name in (rule_name,):
221
+ res = subprocess.run(
222
+ [
223
+ "netsh", "advfirewall", "firewall", "set", "rule",
224
+ f"name={name}", "new", "enable=yes",
225
+ ],
226
+ capture_output=True, text=True, timeout=10,
227
+ )
228
+ if res.returncode == 0:
229
+ return True
230
+ return False
231
+ except Exception:
232
+ return False
233
+
234
+ elif os_name == "linux":
235
+ try:
236
+ # Use sudo -n, never plain sudo. Plain sudo would prompt for a
237
+ # password that the Textual UI cannot show, and would hang
238
+ # until the subprocess timeout. sudo -n fails fast when no
239
+ # passwordless sudo is available, and the direct command
240
+ # still works when Riptide itself runs as root.
241
+ def _try(cmd: list[str]) -> bool:
242
+ for full in ([ "sudo", "-n", *cmd], cmd):
243
+ try:
244
+ res = subprocess.run(full, capture_output=True, text=True, timeout=10)
245
+ except Exception:
246
+ continue
247
+ if res.returncode == 0:
248
+ return True
249
+ return False
250
+
251
+ # 1. Try UFW
252
+ if shutil.which("ufw"):
253
+ if _try(["ufw", "allow", f"{port}/tcp"]):
254
+ return True
255
+
256
+ # 2. Try Firewalld
257
+ if shutil.which("firewall-cmd"):
258
+ if _try(["firewall-cmd", f"--add-port={port}/tcp", "--permanent"]):
259
+ _try(["firewall-cmd", "--reload"])
260
+ return True
261
+
262
+ # 3. Try iptables
263
+ if shutil.which("iptables"):
264
+ if _try(["iptables", "-I", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]):
265
+ return True
266
+
267
+ # 4. Try nftables, common inet filter input chain
268
+ if shutil.which("nft"):
269
+ if _try(["nft", "add", "rule", "inet", "filter", "input", "tcp", "dport", str(port), "accept"]):
270
+ return True
271
+
272
+ return False
273
+ except Exception:
274
+ return False
275
+
276
+ return False
277
+
278
+
279
+ def manual_firewall_command(port: int) -> str:
280
+ """The manual command to open the port, for the Create page banner.
281
+
282
+ Returns the command for the OS and firewall tool found on this
283
+ machine, or a generic command when none is found. No command is
284
+ run by this function.
285
+ """
286
+ os_name = get_os()
287
+ if os_name == "windows":
288
+ return (
289
+ f"netsh advfirewall firewall add rule name=Riptide "
290
+ f"dir=in action=allow protocol=TCP localport={port}"
291
+ )
292
+ if os_name == "linux":
293
+ if shutil.which("ufw"):
294
+ return f"sudo ufw allow {port}/tcp"
295
+ if shutil.which("firewall-cmd"):
296
+ return f"sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload"
297
+ if shutil.which("iptables"):
298
+ return f"sudo iptables -I INPUT -p tcp --dport {port} -j ACCEPT"
299
+ if shutil.which("nft"):
300
+ return f"sudo nft add rule inet filter input tcp dport {port} accept"
301
+ return f"sudo ufw allow {port}/tcp"
302
+ return ""
303
+
304
+
305
+ def is_elevated() -> bool:
306
+ """True if this process already has admin or root rights.
307
+
308
+ Windows: IsUserAnAdmin. Linux and macOS: effective user id 0.
309
+ False on any error, so a failed check never skips a prompt it
310
+ should have shown, it only falls back to the normal path.
311
+ """
312
+ os_name = get_os()
313
+ if os_name == "windows":
314
+ try:
315
+ import ctypes
316
+
317
+ return ctypes.windll.shell32.IsUserAnAdmin() != 0
318
+ except Exception:
319
+ return False
320
+ if os_name in ("linux", "darwin"):
321
+ try:
322
+ return os.geteuid() == 0
323
+ except Exception:
324
+ return False
325
+ return False
326
+
327
+
328
+ def _valid_port(port: int) -> bool:
329
+ return isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535
330
+
331
+
332
+ def _shell_execute_runas(executable: str, parameters: str) -> int:
333
+ """Run one program as administrator on Windows, via ShellExecuteW.
334
+
335
+ This is a thin wrapper so tests can mock it without touching
336
+ ctypes. Return value: above 32 means success, 1223 means the
337
+ user cancelled the UAC prompt, 5 means access denied, 32 or
338
+ below otherwise means another error. A standard-user
339
+ credentials prompt returns 5 when the user clicks No, while
340
+ an admin Yes/No prompt returns 1223, both observed live.
341
+ ShellExecuteW returns immediately, it does not wait for the
342
+ program, so the caller must poll check_firewall_port after it.
343
+ """
344
+ try:
345
+ import ctypes
346
+
347
+ return int(
348
+ ctypes.windll.shell32.ShellExecuteW(
349
+ None, "runas", executable, parameters, None, 0
350
+ )
351
+ )
352
+ except Exception:
353
+ return 0
354
+
355
+
356
+ def _netsh_path() -> str:
357
+ system_root = os.environ.get("SystemRoot", "")
358
+ if system_root:
359
+ return os.path.join(system_root, "System32", "netsh.exe")
360
+ return "netsh"
361
+
362
+
363
+ def _windows_riptide_rule_disabled() -> bool:
364
+ """True if a Riptide rule exists but is turned off on Windows.
365
+
366
+ Knowing this before elevating keeps the prompt to one: an
367
+ existing disabled rule is enabled, otherwise a new rule is
368
+ added. A second elevated call would prompt a second time.
369
+ """
370
+ if get_os() != "windows":
371
+ return False
372
+ try:
373
+ res = subprocess.run(
374
+ ["netsh", "advfirewall", "firewall", "show", "rule", "name=Riptide"],
375
+ capture_output=True, text=True, timeout=10,
376
+ )
377
+ if res.returncode != 0 or not isinstance(res.stdout, str):
378
+ return False
379
+ return any(
380
+ rule.get("rule name") == "riptide" and rule.get("enabled") == "no"
381
+ for rule in _parse_netsh_rules(res.stdout)
382
+ )
383
+ except Exception:
384
+ return False
385
+
386
+
387
+ def _poll_firewall_open(port: int, timeout: float = 30, interval: float = 0.5) -> bool:
388
+ """Poll check_firewall_port until it is open or the timeout ends.
389
+
390
+ Used after a UAC prompt, because ShellExecuteW returns before
391
+ the user has answered the prompt and before netsh has run.
392
+ """
393
+ deadline = time.monotonic() + timeout
394
+ while True:
395
+ if check_firewall_port(port):
396
+ return True
397
+ if time.monotonic() >= deadline:
398
+ return False
399
+ time.sleep(interval)
400
+
401
+
402
+ def elevated_open_firewall_port_windows(port: int, poll_timeout: float = 30) -> str:
403
+ """Open the port with one UAC prompt on Windows.
404
+
405
+ Returns "opened", "cancelled", or "failed". The command is
406
+ fixed: rule name Riptide, inbound, allow, TCP, and the port
407
+ as a validated integer. No user input goes into it.
408
+ """
409
+ if not _valid_port(port):
410
+ return "failed"
411
+ if _windows_riptide_rule_disabled():
412
+ parameters = "advfirewall firewall set rule name=Riptide new enable=yes"
413
+ else:
414
+ parameters = (
415
+ "advfirewall firewall add rule name=Riptide "
416
+ f"dir=in action=allow protocol=TCP localport={port}"
417
+ )
418
+ result = _shell_execute_runas(_netsh_path(), parameters)
419
+ if result in (1223, 5):
420
+ return "cancelled"
421
+ if result <= 32:
422
+ return "failed"
423
+ return "opened" if _poll_firewall_open(port, timeout=poll_timeout) else "failed"
424
+
425
+
426
+ def _pkexec_run(cmd: list[str]) -> int:
427
+ """Run one command as root on Linux, via pkexec.
428
+
429
+ Returns the program return code, 126 if the user dismissed
430
+ the authentication dialog, or 127 if pkexec is missing, no
431
+ authentication agent is available, or authorisation failed.
432
+ Those codes are from the pkexec manual. The
433
+ --disable-internal-agent flag is deliberate: without it,
434
+ pkexec would register its own textual agent and prompt for
435
+ a password in the terminal, which would fight the Textual
436
+ UI the same way plain sudo did. With the flag, a machine
437
+ with no graphical agent fails fast instead of hanging,
438
+ and the caller falls back to the manual command.
439
+ """
440
+ if not shutil.which("pkexec"):
441
+ return 127
442
+ try:
443
+ res = subprocess.run(
444
+ ["pkexec", "--disable-internal-agent", *cmd],
445
+ capture_output=True, text=True, timeout=120,
446
+ )
447
+ return res.returncode
448
+ except Exception:
449
+ return 127
450
+
451
+
452
+ def elevated_open_firewall_port_linux(port: int) -> str:
453
+ """Open the port with one pkexec prompt on Linux.
454
+
455
+ Returns "opened", "cancelled", or "failed". Only the first
456
+ firewall tool found is tried, so at most one prompt appears
457
+ per click. For firewalld, add and reload run in one pkexec
458
+ shell command, again so only one prompt appears.
459
+ """
460
+ if not _valid_port(port):
461
+ return "failed"
462
+ if shutil.which("ufw"):
463
+ cmd = ["ufw", "allow", f"{port}/tcp"]
464
+ elif shutil.which("firewall-cmd"):
465
+ cmd = [
466
+ "/bin/sh", "-c",
467
+ f"firewall-cmd --add-port={port}/tcp --permanent "
468
+ "&& firewall-cmd --reload",
469
+ ]
470
+ elif shutil.which("iptables"):
471
+ cmd = ["iptables", "-I", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]
472
+ elif shutil.which("nft"):
473
+ cmd = ["nft", "add", "rule", "inet", "filter", "input", "tcp", "dport", str(port), "accept"]
474
+ else:
475
+ return "failed"
476
+ result = _pkexec_run(cmd)
477
+ if result == 126:
478
+ return "cancelled"
479
+ if result != 0:
480
+ return "failed"
481
+ return "opened" if check_firewall_port(port) else "failed"
482
+
483
+
484
+ def elevated_open_firewall_port(port: int, poll_timeout: float = 30) -> str:
485
+ """Open the port with an elevation prompt, Windows or Linux.
486
+
487
+ Returns "opened", "cancelled", or "failed". Other operating
488
+ systems have no elevation path here and return "failed".
489
+ """
490
+ os_name = get_os()
491
+ if os_name == "windows":
492
+ return elevated_open_firewall_port_windows(port, poll_timeout=poll_timeout)
493
+ if os_name == "linux":
494
+ return elevated_open_firewall_port_linux(port)
495
+ return "failed"
496
+
497
+
498
+ def ensure_firewall_port_with_elevation(
499
+ port: int, poll_timeout: float = 30
500
+ ) -> tuple[bool, bool, bool, bool]:
501
+ """Check the port, open it if closed, with elevation if needed.
502
+
503
+ Returns (was_open, opened, is_open_now, cancelled). This is
504
+ the menu path only: it may prompt once, on the Create Server
505
+ click. The 20 second watch loop must keep using the plain
506
+ ensure_firewall_port, which never prompts.
507
+
508
+ Order matters, so no prompt appears when it is not needed:
509
+ already open, then a block rule on Windows (an allow rule
510
+ cannot open it, so prompting would mislead), then already
511
+ elevated, then the plain open (passwordless sudo on Linux),
512
+ and only then the elevation prompt. The command is fixed
513
+ to port 50000 in practice through config.session_port, and
514
+ only that port is ever opened; no program rule is checked
515
+ or relied on.
516
+ """
517
+ was_open = check_firewall_port(port)
518
+ if was_open:
519
+ return (True, False, True, False)
520
+ if get_os() == "windows" and windows_block_rule_exists(port):
521
+ return (False, False, False, False)
522
+ if is_elevated() or get_os() not in ("windows", "linux"):
523
+ _was, opened, is_open_now = ensure_firewall_port(port)
524
+ return (_was, opened, is_open_now, False)
525
+ _was, opened, is_open_now = ensure_firewall_port(port)
526
+ if is_open_now:
527
+ return (_was, opened, is_open_now, False)
528
+ result = elevated_open_firewall_port(port, poll_timeout=poll_timeout)
529
+ if result == "opened":
530
+ return (False, True, True, False)
531
+ if result == "cancelled":
532
+ return (False, False, False, True)
533
+ return (False, False, False, False)
534
+
535
+
536
+ def ensure_firewall_port(port: int) -> tuple[bool, bool, bool]:
537
+ """Check the port, open it if closed, and report the result.
538
+
539
+ Returns (was_open, opened, is_open_now). was_open is the state
540
+ before any change. opened and is_open_now are True only when
541
+ a re-check after opening finds the port open. Trusting the
542
+ open command alone was wrong: an allow rule does not open
543
+ the port when a block rule for the same port still exists.
544
+ When opening fails, is_open_now is False and the caller should
545
+ show manual_firewall_command(port) to the user.
546
+ """
547
+ was_open = check_firewall_port(port)
548
+ if was_open:
549
+ return (True, False, True)
550
+ open_firewall_port(port)
551
+ is_open_now = check_firewall_port(port)
552
+ return (False, is_open_now, is_open_now)
riptide/core/focus.py ADDED
@@ -0,0 +1,82 @@
1
+ """Arrow-key focus navigation.
2
+
3
+ Root cause of the reported bug: Textual moves focus with Tab by
4
+ default. Plain Buttons in a Vertical container do not move focus on
5
+ Up/Down, and no widget was focused on mount, so pressing an arrow key
6
+ did nothing visible at all.
7
+
8
+ These helpers make Up/Down work explicitly, in DOM order, wrapping
9
+ around, on every screen. They are deliberately small and unit-testable:
10
+ they only need a screen-like object with ``focused``, ``query`` and
11
+ ``app.set_focus``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+
16
+ def focusable_widgets(screen):
17
+ """Return focusable widgets in DOM order, skipping disabled ones."""
18
+ try:
19
+ widgets = list(screen.query("OptionList, ListView, Button, Input"))
20
+ except Exception:
21
+ return []
22
+ result = []
23
+ for widget in widgets:
24
+ try:
25
+ if getattr(widget, "disabled", False):
26
+ continue
27
+ except Exception:
28
+ pass
29
+ result.append(widget)
30
+ return result
31
+
32
+
33
+ def focus_first(screen) -> bool:
34
+ """Focus the first focusable widget. Return True if one was focused."""
35
+ widgets = focusable_widgets(screen)
36
+ if not widgets:
37
+ return False
38
+ _set_focus(screen, widgets[0])
39
+ return True
40
+
41
+
42
+ def move_focus(screen, direction: int) -> bool:
43
+ """Move focus by direction (+1 down, -1 up), wrapping. Return True if moved."""
44
+ widgets = focusable_widgets(screen)
45
+ if not widgets:
46
+ return False
47
+ focused = getattr(screen, "focused", None)
48
+ if focused not in widgets:
49
+ target = widgets[0] if direction >= 0 else widgets[-1]
50
+ _set_focus(screen, target)
51
+ return True
52
+ index = widgets.index(focused)
53
+ target = widgets[(index + direction) % len(widgets)]
54
+ _set_focus(screen, target)
55
+ return True
56
+
57
+
58
+ def handle_arrow_key(screen, key: str) -> bool:
59
+ """Handle an Up/Down key for a screen. Return True if handled."""
60
+ try:
61
+ from textual.widgets import OptionList, ListView
62
+ focused = getattr(screen, "focused", None)
63
+ if isinstance(focused, (OptionList, ListView)):
64
+ return False
65
+ except Exception:
66
+ pass
67
+ if key == "down":
68
+ return move_focus(screen, +1)
69
+ if key == "up":
70
+ return move_focus(screen, -1)
71
+ return False
72
+
73
+
74
+ def _set_focus(screen, widget) -> None:
75
+ app = getattr(screen, "app", None)
76
+ set_focus = getattr(app, "set_focus", None) if app is not None else None
77
+ if callable(set_focus):
78
+ set_focus(widget)
79
+ else:
80
+ focus = getattr(widget, "focus", None)
81
+ if callable(focus):
82
+ focus()