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