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.
- solari_core/__init__.py +51 -0
- solari_core/_http.py +142 -0
- solari_core/desktop.py +437 -0
- solari_core/errors.py +148 -0
- solari_core/handle.py +758 -0
- solari_core/image.py +140 -0
- solari_core/sandbox.py +34 -0
- solari_core/template_client.py +203 -0
- solari_core/transport.py +349 -0
- solari_core/types.py +431 -0
- solari_core/volume_client.py +115 -0
- solari_core/ws.py +127 -0
- solari_core-0.2.0.dist-info/METADATA +28 -0
- solari_core-0.2.0.dist-info/RECORD +15 -0
- solari_core-0.2.0.dist-info/WHEEL +4 -0
solari_core/__init__.py
ADDED
|
@@ -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"]
|