brickly-sdk 0.1.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.
- brickly/__init__.py +31 -0
- brickly/api.py +601 -0
- brickly/errors.py +36 -0
- brickly/protocol.py +44 -0
- brickly/runtime.py +247 -0
- brickly/system.py +100 -0
- brickly_sdk-0.1.0.dist-info/METADATA +180 -0
- brickly_sdk-0.1.0.dist-info/RECORD +9 -0
- brickly_sdk-0.1.0.dist-info/WHEEL +4 -0
brickly/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .api import (
|
|
2
|
+
BricklyRuntime,
|
|
3
|
+
CommandContext,
|
|
4
|
+
EventEnvelope,
|
|
5
|
+
EventsApi,
|
|
6
|
+
UiApi,
|
|
7
|
+
WebContentsApi,
|
|
8
|
+
WindowHandle,
|
|
9
|
+
)
|
|
10
|
+
from .errors import BppError, payload_to_error
|
|
11
|
+
from .protocol import PROTOCOL_VERSION, PlatformSystemPathName
|
|
12
|
+
from .runtime import BppTransport
|
|
13
|
+
from .system import ClipboardApi, PlatformApi, SystemApi
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BppError",
|
|
17
|
+
"BppTransport",
|
|
18
|
+
"BricklyRuntime",
|
|
19
|
+
"ClipboardApi",
|
|
20
|
+
"CommandContext",
|
|
21
|
+
"EventEnvelope",
|
|
22
|
+
"EventsApi",
|
|
23
|
+
"PlatformApi",
|
|
24
|
+
"PROTOCOL_VERSION",
|
|
25
|
+
"PlatformSystemPathName",
|
|
26
|
+
"SystemApi",
|
|
27
|
+
"UiApi",
|
|
28
|
+
"WebContentsApi",
|
|
29
|
+
"WindowHandle",
|
|
30
|
+
"payload_to_error",
|
|
31
|
+
]
|
brickly/api.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, DefaultDict, Dict, Iterator, List, Optional
|
|
11
|
+
|
|
12
|
+
from .errors import BppError
|
|
13
|
+
from .protocol import PROTOCOL_VERSION, BrickUiWindowOptions, InvokeStreamEvent
|
|
14
|
+
from .runtime import BppTransport
|
|
15
|
+
from .system import PlatformApi, SystemApi
|
|
16
|
+
|
|
17
|
+
CommandHandler = Callable[["CommandContext", Any], Any]
|
|
18
|
+
EventHandler = Callable[[Any, Dict[str, Any]], None]
|
|
19
|
+
_current_request_id: ContextVar[Optional[str]] = ContextVar("brickly_current_request_id", default=None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _current_parent_request_id() -> Optional[str]:
|
|
23
|
+
return _current_request_id.get()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_parent_request_id(operation: str) -> str:
|
|
27
|
+
parent_request_id = _current_parent_request_id()
|
|
28
|
+
if not parent_request_id:
|
|
29
|
+
raise BppError(
|
|
30
|
+
"PARENT_INVOCATION_REQUIRED",
|
|
31
|
+
f"{operation} must run inside command handler; use invoke_root() for root calls",
|
|
32
|
+
)
|
|
33
|
+
return parent_request_id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _explicit_payload_request_id(args: List[Any]) -> Optional[str]:
|
|
37
|
+
if len(args) < 2 or not isinstance(args[1], dict):
|
|
38
|
+
return None
|
|
39
|
+
request_id = args[1].get("requestId")
|
|
40
|
+
return request_id if isinstance(request_id, str) and request_id else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EventEnvelope:
|
|
45
|
+
event: str
|
|
46
|
+
payload: Any
|
|
47
|
+
source_brick_id: str
|
|
48
|
+
published_at: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CommandContext:
|
|
52
|
+
def __init__(self, runtime: "BricklyRuntime", request_id: str, command_id: str) -> None:
|
|
53
|
+
self.request_id = request_id
|
|
54
|
+
self.command_id = command_id
|
|
55
|
+
self._runtime = runtime
|
|
56
|
+
self._cancel_event = threading.Event()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ui(self) -> "UiApi":
|
|
60
|
+
return self._runtime.ui
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def events(self) -> "EventsApi":
|
|
64
|
+
return self._runtime.events
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def platform(self) -> PlatformApi:
|
|
68
|
+
return self._runtime.platform
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def system(self) -> SystemApi:
|
|
72
|
+
return self._runtime.system
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def config(self) -> Dict[str, Any]:
|
|
76
|
+
return self._runtime.config
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def cancel_event(self) -> threading.Event:
|
|
80
|
+
return self._cancel_event
|
|
81
|
+
|
|
82
|
+
def is_cancelled(self) -> bool:
|
|
83
|
+
return self._cancel_event.is_set()
|
|
84
|
+
|
|
85
|
+
def on_cancel(self, handler: Callable[[], None]) -> None:
|
|
86
|
+
self._runtime._set_cancel_handler(self.request_id, handler)
|
|
87
|
+
if self.is_cancelled():
|
|
88
|
+
handler()
|
|
89
|
+
|
|
90
|
+
def progress(self, value: float, message: Optional[str] = None) -> None:
|
|
91
|
+
payload: Dict[str, Any] = {"type": "command.progress", "id": self.request_id, "progress": value}
|
|
92
|
+
if message:
|
|
93
|
+
payload["message"] = message
|
|
94
|
+
self._runtime.transport.send(payload)
|
|
95
|
+
|
|
96
|
+
def chunk(self, chunk: Any, name: Optional[str] = None) -> None:
|
|
97
|
+
payload: Dict[str, Any] = {"type": "command.chunk", "id": self.request_id, "chunk": chunk}
|
|
98
|
+
if name:
|
|
99
|
+
payload["name"] = name
|
|
100
|
+
self._runtime.transport.send(payload)
|
|
101
|
+
|
|
102
|
+
def output(self, name: str, value: Any) -> None:
|
|
103
|
+
self._runtime.transport.send({"type": "command.output", "id": self.request_id, "name": name, "value": value})
|
|
104
|
+
|
|
105
|
+
def invoke(
|
|
106
|
+
self,
|
|
107
|
+
brick_id: str,
|
|
108
|
+
command_id: str,
|
|
109
|
+
input_value: Any = None,
|
|
110
|
+
profile_id: Optional[str] = None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
return self._runtime.invoke(brick_id, command_id, input_value, profile_id)
|
|
113
|
+
|
|
114
|
+
def invoke_stream(
|
|
115
|
+
self,
|
|
116
|
+
brick_id: str,
|
|
117
|
+
command_id: str,
|
|
118
|
+
input_value: Any = None,
|
|
119
|
+
profile_id: Optional[str] = None,
|
|
120
|
+
) -> Iterator[InvokeStreamEvent]:
|
|
121
|
+
return self._runtime.invoke_stream(brick_id, command_id, input_value, profile_id)
|
|
122
|
+
|
|
123
|
+
def open_session(
|
|
124
|
+
self,
|
|
125
|
+
brick_id: str,
|
|
126
|
+
profile_id: Optional[str] = None,
|
|
127
|
+
) -> "BrickSession":
|
|
128
|
+
return self._runtime.open_session(brick_id, profile_id)
|
|
129
|
+
|
|
130
|
+
def _cancel(self) -> None:
|
|
131
|
+
self._cancel_event.set()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WebContentsApi:
|
|
135
|
+
def __init__(self, window: "WindowHandle") -> None:
|
|
136
|
+
self._window = window
|
|
137
|
+
|
|
138
|
+
def send(self, channel: str, *args: Any) -> None:
|
|
139
|
+
self._window.call("webContents.send", [channel, *args])
|
|
140
|
+
|
|
141
|
+
def execute_javascript(self, code: str, user_gesture: Optional[bool] = None) -> Any:
|
|
142
|
+
args: List[Any] = [code] if user_gesture is None else [code, user_gesture]
|
|
143
|
+
return self._window.call("webContents.executeJavaScript", args)
|
|
144
|
+
|
|
145
|
+
def open_dev_tools(self, options: Optional[Dict[str, Any]] = None) -> None:
|
|
146
|
+
self._window.call("webContents.openDevTools", [] if options is None else [options])
|
|
147
|
+
|
|
148
|
+
def close_dev_tools(self) -> None:
|
|
149
|
+
self._window.call("webContents.closeDevTools")
|
|
150
|
+
|
|
151
|
+
def toggle_dev_tools(self) -> None:
|
|
152
|
+
self._window.call("webContents.toggleDevTools")
|
|
153
|
+
|
|
154
|
+
def is_dev_tools_opened(self) -> bool:
|
|
155
|
+
return bool(self._window.call("webContents.isDevToolsOpened"))
|
|
156
|
+
|
|
157
|
+
def get_url(self) -> str:
|
|
158
|
+
return str(self._window.call("webContents.getURL"))
|
|
159
|
+
|
|
160
|
+
def get_title(self) -> str:
|
|
161
|
+
return str(self._window.call("webContents.getTitle"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class WindowHandle:
|
|
165
|
+
def __init__(self, runtime: "BricklyRuntime", window_id: int) -> None:
|
|
166
|
+
self._runtime = runtime
|
|
167
|
+
self.id = window_id
|
|
168
|
+
self.closed = False
|
|
169
|
+
self._listeners: DefaultDict[str, List[Callable[[Any], None]]] = defaultdict(list)
|
|
170
|
+
self.web_contents = WebContentsApi(self)
|
|
171
|
+
|
|
172
|
+
def on(self, event: str, handler: Callable[[Any], None]) -> Callable[[], None]:
|
|
173
|
+
self._listeners[event].append(handler)
|
|
174
|
+
|
|
175
|
+
def off() -> None:
|
|
176
|
+
try:
|
|
177
|
+
self._listeners[event].remove(handler)
|
|
178
|
+
except ValueError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
return off
|
|
182
|
+
|
|
183
|
+
def call(self, method: str, args: Optional[List[Any]] = None) -> Any:
|
|
184
|
+
if self.closed and method != "isDestroyed":
|
|
185
|
+
raise BppError("INVALID_INPUT", f"Window {self.id} already closed")
|
|
186
|
+
payload: Dict[str, Any] = {"type": "host.ui.callWindow", "windowId": self.id, "method": method, "args": args or []}
|
|
187
|
+
parent_request_id = _current_parent_request_id()
|
|
188
|
+
if method == "webContents.send" and not parent_request_id:
|
|
189
|
+
parent_request_id = _explicit_payload_request_id(args or [])
|
|
190
|
+
if method == "webContents.send" and not parent_request_id:
|
|
191
|
+
raise BppError(
|
|
192
|
+
"PARENT_INVOCATION_REQUIRED",
|
|
193
|
+
"web_contents.send() must run inside command handler or include payload.requestId",
|
|
194
|
+
)
|
|
195
|
+
if method == "webContents.send":
|
|
196
|
+
payload["parentRequestId"] = parent_request_id
|
|
197
|
+
return self._runtime.transport.host_call(
|
|
198
|
+
payload
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def close(self) -> None:
|
|
202
|
+
if self.closed:
|
|
203
|
+
return
|
|
204
|
+
self.closed = True
|
|
205
|
+
self._runtime.transport.host_call({"type": "host.ui.closeWindow", "windowId": self.id})
|
|
206
|
+
|
|
207
|
+
def emit(self, event: str, payload: Any) -> None:
|
|
208
|
+
if event == "closed":
|
|
209
|
+
self.closed = True
|
|
210
|
+
self._runtime._windows.pop(self.id, None)
|
|
211
|
+
for handler in list(self._listeners.get(event, [])):
|
|
212
|
+
handler(payload)
|
|
213
|
+
|
|
214
|
+
def set_bounds(self, bounds: Dict[str, Any]) -> None:
|
|
215
|
+
self.call("setBounds", [bounds])
|
|
216
|
+
|
|
217
|
+
def get_bounds(self) -> Any:
|
|
218
|
+
return self.call("getBounds")
|
|
219
|
+
|
|
220
|
+
def set_position(self, x: int, y: int) -> None:
|
|
221
|
+
self.call("setPosition", [x, y])
|
|
222
|
+
|
|
223
|
+
def get_position(self) -> Any:
|
|
224
|
+
return self.call("getPosition")
|
|
225
|
+
|
|
226
|
+
def set_size(self, width: int, height: int) -> None:
|
|
227
|
+
self.call("setSize", [width, height])
|
|
228
|
+
|
|
229
|
+
def get_size(self) -> Any:
|
|
230
|
+
return self.call("getSize")
|
|
231
|
+
|
|
232
|
+
def set_opacity(self, opacity: float) -> None:
|
|
233
|
+
self.call("setOpacity", [opacity])
|
|
234
|
+
|
|
235
|
+
def get_opacity(self) -> float:
|
|
236
|
+
return float(self.call("getOpacity"))
|
|
237
|
+
|
|
238
|
+
def set_always_on_top(self, flag: bool, level: Optional[str] = None) -> None:
|
|
239
|
+
self.call("setAlwaysOnTop", [flag] if level is None else [flag, level])
|
|
240
|
+
|
|
241
|
+
def set_ignore_mouse_events(self, ignore: bool, options: Optional[Dict[str, Any]] = None) -> None:
|
|
242
|
+
self.call("setIgnoreMouseEvents", [ignore] if options is None else [ignore, options])
|
|
243
|
+
|
|
244
|
+
def minimize(self) -> None:
|
|
245
|
+
self.call("minimize")
|
|
246
|
+
|
|
247
|
+
def maximize(self) -> None:
|
|
248
|
+
self.call("maximize")
|
|
249
|
+
|
|
250
|
+
def restore(self) -> None:
|
|
251
|
+
self.call("restore")
|
|
252
|
+
|
|
253
|
+
def hide(self) -> None:
|
|
254
|
+
self.call("hide")
|
|
255
|
+
|
|
256
|
+
def show(self) -> None:
|
|
257
|
+
self.call("show")
|
|
258
|
+
|
|
259
|
+
def focus(self) -> None:
|
|
260
|
+
self.call("focus")
|
|
261
|
+
|
|
262
|
+
def load_url(self, url: str, options: Optional[Dict[str, Any]] = None) -> None:
|
|
263
|
+
self.call("loadURL", [url] if options is None else [url, options])
|
|
264
|
+
|
|
265
|
+
def load_file(self, file_path: str, options: Optional[Dict[str, Any]] = None) -> None:
|
|
266
|
+
self.call("loadFile", [file_path] if options is None else [file_path, options])
|
|
267
|
+
|
|
268
|
+
def reload(self) -> None:
|
|
269
|
+
self.call("reload")
|
|
270
|
+
|
|
271
|
+
def is_destroyed(self) -> bool:
|
|
272
|
+
return bool(self.call("isDestroyed"))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class UiApi:
|
|
276
|
+
def __init__(self, runtime: "BricklyRuntime") -> None:
|
|
277
|
+
self._runtime = runtime
|
|
278
|
+
|
|
279
|
+
def create_browser_window(self, url: str, options: Optional[BrickUiWindowOptions] = None) -> WindowHandle:
|
|
280
|
+
result = self._runtime.transport.host_call(
|
|
281
|
+
{"type": "host.ui.createBrowserWindow", "url": url, "options": options or {}}
|
|
282
|
+
)
|
|
283
|
+
window_id = int(result.get("windowId")) if isinstance(result, dict) else int(result)
|
|
284
|
+
handle = WindowHandle(self._runtime, window_id)
|
|
285
|
+
self._runtime._windows[window_id] = handle
|
|
286
|
+
return handle
|
|
287
|
+
|
|
288
|
+
def list_windows(self) -> Any:
|
|
289
|
+
return self._runtime.transport.host_call({"type": "host.ui.listWindows"})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class EventsApi:
|
|
293
|
+
def __init__(self, runtime: "BricklyRuntime") -> None:
|
|
294
|
+
self._runtime = runtime
|
|
295
|
+
|
|
296
|
+
def on(self, event: str, handler: EventHandler) -> Callable[[], None]:
|
|
297
|
+
self._runtime._event_listeners[event].append(handler)
|
|
298
|
+
|
|
299
|
+
def off() -> None:
|
|
300
|
+
try:
|
|
301
|
+
self._runtime._event_listeners[event].remove(handler)
|
|
302
|
+
except ValueError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
return off
|
|
306
|
+
|
|
307
|
+
def publish(self, event: str, payload: Optional[Any] = None) -> None:
|
|
308
|
+
message: Dict[str, Any] = {"type": "host.event.publish", "event": event}
|
|
309
|
+
if payload is not None:
|
|
310
|
+
message["payload"] = payload
|
|
311
|
+
self._runtime.transport.host_call(message)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class BrickSession:
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
runtime: "BricklyRuntime",
|
|
318
|
+
session_id: str,
|
|
319
|
+
brick_id: str,
|
|
320
|
+
profile_id: Optional[str] = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
self._runtime = runtime
|
|
323
|
+
self.id = session_id
|
|
324
|
+
self.brick_id = brick_id
|
|
325
|
+
self.profile_id = profile_id
|
|
326
|
+
|
|
327
|
+
def invoke(self, command_id: str, input_value: Any = None) -> Any:
|
|
328
|
+
parent_request_id = _require_parent_request_id("session.invoke()")
|
|
329
|
+
return self._runtime.transport.host_call(
|
|
330
|
+
{
|
|
331
|
+
"type": "host.session.invoke",
|
|
332
|
+
"sessionId": self.id,
|
|
333
|
+
"commandId": command_id,
|
|
334
|
+
"input": input_value,
|
|
335
|
+
"parentRequestId": parent_request_id,
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def close(self) -> None:
|
|
340
|
+
self._runtime.transport.host_call({"type": "host.session.close", "sessionId": self.id})
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class BricklyRuntime:
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
brick_id: str,
|
|
347
|
+
protocol_version: str = PROTOCOL_VERSION,
|
|
348
|
+
transport: Optional[BppTransport] = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
self.brick_id = brick_id
|
|
351
|
+
self.protocol_version = protocol_version
|
|
352
|
+
self.transport = transport or BppTransport(brick_id)
|
|
353
|
+
self.ui = UiApi(self)
|
|
354
|
+
self.events = EventsApi(self)
|
|
355
|
+
self.platform = PlatformApi(self)
|
|
356
|
+
self.system = self.platform.system
|
|
357
|
+
self.config: Dict[str, Any] = {}
|
|
358
|
+
self._command_handlers: Dict[str, CommandHandler] = {}
|
|
359
|
+
self._cancel_handlers: Dict[str, Callable[[], None]] = {}
|
|
360
|
+
self._contexts: Dict[str, CommandContext] = {}
|
|
361
|
+
self._event_listeners: DefaultDict[str, List[EventHandler]] = defaultdict(list)
|
|
362
|
+
self._windows: Dict[int, WindowHandle] = {}
|
|
363
|
+
self._ready_handler: Optional[Callable[[], Any]] = None
|
|
364
|
+
self._shutdown_handler: Optional[Callable[[], Any]] = None
|
|
365
|
+
self._started = False
|
|
366
|
+
self._done = threading.Event()
|
|
367
|
+
self.transport.on("command", self._handle_command)
|
|
368
|
+
self.transport.on("event", self._handle_event)
|
|
369
|
+
self.transport.on("message", self._handle_message)
|
|
370
|
+
self.transport.on("shutdown", self._handle_shutdown)
|
|
371
|
+
self.transport.on("end", self._done.set)
|
|
372
|
+
self.transport.on("error", lambda error: self.transport.log("transport error:", repr(error)))
|
|
373
|
+
|
|
374
|
+
def on_command(self, command_id: str, handler: Optional[CommandHandler] = None):
|
|
375
|
+
def decorator(fn: CommandHandler) -> CommandHandler:
|
|
376
|
+
self._command_handlers[command_id] = fn
|
|
377
|
+
return fn
|
|
378
|
+
|
|
379
|
+
if handler is not None:
|
|
380
|
+
return decorator(handler)
|
|
381
|
+
return decorator
|
|
382
|
+
|
|
383
|
+
def on_ready(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
384
|
+
self._ready_handler = handler
|
|
385
|
+
return handler
|
|
386
|
+
|
|
387
|
+
def on_shutdown(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
388
|
+
self._shutdown_handler = handler
|
|
389
|
+
return handler
|
|
390
|
+
|
|
391
|
+
def start(self, block: bool = True) -> "BricklyRuntime":
|
|
392
|
+
if self._started:
|
|
393
|
+
return self
|
|
394
|
+
self._started = True
|
|
395
|
+
self.transport.start()
|
|
396
|
+
if block:
|
|
397
|
+
self._done.wait()
|
|
398
|
+
return self
|
|
399
|
+
|
|
400
|
+
run = start
|
|
401
|
+
|
|
402
|
+
def stop(self) -> None:
|
|
403
|
+
self.transport.stop("brick stopped")
|
|
404
|
+
self._done.set()
|
|
405
|
+
|
|
406
|
+
def log(self, *parts: Any) -> None:
|
|
407
|
+
self.transport.log(*parts)
|
|
408
|
+
|
|
409
|
+
def invoke(
|
|
410
|
+
self,
|
|
411
|
+
brick_id: str,
|
|
412
|
+
command_id: str,
|
|
413
|
+
input_value: Any = None,
|
|
414
|
+
profile_id: Optional[str] = None,
|
|
415
|
+
) -> Any:
|
|
416
|
+
parent_request_id = _require_parent_request_id("invoke()")
|
|
417
|
+
message: Dict[str, Any] = {
|
|
418
|
+
"type": "host.invoke",
|
|
419
|
+
"brickId": brick_id,
|
|
420
|
+
"commandId": command_id,
|
|
421
|
+
"input": input_value,
|
|
422
|
+
"parentRequestId": parent_request_id,
|
|
423
|
+
}
|
|
424
|
+
if profile_id:
|
|
425
|
+
message["profileId"] = profile_id
|
|
426
|
+
return self.transport.host_call(message)
|
|
427
|
+
|
|
428
|
+
def invoke_root(
|
|
429
|
+
self,
|
|
430
|
+
brick_id: str,
|
|
431
|
+
command_id: str,
|
|
432
|
+
input_value: Any = None,
|
|
433
|
+
profile_id: Optional[str] = None,
|
|
434
|
+
) -> Any:
|
|
435
|
+
message: Dict[str, Any] = {
|
|
436
|
+
"type": "host.invokeRoot",
|
|
437
|
+
"brickId": brick_id,
|
|
438
|
+
"commandId": command_id,
|
|
439
|
+
"input": input_value,
|
|
440
|
+
}
|
|
441
|
+
if profile_id:
|
|
442
|
+
message["profileId"] = profile_id
|
|
443
|
+
return self.transport.host_call(message)
|
|
444
|
+
|
|
445
|
+
def invoke_stream(
|
|
446
|
+
self,
|
|
447
|
+
brick_id: str,
|
|
448
|
+
command_id: str,
|
|
449
|
+
input_value: Any = None,
|
|
450
|
+
profile_id: Optional[str] = None,
|
|
451
|
+
) -> Iterator[InvokeStreamEvent]:
|
|
452
|
+
def stream() -> Iterator[InvokeStreamEvent]:
|
|
453
|
+
parent_request_id = _require_parent_request_id("invoke_stream()")
|
|
454
|
+
message: Dict[str, Any] = {
|
|
455
|
+
"type": "host.invoke",
|
|
456
|
+
"brickId": brick_id,
|
|
457
|
+
"commandId": command_id,
|
|
458
|
+
"input": input_value,
|
|
459
|
+
"stream": True,
|
|
460
|
+
"parentRequestId": parent_request_id,
|
|
461
|
+
}
|
|
462
|
+
if profile_id:
|
|
463
|
+
message["profileId"] = profile_id
|
|
464
|
+
yield from self.transport.host_call_stream(message)
|
|
465
|
+
|
|
466
|
+
return stream()
|
|
467
|
+
|
|
468
|
+
def open_session(
|
|
469
|
+
self,
|
|
470
|
+
brick_id: str,
|
|
471
|
+
profile_id: Optional[str] = None,
|
|
472
|
+
) -> BrickSession:
|
|
473
|
+
message: Dict[str, Any] = {"type": "host.session.open", "brickId": brick_id}
|
|
474
|
+
if profile_id:
|
|
475
|
+
message["profileId"] = profile_id
|
|
476
|
+
result = self.transport.host_call(message)
|
|
477
|
+
if not isinstance(result, dict):
|
|
478
|
+
raise BppError("PROTOCOL_ERROR", "host.session.open returned invalid result")
|
|
479
|
+
return BrickSession(
|
|
480
|
+
self,
|
|
481
|
+
str(result.get("sessionId") or ""),
|
|
482
|
+
str(result.get("brickId") or brick_id),
|
|
483
|
+
str(result["profileId"]) if result.get("profileId") else None,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _handle_message(self, message: Dict[str, Any]) -> None:
|
|
487
|
+
if message.get("type") != "host.hello":
|
|
488
|
+
return
|
|
489
|
+
config = message.get("config")
|
|
490
|
+
self.config = config if isinstance(config, dict) else {}
|
|
491
|
+
self.transport.send(
|
|
492
|
+
{"type": "runtime.ready", "protocolVersion": self.protocol_version, "brickId": self.brick_id}
|
|
493
|
+
)
|
|
494
|
+
if self._ready_handler:
|
|
495
|
+
threading.Thread(target=self._run_ready, name="brickly-ready", daemon=True).start()
|
|
496
|
+
|
|
497
|
+
def _run_ready(self) -> None:
|
|
498
|
+
try:
|
|
499
|
+
assert self._ready_handler is not None
|
|
500
|
+
self._ready_handler()
|
|
501
|
+
except BaseException as error: # noqa: BLE001
|
|
502
|
+
self.transport.log("on_ready error:", BppError.from_exception(error).message)
|
|
503
|
+
|
|
504
|
+
def _handle_command(self, message: Dict[str, Any]) -> None:
|
|
505
|
+
msg_type = message.get("type")
|
|
506
|
+
if msg_type == "command.cancel":
|
|
507
|
+
req_id = str(message.get("id") or "")
|
|
508
|
+
ctx = self._contexts.get(req_id)
|
|
509
|
+
if ctx:
|
|
510
|
+
ctx._cancel()
|
|
511
|
+
handler = self._cancel_handlers.get(req_id)
|
|
512
|
+
if handler:
|
|
513
|
+
try:
|
|
514
|
+
handler()
|
|
515
|
+
except BaseException as error: # noqa: BLE001
|
|
516
|
+
self.transport.log("cancel handler error:", repr(error))
|
|
517
|
+
return
|
|
518
|
+
if msg_type != "command.invoke":
|
|
519
|
+
return
|
|
520
|
+
req_id = str(message.get("id") or "")
|
|
521
|
+
command_id = str(message.get("commandId") or "")
|
|
522
|
+
handler = self._command_handlers.get(command_id)
|
|
523
|
+
if handler is None:
|
|
524
|
+
self.transport.send(
|
|
525
|
+
{
|
|
526
|
+
"type": "command.error",
|
|
527
|
+
"id": req_id,
|
|
528
|
+
"error": {"code": "COMMAND_NOT_FOUND", "message": f"Unknown command: {command_id}"},
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
ctx = CommandContext(self, req_id, command_id)
|
|
533
|
+
self._contexts[req_id] = ctx
|
|
534
|
+
thread = threading.Thread(
|
|
535
|
+
target=self._run_command,
|
|
536
|
+
args=(handler, ctx, message.get("input")),
|
|
537
|
+
name=f"brickly-command-{command_id}",
|
|
538
|
+
daemon=True,
|
|
539
|
+
)
|
|
540
|
+
thread.start()
|
|
541
|
+
|
|
542
|
+
def _run_command(self, handler: CommandHandler, ctx: CommandContext, input_value: Any) -> None:
|
|
543
|
+
token = _current_request_id.set(ctx.request_id)
|
|
544
|
+
try:
|
|
545
|
+
result = handler(ctx, input_value)
|
|
546
|
+
self.transport.send({"type": "command.result", "id": ctx.request_id, "result": result})
|
|
547
|
+
except BaseException as error: # noqa: BLE001
|
|
548
|
+
self.transport.send(
|
|
549
|
+
{"type": "command.error", "id": ctx.request_id, "error": BppError.from_exception(error).to_json()}
|
|
550
|
+
)
|
|
551
|
+
finally:
|
|
552
|
+
_current_request_id.reset(token)
|
|
553
|
+
self._contexts.pop(ctx.request_id, None)
|
|
554
|
+
self._cancel_handlers.pop(ctx.request_id, None)
|
|
555
|
+
|
|
556
|
+
def _handle_event(self, message: Dict[str, Any]) -> None:
|
|
557
|
+
event = str(message.get("event") or "")
|
|
558
|
+
payload = message.get("payload")
|
|
559
|
+
if event.startswith("window.") and isinstance(payload, dict):
|
|
560
|
+
window_id = payload.get("windowId")
|
|
561
|
+
if isinstance(window_id, (int, float)):
|
|
562
|
+
handle = self._windows.get(int(window_id))
|
|
563
|
+
if handle:
|
|
564
|
+
handle.emit(event.removeprefix("window."), payload)
|
|
565
|
+
|
|
566
|
+
envelope = {
|
|
567
|
+
"event": event,
|
|
568
|
+
"payload": payload,
|
|
569
|
+
"sourceBrickId": message.get("sourceBrickId", ""),
|
|
570
|
+
"publishedAt": message.get("publishedAt", 0),
|
|
571
|
+
}
|
|
572
|
+
for handler in list(self._event_listeners.get(event, [])):
|
|
573
|
+
threading.Thread(
|
|
574
|
+
target=self._run_event_handler,
|
|
575
|
+
args=(handler, payload, envelope),
|
|
576
|
+
name=f"brickly-event-{event}",
|
|
577
|
+
daemon=True,
|
|
578
|
+
).start()
|
|
579
|
+
|
|
580
|
+
def _handle_shutdown(self) -> None:
|
|
581
|
+
threading.Thread(target=self._run_shutdown, name="brickly-shutdown", daemon=True).start()
|
|
582
|
+
|
|
583
|
+
def _run_event_handler(self, handler: EventHandler, payload: Any, envelope: Dict[str, Any]) -> None:
|
|
584
|
+
try:
|
|
585
|
+
handler(payload, envelope)
|
|
586
|
+
except BaseException as error: # noqa: BLE001
|
|
587
|
+
self.transport.log("event handler error:", repr(error))
|
|
588
|
+
|
|
589
|
+
def _run_shutdown(self) -> None:
|
|
590
|
+
try:
|
|
591
|
+
if self._shutdown_handler:
|
|
592
|
+
self._shutdown_handler()
|
|
593
|
+
except BaseException as error: # noqa: BLE001
|
|
594
|
+
self.transport.log("shutdown handler error:", repr(error))
|
|
595
|
+
self.transport.send({"type": "runtime.bye"})
|
|
596
|
+
time.sleep(0.05)
|
|
597
|
+
self._done.set()
|
|
598
|
+
sys.exit(0)
|
|
599
|
+
|
|
600
|
+
def _set_cancel_handler(self, req_id: str, handler: Callable[[], None]) -> None:
|
|
601
|
+
self._cancel_handlers[req_id] = handler
|
brickly/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BppError(Exception):
|
|
7
|
+
"""SDK error type that serializes directly to BPP error payloads."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: str, message: str, details: Optional[Any] = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
self.message = message
|
|
13
|
+
self.details = details
|
|
14
|
+
|
|
15
|
+
def to_json(self) -> Dict[str, Any]:
|
|
16
|
+
payload: Dict[str, Any] = {"code": self.code, "message": self.message}
|
|
17
|
+
if self.details is not None:
|
|
18
|
+
payload["details"] = self.details
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_exception(cls, error: BaseException) -> "BppError":
|
|
23
|
+
if isinstance(error, BppError):
|
|
24
|
+
return error
|
|
25
|
+
return cls("INTERNAL_ERROR", str(error) or error.__class__.__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def payload_to_error(payload: Optional[Dict[str, Any]]) -> BppError:
|
|
29
|
+
if not payload:
|
|
30
|
+
return BppError("INTERNAL_ERROR", "Unknown BPP error")
|
|
31
|
+
return BppError(
|
|
32
|
+
str(payload.get("code") or "INTERNAL_ERROR"),
|
|
33
|
+
str(payload.get("message") or "Unknown BPP error"),
|
|
34
|
+
payload.get("details"),
|
|
35
|
+
)
|
|
36
|
+
|
brickly/protocol.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Literal, TypedDict
|
|
4
|
+
|
|
5
|
+
PROTOCOL_VERSION = "0.1.0"
|
|
6
|
+
|
|
7
|
+
BridgeErrorCode = str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BridgeErrorPayload(TypedDict, total=False):
|
|
11
|
+
code: BridgeErrorCode
|
|
12
|
+
message: str
|
|
13
|
+
details: Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BppMessage = Dict[str, Any]
|
|
17
|
+
BrickUiWindowOptions = Dict[str, Any]
|
|
18
|
+
BrickWindowMethod = str
|
|
19
|
+
ClipboardContent = Any
|
|
20
|
+
ClipboardReadResult = Dict[str, Any]
|
|
21
|
+
ClipboardSetResult = Dict[str, Any]
|
|
22
|
+
HostResourceRegisterPayload = Dict[str, Any]
|
|
23
|
+
EventName = str
|
|
24
|
+
InvokeStreamEvent = Dict[str, Any]
|
|
25
|
+
CommandMessageType = Literal["command.invoke", "command.cancel"]
|
|
26
|
+
PlatformSystemPathName = Literal[
|
|
27
|
+
"home",
|
|
28
|
+
"appData",
|
|
29
|
+
"assets",
|
|
30
|
+
"userData",
|
|
31
|
+
"sessionData",
|
|
32
|
+
"temp",
|
|
33
|
+
"exe",
|
|
34
|
+
"module",
|
|
35
|
+
"desktop",
|
|
36
|
+
"documents",
|
|
37
|
+
"downloads",
|
|
38
|
+
"music",
|
|
39
|
+
"pictures",
|
|
40
|
+
"videos",
|
|
41
|
+
"recent",
|
|
42
|
+
"logs",
|
|
43
|
+
"crashDumps",
|
|
44
|
+
]
|
brickly/runtime.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import itertools
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, DefaultDict, Dict, Iterator, List, Optional, TextIO, Tuple
|
|
13
|
+
|
|
14
|
+
from .errors import BppError, payload_to_error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MessageHandler = Callable[[Dict[str, Any]], None]
|
|
18
|
+
VoidHandler = Callable[[], None]
|
|
19
|
+
ErrorHandler = Callable[[BaseException], None]
|
|
20
|
+
DEFAULT_HOST_CALL_TIMEOUT_SECONDS = 10.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ensure_utf8_stdio() -> None:
|
|
24
|
+
"""Re-open stdout/stderr with UTF-8 encoding so Chinese text survives pipe transport on Windows.
|
|
25
|
+
|
|
26
|
+
Uses surrogatepass so lone surrogates (from malformed data) are encoded into their
|
|
27
|
+
well-defined UTF-8 byte sequences instead of crashing the write. V8's UTF-8 decoder
|
|
28
|
+
handles these gracefully, and JavaScript strings natively support lone surrogates."""
|
|
29
|
+
for attr in ('stdout', 'stderr'):
|
|
30
|
+
stream = getattr(sys, attr)
|
|
31
|
+
if (
|
|
32
|
+
hasattr(stream, 'buffer')
|
|
33
|
+
and hasattr(stream, 'encoding')
|
|
34
|
+
and stream.encoding is not None
|
|
35
|
+
and stream.encoding.lower() != 'utf-8'
|
|
36
|
+
):
|
|
37
|
+
try:
|
|
38
|
+
setattr(
|
|
39
|
+
sys,
|
|
40
|
+
attr,
|
|
41
|
+
io.TextIOWrapper(stream.buffer, encoding='utf-8', errors='surrogatepass'),
|
|
42
|
+
)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_ensure_utf8_stdio()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BppTransport:
|
|
51
|
+
"""NDJSON-over-stdio transport for Brickly brick runtimes."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
brick_id: str,
|
|
56
|
+
stdin: Optional[TextIO] = None,
|
|
57
|
+
stdout: Optional[TextIO] = None,
|
|
58
|
+
stderr: Optional[TextIO] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.brick_id = brick_id
|
|
61
|
+
self.stdin = stdin or sys.stdin
|
|
62
|
+
self.stdout = stdout or sys.stdout
|
|
63
|
+
self.stderr = stderr or sys.stderr
|
|
64
|
+
self._id_counter = itertools.count(1)
|
|
65
|
+
self._stdout_lock = threading.Lock()
|
|
66
|
+
self._pending: Dict[str, queue.Queue[Tuple[bool, Any]]] = {}
|
|
67
|
+
self._stream_pending: Dict[str, queue.Queue[Tuple[str, Any]]] = {}
|
|
68
|
+
self._pending_lock = threading.Lock()
|
|
69
|
+
self._handlers: DefaultDict[str, List[Callable[..., None]]] = defaultdict(list)
|
|
70
|
+
self._started = False
|
|
71
|
+
self._stop_event = threading.Event()
|
|
72
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
73
|
+
|
|
74
|
+
def on(self, event: str, handler: Callable[..., None]) -> None:
|
|
75
|
+
self._handlers[event].append(handler)
|
|
76
|
+
|
|
77
|
+
def emit(self, event: str, *args: Any) -> None:
|
|
78
|
+
for handler in list(self._handlers.get(event, [])):
|
|
79
|
+
try:
|
|
80
|
+
handler(*args)
|
|
81
|
+
except BaseException as error: # noqa: BLE001
|
|
82
|
+
self.log("handler error:", repr(error))
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
if self._started:
|
|
86
|
+
return
|
|
87
|
+
self._started = True
|
|
88
|
+
self._reader_thread = threading.Thread(target=self._read_loop, name="brickly-bpp-reader", daemon=True)
|
|
89
|
+
self._reader_thread.start()
|
|
90
|
+
|
|
91
|
+
def stop(self, reason: str = "transport stopped") -> None:
|
|
92
|
+
if not self._started:
|
|
93
|
+
return
|
|
94
|
+
self._started = False
|
|
95
|
+
self._stop_event.set()
|
|
96
|
+
with self._pending_lock:
|
|
97
|
+
pending = list(self._pending.values())
|
|
98
|
+
self._pending.clear()
|
|
99
|
+
stream_pending = list(self._stream_pending.values())
|
|
100
|
+
self._stream_pending.clear()
|
|
101
|
+
error = BppError("PROCESS_EXITED", reason)
|
|
102
|
+
for waiter in pending:
|
|
103
|
+
waiter.put((False, error))
|
|
104
|
+
for waiter in stream_pending:
|
|
105
|
+
waiter.put(("error", error))
|
|
106
|
+
|
|
107
|
+
def wait(self) -> None:
|
|
108
|
+
if self._reader_thread:
|
|
109
|
+
self._reader_thread.join()
|
|
110
|
+
|
|
111
|
+
def send(self, message: Dict[str, Any]) -> None:
|
|
112
|
+
line = json.dumps(message, ensure_ascii=False, default=str) + "\n"
|
|
113
|
+
with self._stdout_lock:
|
|
114
|
+
self.stdout.write(line)
|
|
115
|
+
self.stdout.flush()
|
|
116
|
+
|
|
117
|
+
def log(self, *parts: Any) -> None:
|
|
118
|
+
rendered = " ".join(part if isinstance(part, str) else json.dumps(part, ensure_ascii=False, default=str) for part in parts)
|
|
119
|
+
self.stderr.write(f"{rendered}\n")
|
|
120
|
+
self.stderr.flush()
|
|
121
|
+
|
|
122
|
+
def host_call(self, message: Dict[str, Any], timeout: Optional[float] = DEFAULT_HOST_CALL_TIMEOUT_SECONDS) -> Any:
|
|
123
|
+
req_id = self._next_id()
|
|
124
|
+
waiter: queue.Queue[Tuple[bool, Any]] = queue.Queue(maxsize=1)
|
|
125
|
+
with self._pending_lock:
|
|
126
|
+
self._pending[req_id] = waiter
|
|
127
|
+
self.send({**message, "id": req_id})
|
|
128
|
+
try:
|
|
129
|
+
ok, value = waiter.get(timeout=timeout)
|
|
130
|
+
except queue.Empty as exc:
|
|
131
|
+
with self._pending_lock:
|
|
132
|
+
self._pending.pop(req_id, None)
|
|
133
|
+
raise BppError("TIMEOUT", f"Timed out waiting for host response: {message.get('type')}") from exc
|
|
134
|
+
if ok:
|
|
135
|
+
return value
|
|
136
|
+
raise value
|
|
137
|
+
|
|
138
|
+
def host_call_stream(
|
|
139
|
+
self,
|
|
140
|
+
message: Dict[str, Any],
|
|
141
|
+
timeout: Optional[float] = DEFAULT_HOST_CALL_TIMEOUT_SECONDS,
|
|
142
|
+
) -> Iterator[Dict[str, Any]]:
|
|
143
|
+
req_id = self._next_id()
|
|
144
|
+
waiter: queue.Queue[Tuple[str, Any]] = queue.Queue()
|
|
145
|
+
with self._pending_lock:
|
|
146
|
+
self._stream_pending[req_id] = waiter
|
|
147
|
+
self.send({**message, "id": req_id})
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
kind, value = waiter.get(timeout=timeout)
|
|
152
|
+
except queue.Empty as exc:
|
|
153
|
+
with self._pending_lock:
|
|
154
|
+
self._stream_pending.pop(req_id, None)
|
|
155
|
+
raise BppError("TIMEOUT", f"Timed out waiting for host response: {message.get('type')}") from exc
|
|
156
|
+
if kind == "error":
|
|
157
|
+
raise value
|
|
158
|
+
yield value
|
|
159
|
+
if kind == "result":
|
|
160
|
+
break
|
|
161
|
+
finally:
|
|
162
|
+
with self._pending_lock:
|
|
163
|
+
self._stream_pending.pop(req_id, None)
|
|
164
|
+
|
|
165
|
+
def _next_id(self) -> str:
|
|
166
|
+
return f"{self.brick_id}-{os.getpid()}-{next(self._id_counter)}"
|
|
167
|
+
|
|
168
|
+
def _read_loop(self) -> None:
|
|
169
|
+
try:
|
|
170
|
+
for raw in self.stdin:
|
|
171
|
+
if self._stop_event.is_set():
|
|
172
|
+
break
|
|
173
|
+
line = raw.strip()
|
|
174
|
+
if not line:
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
message = json.loads(line)
|
|
178
|
+
except json.JSONDecodeError as exc:
|
|
179
|
+
self.emit("error", BppError("PROTOCOL_ERROR", f"Invalid JSON line: {exc}"))
|
|
180
|
+
continue
|
|
181
|
+
if isinstance(message, dict):
|
|
182
|
+
self._dispatch(message)
|
|
183
|
+
finally:
|
|
184
|
+
self.emit("end")
|
|
185
|
+
|
|
186
|
+
def _dispatch(self, message: Dict[str, Any]) -> None:
|
|
187
|
+
msg_type = message.get("type")
|
|
188
|
+
if msg_type in ("host.invoke.progress", "host.invoke.chunk", "host.invoke.output"):
|
|
189
|
+
self._resolve_stream_event(str(message.get("id", "")), message)
|
|
190
|
+
return
|
|
191
|
+
if msg_type == "host.result":
|
|
192
|
+
if self._resolve_stream_result(str(message.get("id", "")), True, message.get("result")):
|
|
193
|
+
return
|
|
194
|
+
self._resolve_pending(str(message.get("id", "")), True, message.get("result"))
|
|
195
|
+
return
|
|
196
|
+
if msg_type == "host.error":
|
|
197
|
+
if self._resolve_stream_result(
|
|
198
|
+
str(message.get("id", "")),
|
|
199
|
+
False,
|
|
200
|
+
payload_to_error(message.get("error")),
|
|
201
|
+
):
|
|
202
|
+
return
|
|
203
|
+
self._resolve_pending(str(message.get("id", "")), False, payload_to_error(message.get("error")))
|
|
204
|
+
return
|
|
205
|
+
if msg_type == "runtime.ping":
|
|
206
|
+
self.send({"type": "runtime.pong", "id": message.get("id", "")})
|
|
207
|
+
return
|
|
208
|
+
if msg_type == "runtime.shutdown":
|
|
209
|
+
self.emit("shutdown")
|
|
210
|
+
return
|
|
211
|
+
if msg_type == "event.notify":
|
|
212
|
+
self.emit("event", message)
|
|
213
|
+
self.emit("message", message)
|
|
214
|
+
return
|
|
215
|
+
if msg_type in ("command.invoke", "command.cancel"):
|
|
216
|
+
self.emit("command", message)
|
|
217
|
+
self.emit("message", message)
|
|
218
|
+
return
|
|
219
|
+
self.emit("message", message)
|
|
220
|
+
|
|
221
|
+
def _resolve_pending(self, req_id: str, ok: bool, value: Any) -> None:
|
|
222
|
+
with self._pending_lock:
|
|
223
|
+
waiter = self._pending.pop(req_id, None)
|
|
224
|
+
if waiter:
|
|
225
|
+
waiter.put((ok, value))
|
|
226
|
+
|
|
227
|
+
def _resolve_stream_event(self, req_id: str, message: Dict[str, Any]) -> None:
|
|
228
|
+
with self._pending_lock:
|
|
229
|
+
waiter = self._stream_pending.get(req_id)
|
|
230
|
+
if not waiter:
|
|
231
|
+
return
|
|
232
|
+
msg_type = message.get("type")
|
|
233
|
+
event_type = str(msg_type).removeprefix("host.invoke.")
|
|
234
|
+
event = {key: value for key, value in message.items() if key not in ("type", "id")}
|
|
235
|
+
event["type"] = event_type
|
|
236
|
+
waiter.put(("event", event))
|
|
237
|
+
|
|
238
|
+
def _resolve_stream_result(self, req_id: str, ok: bool, value: Any) -> bool:
|
|
239
|
+
with self._pending_lock:
|
|
240
|
+
waiter = self._stream_pending.get(req_id)
|
|
241
|
+
if not waiter:
|
|
242
|
+
return False
|
|
243
|
+
if ok:
|
|
244
|
+
waiter.put(("result", {"type": "result", "result": value}))
|
|
245
|
+
else:
|
|
246
|
+
waiter.put(("error", value))
|
|
247
|
+
return True
|
brickly/system.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from .protocol import ClipboardContent, ClipboardReadResult, ClipboardSetResult, PlatformSystemPathName
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .api import BricklyRuntime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlatformApi:
|
|
12
|
+
def __init__(self, runtime: "BricklyRuntime") -> None:
|
|
13
|
+
self.system = SystemApi(runtime)
|
|
14
|
+
self.clipboard = ClipboardApi(runtime)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClipboardApi:
|
|
18
|
+
def __init__(self, runtime: "BricklyRuntime") -> None:
|
|
19
|
+
self._runtime = runtime
|
|
20
|
+
|
|
21
|
+
def read_content(self) -> ClipboardReadResult:
|
|
22
|
+
return self._runtime.transport.host_call({"type": "host.platform.clipboard.readContent"})
|
|
23
|
+
|
|
24
|
+
def set_content(self, content: ClipboardContent) -> ClipboardSetResult:
|
|
25
|
+
return self._runtime.transport.host_call(
|
|
26
|
+
{"type": "host.platform.clipboard.setContent", "content": content}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SystemApi:
|
|
31
|
+
def __init__(self, runtime: "BricklyRuntime") -> None:
|
|
32
|
+
self._runtime = runtime
|
|
33
|
+
|
|
34
|
+
def show_notification(self, body: str, click_feature_code: Optional[str] = None) -> None:
|
|
35
|
+
message: dict[str, str] = {"type": "host.platform.system.showNotification", "body": body}
|
|
36
|
+
if click_feature_code:
|
|
37
|
+
message["clickFeatureCode"] = click_feature_code
|
|
38
|
+
self._runtime.transport.host_call(message)
|
|
39
|
+
|
|
40
|
+
def shell_open_path(self, full_path: str) -> None:
|
|
41
|
+
self._runtime.transport.host_call(
|
|
42
|
+
{"type": "host.platform.system.shellOpenPath", "fullPath": full_path}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def shell_trash_item(self, full_path: str) -> None:
|
|
46
|
+
self._runtime.transport.host_call(
|
|
47
|
+
{"type": "host.platform.system.shellTrashItem", "fullPath": full_path}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def shell_show_item_in_folder(self, full_path: str) -> None:
|
|
51
|
+
self._runtime.transport.host_call(
|
|
52
|
+
{"type": "host.platform.system.shellShowItemInFolder", "fullPath": full_path}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def shell_open_external(self, url: str) -> None:
|
|
56
|
+
self._runtime.transport.host_call({"type": "host.platform.system.shellOpenExternal", "url": url})
|
|
57
|
+
|
|
58
|
+
def shell_beep(self) -> None:
|
|
59
|
+
self._runtime.transport.host_call({"type": "host.platform.system.shellBeep"})
|
|
60
|
+
|
|
61
|
+
def get_native_id(self) -> str:
|
|
62
|
+
return self._string_call("host.platform.system.getNativeId")
|
|
63
|
+
|
|
64
|
+
def get_app_name(self) -> str:
|
|
65
|
+
return self._string_call("host.platform.system.getAppName")
|
|
66
|
+
|
|
67
|
+
def get_app_version(self) -> str:
|
|
68
|
+
return self._string_call("host.platform.system.getAppVersion")
|
|
69
|
+
|
|
70
|
+
def get_path(self, name: PlatformSystemPathName) -> str:
|
|
71
|
+
result = self._runtime.transport.host_call({"type": "host.platform.system.getPath", "name": name})
|
|
72
|
+
return "" if result is None else str(result)
|
|
73
|
+
|
|
74
|
+
def get_file_icon(self, file_path: str) -> str:
|
|
75
|
+
result = self._runtime.transport.host_call(
|
|
76
|
+
{"type": "host.platform.system.getFileIcon", "filePath": file_path}
|
|
77
|
+
)
|
|
78
|
+
return "" if result is None else str(result)
|
|
79
|
+
|
|
80
|
+
def read_current_folder_path(self) -> str:
|
|
81
|
+
return self._string_call("host.platform.system.readCurrentFolderPath")
|
|
82
|
+
|
|
83
|
+
def read_current_browser_url(self) -> str:
|
|
84
|
+
return self._string_call("host.platform.system.readCurrentBrowserUrl")
|
|
85
|
+
|
|
86
|
+
def is_dev(self) -> bool:
|
|
87
|
+
return bool(self._runtime.transport.host_call({"type": "host.platform.system.isDev"}))
|
|
88
|
+
|
|
89
|
+
def is_macos(self) -> bool:
|
|
90
|
+
return bool(self._runtime.transport.host_call({"type": "host.platform.system.isMacOS"}))
|
|
91
|
+
|
|
92
|
+
def is_windows(self) -> bool:
|
|
93
|
+
return bool(self._runtime.transport.host_call({"type": "host.platform.system.isWindows"}))
|
|
94
|
+
|
|
95
|
+
def is_linux(self) -> bool:
|
|
96
|
+
return bool(self._runtime.transport.host_call({"type": "host.platform.system.isLinux"}))
|
|
97
|
+
|
|
98
|
+
def _string_call(self, message_type: str) -> str:
|
|
99
|
+
result = self._runtime.transport.host_call({"type": message_type})
|
|
100
|
+
return "" if result is None else str(result)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brickly-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Brickly brick runtimes.
|
|
5
|
+
Author: Brickly
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# brickly-sdk-python
|
|
11
|
+
|
|
12
|
+
Python SDK for Brickly brick runtimes. It speaks BPP over stdin/stdout and keeps stdout reserved for protocol messages.
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from brickly import BricklyRuntime
|
|
18
|
+
|
|
19
|
+
brick = BricklyRuntime("com.example.python")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@brick.on_command("hello")
|
|
23
|
+
def hello(ctx, input):
|
|
24
|
+
ctx.progress(0.5, "working...")
|
|
25
|
+
ctx.chunk("hello\n")
|
|
26
|
+
return {"ok": True, "input": input}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
brick.run()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The SDK handles:
|
|
33
|
+
|
|
34
|
+
- `host.hello` -> `runtime.ready`
|
|
35
|
+
- `runtime.ping` -> `runtime.pong`
|
|
36
|
+
- `command.invoke` dispatch
|
|
37
|
+
- `command.cancel` via `ctx.cancel_event`, `ctx.is_cancelled()`, and `ctx.on_cancel(...)`
|
|
38
|
+
- `command.progress`, `command.chunk`, and `command.output`
|
|
39
|
+
- `host.*` request id allocation and `host.result` / `host.error` routing
|
|
40
|
+
- `runtime.shutdown` -> optional shutdown hook -> `runtime.bye`
|
|
41
|
+
- `ui.create_browser_window`, `WindowHandle.call(...)`, and event routing for `window.*`
|
|
42
|
+
- `events.publish(...)` and `events.on(...)`
|
|
43
|
+
- `brick.platform.system.*` / `ctx.platform.system.*`, plus `brick.system.*` / `ctx.system.*` aliases
|
|
44
|
+
- `brick.invoke(...)` / `ctx.invoke(...)` for child cross-brick command calls inside command scope
|
|
45
|
+
- `brick.invoke_root(...)` for root cross-brick command calls outside command scope
|
|
46
|
+
- `brick.invoke_stream(...)` / `ctx.invoke_stream(...)` for streaming child cross-brick command calls
|
|
47
|
+
- `brick.open_session(...)` / `ctx.open_session(...)` for stateful cross-brick sessions
|
|
48
|
+
- `brick.platform.clipboard.read_content()` / `set_content(...)`
|
|
49
|
+
|
|
50
|
+
## Logging
|
|
51
|
+
|
|
52
|
+
`brick.log(...)` writes plain messages to stderr. Stdout is reserved for BPP protocol messages. The Brickly host log center attaches the brick id, source, stream, and scope metadata when collecting stderr, so brick code should not add a `[brickId]` prefix by itself.
|
|
53
|
+
|
|
54
|
+
## Window Example
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from brickly import BricklyRuntime
|
|
58
|
+
|
|
59
|
+
brick = BricklyRuntime("com.example.window")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@brick.on_command("open")
|
|
63
|
+
def open_window(ctx, input):
|
|
64
|
+
win = ctx.ui.create_browser_window("ui/index.html", {"width": 640, "height": 480})
|
|
65
|
+
win.on("closed", lambda payload: brick.log("closed", payload))
|
|
66
|
+
win.set_title("Hello from Python")
|
|
67
|
+
return {"windowId": win.id}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
brick.run()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Cross-Brick Invoke
|
|
74
|
+
|
|
75
|
+
Inside a command handler, use `ctx.invoke` to call another brick command. The SDK automatically sends the current `parentRequestId`, so the host attaches the child call to the same invocation graph. Brickly starts, reuses, and recycles the target brick instance automatically. Pass `profile_id` when the target brick should run with a specific Profile; omit it to use the target brick's default Profile.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
result = ctx.invoke(
|
|
79
|
+
"com.brickly.openai",
|
|
80
|
+
"chat",
|
|
81
|
+
{"prompt": "hello"},
|
|
82
|
+
profile_id="work",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use `invoke_stream` when the target command emits progress, chunks, or named outputs before its final result. The SDK sends `host.invoke` with `stream: true` and yields events in host order:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
for event in ctx.invoke_stream("com.brickly.openai", "chat", {"prompt": "hello"}):
|
|
90
|
+
if event["type"] == "progress":
|
|
91
|
+
ctx.progress(event.get("progress", 0), event.get("message"))
|
|
92
|
+
elif event["type"] == "chunk":
|
|
93
|
+
ctx.chunk(event.get("chunk"))
|
|
94
|
+
elif event["type"] == "output":
|
|
95
|
+
ctx.output(event["name"], event.get("value"))
|
|
96
|
+
elif event["type"] == "result":
|
|
97
|
+
return event["result"]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If the host returns `host.error`, `invoke_stream` raises `BppError`, matching normal `invoke` error handling.
|
|
101
|
+
|
|
102
|
+
To create a top-level cross-brick call outside command scope, use the explicit root API:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
result = brick.invoke_root(
|
|
106
|
+
"com.brickly.openai",
|
|
107
|
+
"chat",
|
|
108
|
+
{"prompt": "hello"},
|
|
109
|
+
profile_id="work",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The caller manifest must declare the target brick and allowed commands in `dependencies`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
"dependencies": {
|
|
117
|
+
"com.brickly.openai": {
|
|
118
|
+
"commands": ["chat"]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Platform System API
|
|
124
|
+
|
|
125
|
+
`brick.platform.system.*`, `brick.system.*`, `ctx.platform.system.*`, and `ctx.system.*` call host system capabilities through BPP `host.platform.system.*`:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@brick.on_command("show-app-info")
|
|
129
|
+
def show_app_info(ctx, _input):
|
|
130
|
+
return {
|
|
131
|
+
"appName": ctx.system.get_app_name(),
|
|
132
|
+
"appVersion": ctx.system.get_app_version(),
|
|
133
|
+
"userData": ctx.system.get_path("userData"),
|
|
134
|
+
"isMacOS": ctx.system.is_macos(),
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Current methods are `show_notification`, `shell_open_path`, `shell_trash_item`, `shell_show_item_in_folder`, `shell_open_external`, `shell_beep`, `get_native_id`, `get_app_name`, `get_app_version`, `get_path`, `get_file_icon`, `read_current_folder_path`, `read_current_browser_url`, `is_dev`, `is_macos`, `is_windows`, and `is_linux`.
|
|
139
|
+
|
|
140
|
+
`get_path()` supports `home`, `appData`, `assets`, `userData`, `sessionData`, `temp`, `exe`, `module`, `desktop`, `documents`, `downloads`, `music`, `pictures`, `videos`, `recent`, `logs`, and `crashDumps`. Runtime calls are still checked by manifest permissions on the host side: notifications require `os.notification`; Shell capabilities require `os.exec`; app info, paths, native ID, platform checks, and current folder path reads require `os.env`; file icons require `fs.read`. `read_current_folder_path()` works when Finder is frontmost on macOS or Explorer is frontmost on Windows; it raises `CURRENT_FOLDER_UNAVAILABLE` when no readable foreground file-manager folder is available. `read_current_browser_url()` is reserved and currently returns `UNSUPPORTED_PLATFORM`.
|
|
141
|
+
|
|
142
|
+
## Platform Clipboard API
|
|
143
|
+
|
|
144
|
+
`brick.platform.clipboard.*` and `ctx.platform.clipboard.*` call host clipboard capabilities through BPP `host.platform.clipboard.*`:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
@brick.on_command("replace-clipboard")
|
|
148
|
+
def replace_clipboard(ctx, _input):
|
|
149
|
+
previous = ctx.platform.clipboard.read_content()
|
|
150
|
+
updated = ctx.platform.clipboard.set_content({"kind": "text", "text": "Updated by Python"})
|
|
151
|
+
return {"previous": previous, "updated": updated}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Current methods are `read_content()` and `set_content(content)`.
|
|
155
|
+
|
|
156
|
+
## Cross-Brick Sessions
|
|
157
|
+
|
|
158
|
+
Use `ctx.open_session` when the target brick keeps in-memory state that must survive across multiple command calls. Calls through the same session are routed to the same target brick instance, and each `session.invoke` automatically carries the current `parentRequestId`, until `close()` is called or the caller brick instance exits.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
session = ctx.open_session("com.brickly.openai", profile_id="work")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
session.invoke("start-thread", {"title": "Draft"})
|
|
165
|
+
reply = session.invoke("chat", {"prompt": "continue the thread"})
|
|
166
|
+
finally:
|
|
167
|
+
session.close()
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`profile_id` is the target brick Profile ID. Session invokes are checked against the caller manifest's `dependencies[target].commands` on every command call.
|
|
171
|
+
|
|
172
|
+
## Errors
|
|
173
|
+
|
|
174
|
+
Raise `BppError` to preserve a specific Brickly error code:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from brickly import BppError
|
|
178
|
+
|
|
179
|
+
raise BppError("INVALID_INPUT", "url is required")
|
|
180
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
brickly/__init__.py,sha256=S3ouUBU8SRYpcPwDCKZ2JUeCNvNl1Qf4S-tmUa7x9PE,667
|
|
2
|
+
brickly/api.py,sha256=i2YmfwmqPHAPFtE9DC5khxbbavEI5QpGP1h_bf-jYHk,21380
|
|
3
|
+
brickly/errors.py,sha256=tHgmiCYX4JpYGKb6bXzlhTxAyZN8Z-GxzB_upVF9gBo,1167
|
|
4
|
+
brickly/protocol.py,sha256=-hnB5mbh7vXW3Z_6hCEDCxrZhB6vjEVOkv2gzpFPgpA,882
|
|
5
|
+
brickly/runtime.py,sha256=lHnILaNrUipJ6nDLiv8gAaHh9NdxCLZaR69eC8XLgN0,9290
|
|
6
|
+
brickly/system.py,sha256=lMfMHR4bfaGXolPIpfoLWn32QJpMbqGyumSPNWoOE8Y,3953
|
|
7
|
+
brickly_sdk-0.1.0.dist-info/METADATA,sha256=bk0_L4wVkCE1tYvckKk2jriX_E2GP6Uj7Lr0fRTF8AI,7116
|
|
8
|
+
brickly_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
brickly_sdk-0.1.0.dist-info/RECORD,,
|