solari-core 0.2.0__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,51 @@
1
+ """solari_core — shared runtime for the Solari VM SDKs.
2
+
3
+ The transport, session handles (:class:`Desktop`, :class:`Sandbox`), image
4
+ builder, and typed errors shared by ``solari-desktop`` and ``solari-sandbox``.
5
+ Mirrors the TypeScript ``@solarisdk/core`` package. You normally install one of
6
+ the leaf packages (``solari-desktop`` / ``solari-sandbox``), which re-export
7
+ this surface; import from here directly only for shared types.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ._http import HttpTransport, new_idempotency_key
12
+ from .desktop import Desktop, DesktopConfig, ExecStreamHandler
13
+ from .handle import SessionConfig, SessionHandle, SessionHooks
14
+ from .image import CompiledImage, Image, LocalCopy
15
+ from .sandbox import Sandbox
16
+ from .template_client import SyncTemplateClient, TemplateClient
17
+ from .volume_client import SyncVolumeClient, VolumeClient
18
+ from .errors import (
19
+ ActionError, AuthError, ConcurrencyLimitError, ConnectionError,
20
+ GatewayError, NoCapacityError, SolariError, PlanError, TimeoutError,
21
+ )
22
+ from .types import (
23
+ CodeLanguage, CodeResultItem, CommandResult, CreateDesktopResponse,
24
+ CreateSandboxResponse, DeleteDesktopResponse, DesktopLifecycleResponse,
25
+ DesktopStatus, ExecResult, ExecStreamChunk, FsEntry, FsSearchMatch, FsStat,
26
+ FsWatchEvent, GatewayErrorBody, GitBranch, GitCommit, GitStatus,
27
+ GetDesktopResponse, HealthResult, KeyAction, MetricsResult, MouseAction,
28
+ MouseButton, PackageManager, PkgInstallResult, PortInfo, ProcessInfo,
29
+ RpcErrorBody, RpcResponse, RunCodeResult, SandboxKind, SandboxState,
30
+ SandboxView, ScreenshotFormat, SnapshotView,
31
+ )
32
+
33
+ __version__ = "0.2.0"
34
+
35
+ __all__ = [
36
+ "HttpTransport", "new_idempotency_key",
37
+ "Image", "CompiledImage", "LocalCopy",
38
+ "TemplateClient", "SyncTemplateClient", "VolumeClient", "SyncVolumeClient",
39
+ "Desktop", "DesktopConfig", "ExecStreamHandler", "Sandbox",
40
+ "SessionHandle", "SessionConfig", "SessionHooks",
41
+ "ActionError", "AuthError", "ConcurrencyLimitError", "ConnectionError",
42
+ "GatewayError", "NoCapacityError", "SolariError", "PlanError", "TimeoutError",
43
+ "CreateDesktopResponse", "CreateSandboxResponse", "DeleteDesktopResponse",
44
+ "DesktopLifecycleResponse", "DesktopStatus", "ExecResult", "ExecStreamChunk",
45
+ "FsEntry", "FsStat", "GatewayErrorBody", "GetDesktopResponse", "HealthResult",
46
+ "KeyAction", "MouseAction", "MouseButton", "PackageManager", "PkgInstallResult",
47
+ "PortInfo", "ProcessInfo", "RpcErrorBody", "RpcResponse", "ScreenshotFormat",
48
+ "CodeLanguage", "CodeResultItem", "CommandResult", "FsSearchMatch",
49
+ "FsWatchEvent", "GitBranch", "GitCommit", "GitStatus", "MetricsResult",
50
+ "RunCodeResult", "SandboxKind", "SandboxState", "SandboxView", "SnapshotView",
51
+ ]
solari_core/_http.py ADDED
@@ -0,0 +1,142 @@
1
+ """Shared async HTTP transport (mirrors the TypeScript ``src/http.ts``).
2
+
3
+ Both :class:`DesktopClient` and :class:`SandboxClient` delegate here so there is
4
+ ONE place owning auth headers, error mapping, retries/backoff, idempotency keys,
5
+ and timeouts.
6
+
7
+ Retry policy: idempotent requests (GET, DELETE, or any carrying an
8
+ Idempotency-Key) are retried on network errors, HTTP 5xx, and bodies flagged
9
+ ``retryable``, with exponential backoff + jitter. Non-idempotent writes are
10
+ never silently retried — pass ``idempotency_key`` to opt a create into safe
11
+ retries. 429 is NOT retried (it is our ConcurrencyLimitError).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import random
17
+ import uuid
18
+ from typing import Any, Dict, Optional
19
+
20
+ import httpx
21
+
22
+ from .errors import ConnectionError as PtConnectionError
23
+ from .errors import SolariError, map_gateway_error
24
+ from .types import GatewayErrorBody
25
+
26
+
27
+ def new_idempotency_key() -> str:
28
+ """A fresh idempotency key (UUID)."""
29
+ return str(uuid.uuid4())
30
+
31
+
32
+ class HttpTransport:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ api_key: str,
37
+ base_url: str,
38
+ http: Optional[httpx.AsyncClient] = None,
39
+ max_retries: int = 5,
40
+ request_timeout_ms: int = 300_000,
41
+ retry_delay_ms: Optional[int] = None,
42
+ ) -> None:
43
+ if not api_key:
44
+ raise SolariError("HttpTransport requires an api_key")
45
+ if not base_url:
46
+ raise SolariError("HttpTransport requires a base_url")
47
+ self._api_key = api_key
48
+ self._base_url = base_url.rstrip("/")
49
+ self._http = http
50
+ self._owns_http = http is None
51
+ self._max_retries = max_retries
52
+ self._timeout = request_timeout_ms / 1000.0
53
+ self._retry_delay_ms = retry_delay_ms
54
+
55
+ def _client(self) -> httpx.AsyncClient:
56
+ if self._http is None:
57
+ self._http = httpx.AsyncClient()
58
+ return self._http
59
+
60
+ def auth_headers(self) -> Dict[str, str]:
61
+ return {"Authorization": f"Bearer {self._api_key}"}
62
+
63
+ def ws_origin(self) -> str:
64
+ if self._base_url.startswith("https"):
65
+ return "wss" + self._base_url[len("https"):]
66
+ if self._base_url.startswith("http"):
67
+ return "ws" + self._base_url[len("http"):]
68
+ return self._base_url
69
+
70
+ async def request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ body: Optional[Any] = None,
75
+ *,
76
+ idempotency_key: Optional[str] = None,
77
+ ) -> Any:
78
+ idempotent = method in ("GET", "DELETE") or idempotency_key is not None
79
+ headers: Dict[str, str] = {
80
+ "Authorization": f"Bearer {self._api_key}",
81
+ "Accept": "application/json",
82
+ }
83
+ if body is not None:
84
+ headers["Content-Type"] = "application/json"
85
+ if idempotency_key:
86
+ headers["Idempotency-Key"] = idempotency_key
87
+
88
+ attempt = 0
89
+ while True:
90
+ try:
91
+ res = await self._client().request(
92
+ method,
93
+ f"{self._base_url}{path}",
94
+ headers=headers,
95
+ json=body if body is not None else None,
96
+ timeout=self._timeout,
97
+ )
98
+ except httpx.HTTPError as exc:
99
+ if idempotent and attempt < self._max_retries:
100
+ await asyncio.sleep(self._backoff(attempt))
101
+ attempt += 1
102
+ continue
103
+ raise PtConnectionError(f"{method} {path} failed: {exc}") from exc
104
+
105
+ if res.is_error:
106
+ err_body: Optional[GatewayErrorBody] = None
107
+ try:
108
+ parsed = res.json()
109
+ if isinstance(parsed, dict):
110
+ err_body = GatewayErrorBody(
111
+ code=parsed.get("code"),
112
+ error=parsed.get("error"),
113
+ message=parsed.get("message"),
114
+ retryable=parsed.get("retryable"),
115
+ )
116
+ except Exception: # noqa: BLE001 - body may be empty/non-JSON
117
+ err_body = None
118
+ # 5xx or explicit retryable hint; NOT 429 (ConcurrencyLimitError).
119
+ retryable = res.status_code >= 500 or (
120
+ err_body is not None and err_body.retryable is True
121
+ )
122
+ if idempotent and retryable and attempt < self._max_retries:
123
+ await asyncio.sleep(self._backoff(attempt))
124
+ attempt += 1
125
+ continue
126
+ raise map_gateway_error(res.status_code, err_body)
127
+
128
+ if not res.content:
129
+ return None
130
+ return res.json()
131
+
132
+ def _backoff(self, attempt: int) -> float:
133
+ if self._retry_delay_ms is not None:
134
+ return self._retry_delay_ms / 1000.0
135
+ # Base 150ms (was 1000): a create bounced by a transient host no_capacity
136
+ # during a burst re-picks a different host in ~150ms, not a full second.
137
+ return min(0.15 * 2 ** attempt, 8.0) + random.random() * 0.25
138
+
139
+ async def aclose(self) -> None:
140
+ if self._owns_http and self._http is not None:
141
+ await self._http.aclose()
142
+ self._http = None
solari_core/desktop.py ADDED
@@ -0,0 +1,437 @@
1
+ """``Desktop`` — a handle to one live GUI session.
2
+
3
+ Extends the shared :class:`~solari_desktop.handle.SessionHandle` (commands,
4
+ pty, run_code, files, metrics, snapshot, revert, pause/resume, set_timeout, env,
5
+ volumes, kill) and adds the computer-use GUI surface (CONTRACTS §4 +
6
+ CONTRACTS-V2 §4): screenshot, mouse.*, keyboard.*, display.*, clipboard.*, open,
7
+ stream.*, record.*. Mirrors ``sdk/src/desktop.ts``.
8
+
9
+ Back-compat: the v1 :class:`~solari_desktop.client.DesktopClient` constructs
10
+ ``Desktop(session, DesktopConfig(on_pause=...))`` where ``on_pause(session_id)``
11
+ calls ``POST /desktops/:id/pause``. That path is preserved (folded into the
12
+ unified hooks), and the older convenience surfaces (``exec``, ``exec_stream``,
13
+ ``health``, ``process``, ``ports``, ``pkg``, ``fs``) are kept.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ from dataclasses import dataclass
20
+ from typing import Any, Callable, Dict, List, Optional, Union
21
+
22
+ from .handle import SessionConfig, SessionHandle, SessionHooks
23
+ from .types import (
24
+ CreateDesktopResponse,
25
+ ExecResult,
26
+ ExecStreamChunk,
27
+ FsEntry,
28
+ FsStat,
29
+ HealthResult,
30
+ KeyAction,
31
+ MouseAction,
32
+ MouseButton,
33
+ PackageManager,
34
+ PkgInstallResult,
35
+ PortInfo,
36
+ ProcessInfo,
37
+ ScreenshotFormat,
38
+ )
39
+
40
+ #: Callback invoked for each streamed exec output chunk.
41
+ ExecStreamHandler = Callable[[ExecStreamChunk], None]
42
+
43
+
44
+ @dataclass
45
+ class DesktopConfig(SessionConfig):
46
+ """Construction config passed by :class:`~solari_desktop.client.DesktopClient`.
47
+
48
+ Adds the legacy ``on_pause`` shim (folded into ``hooks.pause``).
49
+ """
50
+
51
+ on_pause: Optional[Callable[[str], Any]] = None
52
+
53
+
54
+ class Desktop(SessionHandle):
55
+ """A live desktop session. Construct via :class:`~solari_desktop.client.DesktopClient`."""
56
+
57
+ def __init__(
58
+ self,
59
+ session: CreateDesktopResponse,
60
+ config: Optional[DesktopConfig] = None,
61
+ ) -> None:
62
+ config = config or DesktopConfig()
63
+ hooks = config.hooks or SessionHooks()
64
+ # Fold the legacy on_pause shim into the unified hooks.pause.
65
+ if config.on_pause is not None and hooks.pause is None:
66
+ _on_pause = config.on_pause
67
+
68
+ async def _pause(session_id: str) -> None:
69
+ res = _on_pause(session_id)
70
+ if hasattr(res, "__await__"):
71
+ await res
72
+
73
+ hooks.pause = _pause
74
+
75
+ base = SessionConfig(callTimeoutMs=config.callTimeoutMs, headers=config.headers, hooks=hooks)
76
+ super().__init__(session.sessionId, session.controlUrl, session.expiresAt, base)
77
+ self.streamUrl: str = session.streamUrl
78
+ #: Presigned playback URL for the session recording — set when the session
79
+ #: was created with ``record=True``, ``None`` otherwise. The guest uploads
80
+ #: the mp4 on ``record.stop()``, so it only resolves once a recording has
81
+ #: been started and stopped.
82
+ self.recordingUrl: Optional[str] = session.recordingUrl
83
+
84
+ self.fs = _Fs(self)
85
+ self.mouse = _Mouse(self)
86
+ self.keyboard = _Keyboard(self)
87
+ self.display = _Display(self)
88
+ self.clipboard = _Clipboard(self)
89
+ self.process = _Process(self)
90
+ self.ports = _Ports(self)
91
+ self.pkg = _Pkg(self)
92
+ self.record = _Record(self)
93
+ self.stream = _Stream(self)
94
+
95
+ @property
96
+ def sessionId(self) -> str:
97
+ """Alias of :attr:`SessionHandle.id` (back-compat)."""
98
+ return self.id
99
+
100
+ async def _call(self, method: str, params: Any) -> Any:
101
+ return await self._channel.call(method, params)
102
+
103
+ # --- readiness ------------------------------------------------------------
104
+
105
+ async def health(self) -> HealthResult:
106
+ r = await self._call("health", {})
107
+ return HealthResult(
108
+ ready=bool(r.get("ready")), display=bool(r.get("display")), vnc=bool(r.get("vnc"))
109
+ )
110
+
111
+ # --- exec (v1 convenience; prefer commands.run) ---------------------------
112
+
113
+ async def exec(
114
+ self,
115
+ cmd: str,
116
+ *,
117
+ args: Optional[List[str]] = None,
118
+ cwd: Optional[str] = None,
119
+ timeout_ms: Optional[int] = None,
120
+ stream: Optional[bool] = None,
121
+ ) -> ExecResult:
122
+ r = await self._call(
123
+ "exec",
124
+ {"cmd": cmd, "args": args or [], "cwd": cwd, "timeoutMs": timeout_ms, "stream": stream},
125
+ )
126
+ return ExecResult(
127
+ exitCode=int(r.get("exitCode", 0)),
128
+ stdout=str(r.get("stdout", "")),
129
+ stderr=str(r.get("stderr", "")),
130
+ )
131
+
132
+ async def exec_stream(
133
+ self,
134
+ cmd: str,
135
+ on_chunk: ExecStreamHandler,
136
+ *,
137
+ args: Optional[List[str]] = None,
138
+ cwd: Optional[str] = None,
139
+ timeout_ms: Optional[int] = None,
140
+ ) -> ExecResult:
141
+ def _on_stream(stream: str, data: Optional[str]) -> None:
142
+ raw = base64.b64decode(data) if data else b""
143
+ on_chunk(ExecStreamChunk(stream=stream, text=raw.decode("utf-8", "replace"), bytes=raw))
144
+
145
+ r = await self._channel.call(
146
+ "exec",
147
+ {"cmd": cmd, "args": args or [], "cwd": cwd, "timeoutMs": timeout_ms, "stream": True},
148
+ on_stream=_on_stream,
149
+ )
150
+ return ExecResult(
151
+ exitCode=int(r.get("exitCode", 0)),
152
+ stdout=str(r.get("stdout", "")),
153
+ stderr=str(r.get("stderr", "")),
154
+ )
155
+
156
+ # --- screenshot -----------------------------------------------------------
157
+
158
+ async def screenshot(
159
+ self, *, format: ScreenshotFormat = "png", quality: Optional[int] = None
160
+ ) -> bytes:
161
+ r = await self._call("screenshot", {"format": format, "quality": quality})
162
+ return base64.b64decode(r["base64"])
163
+
164
+ # --- open an app ----------------------------------------------------------
165
+
166
+ async def open(self, name: str, args: Optional[List[str]] = None) -> int:
167
+ """Launch a GUI app by name (``app.open``); returns its pid."""
168
+ r = await self._call("app.open", {"name": name, "args": args})
169
+ return int(r["pid"])
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Grouped action namespaces
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ class _Fs:
178
+ """Filesystem actions: ``desktop.fs`` (v1 alias of ``desktop.files``)."""
179
+
180
+ def __init__(self, d: Desktop) -> None:
181
+ self._d = d
182
+
183
+ async def read(self, path: str) -> bytes:
184
+ return await self._d.files.read(path)
185
+
186
+ async def read_text(self, path: str) -> str:
187
+ return await self._d.files.read_text(path)
188
+
189
+ async def write(self, path: str, data: Union[bytes, str], mode: Optional[int] = None) -> None:
190
+ await self._d.files.write(path, data, mode)
191
+
192
+ async def list(self, path: str) -> List[FsEntry]:
193
+ return await self._d.files.list(path)
194
+
195
+ async def stat(self, path: str) -> FsStat:
196
+ return await self._d.files.stat(path)
197
+
198
+ async def remove(self, path: str, recursive: bool = False) -> None:
199
+ await self._d.files.remove(path, recursive)
200
+
201
+ async def mkdir(self, path: str) -> None:
202
+ await self._d.files.mkdir(path)
203
+
204
+
205
+ _BUTTON_CODES = {"left": 1, "middle": 2, "right": 3}
206
+
207
+
208
+ def _button_to_code(button: Optional[MouseButton]) -> Optional[int]:
209
+ """Map a named button to the X11/xdotool code the guest agent expects
210
+ (``button`` is an ``int``: left=1, middle=2, right=3). ``None`` passes
211
+ through so the guest applies its default (left). Without this the wire
212
+ carries a raw string and the guest's JSON decode fails with
213
+ ``cannot unmarshal string into ... button of type int``."""
214
+ if button is None:
215
+ return None
216
+ return _BUTTON_CODES[button]
217
+
218
+
219
+ class _Mouse:
220
+ """Mouse actions: ``desktop.mouse``."""
221
+
222
+ def __init__(self, d: Desktop) -> None:
223
+ self._d = d
224
+
225
+ async def _mouse_call(
226
+ self,
227
+ x: int,
228
+ y: int,
229
+ action: MouseAction,
230
+ button: Optional[MouseButton] = None,
231
+ humanize: Optional[bool] = None,
232
+ ) -> None:
233
+ await self._d._call(
234
+ "input.mouse",
235
+ {"x": x, "y": y, "action": action, "button": _button_to_code(button), "humanize": humanize},
236
+ )
237
+
238
+ async def move(self, x: int, y: int, *, humanize: Optional[bool] = None) -> None:
239
+ await self._mouse_call(x, y, "move", None, humanize)
240
+
241
+ async def click(
242
+ self, x: int, y: int, *, button: Optional[MouseButton] = None, humanize: Optional[bool] = None
243
+ ) -> None:
244
+ await self._mouse_call(x, y, "click", button, humanize)
245
+
246
+ async def double_click(self, x: int, y: int, *, button: Optional[MouseButton] = None) -> None:
247
+ await self._d._call("input.doubleClick", {"x": x, "y": y, "button": _button_to_code(button)})
248
+
249
+ async def down(self, x: int, y: int, button: MouseButton = "left") -> None:
250
+ await self._mouse_call(x, y, "down", button)
251
+
252
+ async def up(self, x: int, y: int, button: MouseButton = "left") -> None:
253
+ await self._mouse_call(x, y, "up", button)
254
+
255
+ async def scroll(
256
+ self, x: int, y: int, *, button: Optional[MouseButton] = None, humanize: Optional[bool] = None
257
+ ) -> None:
258
+ await self._mouse_call(x, y, "scroll", button, humanize)
259
+
260
+ async def drag(
261
+ self,
262
+ frm: Dict[str, int],
263
+ to: Dict[str, int],
264
+ button: MouseButton = "left",
265
+ ) -> None:
266
+ await self._d._call("input.drag", {"from": frm, "to": to, "button": _button_to_code(button)})
267
+
268
+
269
+ class _Keyboard:
270
+ """Keyboard actions: ``desktop.keyboard``."""
271
+
272
+ def __init__(self, d: Desktop) -> None:
273
+ self._d = d
274
+
275
+ async def _key_call(
276
+ self,
277
+ *,
278
+ text: Optional[str] = None,
279
+ keys: Optional[List[str]] = None,
280
+ action: KeyAction = "press",
281
+ ) -> None:
282
+ params: Dict[str, Any] = {"action": action}
283
+ if text is not None:
284
+ params["text"] = text
285
+ if keys is not None:
286
+ params["keys"] = keys
287
+ await self._d._call("input.key", params)
288
+
289
+ async def type(self, text: str) -> None:
290
+ await self._key_call(text=text, action="press")
291
+
292
+ async def press(self, keys: Union[str, List[str]]) -> None:
293
+ await self._key_call(keys=[keys] if isinstance(keys, str) else keys, action="press")
294
+
295
+ async def hotkey(self, *keys: str) -> None:
296
+ """Press a chord, e.g. ``hotkey("ctrl", "c")``."""
297
+ await self._key_call(keys=list(keys), action="press")
298
+
299
+ async def down(self, keys: Union[str, List[str]]) -> None:
300
+ await self._key_call(keys=[keys] if isinstance(keys, str) else keys, action="down")
301
+
302
+ async def up(self, keys: Union[str, List[str]]) -> None:
303
+ await self._key_call(keys=[keys] if isinstance(keys, str) else keys, action="up")
304
+
305
+
306
+ class _Display:
307
+ """Display actions: ``desktop.display``."""
308
+
309
+ def __init__(self, d: Desktop) -> None:
310
+ self._d = d
311
+
312
+ async def set(self, w: int, h: int) -> None:
313
+ await self._d._call("display.set", {"w": w, "h": h})
314
+
315
+ async def size(self) -> Dict[str, int]:
316
+ """Current display size ``{w, h}``."""
317
+ return await self._d._call("display.size", {})
318
+
319
+ async def cursor(self) -> Dict[str, int]:
320
+ """Current cursor position ``{x, y}``."""
321
+ return await self._d._call("display.cursor", {})
322
+
323
+
324
+ class _Clipboard:
325
+ """Clipboard actions: ``desktop.clipboard``."""
326
+
327
+ def __init__(self, d: Desktop) -> None:
328
+ self._d = d
329
+
330
+ async def get(self) -> str:
331
+ r = await self._d._call("clipboard.get", {})
332
+ return r.get("text") or ""
333
+
334
+ async def set(self, text: str) -> None:
335
+ await self._d._call("clipboard.set", {"text": text})
336
+
337
+
338
+ class _Process:
339
+ """Process actions: ``desktop.process``."""
340
+
341
+ def __init__(self, d: Desktop) -> None:
342
+ self._d = d
343
+
344
+ async def list(self) -> List[ProcessInfo]:
345
+ r = await self._d._call("process.list", {})
346
+ return [
347
+ ProcessInfo(pid=int(p["pid"]), name=str(p.get("name", "")), cmd=p.get("cmd"))
348
+ for p in r.get("processes", [])
349
+ ]
350
+
351
+ async def kill(self, pid: int) -> None:
352
+ await self._d._call("process.kill", {"pid": pid})
353
+
354
+ async def start(
355
+ self, cmd: str, *, args: Optional[List[str]] = None, cwd: Optional[str] = None
356
+ ) -> int:
357
+ r = await self._d._call("process.start", {"cmd": cmd, "args": args or [], "cwd": cwd})
358
+ return int(r["pid"])
359
+
360
+ async def signal(self, pid: int, signal: Optional[int] = None) -> None:
361
+ await self._d._call("process.signal", {"pid": pid, "signal": signal})
362
+
363
+
364
+ class _Ports:
365
+ """Listening-port introspection: ``desktop.ports``."""
366
+
367
+ def __init__(self, d: Desktop) -> None:
368
+ self._d = d
369
+
370
+ async def list(self) -> List[PortInfo]:
371
+ r = await self._d._call("ports.list", {})
372
+ return [
373
+ PortInfo(
374
+ port=int(p["port"]),
375
+ addr=str(p.get("addr", "")),
376
+ pid=int(p["pid"]) if p.get("pid") else None,
377
+ )
378
+ for p in r.get("ports", [])
379
+ ]
380
+
381
+
382
+ class _Pkg:
383
+ """Package installation: ``desktop.pkg``."""
384
+
385
+ def __init__(self, d: Desktop) -> None:
386
+ self._d = d
387
+
388
+ async def install(self, manager: PackageManager, packages: List[str]) -> PkgInstallResult:
389
+ r = await self._d._call("pkg.install", {"manager": manager, "packages": packages})
390
+ return PkgInstallResult(
391
+ exitCode=int(r.get("exitCode", 0)),
392
+ stdout=str(r.get("stdout", "")),
393
+ stderr=str(r.get("stderr", "")),
394
+ )
395
+
396
+
397
+ class _Record:
398
+ """Server-side session recording: ``desktop.record``."""
399
+
400
+ def __init__(self, d: Desktop) -> None:
401
+ self._d = d
402
+
403
+ async def start(
404
+ self,
405
+ fps: Optional[int] = None,
406
+ format: Optional[str] = None,
407
+ path: Optional[str] = None,
408
+ ) -> dict:
409
+ params: dict = {}
410
+ if fps is not None:
411
+ params["fps"] = fps
412
+ if format is not None:
413
+ params["format"] = format
414
+ if path is not None:
415
+ params["path"] = path
416
+ return await self._d._call("record.start", params)
417
+
418
+ async def stop(self) -> dict:
419
+ return await self._d._call("record.stop", {})
420
+
421
+
422
+ class _Stream:
423
+ """Live VNC stream control: ``desktop.stream``."""
424
+
425
+ def __init__(self, d: Desktop) -> None:
426
+ self._d = d
427
+
428
+ async def start(self) -> Dict[str, Any]:
429
+ """Return the embeddable stream URL (minted at create time)."""
430
+ return {"streamUrl": self._d.streamUrl}
431
+
432
+ async def stop(self) -> None:
433
+ """No-op: the RFB stream is a separate socket the caller owns."""
434
+ return None
435
+
436
+
437
+ __all__ = ["Desktop", "DesktopConfig", "ExecStreamHandler"]