VirtualDesktop 1.0.1__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,462 @@
1
+ """Download, cache, verify and safely extract release artifacts.
2
+
3
+ Security posture (all of it is enforced, not optional):
4
+
5
+ * **HTTPS only** – the manifest rejects anything else and redirects are re-checked.
6
+ * **Digest pinning** – a file is only accepted when its SHA-256 matches the manifest.
7
+ Anything else is moved to ``<cache>/quarantine`` and never executed.
8
+ * **Size ceilings** – a declared size is enforced (±1 %), and an undeclared size is capped
9
+ at ``max_bytes`` so a hostile endpoint cannot exhaust the disk.
10
+ * **Atomic publish** – downloads land in ``<cache>/.tmp`` and are moved into place with
11
+ ``os.replace`` only after verification, so a crash never leaves a half-written artifact.
12
+ * **Locking** – a per-artifact lock file serialises concurrent processes (multi-process
13
+ safety matters because the first ``mount()`` of two processes can race).
14
+ * **Safe extraction** – zip members are validated against traversal, absolute paths,
15
+ symlinks, member-count, and expansion-ratio limits before anything is written.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ import shutil
23
+ import ssl
24
+ import stat
25
+ import tempfile
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ import uuid
30
+ import zipfile
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
34
+
35
+ from ._log import get_logger
36
+ from .errors import DownloadError, ExtractionError, IntegrityError
37
+ from .manifest import Artifact
38
+
39
+ log = get_logger("download")
40
+
41
+ __all__ = [
42
+ "default_cache_dir",
43
+ "verified_path",
44
+ "cache_status",
45
+ "download_artifact",
46
+ "extract_archive",
47
+ "file_sha256",
48
+ "sha256_of",
49
+ "FileLock",
50
+ "VerifyResult",
51
+ ]
52
+
53
+ _CHUNK = 256 * 1024
54
+ #: A zip may legitimately expand, but not without bound (zip-bomb guard).
55
+ _MAX_EXPANSION_RATIO = 40
56
+ _MAX_MEMBERS = 512
57
+ _USER_AGENT = "VirtualDesktop/0.1 (+https://github.com/example/VirtualDesktop)"
58
+
59
+
60
+ def default_cache_dir() -> Path:
61
+ """Per-user cache root, overridable with ``VIRTUALDESKTOP_CACHE_DIR``."""
62
+ override = os.environ.get("VIRTUALDESKTOP_CACHE_DIR")
63
+ if override:
64
+ return Path(override).expanduser()
65
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_CACHE_HOME")
66
+ if base:
67
+ return Path(base) / "VirtualDesktop" / "cache"
68
+ return Path(tempfile.gettempdir()) / "VirtualDesktop-cache" # pragma: no cover
69
+
70
+
71
+ def file_sha256(path: os.PathLike, *, chunk_size: int = 1024 * 1024) -> str:
72
+ digest = hashlib.sha256()
73
+ with open(path, "rb") as handle:
74
+ for block in iter(lambda: handle.read(chunk_size), b""):
75
+ digest.update(block)
76
+ return digest.hexdigest()
77
+
78
+
79
+ #: Backwards-compatible alias.
80
+ sha256_of = file_sha256
81
+
82
+
83
+ @dataclass
84
+ class VerifyResult:
85
+ """Outcome of checking one cached artifact against its pinned digest."""
86
+
87
+ path: Path
88
+ expected: str
89
+ actual: str = ""
90
+ size: int = 0
91
+ ok: bool = False
92
+ reason: str = ""
93
+
94
+ def to_dict(self) -> dict:
95
+ return {
96
+ "path": str(self.path),
97
+ "expected": self.expected,
98
+ "actual": self.actual,
99
+ "size": self.size,
100
+ "ok": self.ok,
101
+ "reason": self.reason,
102
+ }
103
+
104
+
105
+ def verified_path(artifact: Artifact, cache_dir: Optional[os.PathLike] = None) -> Optional[Path]:
106
+ """Return the cached file path when it exists *and* matches the pinned digest."""
107
+ root = Path(cache_dir) if cache_dir else default_cache_dir()
108
+ candidate = root / artifact.filename
109
+ if not candidate.is_file():
110
+ return None
111
+ if artifact.size is not None and candidate.stat().st_size != artifact.size:
112
+ log.debug("cache size mismatch for %s", candidate)
113
+ return None
114
+ if file_sha256(candidate) != artifact.sha256:
115
+ log.warning(
116
+ "cached artifact %s does not match the pinned digest; it will be re-downloaded",
117
+ candidate,
118
+ )
119
+ return None
120
+ return candidate
121
+
122
+
123
+ def cache_status(artifact: Artifact, cache_dir: Optional[os.PathLike] = None) -> VerifyResult:
124
+ """Detailed verification report, used by ``VirtualDesktop check``."""
125
+ root = Path(cache_dir) if cache_dir else default_cache_dir()
126
+ path = root / artifact.filename
127
+ result = VerifyResult(path=path, expected=artifact.sha256)
128
+ if not path.is_file():
129
+ result.reason = "not-cached"
130
+ return result
131
+ result.size = path.stat().st_size
132
+ result.actual = file_sha256(path)
133
+ if result.actual != artifact.sha256:
134
+ result.reason = "digest-mismatch"
135
+ return result
136
+ if artifact.size is not None and result.size != artifact.size:
137
+ result.reason = "size-mismatch"
138
+ return result
139
+ result.ok = True
140
+ result.reason = "ok"
141
+ return result
142
+
143
+
144
+ class FileLock:
145
+ """Cross-process advisory lock built on ``O_CREAT | O_EXCL``.
146
+
147
+ Windows has no portable ``flock``; creating a lock file exclusively is atomic on NTFS
148
+ and behaves correctly across processes. Stale locks (crashed process) are reclaimed
149
+ after ``stale_after`` seconds.
150
+ """
151
+
152
+ def __init__(self, path: os.PathLike, *, timeout: float = 180.0, stale_after: float = 900.0):
153
+ self.path = Path(path)
154
+ self.timeout = timeout
155
+ self.stale_after = stale_after
156
+ self._fd: Optional[int] = None
157
+
158
+ def __enter__(self) -> "FileLock":
159
+ deadline = time.monotonic() + self.timeout
160
+ self.path.parent.mkdir(parents=True, exist_ok=True)
161
+ delay = 0.05
162
+ while True:
163
+ try:
164
+ self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
165
+ os.write(self._fd, f"{os.getpid()} {time.time():.3f}".encode())
166
+ return self
167
+ except FileExistsError:
168
+ try:
169
+ age = time.time() - self.path.stat().st_mtime
170
+ except OSError:
171
+ age = 0
172
+ if age > self.stale_after:
173
+ log.warning("reclaiming stale download lock %s (age %.0fs)", self.path, age)
174
+ try:
175
+ self.path.unlink()
176
+ except OSError:
177
+ pass
178
+ continue
179
+ if time.monotonic() > deadline:
180
+ raise DownloadError(
181
+ f"timed out waiting for the artifact lock {self.path}",
182
+ code="download-lock-timeout",
183
+ hint="Another process is downloading the driver. Remove the .lock file if it is stale.",
184
+ )
185
+ time.sleep(delay)
186
+ delay = min(delay * 1.5, 0.5)
187
+ except OSError as exc: # pragma: no cover - filesystem specific
188
+ raise DownloadError(f"cannot create lock {self.path}: {exc}") from exc
189
+
190
+ def __exit__(self, exc_type, exc, tb) -> None:
191
+ if self._fd is not None:
192
+ try:
193
+ os.close(self._fd)
194
+ finally:
195
+ self._fd = None
196
+ try:
197
+ self.path.unlink()
198
+ except OSError: # pragma: no cover
199
+ pass
200
+
201
+
202
+ def _quarantine(path: Path, *, reason: str, dest_dir: Optional[Path] = None) -> Optional[Path]:
203
+ """Move a bad artifact out of the way so it can never be executed or reused.
204
+
205
+ Quarantining (rather than deleting) keeps the evidence for the operator, which matters
206
+ when the cause is a hostile mirror rather than a flaky connection.
207
+ """
208
+ try:
209
+ target_dir = dest_dir if dest_dir is not None else path.parent / "quarantine"
210
+ target_dir.mkdir(parents=True, exist_ok=True)
211
+ target = target_dir / f"{path.name}.{int(time.time())}.{uuid.uuid4().hex[:8]}.bad"
212
+ os.replace(path, target)
213
+ log.error("quarantined %s (%s) as %s", path, reason, target)
214
+ return target
215
+ except OSError as exc: # pragma: no cover - defensive
216
+ log.error("could not quarantine %s: %s", path, exc)
217
+ return None
218
+
219
+
220
+ def _make_ssl_context() -> ssl.SSLContext:
221
+ context = ssl.create_default_context()
222
+ context.check_hostname = True
223
+ context.verify_mode = ssl.CERT_REQUIRED
224
+ return context
225
+
226
+
227
+ def download_artifact(
228
+ artifact: Artifact,
229
+ *,
230
+ cache_dir: Optional[os.PathLike] = None,
231
+ timeout: float = 120.0,
232
+ progress: Optional[Callable[[int, Optional[int]], None]] = None,
233
+ force: bool = False,
234
+ max_retries: int = 3,
235
+ mirror_extra: Sequence[str] = (),
236
+ ) -> Path:
237
+ """Return a verified local copy of ``artifact``, downloading it when needed.
238
+
239
+ ``mirror_extra`` adds URL prefixes for this call only (the ``VIRTUALDESKTOP_GITHUB_MIRRORS``
240
+ environment variable is folded in by the callers). The artifact's own ``mirrors`` from the
241
+ manifest are always tried first after the primary URL.
242
+ """
243
+ cache = Path(cache_dir) if cache_dir else default_cache_dir()
244
+ cache.mkdir(parents=True, exist_ok=True)
245
+ target = cache / artifact.filename
246
+
247
+ if not force:
248
+ existing = verified_path(artifact, cache)
249
+ if existing is not None:
250
+ log.debug("using cached artifact %s", existing)
251
+ return existing
252
+ if target.exists():
253
+ _quarantine(target, reason="digest mismatch", dest_dir=cache / "quarantine")
254
+
255
+ with FileLock(cache / f"{artifact.filename}.lock"):
256
+ # Another process may have completed the download while we waited.
257
+ if not force:
258
+ existing = verified_path(artifact, cache)
259
+ if existing is not None:
260
+ return existing
261
+
262
+ tmp_dir = cache / ".tmp"
263
+ tmp_dir.mkdir(parents=True, exist_ok=True)
264
+ tmp_path = tmp_dir / f"{artifact.filename}.{os.getpid()}.{uuid.uuid4().hex[:8]}.part"
265
+ limit = artifact.size or 512 * 1024 * 1024
266
+ if artifact.size:
267
+ # Allow a tiny slack for servers that re-encode, but stay tight.
268
+ limit = int(artifact.size * 1.01) + 1024
269
+
270
+ # Every candidate URL serves the *same bytes*; the digest below is what decides. The
271
+ # mirrors exist because github.com is unreachable or unusably slow on many networks in
272
+ # mainland China, and they are tried in order after the primary URL fails.
273
+ urls = artifact.candidate_urls(mirror_extra)
274
+ last_error: Optional[Exception] = None
275
+ for url in urls:
276
+ for attempt in range(1, max_retries + 1):
277
+ try:
278
+ _fetch(artifact, tmp_path, url=url, timeout=timeout, limit=limit, progress=progress)
279
+ break
280
+ except (urllib.error.URLError, TimeoutError, OSError, DownloadError) as exc:
281
+ last_error = exc
282
+ log.warning(
283
+ "download attempt %d/%d from %s failed: %s", attempt, max_retries, url, exc
284
+ )
285
+ if attempt == max_retries:
286
+ break
287
+ time.sleep(min(2 ** attempt, 8))
288
+ else: # pragma: no cover - the inner loop always breaks or exhausts
289
+ continue
290
+ if tmp_path.exists() and tmp_path.stat().st_size:
291
+ break
292
+ else:
293
+ raise DownloadError(
294
+ f"failed to download {artifact.filename} from {len(urls)} URL(s): {last_error}",
295
+ code="download-failed",
296
+ details={"urls": urls, "filename": artifact.filename},
297
+ )
298
+
299
+ digest = file_sha256(tmp_path)
300
+ if digest != artifact.sha256:
301
+ size = tmp_path.stat().st_size
302
+ _quarantine(tmp_path, reason="sha256 mismatch", dest_dir=cache / "quarantine")
303
+ raise IntegrityError(
304
+ f"SHA-256 mismatch for {artifact.filename}: expected {artifact.sha256}, got {digest}",
305
+ code="digest-mismatch",
306
+ details={
307
+ "url": artifact.url,
308
+ "expected": artifact.sha256,
309
+ "actual": digest,
310
+ "bytes": size,
311
+ },
312
+ hint="The download was corrupted or the mirror serves different content; "
313
+ "update the manifest if the upstream release legitimately changed.",
314
+ )
315
+ if artifact.size is not None and tmp_path.stat().st_size != artifact.size:
316
+ actual_size = tmp_path.stat().st_size
317
+ _quarantine(tmp_path, reason="size mismatch", dest_dir=cache / "quarantine")
318
+ raise IntegrityError(
319
+ f"size mismatch for {artifact.filename}: expected {artifact.size}, got {actual_size}",
320
+ code="size-mismatch",
321
+ )
322
+ os.replace(tmp_path, target)
323
+ log.info(
324
+ "verified %s (%d bytes, sha256 %s)", target.name, target.stat().st_size, digest[:12]
325
+ )
326
+ return target
327
+
328
+
329
+ def _fetch(
330
+ artifact: Artifact,
331
+ tmp_path: Path,
332
+ *,
333
+ url: Optional[str] = None,
334
+ timeout: float,
335
+ limit: int,
336
+ progress: Optional[Callable[[int, Optional[int]], None]],
337
+ ) -> None:
338
+ """Download ``url`` (default: the artifact's primary URL) into ``tmp_path``."""
339
+ source = url or artifact.url
340
+ request = urllib.request.Request(
341
+ source,
342
+ headers={"User-Agent": _USER_AGENT, "Accept": "application/octet-stream"},
343
+ )
344
+ handlers: List[urllib.request.BaseHandler] = [urllib.request.HTTPSHandler(context=_make_ssl_context())]
345
+ opener = urllib.request.build_opener(*handlers)
346
+ written = 0
347
+ with opener.open(request, timeout=timeout) as response:
348
+ final_url = response.geturl()
349
+ if not str(final_url).lower().startswith("https://"):
350
+ raise DownloadError(
351
+ f"refusing to download over an insecure channel: {final_url}",
352
+ code="insecure-redirect",
353
+ )
354
+ declared = response.headers.get("Content-Length")
355
+ total = int(declared) if declared and declared.isdigit() else artifact.size
356
+ if total and total > limit:
357
+ raise DownloadError(
358
+ f"remote file is {total} bytes which exceeds the accepted limit of {limit}",
359
+ code="size-exceeded",
360
+ )
361
+ with open(tmp_path, "wb") as handle:
362
+ while True:
363
+ chunk = response.read(_CHUNK)
364
+ if not chunk:
365
+ break
366
+ written += len(chunk)
367
+ if written > limit:
368
+ raise DownloadError(
369
+ f"download exceeded the accepted limit of {limit} bytes",
370
+ code="size-exceeded",
371
+ )
372
+ handle.write(chunk)
373
+ if progress is not None:
374
+ progress(written, total)
375
+ handle.flush()
376
+ os.fsync(handle.fileno())
377
+ if progress is not None:
378
+ progress(written, written)
379
+ log.debug("fetched %s -> %s (%d bytes)", source, tmp_path, written)
380
+
381
+
382
+ def extract_archive(
383
+ archive_path: os.PathLike,
384
+ dest_dir: os.PathLike,
385
+ *,
386
+ strip_components: int = 0,
387
+ members: Sequence[str] = (),
388
+ overwrite: bool = True,
389
+ ) -> Path:
390
+ """Safely extract a zip archive and return the destination directory.
391
+
392
+ Raises :class:`ExtractionError` on any suspicious member instead of skipping it — a
393
+ silently skipped member would surface later as a confusing "INF not found".
394
+ """
395
+ archive_path = Path(archive_path)
396
+ dest = Path(dest_dir)
397
+ dest.mkdir(parents=True, exist_ok=True)
398
+ try:
399
+ zf = zipfile.ZipFile(archive_path)
400
+ except zipfile.BadZipFile as exc:
401
+ raise ExtractionError(
402
+ f"{archive_path} is not a valid zip archive: {exc}", code="bad-archive"
403
+ ) from exc
404
+
405
+ with zf:
406
+ infos = zf.infolist()
407
+ if len(infos) > _MAX_MEMBERS:
408
+ raise ExtractionError(
409
+ f"archive contains {len(infos)} members (limit {_MAX_MEMBERS})",
410
+ code="archive-too-many-members",
411
+ )
412
+ total_uncompressed = sum(i.file_size for i in infos)
413
+ archive_size = max(archive_path.stat().st_size, 1)
414
+ if total_uncompressed > archive_size * _MAX_EXPANSION_RATIO:
415
+ raise ExtractionError(
416
+ f"archive expands to {total_uncompressed} bytes ({_MAX_EXPANSION_RATIO}x limit); refusing",
417
+ code="archive-bomb",
418
+ )
419
+ root = dest.resolve()
420
+ extracted: List[str] = []
421
+ for info in infos:
422
+ name = info.filename.replace("\\", "/")
423
+ if not name or name.endswith("/"):
424
+ continue
425
+ mode = (info.external_attr >> 16) & 0xFFFF
426
+ if mode and stat.S_ISLNK(mode):
427
+ raise ExtractionError(
428
+ f"archive member {info.filename!r} is a symlink; refusing",
429
+ code="archive-symlink",
430
+ )
431
+ parts = [p for p in name.split("/") if p not in ("", ".")]
432
+ if any(p == ".." for p in parts):
433
+ raise ExtractionError(
434
+ f"archive member {info.filename!r} escapes the extraction root",
435
+ code="archive-traversal",
436
+ )
437
+ if len(parts) <= strip_components:
438
+ continue
439
+ relative = Path(*parts[strip_components:])
440
+ target = (root / relative).resolve()
441
+ if root != target and root not in target.parents:
442
+ raise ExtractionError(
443
+ f"archive member {info.filename!r} resolves outside {root}",
444
+ code="archive-traversal",
445
+ )
446
+ target.parent.mkdir(parents=True, exist_ok=True)
447
+ if target.exists() and not overwrite:
448
+ extracted.append(str(relative).replace("\\", "/"))
449
+ continue
450
+ with zf.open(info) as src, open(target, "wb") as dst:
451
+ shutil.copyfileobj(src, dst, _CHUNK)
452
+ extracted.append(str(relative).replace("\\", "/"))
453
+
454
+ missing = [m for m in members if m not in set(extracted)]
455
+ if missing:
456
+ raise ExtractionError(
457
+ f"archive {archive_path.name} is missing expected member(s): {', '.join(missing)}",
458
+ code="archive-missing-members",
459
+ details={"found": extracted[:50]},
460
+ )
461
+ log.debug("extracted %s -> %s (%d files)", archive_path.name, dest, len(extracted))
462
+ return dest
@@ -0,0 +1,31 @@
1
+ """Absolute-path entry point for the elevated helper.
2
+
3
+ Run as a *script* (``python <this file> _elevated-install ...``) rather than with ``-m``,
4
+ because the elevated child's ``sys.path`` contains only its own working directory, which is
5
+ not necessarily the project directory.
6
+
7
+ Bootstrapping note: this file lives *inside* the package, so ``python script.py`` puts
8
+ ``<...>/VirtualDesktop`` on ``sys.path`` — enough to import ``errors``/``cli`` as siblings,
9
+ but **not** enough for ``import VirtualDesktop.cli``, which needs the *parent* directory.
10
+ Both are added below, so it works for a source checkout and for an installed wheel, with no
11
+ reliance on the current working directory or on ``PYTHONPATH``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _PACKAGE_DIR = Path(__file__).resolve().parent
20
+ _PARENT_DIR = _PACKAGE_DIR.parent
21
+ for _candidate in (str(_PARENT_DIR), str(_PACKAGE_DIR)):
22
+ if _candidate not in sys.path:
23
+ sys.path.insert(0, _candidate)
24
+
25
+ try:
26
+ from VirtualDesktop.cli import main
27
+ except ModuleNotFoundError: # pragma: no cover - fallback for a renamed checkout
28
+ from cli import main # type: ignore[no-redef]
29
+
30
+ if __name__ == "__main__":
31
+ sys.exit(main())
VirtualDesktop/_log.py ADDED
@@ -0,0 +1,85 @@
1
+ """Logging helpers.
2
+
3
+ The library never configures the root logger and never attaches handlers unless the
4
+ application explicitly asks for it (``enable_debug_logging()`` or the
5
+ ``VIRTUALDESKTOP_LOG_FILE`` environment variable), so importing it stays side-effect free.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+ from logging.handlers import RotatingFileHandler
14
+ from typing import Optional
15
+
16
+ __all__ = ["get_logger", "enable_debug_logging", "configure_from_env", "LOG_NAMESPACE"]
17
+
18
+ LOG_NAMESPACE = "VirtualDesktop"
19
+ _CONFIGURED = False
20
+
21
+ _FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
22
+
23
+
24
+ def get_logger(name: str = "") -> logging.Logger:
25
+ """Return a namespaced logger (``VirtualDesktop.<name>``)."""
26
+ if not name:
27
+ return logging.getLogger(LOG_NAMESPACE)
28
+ if name.startswith(LOG_NAMESPACE):
29
+ return logging.getLogger(name)
30
+ return logging.getLogger(f"{LOG_NAMESPACE}.{name}")
31
+
32
+
33
+ def _ensure_namespace_handler(handler: logging.Handler) -> logging.Logger:
34
+ logger = logging.getLogger(LOG_NAMESPACE)
35
+ logger.addHandler(handler)
36
+ # Do not duplicate records through the root logger; libraries must not own root.
37
+ logger.propagate = False
38
+ return logger
39
+
40
+
41
+ def enable_debug_logging(
42
+ *,
43
+ level: int = logging.DEBUG,
44
+ stream=None,
45
+ log_file: Optional[str] = None,
46
+ max_bytes: int = 1_000_000,
47
+ backup_count: int = 2,
48
+ ) -> logging.Logger:
49
+ """Turn on library logging once; safe to call repeatedly."""
50
+ global _CONFIGURED
51
+ logger = logging.getLogger(LOG_NAMESPACE)
52
+ logger.setLevel(level)
53
+ if not _CONFIGURED:
54
+ formatter = logging.Formatter(_FORMAT)
55
+ if stream is None and not log_file:
56
+ stream = sys.stderr
57
+ if stream is not None:
58
+ handler = logging.StreamHandler(stream)
59
+ handler.setFormatter(formatter)
60
+ _ensure_namespace_handler(handler)
61
+ if log_file:
62
+ try:
63
+ directory = os.path.dirname(os.path.abspath(log_file))
64
+ if directory:
65
+ os.makedirs(directory, exist_ok=True)
66
+ file_handler = RotatingFileHandler(
67
+ log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
68
+ )
69
+ except OSError: # pragma: no cover - depends on filesystem state
70
+ file_handler = None
71
+ if file_handler is not None:
72
+ file_handler.setFormatter(formatter)
73
+ _ensure_namespace_handler(file_handler)
74
+ _CONFIGURED = True
75
+ return logger
76
+
77
+
78
+ def configure_from_env() -> None:
79
+ """Honour ``VIRTUALDESKTOP_DEBUG`` / ``VIRTUALDESKTOP_LOG_FILE`` if set."""
80
+ debug = os.environ.get("VIRTUALDESKTOP_DEBUG", "").strip().lower()
81
+ log_file = os.environ.get("VIRTUALDESKTOP_LOG_FILE") or None
82
+ if debug in ("1", "true", "yes", "on"):
83
+ enable_debug_logging(level=logging.DEBUG, log_file=log_file)
84
+ elif log_file:
85
+ enable_debug_logging(level=logging.INFO, stream=None, log_file=log_file)