main
py 617 lines 17.7 KB
Raw
1 from __future__ import annotations
2
3 import math
4 import os
5 import re
6 import shutil
7 import subprocess
8 import threading
9 import time
10 from dataclasses import dataclass
11 from pathlib import Path
12 from typing import Any, Callable
13 from urllib.parse import quote, urlencode
14
15 from helpers import files
16 from helpers.localization import Localization
17
18
19 STATE_DIR = Path(files.get_abs_path("usr", "plugins", "_desktop", "virtual_desktop"))
20 DEFAULT_WIDTH = 1440
21 DEFAULT_HEIGHT = 900
22 MAX_WIDTH = 1920
23 MAX_HEIGHT = 1080
24 MIN_WIDTH = 360
25 MIN_HEIGHT = 240
26 MIN_DESKTOP_ASPECT_RATIO = 4 / 3
27 SESSION_PATH = "/desktop/session"
28 XPRA_HTML_ROOT_CANDIDATES = (
29 Path("/usr/share/xpra/www"),
30 )
31
32
33 ResizeCallback = Callable[[int, int], dict[str, Any]]
34
35
36 @dataclass
37 class VirtualDesktopEndpoint:
38 token: str
39 host: str
40 port: int
41 owner: str = "desktop"
42 title: str = "Desktop"
43 resize: ResizeCallback | None = None
44
45
46 class VirtualDesktopRegistry:
47 def __init__(self) -> None:
48 self._lock = threading.RLock()
49 self._endpoints: dict[str, VirtualDesktopEndpoint] = {}
50
51 def register(self, endpoint: VirtualDesktopEndpoint) -> None:
52 with self._lock:
53 self._endpoints[str(endpoint.token)] = endpoint
54
55 def unregister(self, token: str) -> None:
56 with self._lock:
57 self._endpoints.pop(str(token), None)
58
59 def proxy_for_token(self, token: str) -> VirtualDesktopEndpoint | None:
60 with self._lock:
61 endpoint = self._endpoints.get(str(token or ""))
62 if not endpoint:
63 return None
64 return endpoint
65
66 def resize(self, token: str, width: int, height: int) -> dict[str, Any]:
67 with self._lock:
68 endpoint = self._endpoints.get(str(token or ""))
69 if not endpoint:
70 return {"ok": False, "error": "Virtual desktop session not found."}
71 if not endpoint.resize:
72 return {"ok": True, "resized": False, "reason": "Session does not expose resize."}
73 return endpoint.resize(width, height)
74
75
76 def register_session(
77 *,
78 token: str,
79 host: str,
80 port: int,
81 owner: str = "desktop",
82 title: str = "Desktop",
83 resize: ResizeCallback | None = None,
84 ) -> None:
85 get_registry().register(
86 VirtualDesktopEndpoint(
87 token=str(token),
88 host=str(host),
89 port=int(port),
90 owner=str(owner),
91 title=str(title),
92 resize=resize,
93 ),
94 )
95
96
97 def unregister_session(token: str) -> None:
98 get_registry().unregister(token)
99
100
101 def proxy_for_token(token: str) -> VirtualDesktopEndpoint | None:
102 return get_registry().proxy_for_token(token)
103
104
105 def resize_session(token: str, width: int, height: int) -> dict[str, Any]:
106 return get_registry().resize(token, width, height)
107
108
109 def get_registry() -> VirtualDesktopRegistry:
110 global _registry
111 try:
112 return _registry
113 except NameError:
114 _registry = VirtualDesktopRegistry()
115 return _registry
116
117
118 def session_url(
119 token: str,
120 *,
121 title: str = "Desktop",
122 encoding: str = "jpeg",
123 quality: int = 85,
124 speed: int = 80,
125 file_transfer: bool = True,
126 printing: bool = True,
127 ) -> str:
128 quoted_token = quote(str(token), safe="")
129 base_path = f"{SESSION_PATH}/{quoted_token}/"
130 options = {
131 "path": base_path,
132 "title": title,
133 "quality": str(max(0, min(100, int(quality)))),
134 "speed": str(max(0, min(100, int(speed)))),
135 "sharing": "true",
136 "clipboard": "true",
137 "clipboard_direction": "both",
138 "clipboard_poll": "true",
139 "clipboard_preferred_format": "text/plain",
140 "printing": str(bool(printing)).lower(),
141 "file_transfer": str(bool(file_transfer)).lower(),
142 "sound": "false",
143 "offscreen": "true",
144 "floating_menu": "false",
145 "xpramenu": "false",
146 }
147 if encoding:
148 options["encoding"] = str(encoding)
149 query = urlencode(options)
150 return f"{base_path}index.html?{query}"
151
152
153 def collect_status() -> dict[str, Any]:
154 binaries = {
155 "xpra": shutil.which("xpra") or "",
156 "Xvfb": shutil.which("Xvfb") or "",
157 "xfce4-session": shutil.which("xfce4-session") or "",
158 "dbus-launch": shutil.which("dbus-launch") or "",
159 "xrandr": shutil.which("xrandr") or "",
160 "xdotool": shutil.which("xdotool") or "",
161 "xsetroot": shutil.which("xsetroot") or "",
162 }
163 packages = {
164 "xpra-x11": _package_installed("xpra-x11") if binaries["xpra"] else False,
165 }
166 xpra_html_root = find_xpra_html_root()
167 missing = [
168 name
169 for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
170 if not binaries[name]
171 ]
172 if binaries["xpra"] and not packages["xpra-x11"]:
173 missing.append("xpra-x11")
174 if not xpra_html_root:
175 missing.append("xpra-html5")
176 healthy = not missing
177 return {
178 "ok": True,
179 "healthy": healthy,
180 "state": "healthy" if healthy else "missing",
181 "binaries": binaries,
182 "packages": packages,
183 "xpra_html_root": str(xpra_html_root) if xpra_html_root else "",
184 "message": (
185 "Virtual desktop sessions are available."
186 if healthy
187 else f"Virtual desktop sessions need: {', '.join(missing)}."
188 ),
189 }
190
191
192 def find_xpra_html_root() -> Path | None:
193 for root in XPRA_HTML_ROOT_CANDIDATES:
194 if (root / "index.html").exists() or (root / "connect.html").exists():
195 return root
196 return None
197
198
199 def _package_installed(package: str) -> bool:
200 if not shutil.which("dpkg-query"):
201 return True
202 result = subprocess.run(
203 ["dpkg-query", "-W", "-f=${Status}", package],
204 check=False,
205 text=True,
206 capture_output=True,
207 timeout=8,
208 )
209 return result.returncode == 0 and "install ok installed" in result.stdout
210
211
212 def normalize_size(
213 width: int | float | str,
214 height: int | float | str,
215 *,
216 max_width: int = MAX_WIDTH,
217 max_height: int = MAX_HEIGHT,
218 min_width: int = MIN_WIDTH,
219 min_height: int = MIN_HEIGHT,
220 ) -> tuple[int, int]:
221 requested_width = max(1, int(float(width or DEFAULT_WIDTH)))
222 requested_height = max(1, int(float(height or DEFAULT_HEIGHT)))
223 scale = min(max_width / requested_width, max_height / requested_height, 1.0)
224 if scale < 1.0:
225 requested_width = max(1, math.floor(requested_width * scale))
226 requested_height = max(1, math.floor(requested_height * scale))
227 return (
228 max(min_width, min(max_width, requested_width)),
229 max(min_height, min(max_height, requested_height)),
230 )
231
232
233 def normalize_desktop_display_size(
234 width: int | float | str,
235 height: int | float | str,
236 *,
237 max_width: int = MAX_WIDTH,
238 max_height: int = MAX_HEIGHT,
239 min_width: int = MIN_WIDTH,
240 min_height: int = MIN_HEIGHT,
241 min_aspect_ratio: float = MIN_DESKTOP_ASPECT_RATIO,
242 ) -> tuple[int, int]:
243 normalized_width, normalized_height = normalize_size(
244 width,
245 height,
246 max_width=max_width,
247 max_height=max_height,
248 min_width=min_width,
249 min_height=min_height,
250 )
251 if normalized_height <= 0:
252 return normalized_width, normalized_height
253 if normalized_width / normalized_height >= min_aspect_ratio:
254 return normalized_width, normalized_height
255 return normalize_size(
256 DEFAULT_WIDTH,
257 DEFAULT_HEIGHT,
258 max_width=max_width,
259 max_height=max_height,
260 min_width=min_width,
261 min_height=min_height,
262 )
263
264
265 def resize_display(
266 *,
267 display: int,
268 width: int,
269 height: int,
270 max_width: int = MAX_WIDTH,
271 max_height: int = MAX_HEIGHT,
272 window_class: str = "",
273 keys: tuple[str, ...] = (),
274 xauthority: str = "",
275 home: str = "",
276 settle_seconds: float = 0.15,
277 ) -> dict[str, Any]:
278 target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height)
279 xrandr = shutil.which("xrandr")
280 if not xrandr:
281 return {"ok": False, "error": "xrandr is not installed."}
282
283 env = _display_env(display, xauthority=xauthority, home=home)
284 current_before = current_display_size(display, xauthority=xauthority, home=home)
285 if current_before == (target_width, target_height):
286 if window_class:
287 fit_window(
288 display=display,
289 width=target_width,
290 height=target_height,
291 window_class=window_class,
292 keys=keys,
293 xauthority=xauthority,
294 home=home,
295 )
296 return {"ok": True, "width": target_width, "height": target_height, "resized": False}
297
298 _ensure_xrandr_mode(env, target_width, target_height)
299 result = _select_xrandr_mode(env, target_width, target_height)
300 if result.returncode != 0:
301 result = subprocess.run(
302 [xrandr, "--fb", f"{target_width}x{target_height}"],
303 check=False,
304 capture_output=True,
305 text=True,
306 timeout=4,
307 env=env,
308 )
309 if settle_seconds > 0:
310 time.sleep(settle_seconds)
311 current = current_display_size(display, xauthority=xauthority, home=home)
312 ok = current == (target_width, target_height)
313 if ok:
314 if window_class:
315 fit_window(
316 display=display,
317 width=target_width,
318 height=target_height,
319 window_class=window_class,
320 keys=keys,
321 xauthority=xauthority,
322 home=home,
323 )
324 return {"ok": True, "width": target_width, "height": target_height, "resized": True}
325 detail = (result.stderr or result.stdout or "xrandr resize failed").strip()
326 return {
327 "ok": False,
328 "error": detail,
329 "width": current[0] if current else target_width,
330 "height": current[1] if current else target_height,
331 }
332
333
334 def _ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None:
335 xrandr = shutil.which("xrandr")
336 if not xrandr:
337 return
338 output, existing_modes = _xrandr_output_modes(env)
339 if not output:
340 return
341 mode = f"{width}x{height}"
342 if mode not in existing_modes:
343 subprocess.run(
344 [xrandr, "--newmode", mode, "0", str(width), "0", "0", "0", str(height), "0", "0", "0"],
345 check=False,
346 stdout=subprocess.DEVNULL,
347 stderr=subprocess.DEVNULL,
348 timeout=2,
349 env=env,
350 )
351 subprocess.run(
352 [xrandr, "--addmode", output, mode],
353 check=False,
354 stdout=subprocess.DEVNULL,
355 stderr=subprocess.DEVNULL,
356 timeout=2,
357 env=env,
358 )
359
360
361 def _select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]:
362 xrandr = shutil.which("xrandr")
363 if not xrandr:
364 return subprocess.CompletedProcess([], 1, "", "xrandr is not installed.")
365 output, _ = _xrandr_output_modes(env)
366 if not output:
367 return subprocess.CompletedProcess([], 1, "", "No connected XRandR output found.")
368 mode = f"{width}x{height}"
369 return subprocess.run(
370 [xrandr, "--output", output, "--mode", mode],
371 check=False,
372 capture_output=True,
373 text=True,
374 timeout=4,
375 env=env,
376 )
377
378
379 def _xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]:
380 xrandr = shutil.which("xrandr")
381 if not xrandr:
382 return "", set()
383 result = subprocess.run(
384 [xrandr, "-q"],
385 check=False,
386 capture_output=True,
387 text=True,
388 timeout=4,
389 env=env,
390 )
391 output = ""
392 modes: set[str] = set()
393 for line in result.stdout.splitlines():
394 output_match = re.match(r"^(\S+)\s+connected\b", line)
395 if output_match:
396 output = output_match.group(1)
397 continue
398 if output:
399 mode_match = re.match(r"^\s+(\d+x\d+)\b", line)
400 if mode_match:
401 modes.add(mode_match.group(1))
402 return output, modes
403
404
405 def current_display_size(display: int, *, xauthority: str = "", home: str = "") -> tuple[int, int] | None:
406 xrandr = shutil.which("xrandr")
407 if not xrandr:
408 return None
409 result = subprocess.run(
410 [xrandr, "-q"],
411 check=False,
412 capture_output=True,
413 text=True,
414 timeout=4,
415 env=_display_env(display, xauthority=xauthority, home=home),
416 )
417 match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout)
418 if not match:
419 return None
420 return int(match.group(1)), int(match.group(2))
421
422
423 def fit_window_until(
424 *,
425 display: int,
426 width: int,
427 height: int,
428 window_class: str = "",
429 keys: tuple[str, ...] = (),
430 settle_seconds: float = 4.0,
431 timeout_seconds: float = 10.0,
432 process: subprocess.Popen[Any] | None = None,
433 xauthority: str = "",
434 home: str = "",
435 ) -> None:
436 xdotool = shutil.which("xdotool")
437 if not xdotool:
438 return
439 deadline = time.time() + timeout_seconds
440 settle_until = 0.0
441 while time.time() < deadline:
442 window_id = _find_window(display, window_class=window_class, xauthority=xauthority, home=home)
443 if window_id:
444 if not settle_until:
445 settle_until = time.time() + settle_seconds
446 fit_window(
447 display=display,
448 width=width,
449 height=height,
450 window_class=window_class,
451 keys=keys,
452 xauthority=xauthority,
453 home=home,
454 )
455 if time.time() >= settle_until:
456 return
457 time.sleep(0.5)
458 continue
459 if process and process.poll() is not None:
460 return
461 time.sleep(0.25)
462
463
464 def fit_window(
465 *,
466 display: int,
467 width: int,
468 height: int,
469 window_class: str = "",
470 keys: tuple[str, ...] = (),
471 xauthority: str = "",
472 home: str = "",
473 ) -> bool:
474 xdotool = shutil.which("xdotool")
475 if not xdotool:
476 return False
477 env = _display_env(display, xauthority=xauthority, home=home)
478 window_id = _find_window(display, window_class=window_class, xauthority=xauthority, home=home)
479 if not window_id:
480 return False
481 subprocess.run(
482 [xdotool, "windowactivate", window_id],
483 check=False,
484 stdout=subprocess.DEVNULL,
485 stderr=subprocess.DEVNULL,
486 timeout=2,
487 env=env,
488 )
489 subprocess.run(
490 [xdotool, "windowmove", window_id, "0", "0", "windowsize", window_id, str(width), str(height)],
491 check=False,
492 stdout=subprocess.DEVNULL,
493 stderr=subprocess.DEVNULL,
494 timeout=2,
495 env=env,
496 )
497 for key in keys:
498 subprocess.run(
499 [xdotool, "key", "--clearmodifiers", key],
500 check=False,
501 stdout=subprocess.DEVNULL,
502 stderr=subprocess.DEVNULL,
503 timeout=2,
504 env=env,
505 )
506 return True
507
508
509 def has_window(
510 display: int,
511 *,
512 window_class: str = "",
513 name: str = "",
514 xauthority: str = "",
515 home: str = "",
516 ) -> bool:
517 return bool(find_window(display, window_class=window_class, name=name, xauthority=xauthority, home=home))
518
519
520 def find_window(
521 display: int,
522 *,
523 window_class: str = "",
524 name: str = "",
525 xauthority: str = "",
526 home: str = "",
527 ) -> str:
528 return _find_window(display, window_class=window_class, name=name, xauthority=xauthority, home=home)
529
530
531 def close_windows(
532 display: int,
533 *,
534 names: tuple[str, ...] = (),
535 window_class: str = "",
536 xauthority: str = "",
537 home: str = "",
538 ) -> int:
539 xdotool = shutil.which("xdotool")
540 if not xdotool:
541 return 0
542 closed = 0
543 env = _display_env(display, xauthority=xauthority, home=home)
544 for pattern in names:
545 command = [xdotool, "search", "--onlyvisible"]
546 if window_class:
547 command.extend(["--class", window_class])
548 command.extend(["--name", pattern])
549 result = subprocess.run(
550 command,
551 check=False,
552 capture_output=True,
553 text=True,
554 timeout=2,
555 env=env,
556 )
557 for window_id in [line.strip() for line in result.stdout.splitlines() if line.strip()]:
558 subprocess.run(
559 [xdotool, "windowclose", window_id],
560 check=False,
561 stdout=subprocess.DEVNULL,
562 stderr=subprocess.DEVNULL,
563 timeout=2,
564 env=env,
565 )
566 closed += 1
567 return closed
568
569
570 def _find_window(
571 display: int,
572 *,
573 window_class: str = "",
574 name: str = "",
575 xauthority: str = "",
576 home: str = "",
577 ) -> str:
578 xdotool = shutil.which("xdotool")
579 if not xdotool:
580 return ""
581 command = [xdotool, "search", "--onlyvisible"]
582 if window_class:
583 command.extend(["--class", window_class])
584 if name:
585 command.extend(["--name", name])
586 if not window_class and not name:
587 command.extend(["--name", "."])
588 result = subprocess.run(
589 command,
590 check=False,
591 capture_output=True,
592 text=True,
593 timeout=2,
594 env=_display_env(display, xauthority=xauthority, home=home),
595 )
596 window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
597 return window_ids[-1] if window_ids else ""
598
599
600 def _display_env(display: int, *, xauthority: str = "", home: str = "") -> dict[str, str]:
601 runtime_dir = STATE_DIR / "xdg-runtime"
602 runtime_dir.mkdir(parents=True, exist_ok=True)
603 try:
604 runtime_dir.chmod(0o700)
605 except OSError:
606 pass
607 env = {
608 **os.environ,
609 "DISPLAY": f":{display}",
610 "XDG_RUNTIME_DIR": str(runtime_dir),
611 "TZ": Localization.get().get_timezone(),
612 }
613 if home:
614 env["HOME"] = home
615 if xauthority:
616 env["XAUTHORITY"] = xauthority
617 return env