gpui-bridge 0.2.0__py3-none-win_amd64.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.
- gpui_bridge/__init__.py +695 -0
- gpui_bridge/bin/gpui-bridge-host.exe +0 -0
- gpui_bridge/bin/host.json +6 -0
- gpui_bridge-0.2.0.dist-info/METADATA +88 -0
- gpui_bridge-0.2.0.dist-info/RECORD +8 -0
- gpui_bridge-0.2.0.dist-info/WHEEL +5 -0
- gpui_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- gpui_bridge-0.2.0.dist-info/top_level.txt +1 -0
gpui_bridge/__init__.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""gpui-bridge Python client: drives the gpui-bridge host over JSON Lines (stdlib only).
|
|
2
|
+
|
|
3
|
+
Same model as the Node client: build nodes, queue operations, `commit()` sends
|
|
4
|
+
them in one batch. `run()` blocks and calls event handlers on the calling thread
|
|
5
|
+
until the host exits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import queue
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections import deque
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
PROTOCOL_VERSION = 1
|
|
20
|
+
HANDSHAKE_TIMEOUT = 10.0
|
|
21
|
+
STDERR_TAIL_LINES = 20
|
|
22
|
+
# Window events a client can subscribe to (PROTOCOL.md section 3).
|
|
23
|
+
WINDOW_EVENTS = ("focus", "blur", "resize", "move", "visibility")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class HostExit:
|
|
28
|
+
code: int | None
|
|
29
|
+
# Last lines the host wrote to stderr, for crash reports.
|
|
30
|
+
stderr_tail: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class HostError:
|
|
35
|
+
"""An `error` message from the host: the operation was rejected, the host keeps running."""
|
|
36
|
+
|
|
37
|
+
code: str
|
|
38
|
+
message: str
|
|
39
|
+
op: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HostExitedError(Exception):
|
|
43
|
+
def __init__(self, message: str, exit: HostExit):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.exit = exit
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _Record:
|
|
50
|
+
type: str
|
|
51
|
+
parent: int | None = None
|
|
52
|
+
children: set[int] = field(default_factory=set)
|
|
53
|
+
handlers: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_host_path() -> str:
|
|
57
|
+
"""`$GPUI_BRIDGE_HOST`, then the host bundled in this package, then a Cargo build in this repository."""
|
|
58
|
+
if env := os.environ.get("GPUI_BRIDGE_HOST"):
|
|
59
|
+
return env
|
|
60
|
+
exe = "gpui-bridge-host.exe" if sys.platform == "win32" else "gpui-bridge-host"
|
|
61
|
+
package = Path(__file__).resolve().parent
|
|
62
|
+
# build_wheel.py puts the optimized host in bin/; a checkout of this repository has none.
|
|
63
|
+
bundled = package / "bin" / exe
|
|
64
|
+
if bundled.exists():
|
|
65
|
+
return str(bundled)
|
|
66
|
+
# Walk up for the workspace `target/`: how many folders separate this client from the
|
|
67
|
+
# repository root is not part of the contract, so a move must not break the lookup.
|
|
68
|
+
for parent in package.parents:
|
|
69
|
+
for profile in ("debug", "release"):
|
|
70
|
+
candidate = parent / "target" / profile / exe
|
|
71
|
+
if candidate.exists():
|
|
72
|
+
return str(candidate)
|
|
73
|
+
return str(package.parents[2] / "target" / "debug" / exe)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _props(kwargs: dict) -> dict:
|
|
77
|
+
# `class` is a Python keyword, so callers write `class_`.
|
|
78
|
+
return {key.rstrip("_"): value for key, value in kwargs.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _describe_exit(exit: HostExit) -> str:
|
|
82
|
+
tail = "\nLast host output:\n " + "\n ".join(exit.stderr_tail) if exit.stderr_tail else ""
|
|
83
|
+
return f"gpui-bridge host exited (code {exit.code}).{tail}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Node:
|
|
87
|
+
def __init__(self, app: "App", id: int, type: str):
|
|
88
|
+
self._app = app
|
|
89
|
+
self.id = id
|
|
90
|
+
self.type = type
|
|
91
|
+
|
|
92
|
+
def append(self, *children: "Node") -> "Node":
|
|
93
|
+
for child in children:
|
|
94
|
+
self._app._pending.append({"op": "append", "parent": self.id, "child": child.id})
|
|
95
|
+
self._app._attach(self.id, child.id)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def insert_before(self, child: "Node", before: "Node") -> "Node":
|
|
99
|
+
self._app._pending.append({"op": "insertBefore", "parent": self.id, "child": child.id, "before": before.id})
|
|
100
|
+
self._app._attach(self.id, child.id)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def set(self, **props) -> "Node":
|
|
104
|
+
"""Merges props; `None` removes one."""
|
|
105
|
+
self._app._pending.append({"op": "setProps", "id": self.id, "props": _props(props)})
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def on(self, event: str, handler) -> "Node":
|
|
109
|
+
"""Subscribes to an event. Handlers get the Input text, the Checkbox state, or nothing (click)."""
|
|
110
|
+
self._app._set_handler(self.id, event, handler)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def off(self, event: str) -> "Node":
|
|
114
|
+
self._app._set_handler(self.id, event, None)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def focus(self) -> "Node":
|
|
118
|
+
"""Gives the node the keyboard focus on the next commit (an Input, a Textarea or a div with keyDown)."""
|
|
119
|
+
self._app._pending.append({"op": "focus", "id": self.id})
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def remove(self) -> None:
|
|
123
|
+
"""Removes the node and all its descendants."""
|
|
124
|
+
self._app._remove(self.id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Window:
|
|
128
|
+
"""A window other than the main one (`App.create_window`)."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, app: "App", window_id: int):
|
|
131
|
+
self._app = app
|
|
132
|
+
self.id = window_id
|
|
133
|
+
self.on_closed = None # () -> None; the user closed the window
|
|
134
|
+
self._handlers: dict = {} # window event -> handler
|
|
135
|
+
self._events_sent: list[str] = []
|
|
136
|
+
|
|
137
|
+
def set(self, *, root: Node | None = None, **options) -> "Window":
|
|
138
|
+
"""Updates the window; like `App.window`, it opens on the first commit that sets `root`."""
|
|
139
|
+
self._app._queue_window(self.id, root, options)
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Closes the window on the next commit. `on_closed` is not called: it is for the user closing it."""
|
|
144
|
+
self._app._pending.append({"op": "closeWindow", "id": self.id})
|
|
145
|
+
self._app._open_windows.discard(self.id)
|
|
146
|
+
|
|
147
|
+
def on(self, event: str, handler) -> "Window":
|
|
148
|
+
"""Subscribes to a window event (`focus`, `blur`, `resize`, `move`, `visibility`) or to
|
|
149
|
+
`closed`. Handlers get the message: bounds for `move`/`resize`, `visible` for `visibility`."""
|
|
150
|
+
if event == "closed":
|
|
151
|
+
self.on_closed = handler
|
|
152
|
+
return self
|
|
153
|
+
if event not in WINDOW_EVENTS:
|
|
154
|
+
expected = ", ".join(("closed", *WINDOW_EVENTS))
|
|
155
|
+
raise ValueError(f"unknown window event '{event}' (expected one of {expected})")
|
|
156
|
+
self._handlers[event] = handler
|
|
157
|
+
self._sync_events()
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def off(self, event: str) -> "Window":
|
|
161
|
+
"""Unsubscribes; the last one removes the window's event subscription (no messages follow)."""
|
|
162
|
+
if event == "closed":
|
|
163
|
+
self.on_closed = None
|
|
164
|
+
return self
|
|
165
|
+
self._handlers.pop(event, None)
|
|
166
|
+
self._sync_events()
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def _sync_events(self) -> None:
|
|
170
|
+
"""Sends the subscription list only when the set of subscribed events changes."""
|
|
171
|
+
events = list(self._handlers)
|
|
172
|
+
if events != self._events_sent:
|
|
173
|
+
self._events_sent = events
|
|
174
|
+
self._app._queue_window(self.id, None, {"events": events})
|
|
175
|
+
|
|
176
|
+
def _dispatch_window_event(self, message: dict) -> None:
|
|
177
|
+
handler = self._handlers.get(message.get("event"))
|
|
178
|
+
if handler:
|
|
179
|
+
handler(message)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class App:
|
|
183
|
+
"""Starts the host and completes the handshake. Raises with a clear message if the host cannot start."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, host_path: str | None = None, *, debug: bool = False, headless: bool = False):
|
|
186
|
+
self.on_error = None # (HostError) -> None; without it errors are printed to stderr
|
|
187
|
+
self.on_window_closed = None # (window_id) -> None; the user closed a window
|
|
188
|
+
self.on_window_event = None # (message) -> None; a window event the app subscribed to
|
|
189
|
+
self.on_tray = None # (event, item) -> None; tray "click" (item None) or "menu" (item id)
|
|
190
|
+
self.on_shortcut = None # (keys, window_id) -> None; a key registered with shortcuts()
|
|
191
|
+
self.on_notification = None # (id, window_id) -> None; the user clicked a toast that has an id
|
|
192
|
+
self._has_tray = False # a tray keeps the host running after the last window closes
|
|
193
|
+
self._next_request = 1
|
|
194
|
+
self._results: dict[int, dict] = {} # answers that arrived while waiting for another one
|
|
195
|
+
self._next_id = 1
|
|
196
|
+
self._nodes: dict[int, _Record] = {}
|
|
197
|
+
self._pending: list[dict] = []
|
|
198
|
+
self._inbox: queue.Queue = queue.Queue()
|
|
199
|
+
self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
|
200
|
+
self._exit: HostExit | None = None
|
|
201
|
+
self._closing = False
|
|
202
|
+
self._next_window_id = 2
|
|
203
|
+
self._windows: dict[int, Window] = {}
|
|
204
|
+
# Windows given a root and not closed: the host exits when the user closes the last one.
|
|
205
|
+
self._open_windows: set[int] = set()
|
|
206
|
+
|
|
207
|
+
path = host_path or default_host_path()
|
|
208
|
+
args = [path, "--no-window"] if headless else [path]
|
|
209
|
+
try:
|
|
210
|
+
self._proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
raise HostExitedError(
|
|
213
|
+
f"gpui-bridge host not found at {path}. Build it with `cargo build` in host/ or pass host_path.",
|
|
214
|
+
HostExit(None, []),
|
|
215
|
+
) from None
|
|
216
|
+
|
|
217
|
+
self._stderr_thread = threading.Thread(target=self._read_stderr, args=(debug,), daemon=True)
|
|
218
|
+
self._stderr_thread.start()
|
|
219
|
+
threading.Thread(target=self._read_stdout, daemon=True).start()
|
|
220
|
+
self._write([{"op": "hello", "protocol": PROTOCOL_VERSION}])
|
|
221
|
+
self._handshake()
|
|
222
|
+
|
|
223
|
+
def div(self, **props) -> Node:
|
|
224
|
+
return self._create("div", props)
|
|
225
|
+
|
|
226
|
+
def text(self, **props) -> Node:
|
|
227
|
+
return self._create("text", props)
|
|
228
|
+
|
|
229
|
+
def button(self, **props) -> Node:
|
|
230
|
+
return self._create("Button", props)
|
|
231
|
+
|
|
232
|
+
def input(self, **props) -> Node:
|
|
233
|
+
return self._create("Input", props)
|
|
234
|
+
|
|
235
|
+
def checkbox(self, **props) -> Node:
|
|
236
|
+
return self._create("Checkbox", props)
|
|
237
|
+
|
|
238
|
+
def icon(self, **props) -> Node:
|
|
239
|
+
return self._create("Icon", props)
|
|
240
|
+
|
|
241
|
+
def image(self, **props) -> Node:
|
|
242
|
+
"""An image from `src` (a local file) or `data` (standard base64 of the bytes,
|
|
243
|
+
for images the app downloads itself). `fit` is the CSS `object-fit`."""
|
|
244
|
+
return self._create("Image", props)
|
|
245
|
+
|
|
246
|
+
def switch(self, **props) -> Node:
|
|
247
|
+
return self._create("Switch", props)
|
|
248
|
+
|
|
249
|
+
def radio_group(self, **props) -> Node:
|
|
250
|
+
"""`options=[{"value": ..., "label": ...}]`, `value`, `direction`."""
|
|
251
|
+
return self._create("RadioGroup", props)
|
|
252
|
+
|
|
253
|
+
def select(self, **props) -> Node:
|
|
254
|
+
"""`options=[{"value": ..., "label": ...}]`, `value`, `placeholder`, `searchable`."""
|
|
255
|
+
return self._create("Select", props)
|
|
256
|
+
|
|
257
|
+
def slider(self, **props) -> Node:
|
|
258
|
+
"""`value`, `min` (0), `max` (100), `step` (1); `change` passes a number."""
|
|
259
|
+
return self._create("Slider", props)
|
|
260
|
+
|
|
261
|
+
def textarea(self, **props) -> Node:
|
|
262
|
+
return self._create("Textarea", props)
|
|
263
|
+
|
|
264
|
+
def dialog(self, **props) -> Node:
|
|
265
|
+
"""A modal dialog shown while `open=True`; append its body as children. Events `ok`, `cancel`."""
|
|
266
|
+
return self._create("Dialog", props)
|
|
267
|
+
|
|
268
|
+
def virtual_list(self, **props) -> Node:
|
|
269
|
+
"""A list that draws only the rows in view (PROTOCOL.md section 15): append one child per
|
|
270
|
+
row; `rowHeight` (32). Give it a height (`class_="flex-1"`)."""
|
|
271
|
+
return self._create("VirtualList", props)
|
|
272
|
+
|
|
273
|
+
def table(self, **props) -> Node:
|
|
274
|
+
"""A data table with text cells: `columns=[{"key": ..., "name": ...}]`, `rows=[{key: cell}]`,
|
|
275
|
+
`selected`, `stripe`, `bordered`. `change` passes a row index (None when cleared),
|
|
276
|
+
`doubleClick` a row index, `sort` a dict `{"column": ..., "direction": "asc" | "desc" | "none"}`."""
|
|
277
|
+
return self._create("Table", props)
|
|
278
|
+
|
|
279
|
+
def tabs(self, **props) -> Node:
|
|
280
|
+
"""A tab bar: `tabs=[{"value": ..., "label": ...}]`, `value`, `variant`. Show the content yourself."""
|
|
281
|
+
return self._create("Tabs", props)
|
|
282
|
+
|
|
283
|
+
def tab(self, **props) -> Node:
|
|
284
|
+
"""One tab of a `tabs` bar: `value` (required, not empty), `label` (its text, before the
|
|
285
|
+
children), `disabled` and `class` (of the chip). Append the nodes of its chip (an `icon`, a
|
|
286
|
+
`text`, a `tag`; a last `button` is drawn after them); a `Tabs` with `tab` children uses
|
|
287
|
+
them and ignores `tabs`."""
|
|
288
|
+
return self._create("Tab", props)
|
|
289
|
+
|
|
290
|
+
def menu_bar(self, **props) -> Node:
|
|
291
|
+
"""A menu bar (PROTOCOL.md section 10): `menus=[{"label": ..., "items": [...]}]`."""
|
|
292
|
+
return self._create("MenuBar", props)
|
|
293
|
+
|
|
294
|
+
# gpui-kit's Basic, Form, Layout and Advanced groups (PROTOCOL.md section 17).
|
|
295
|
+
|
|
296
|
+
def alert(self, **props) -> Node:
|
|
297
|
+
"""An inline message: `message`, `title`, `variant`, `banner`, `closable`. Event `close`."""
|
|
298
|
+
return self._create("Alert", props)
|
|
299
|
+
|
|
300
|
+
def badge(self, **props) -> Node:
|
|
301
|
+
"""A count (`count`, `max`), a `dot` or an `icon`, over its children."""
|
|
302
|
+
return self._create("Badge", props)
|
|
303
|
+
|
|
304
|
+
def tag(self, **props) -> Node:
|
|
305
|
+
"""`label`, `variant` (primary, secondary, danger, success, warning, info), `outline`, `icon`."""
|
|
306
|
+
return self._create("Tag", props)
|
|
307
|
+
|
|
308
|
+
def kbd(self, **props) -> Node:
|
|
309
|
+
"""A key or shortcut, such as `keys="ctrl-s"`; `outline`."""
|
|
310
|
+
return self._create("Kbd", props)
|
|
311
|
+
|
|
312
|
+
def progress(self, **props) -> Node:
|
|
313
|
+
"""A bar: `value` from 0 to 100, `loading`."""
|
|
314
|
+
return self._create("Progress", props)
|
|
315
|
+
|
|
316
|
+
def spinner(self, **props) -> Node:
|
|
317
|
+
"""A spinner; size it with `class_` (`size-4`)."""
|
|
318
|
+
return self._create("Spinner", props)
|
|
319
|
+
|
|
320
|
+
def skeleton(self, **props) -> Node:
|
|
321
|
+
"""A loading placeholder; size it with `class_`."""
|
|
322
|
+
return self._create("Skeleton", props)
|
|
323
|
+
|
|
324
|
+
def rating(self, **props) -> Node:
|
|
325
|
+
"""Stars: `value`, `max` (5), `controlled`. `change` passes the chosen count."""
|
|
326
|
+
return self._create("Rating", props)
|
|
327
|
+
|
|
328
|
+
def pagination(self, **props) -> Node:
|
|
329
|
+
"""Pages: `page` (1-based), `total`, `compact`, `controlled`. `change` passes the page."""
|
|
330
|
+
return self._create("Pagination", props)
|
|
331
|
+
|
|
332
|
+
def collapsible(self, **props) -> Node:
|
|
333
|
+
"""Content shown while `open`; append it as children."""
|
|
334
|
+
return self._create("Collapsible", props)
|
|
335
|
+
|
|
336
|
+
def accordion(self, **props) -> Node:
|
|
337
|
+
"""Sections as `items=[{"id", "title", "content", "open", "disabled"}]`, `multiple`,
|
|
338
|
+
`bordered`, `controlled`. `change` passes the id of the section toggled."""
|
|
339
|
+
return self._create("Accordion", props)
|
|
340
|
+
|
|
341
|
+
def avatar(self, **props) -> Node:
|
|
342
|
+
"""A picture from `src`, or the initial of `name`."""
|
|
343
|
+
return self._create("Avatar", props)
|
|
344
|
+
|
|
345
|
+
def stepper(self, **props) -> Node:
|
|
346
|
+
"""Steps as `items=[{"value", "label"}]`, `value`, `vertical`, `controlled`.
|
|
347
|
+
`change` passes the chosen step's value."""
|
|
348
|
+
return self._create("Stepper", props)
|
|
349
|
+
|
|
350
|
+
def number_input(self, **props) -> Node:
|
|
351
|
+
"""A text field with increment and decrement buttons: `value`, `placeholder`, `controlled`."""
|
|
352
|
+
return self._create("NumberInput", props)
|
|
353
|
+
|
|
354
|
+
def otp_input(self, **props) -> Node:
|
|
355
|
+
"""A one-time code: `length` (6), `value`, `masked`, `controlled`."""
|
|
356
|
+
return self._create("OtpInput", props)
|
|
357
|
+
|
|
358
|
+
def form(self, **props) -> Node:
|
|
359
|
+
"""A form: `direction` ("vertical" or "horizontal"); append `field` nodes."""
|
|
360
|
+
return self._create("Form", props)
|
|
361
|
+
|
|
362
|
+
def field(self, **props) -> Node:
|
|
363
|
+
"""A label, an optional `description`, and its children as the control."""
|
|
364
|
+
return self._create("Field", props)
|
|
365
|
+
|
|
366
|
+
def group_box(self, **props) -> Node:
|
|
367
|
+
"""A titled box: `title`, `variant` (normal, fill, outline); append its content."""
|
|
368
|
+
return self._create("GroupBox", props)
|
|
369
|
+
|
|
370
|
+
def description_list(self, **props) -> Node:
|
|
371
|
+
"""Key-value rows: `items=[{"label", "value", "span"}]`, `columns`, `bordered`, `horizontal`."""
|
|
372
|
+
return self._create("DescriptionList", props)
|
|
373
|
+
|
|
374
|
+
def status_bar(self, **props) -> Node:
|
|
375
|
+
"""A status bar: `left` and `right` text."""
|
|
376
|
+
return self._create("StatusBar", props)
|
|
377
|
+
|
|
378
|
+
def toolbar(self, **props) -> Node:
|
|
379
|
+
"""A toolbar; append its content."""
|
|
380
|
+
return self._create("Toolbar", props)
|
|
381
|
+
|
|
382
|
+
def resizable(self, **props) -> Node:
|
|
383
|
+
"""Panels the user can resize: `axis` (horizontal or vertical), `sizes` in pixels. Append
|
|
384
|
+
one child per panel."""
|
|
385
|
+
return self._create("Resizable", props)
|
|
386
|
+
|
|
387
|
+
def calendar(self, **props) -> Node:
|
|
388
|
+
"""A month view: `value` ("YYYY-MM-DD"), `months`, `weekStartsOn`, `controlled`.
|
|
389
|
+
`change` passes the chosen day."""
|
|
390
|
+
return self._create("Calendar", props)
|
|
391
|
+
|
|
392
|
+
def tree(self, **props) -> Node:
|
|
393
|
+
"""Items in props: `items=[{"id", "label", "children", "expanded", "disabled"}]`, `value`
|
|
394
|
+
(the selected item id), `controlled`. `change` passes the chosen id; `expand` and
|
|
395
|
+
`collapse` pass a folder's id."""
|
|
396
|
+
return self._create("Tree", props)
|
|
397
|
+
|
|
398
|
+
def diff(self, **props) -> Node:
|
|
399
|
+
"""A patch: `patch` (a unified or Git diff), `mode` (unified or split), `lineNumbers`,
|
|
400
|
+
`softWrap`, `header`. `change` passes the selected lines ({path, side, start, end,
|
|
401
|
+
endSide}) or None."""
|
|
402
|
+
return self._create("Diff", props)
|
|
403
|
+
|
|
404
|
+
def carousel(self, **props) -> Node:
|
|
405
|
+
"""A carousel: `value` (the selected slide index), `axis`, `looping`, `controls`,
|
|
406
|
+
`pagination`, `controlled`. Append one child per slide; `change` passes the index."""
|
|
407
|
+
return self._create("Carousel", props)
|
|
408
|
+
|
|
409
|
+
def sidebar(self, **props) -> Node:
|
|
410
|
+
"""A sidebar: `items` (sections: {"label", "items": [{"id", "label", "icon",
|
|
411
|
+
"disabled", "open", "children"}]}), `value` (the active entry id), `collapsed`,
|
|
412
|
+
`collapsible` (icon, offcanvas, none), `side` (left, right), `controlled`. `change`
|
|
413
|
+
passes the clicked entry's id."""
|
|
414
|
+
return self._create("Sidebar", props)
|
|
415
|
+
|
|
416
|
+
def chart(self, **props) -> Node:
|
|
417
|
+
"""A chart: `chart` (line, bar, pie), `data` ([{"label", "value"}]), `color`, `name`,
|
|
418
|
+
plus the options of each plot (`curve`, `dot`, `horizontal`, `xAxis`, `xTicks`, `yAxis`, `yTicks`, `grid`,
|
|
419
|
+
`yMin`, `yMax`, `innerRadius`, `padAngle`, `interactive`). It has no events."""
|
|
420
|
+
return self._create("Chart", props)
|
|
421
|
+
|
|
422
|
+
def window(self, *, root: Node | None = None, **options) -> "App":
|
|
423
|
+
"""Creates or updates the main window (window 1). It opens on the first commit that sets `root`."""
|
|
424
|
+
self._queue_window(None, root, options)
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def create_window(self, *, root: Node | None = None, **options) -> Window:
|
|
428
|
+
"""Another window, with its own root; it opens on the first commit that sets `root`."""
|
|
429
|
+
window = Window(self, self._next_window_id)
|
|
430
|
+
self._next_window_id += 1
|
|
431
|
+
self._windows[window.id] = window
|
|
432
|
+
return window.set(root=root, **options)
|
|
433
|
+
|
|
434
|
+
def _queue_window(self, window_id: int | None, root: Node | None, options: dict) -> None:
|
|
435
|
+
message = {"op": "window", **({"id": window_id} if window_id is not None else {}), **options}
|
|
436
|
+
if root:
|
|
437
|
+
message["root"] = root.id
|
|
438
|
+
self._open_windows.add(window_id or 1)
|
|
439
|
+
self._pending.append(message)
|
|
440
|
+
|
|
441
|
+
def open_dialog(self, *, files: bool = True, directories: bool = False, multiple: bool = False, prompt: str | None = None):
|
|
442
|
+
"""Native "open" dialog: the chosen paths, or None if cancelled. Handlers keep running meanwhile."""
|
|
443
|
+
options = {"files": files, "directories": directories, "multiple": multiple, **({"prompt": prompt} if prompt else {})}
|
|
444
|
+
return self._request({"op": "openDialog", **options}, "paths")
|
|
445
|
+
|
|
446
|
+
def save_dialog(self, *, directory: str | None = None, name: str | None = None):
|
|
447
|
+
"""Native "save" dialog: the chosen path, or None if cancelled."""
|
|
448
|
+
options = {key: value for key, value in (("directory", directory), ("name", name)) if value is not None}
|
|
449
|
+
return self._request({"op": "saveDialog", **options}, "path")
|
|
450
|
+
|
|
451
|
+
def read_clipboard(self):
|
|
452
|
+
"""The clipboard's text, or None if it holds none."""
|
|
453
|
+
return self._request({"op": "readClipboard"}, "text")
|
|
454
|
+
|
|
455
|
+
def notify(self, message: str, **options) -> None:
|
|
456
|
+
"""Shows a toast now: `title`, `type`, `window`, `id`, `autohide`, `system`."""
|
|
457
|
+
self._write([{"op": "notify", "message": message, **options}])
|
|
458
|
+
|
|
459
|
+
def write_clipboard(self, text: str) -> None:
|
|
460
|
+
self._write([{"op": "writeClipboard", "text": text}])
|
|
461
|
+
|
|
462
|
+
def open_url(self, url: str) -> None:
|
|
463
|
+
"""Opens a URL with the system's handler (the browser for http)."""
|
|
464
|
+
self._write([{"op": "openUrl", "url": url}])
|
|
465
|
+
|
|
466
|
+
def open_path(self, path: str) -> None:
|
|
467
|
+
"""Opens a file or folder with its default app."""
|
|
468
|
+
self._write([{"op": "openPath", "path": path}])
|
|
469
|
+
|
|
470
|
+
def _request(self, message: dict, key: str):
|
|
471
|
+
"""Sends a system action and waits for its result, dispatching other messages meanwhile."""
|
|
472
|
+
if self._exit:
|
|
473
|
+
raise HostExitedError(_describe_exit(self._exit), self._exit)
|
|
474
|
+
request = self._next_request
|
|
475
|
+
self._next_request += 1
|
|
476
|
+
self._write([{**message, "request": request}])
|
|
477
|
+
while request not in self._results:
|
|
478
|
+
try:
|
|
479
|
+
message = self._inbox.get(timeout=0.5)
|
|
480
|
+
except queue.Empty:
|
|
481
|
+
continue
|
|
482
|
+
if isinstance(message, HostExit):
|
|
483
|
+
self._inbox.put(message) # left for run()
|
|
484
|
+
raise HostExitedError(_describe_exit(message), message)
|
|
485
|
+
if message.get("type") in ("result", "error") and message.get("request") is not None:
|
|
486
|
+
self._results[message.get("request")] = message
|
|
487
|
+
else:
|
|
488
|
+
self._dispatch(message)
|
|
489
|
+
answer = self._results.pop(request)
|
|
490
|
+
if answer.get("type") == "error":
|
|
491
|
+
raise RuntimeError(f"gpui-bridge: host rejected '{answer.get('op')}': {answer.get('message')}")
|
|
492
|
+
return answer.get(key)
|
|
493
|
+
|
|
494
|
+
def shortcuts(self, keys: list[str]) -> "App":
|
|
495
|
+
"""App-wide keyboard shortcuts ("ctrl-s", "f5"...), applied on the next commit; replaces the list."""
|
|
496
|
+
self._pending.append({"op": "shortcuts", "keys": list(keys)})
|
|
497
|
+
return self
|
|
498
|
+
|
|
499
|
+
def tray(self, **options) -> "App":
|
|
500
|
+
"""Shows or updates the system tray icon (PROTOCOL.md section 11): `icon`, `tooltip`, `menu`."""
|
|
501
|
+
self._pending.append({"op": "tray", **options})
|
|
502
|
+
self._has_tray = True
|
|
503
|
+
return self
|
|
504
|
+
|
|
505
|
+
def remove_tray(self) -> "App":
|
|
506
|
+
"""Removes the tray icon on the next commit; with no window open, the host then exits."""
|
|
507
|
+
self._pending.append({"op": "removeTray"})
|
|
508
|
+
self._has_tray = False
|
|
509
|
+
if not self._open_windows:
|
|
510
|
+
self._closing = True
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
def commit(self) -> None:
|
|
514
|
+
"""Sends every pending operation; the host renders once per commit."""
|
|
515
|
+
if self._exit:
|
|
516
|
+
raise HostExitedError(_describe_exit(self._exit), self._exit)
|
|
517
|
+
self._write([*self._pending, {"op": "commit"}])
|
|
518
|
+
self._pending.clear()
|
|
519
|
+
|
|
520
|
+
def run(self) -> HostExit:
|
|
521
|
+
"""Calls event handlers until the host exits (window closed, `close()` or crash)."""
|
|
522
|
+
try:
|
|
523
|
+
while self._exit is None:
|
|
524
|
+
try:
|
|
525
|
+
# A timeout keeps Ctrl+C responsive on Windows.
|
|
526
|
+
message = self._inbox.get(timeout=0.5)
|
|
527
|
+
except queue.Empty:
|
|
528
|
+
continue
|
|
529
|
+
if isinstance(message, HostExit):
|
|
530
|
+
self._exit = message
|
|
531
|
+
try:
|
|
532
|
+
self._proc.stdin.close()
|
|
533
|
+
except OSError:
|
|
534
|
+
pass # unflushed data to a dead host
|
|
535
|
+
# An exit after close() or windowClosed is expected; anything else is a crash.
|
|
536
|
+
if not self._closing:
|
|
537
|
+
print(_describe_exit(message), file=sys.stderr)
|
|
538
|
+
else:
|
|
539
|
+
self._dispatch(message)
|
|
540
|
+
except BaseException:
|
|
541
|
+
self._proc.kill()
|
|
542
|
+
raise
|
|
543
|
+
return self._exit
|
|
544
|
+
|
|
545
|
+
def close(self) -> HostExit:
|
|
546
|
+
"""Closes stdin (the host exits on EOF), handles remaining messages and waits for the exit."""
|
|
547
|
+
self._closing = True
|
|
548
|
+
try:
|
|
549
|
+
self._proc.stdin.close()
|
|
550
|
+
except OSError:
|
|
551
|
+
pass
|
|
552
|
+
return self.run()
|
|
553
|
+
|
|
554
|
+
def _create(self, type: str, props: dict) -> Node:
|
|
555
|
+
# Ids are positive, unique for the whole session and never reused.
|
|
556
|
+
id = self._next_id
|
|
557
|
+
self._next_id += 1
|
|
558
|
+
self._nodes[id] = _Record(type)
|
|
559
|
+
self._pending.append({"op": "create", "id": id, "type": type, "props": _props(props)})
|
|
560
|
+
return Node(self, id, type)
|
|
561
|
+
|
|
562
|
+
def _attach(self, parent: int, child: int) -> None:
|
|
563
|
+
# Mirrors the host's move semantics so `remove` can drop descendant handlers.
|
|
564
|
+
record = self._nodes.get(child)
|
|
565
|
+
if not record:
|
|
566
|
+
return
|
|
567
|
+
if record.parent is not None and record.parent in self._nodes:
|
|
568
|
+
self._nodes[record.parent].children.discard(child)
|
|
569
|
+
record.parent = parent
|
|
570
|
+
if parent in self._nodes:
|
|
571
|
+
self._nodes[parent].children.add(child)
|
|
572
|
+
|
|
573
|
+
def _remove(self, id: int) -> None:
|
|
574
|
+
record = self._nodes.get(id)
|
|
575
|
+
if not record:
|
|
576
|
+
return
|
|
577
|
+
if record.parent is not None and record.parent in self._nodes:
|
|
578
|
+
self._nodes[record.parent].children.discard(id)
|
|
579
|
+
stack = [id]
|
|
580
|
+
while stack:
|
|
581
|
+
node = self._nodes.pop(stack.pop(), None)
|
|
582
|
+
if node:
|
|
583
|
+
stack.extend(node.children)
|
|
584
|
+
self._pending.append({"op": "remove", "id": id})
|
|
585
|
+
|
|
586
|
+
def _set_handler(self, id: int, event: str, handler) -> None:
|
|
587
|
+
# Queues `setEvents` only when the subscription set changes.
|
|
588
|
+
record = self._nodes.get(id)
|
|
589
|
+
if not record:
|
|
590
|
+
return
|
|
591
|
+
had = event in record.handlers
|
|
592
|
+
if handler:
|
|
593
|
+
record.handlers[event] = handler
|
|
594
|
+
else:
|
|
595
|
+
record.handlers.pop(event, None)
|
|
596
|
+
if had != (event in record.handlers):
|
|
597
|
+
self._pending.append({"op": "setEvents", "id": id, "events": list(record.handlers)})
|
|
598
|
+
|
|
599
|
+
def _dispatch(self, message: dict) -> None:
|
|
600
|
+
match message.get("type"):
|
|
601
|
+
case "event":
|
|
602
|
+
record = self._nodes.get(message.get("id"))
|
|
603
|
+
handler = record and record.handlers.get(message.get("event"))
|
|
604
|
+
if not handler:
|
|
605
|
+
return
|
|
606
|
+
# A menu choice passes the item id; a Slider, a number.
|
|
607
|
+
if record.type in ("Checkbox", "Switch"):
|
|
608
|
+
handler(message.get("checked", False))
|
|
609
|
+
elif "paths" in message:
|
|
610
|
+
handler(message["paths"])
|
|
611
|
+
elif "value" in message or record.type in ("Input", "Textarea"):
|
|
612
|
+
handler(message.get("value", ""))
|
|
613
|
+
else:
|
|
614
|
+
handler()
|
|
615
|
+
case "error":
|
|
616
|
+
error = HostError(message.get("code"), message.get("message"), message.get("op"))
|
|
617
|
+
if self.on_error:
|
|
618
|
+
self.on_error(error)
|
|
619
|
+
else:
|
|
620
|
+
print(f"gpui-bridge: host rejected '{error.op}': {error.message}", file=sys.stderr)
|
|
621
|
+
case "windowClosed":
|
|
622
|
+
window_id = message.get("id", 1)
|
|
623
|
+
self._open_windows.discard(window_id)
|
|
624
|
+
# The host exits after the last window, unless there is a tray.
|
|
625
|
+
if not self._open_windows and not self._has_tray:
|
|
626
|
+
self._closing = True
|
|
627
|
+
window = self._windows.get(window_id)
|
|
628
|
+
if window and window.on_closed:
|
|
629
|
+
window.on_closed()
|
|
630
|
+
if self.on_window_closed:
|
|
631
|
+
self.on_window_closed(window_id)
|
|
632
|
+
case "windowEvent":
|
|
633
|
+
window = self._windows.get(message.get("id", 1))
|
|
634
|
+
if window:
|
|
635
|
+
window._dispatch_window_event(message)
|
|
636
|
+
if self.on_window_event:
|
|
637
|
+
self.on_window_event(message)
|
|
638
|
+
case "notification":
|
|
639
|
+
if self.on_notification:
|
|
640
|
+
self.on_notification(message.get("id"), message.get("window"))
|
|
641
|
+
case "shortcut":
|
|
642
|
+
if self.on_shortcut:
|
|
643
|
+
self.on_shortcut(message.get("keys"), message.get("window"))
|
|
644
|
+
case "tray":
|
|
645
|
+
if self.on_tray:
|
|
646
|
+
self.on_tray(message.get("event"), message.get("value"))
|
|
647
|
+
|
|
648
|
+
def _handshake(self) -> None:
|
|
649
|
+
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
|
|
650
|
+
while True:
|
|
651
|
+
try:
|
|
652
|
+
message = self._inbox.get(timeout=max(0.0, deadline - time.monotonic()))
|
|
653
|
+
except queue.Empty:
|
|
654
|
+
self._proc.kill()
|
|
655
|
+
raise TimeoutError(f"gpui-bridge host did not answer the handshake within {HANDSHAKE_TIMEOUT:g} s") from None
|
|
656
|
+
if isinstance(message, HostExit):
|
|
657
|
+
raise HostExitedError(_describe_exit(message), message)
|
|
658
|
+
if message.get("type") == "ready":
|
|
659
|
+
return
|
|
660
|
+
if message.get("type") == "error":
|
|
661
|
+
raise RuntimeError(f"gpui-bridge handshake failed ({message.get('code')}): {message.get('message')}")
|
|
662
|
+
|
|
663
|
+
def _write(self, messages: list[dict]) -> None:
|
|
664
|
+
if self._proc.stdin.closed:
|
|
665
|
+
return
|
|
666
|
+
try:
|
|
667
|
+
for message in messages:
|
|
668
|
+
self._proc.stdin.write(json.dumps(message, separators=(",", ":")).encode() + b"\n")
|
|
669
|
+
self._proc.stdin.flush()
|
|
670
|
+
except OSError:
|
|
671
|
+
pass # Writes racing a dying host fail; run() reports the real cause.
|
|
672
|
+
|
|
673
|
+
def _read_stdout(self) -> None:
|
|
674
|
+
for raw in self._proc.stdout:
|
|
675
|
+
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
|
676
|
+
try:
|
|
677
|
+
message = json.loads(line)
|
|
678
|
+
except ValueError:
|
|
679
|
+
message = None
|
|
680
|
+
if isinstance(message, dict):
|
|
681
|
+
self._inbox.put(message)
|
|
682
|
+
else:
|
|
683
|
+
print(f"gpui-bridge: host wrote a non-protocol line to stdout: {line}", file=sys.stderr)
|
|
684
|
+
self._proc.stdout.close()
|
|
685
|
+
# Wait for stderr too, so the exit carries the complete tail.
|
|
686
|
+
self._stderr_thread.join()
|
|
687
|
+
self._inbox.put(HostExit(self._proc.wait(), list(self._stderr_tail)))
|
|
688
|
+
|
|
689
|
+
def _read_stderr(self, debug: bool) -> None:
|
|
690
|
+
for raw in self._proc.stderr:
|
|
691
|
+
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
|
692
|
+
self._stderr_tail.append(line)
|
|
693
|
+
if debug:
|
|
694
|
+
print(f"[host] {line}", file=sys.stderr)
|
|
695
|
+
self._proc.stderr.close()
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gpui-bridge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the gpui-bridge native UI host
|
|
5
|
+
Author: gpui-bridge contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/InDIOS/gpui-bridge#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/InDIOS/gpui-bridge
|
|
9
|
+
Project-URL: Issues, https://github.com/InDIOS/gpui-bridge/issues
|
|
10
|
+
Project-URL: Protocol, https://github.com/InDIOS/gpui-bridge/blob/main/docs/PROTOCOL.md
|
|
11
|
+
Keywords: gpui,gpui-component,native,desktop,ui
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: MacOS X
|
|
14
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
15
|
+
Classifier: Environment :: X11 Applications
|
|
16
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# gpui-bridge (Python)
|
|
31
|
+
|
|
32
|
+
Native desktop UIs with [GPUI](https://www.gpui.rs/) and
|
|
33
|
+
[gpui-kit](https://gpui-kit.com/), driven from Python. A Rust host
|
|
34
|
+
owns the window, the event loop and the component tree; this client describes the UI as
|
|
35
|
+
operations on that tree and receives events. Standard library only.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from gpui_bridge import App
|
|
39
|
+
|
|
40
|
+
app = App()
|
|
41
|
+
count = 0
|
|
42
|
+
|
|
43
|
+
root = app.div(class_="flex flex-col items-center gap-4 p-6 size-full")
|
|
44
|
+
label = app.text(content="0", class_="text-xl font-bold")
|
|
45
|
+
|
|
46
|
+
def add(_event=None):
|
|
47
|
+
global count
|
|
48
|
+
count += 1
|
|
49
|
+
label.set(content=str(count))
|
|
50
|
+
app.commit()
|
|
51
|
+
|
|
52
|
+
root.append(label, app.button(label="Add", variant="primary").on("click", add))
|
|
53
|
+
app.window(title="Counter", width=320, height=200, root=root)
|
|
54
|
+
app.commit()
|
|
55
|
+
app.run()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pip install gpui-bridge
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.10+. The wheel is built per platform and carries an optimized
|
|
65
|
+
`gpui-bridge-host` binary, so its tag names that platform (`py3-none-win_amd64`,
|
|
66
|
+
`py3-none-macosx_11_0_arm64`, `py3-none-manylinux_2_35_x86_64`) and no Rust toolchain is
|
|
67
|
+
needed at runtime. Those three are the platforms with a wheel; the host has been drawn in a real
|
|
68
|
+
window only on Windows so far.
|
|
69
|
+
|
|
70
|
+
`class` is a Python keyword: write `class_="..."`, and the client strips the trailing underscore.
|
|
71
|
+
Props and events are keyword arguments, and every node has `append`, `insert_before`, `set`,
|
|
72
|
+
`remove`, `on` and `off`.
|
|
73
|
+
|
|
74
|
+
## Host lookup
|
|
75
|
+
|
|
76
|
+
1. `$GPUI_BRIDGE_HOST`
|
|
77
|
+
2. `gpui_bridge/bin/gpui-bridge-host[.exe]` inside the installed package
|
|
78
|
+
3. A Cargo build in `target/debug` or `target/release`, searched upwards (a checkout)
|
|
79
|
+
|
|
80
|
+
## Links
|
|
81
|
+
|
|
82
|
+
- [Protocol specification](https://github.com/InDIOS/gpui-bridge/blob/main/docs/PROTOCOL.md)
|
|
83
|
+
and the [conformance transcripts](https://github.com/InDIOS/gpui-bridge/tree/main/conformance):
|
|
84
|
+
everything a client in another language needs.
|
|
85
|
+
- [Examples](https://github.com/InDIOS/gpui-bridge/tree/main/examples).
|
|
86
|
+
- Node.js and React clients: `gpui-bridge` and `gpui-bridge-react` on npm.
|
|
87
|
+
|
|
88
|
+
Protocol version 1. MIT licensed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
gpui_bridge/__init__.py,sha256=9eS9ED0N7gNDsVmbzNURAzDw7169ui6RyMmthFg5_KM,30211
|
|
2
|
+
gpui_bridge/bin/gpui-bridge-host.exe,sha256=_UV1JiYwKlvStxgX8B3G4cKvZEiqYGgg1x0ElZqUwts,19076608
|
|
3
|
+
gpui_bridge/bin/host.json,sha256=uxmHKJOO9mpxc5KtfZIkil9AYxWYSxRHXICdVcl_Nes,147
|
|
4
|
+
gpui_bridge-0.2.0.dist-info/licenses/LICENSE,sha256=u_pqOxKY12j1fgcsnE3ADgQi5BE_wgGzBb2kalkJc1k,1081
|
|
5
|
+
gpui_bridge-0.2.0.dist-info/METADATA,sha256=FkNYmL9lF9TBY76i7s6BlZ3Ai6nEdk9nD7-2t4LBQcM,3380
|
|
6
|
+
gpui_bridge-0.2.0.dist-info/WHEEL,sha256=bhMFZwp6VMtrbl2Ck6PlXryKaCggO1NzYn_HcFbBF1I,97
|
|
7
|
+
gpui_bridge-0.2.0.dist-info/top_level.txt,sha256=FO3OApNVj-wGLrFJYiY5MU8ubLUGlCvLgm6PBoOaIdU,12
|
|
8
|
+
gpui_bridge-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gpui-bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gpui_bridge
|