mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
feat: enhance desktop computer-use streaming with neko integration
Improve desktop streaming architecture, add inspector dev tooling, React DesktopViewer updates, and computer-use documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4252c705df
commit
2d8508d6e2
17 changed files with 2712 additions and 688 deletions
7
docker/inspector-dev/Dockerfile
Normal file
7
docker/inspector-dev/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.28.2
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["bash", "-lc", "pnpm install --filter @sandbox-agent/inspector... && cd frontend/packages/inspector && exec pnpm vite --host 0.0.0.0 --port 5173"]
|
||||||
|
|
@ -6,6 +6,7 @@ COPY server/ ./server/
|
||||||
COPY gigacode/ ./gigacode/
|
COPY gigacode/ ./gigacode/
|
||||||
COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/
|
COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/
|
||||||
COPY scripts/agent-configs/ ./scripts/agent-configs/
|
COPY scripts/agent-configs/ ./scripts/agent-configs/
|
||||||
|
COPY scripts/audit-acp-deps/ ./scripts/audit-acp-deps/
|
||||||
|
|
||||||
ENV SANDBOX_AGENT_SKIP_INSPECTOR=1
|
ENV SANDBOX_AGENT_SKIP_INSPECTOR=1
|
||||||
|
|
||||||
|
|
@ -15,6 +16,12 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
cargo build -p sandbox-agent --release && \
|
cargo build -p sandbox-agent --release && \
|
||||||
cp target/release/sandbox-agent /sandbox-agent
|
cp target/release/sandbox-agent /sandbox-agent
|
||||||
|
|
||||||
|
# Extract neko binary from the official image for WebRTC desktop streaming.
|
||||||
|
# Using neko v3 base image from GHCR which provides multi-arch support (amd64, arm64).
|
||||||
|
# Pinned by digest to prevent breaking changes from upstream.
|
||||||
|
# Reference client: https://github.com/demodesk/neko-client/blob/37f93eae6bd55b333c94bd009d7f2b079075a026/src/component/internal/webrtc.ts
|
||||||
|
FROM ghcr.io/m1k1o/neko/base@sha256:0c384afa56268aaa2d5570211d284763d0840dcdd1a7d9a24be3081d94d3dfce AS neko-base
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
apt-get install -y -qq --no-install-recommends \
|
apt-get install -y -qq --no-install-recommends \
|
||||||
|
|
@ -26,6 +33,15 @@ RUN apt-get update -qq && \
|
||||||
xdotool \
|
xdotool \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
gstreamer1.0-tools \
|
||||||
|
gstreamer1.0-plugins-base \
|
||||||
|
gstreamer1.0-plugins-good \
|
||||||
|
gstreamer1.0-plugins-bad \
|
||||||
|
gstreamer1.0-plugins-ugly \
|
||||||
|
gstreamer1.0-nice \
|
||||||
|
gstreamer1.0-x \
|
||||||
|
gstreamer1.0-pulseaudio \
|
||||||
|
libxcvt0 \
|
||||||
x11-xserver-utils \
|
x11-xserver-utils \
|
||||||
dbus-x11 \
|
dbus-x11 \
|
||||||
xauth \
|
xauth \
|
||||||
|
|
@ -35,8 +51,11 @@ RUN apt-get update -qq && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
|
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
|
||||||
|
COPY --from=neko-base /usr/bin/neko /usr/local/bin/neko
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
# Expose UDP port range for WebRTC media transport
|
||||||
|
EXPOSE 59050-59070/udp
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/sandbox-agent"]
|
ENTRYPOINT ["/usr/local/bin/sandbox-agent"]
|
||||||
CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]
|
CMD ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]
|
||||||
|
|
|
||||||
560
docs/common-software.mdx
Normal file
560
docs/common-software.mdx
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
---
|
||||||
|
title: "Common Software"
|
||||||
|
description: "Install browsers, languages, databases, and other tools inside the sandbox."
|
||||||
|
sidebarTitle: "Common Software"
|
||||||
|
icon: "box-open"
|
||||||
|
---
|
||||||
|
|
||||||
|
The sandbox runs a Debian/Ubuntu base image. You can install software with `apt-get` via the [Process API](/processes) or by customizing your Docker image. This page covers commonly needed packages and how to install them.
|
||||||
|
|
||||||
|
## Browsers
|
||||||
|
|
||||||
|
### Chromium
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "chromium", "chromium-sandbox"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Launch headless
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "chromium",
|
||||||
|
args: ["--headless", "--no-sandbox", "--disable-gpu", "https://example.com"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","chromium","chromium-sandbox"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Use `--no-sandbox` when running Chromium inside a container. The container itself provides isolation.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "firefox-esr"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","firefox-esr"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Playwright browsers
|
||||||
|
|
||||||
|
Playwright bundles its own browser binaries. Install the Playwright CLI and let it download browsers for you.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "npx",
|
||||||
|
args: ["playwright", "install", "--with-deps", "chromium"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"npx","args":["playwright","install","--with-deps","chromium"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Languages and runtimes
|
||||||
|
|
||||||
|
### Node.js
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "nodejs", "npm"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","nodejs","npm"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
For a specific version, use [nvm](https://github.com/nvm-sh/nvm):
|
||||||
|
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . ~/.nvm/nvm.sh && nvm install 22"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
Python 3 is typically pre-installed. To add pip and common packages:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "python3", "python3-pip", "python3-venv"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "pip3",
|
||||||
|
args: ["install", "numpy", "pandas", "matplotlib"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","python3","python3-pip","python3-venv"]}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"pip3","args":["install","numpy","pandas","matplotlib"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Go
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to PATH for subsequent commands
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "export PATH=$PATH:/usr/local/go/bin && go version"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"bash","args":["-c","curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"bash","args":["-c","curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Java (OpenJDK)
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "default-jdk"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","default-jdk"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Ruby
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "ruby-full"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","ruby-full"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Databases
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "postgresql", "postgresql-client"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the service
|
||||||
|
const proc = await sdk.createProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "su - postgres -c 'pg_ctlcluster 15 main start'"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","postgresql","postgresql-client"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "sqlite3"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","sqlite3"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "redis-server"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const proc = await sdk.createProcess({
|
||||||
|
command: "redis-server",
|
||||||
|
args: ["--daemonize", "no"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","redis-server"]}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"redis-server","args":["--daemonize","no"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### MySQL / MariaDB
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "mariadb-server", "mariadb-client"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","mariadb-server","mariadb-client"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build tools
|
||||||
|
|
||||||
|
### Essential build toolchain
|
||||||
|
|
||||||
|
Most compiled software needs the standard build toolchain:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "build-essential", "cmake", "pkg-config"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","build-essential","cmake","pkg-config"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
This installs `gcc`, `g++`, `make`, `cmake`, and related tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop applications
|
||||||
|
|
||||||
|
These require the [Computer Use](/computer-use) desktop to be started first.
|
||||||
|
|
||||||
|
### LibreOffice
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "libreoffice"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","libreoffice"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### GIMP
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "gimp"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","gimp"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### VLC
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "vlc"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","vlc"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### VS Code (code-server)
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "curl -fsSL https://code-server.dev/install.sh | sh"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const proc = await sdk.createProcess({
|
||||||
|
command: "code-server",
|
||||||
|
args: ["--bind-addr", "0.0.0.0:8080", "--auth", "none"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"bash","args":["-c","curl -fsSL https://code-server.dev/install.sh | sh"]}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"code-server","args":["--bind-addr","0.0.0.0:8080","--auth","none"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI tools
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "git"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","git"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "bash",
|
||||||
|
args: ["-c", "curl -fsSL https://get.docker.com | sh"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"bash","args":["-c","curl -fsSL https://get.docker.com | sh"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### jq
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "jq"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","jq"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### tmux
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "tmux"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","tmux"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media and graphics
|
||||||
|
|
||||||
|
### FFmpeg
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "ffmpeg"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","ffmpeg"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### ImageMagick
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "imagemagick"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","imagemagick"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Poppler (PDF utilities)
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "poppler-utils"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert PDF to images
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "pdftoppm",
|
||||||
|
args: ["-png", "document.pdf", "output"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","poppler-utils"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-installing in a Docker image
|
||||||
|
|
||||||
|
For production use, install software in your Dockerfile instead of at runtime. This avoids repeated downloads and makes startup faster.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
chromium \
|
||||||
|
firefox-esr \
|
||||||
|
nodejs npm \
|
||||||
|
python3 python3-pip \
|
||||||
|
git curl wget \
|
||||||
|
build-essential \
|
||||||
|
sqlite3 \
|
||||||
|
ffmpeg \
|
||||||
|
imagemagick \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip3 install numpy pandas matplotlib
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Docker deployment](/deploy/docker) for how to use custom images with Sandbox Agent.
|
||||||
553
docs/computer-use.mdx
Normal file
553
docs/computer-use.mdx
Normal file
|
|
@ -0,0 +1,553 @@
|
||||||
|
---
|
||||||
|
title: "Computer Use"
|
||||||
|
description: "Control a virtual desktop inside the sandbox with mouse, keyboard, screenshots, recordings, and live streaming."
|
||||||
|
sidebarTitle: "Computer Use"
|
||||||
|
icon: "desktop"
|
||||||
|
---
|
||||||
|
|
||||||
|
Sandbox Agent provides a managed virtual desktop (Xvfb + openbox) that you can control programmatically. This is useful for browser automation, GUI testing, and AI computer-use workflows.
|
||||||
|
|
||||||
|
## Start and stop
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
baseUrl: "http://127.0.0.1:2468",
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await sdk.startDesktop({
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
dpi: 96,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(status.state); // "active"
|
||||||
|
console.log(status.display); // ":99"
|
||||||
|
|
||||||
|
// When done
|
||||||
|
await sdk.stopDesktop();
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/start" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"width":1920,"height":1080,"dpi":96}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/stop"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
All fields in the start request are optional. Defaults are 1440x900 at 96 DPI.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const status = await sdk.getDesktopStatus();
|
||||||
|
console.log(status.state); // "inactive" | "active" | "failed" | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/status"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Capture the full desktop or a specific region.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// Full screenshot (PNG by default)
|
||||||
|
const png = await sdk.takeDesktopScreenshot();
|
||||||
|
|
||||||
|
// JPEG at 70% quality, half scale
|
||||||
|
const jpeg = await sdk.takeDesktopScreenshot({
|
||||||
|
format: "jpeg",
|
||||||
|
quality: 70,
|
||||||
|
scale: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Region screenshot
|
||||||
|
const region = await sdk.takeDesktopRegionScreenshot({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/screenshot" --output screenshot.png
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/screenshot?format=jpeg&quality=70&scale=0.5" \
|
||||||
|
--output screenshot.jpg
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/screenshot/region?x=100&y=100&width=400&height=300" \
|
||||||
|
--output region.png
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Mouse
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// Get current position
|
||||||
|
const pos = await sdk.getDesktopMousePosition();
|
||||||
|
console.log(pos.x, pos.y);
|
||||||
|
|
||||||
|
// Move
|
||||||
|
await sdk.moveDesktopMouse({ x: 500, y: 300 });
|
||||||
|
|
||||||
|
// Click (left by default)
|
||||||
|
await sdk.clickDesktop({ x: 500, y: 300 });
|
||||||
|
|
||||||
|
// Right click
|
||||||
|
await sdk.clickDesktop({ x: 500, y: 300, button: "right" });
|
||||||
|
|
||||||
|
// Double click
|
||||||
|
await sdk.clickDesktop({ x: 500, y: 300, clickCount: 2 });
|
||||||
|
|
||||||
|
// Drag
|
||||||
|
await sdk.dragDesktopMouse({
|
||||||
|
startX: 100, startY: 100,
|
||||||
|
endX: 400, endY: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
await sdk.scrollDesktop({ x: 500, y: 300, deltaY: -3 });
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/mouse/position"
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/click" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"x":500,"y":300}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/drag" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"startX":100,"startY":100,"endX":400,"endY":400}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/mouse/scroll" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"x":500,"y":300,"deltaY":-3}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// Type text
|
||||||
|
await sdk.typeDesktopText({ text: "Hello, world!" });
|
||||||
|
|
||||||
|
// Press a key with modifiers
|
||||||
|
await sdk.pressDesktopKey({
|
||||||
|
key: "c",
|
||||||
|
modifiers: { ctrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Low-level key down/up
|
||||||
|
await sdk.keyDownDesktop({ key: "Shift_L" });
|
||||||
|
await sdk.keyUpDesktop({ key: "Shift_L" });
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/type" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"Hello, world!"}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/keyboard/press" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"key":"c","modifiers":{"ctrl":true}}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Display and windows
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const display = await sdk.getDesktopDisplayInfo();
|
||||||
|
console.log(display.resolution); // { width: 1920, height: 1080, dpi: 96 }
|
||||||
|
|
||||||
|
const { windows } = await sdk.listDesktopWindows();
|
||||||
|
for (const win of windows) {
|
||||||
|
console.log(win.title, win.x, win.y, win.width, win.height);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/display/info"
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/windows"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Recording
|
||||||
|
|
||||||
|
Record the desktop to MP4.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const recording = await sdk.startDesktopRecording({ fps: 30 });
|
||||||
|
console.log(recording.id);
|
||||||
|
|
||||||
|
// ... do things ...
|
||||||
|
|
||||||
|
const stopped = await sdk.stopDesktopRecording();
|
||||||
|
|
||||||
|
// List all recordings
|
||||||
|
const { recordings } = await sdk.listDesktopRecordings();
|
||||||
|
|
||||||
|
// Download
|
||||||
|
const mp4 = await sdk.downloadDesktopRecording(recording.id);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await sdk.deleteDesktopRecording(recording.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/start" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"fps":30}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/recording/stop"
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/recordings"
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/recordings/rec_1/download" --output recording.mp4
|
||||||
|
|
||||||
|
curl -X DELETE "http://127.0.0.1:2468/v1/desktop/recordings/rec_1"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Desktop processes
|
||||||
|
|
||||||
|
The desktop runtime manages several background processes (Xvfb, openbox, neko, ffmpeg). These are all registered with the general [Process API](/processes) under the `desktop` owner, so you can inspect logs, check status, and troubleshoot using the same tools you use for any other managed process.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// List all processes, including desktop-owned ones
|
||||||
|
const { processes } = await sdk.listProcesses();
|
||||||
|
|
||||||
|
const desktopProcs = processes.filter((p) => p.owner === "desktop");
|
||||||
|
for (const p of desktopProcs) {
|
||||||
|
console.log(p.id, p.command, p.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read logs from a specific desktop process
|
||||||
|
const logs = await sdk.getProcessLogs(desktopProcs[0].id, { tail: 50 });
|
||||||
|
for (const entry of logs.entries) {
|
||||||
|
console.log(entry.stream, atob(entry.data));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
# List all processes (desktop processes have owner: "desktop")
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes"
|
||||||
|
|
||||||
|
# Get logs from a specific desktop process
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
The desktop status endpoint also includes a summary of running processes:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const status = await sdk.getDesktopStatus();
|
||||||
|
for (const proc of status.processes) {
|
||||||
|
console.log(proc.name, proc.pid, proc.running);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/desktop/status"
|
||||||
|
# Response includes: processes: [{ name: "Xvfb", pid: 123, running: true }, ...]
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
| Process | Role | Restart policy |
|
||||||
|
|---------|------|---------------|
|
||||||
|
| Xvfb | Virtual X11 framebuffer | Auto-restart while desktop is active |
|
||||||
|
| openbox | Window manager | Auto-restart while desktop is active |
|
||||||
|
| neko | WebRTC streaming server (started by `startDesktopStream`) | No auto-restart |
|
||||||
|
| ffmpeg | Screen recorder (started by `startDesktopRecording`) | No auto-restart |
|
||||||
|
|
||||||
|
## Live streaming
|
||||||
|
|
||||||
|
Start a WebRTC stream for real-time desktop viewing in a browser.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.startDesktopStream();
|
||||||
|
|
||||||
|
// Connect via the React DesktopViewer component or
|
||||||
|
// use the WebSocket signaling endpoint directly
|
||||||
|
// at ws://127.0.0.1:2468/v1/desktop/stream/signaling
|
||||||
|
|
||||||
|
await sdk.stopDesktopStream();
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/start"
|
||||||
|
|
||||||
|
# Connect to ws://127.0.0.1:2468/v1/desktop/stream/signaling for WebRTC signaling
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/desktop/stream/stop"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
For a drop-in React component, see [React Components](/react-components).
|
||||||
|
|
||||||
|
## Customizing the desktop environment
|
||||||
|
|
||||||
|
The desktop runs inside the sandbox filesystem, so you can customize it using the [File System](/file-system) API before or after starting the desktop. The desktop HOME directory is located at `~/.local/state/sandbox-agent/desktop/home` (or `$XDG_STATE_HOME/sandbox-agent/desktop/home` if `XDG_STATE_HOME` is set).
|
||||||
|
|
||||||
|
All configuration files below are written to paths relative to this HOME directory.
|
||||||
|
|
||||||
|
### Window manager (openbox)
|
||||||
|
|
||||||
|
The desktop uses [openbox](http://openbox.org/) as its window manager. You can customize its behavior, theme, and keyboard shortcuts by writing an `rc.xml` config file.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const openboxConfig = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<openbox_config xmlns="http://openbox.org/3.4/rc">
|
||||||
|
<theme>
|
||||||
|
<name>Clearlooks</name>
|
||||||
|
<titleLayout>NLIMC</titleLayout>
|
||||||
|
<font place="ActiveWindow"><name>DejaVu Sans</name><size>10</size></font>
|
||||||
|
</theme>
|
||||||
|
<desktops><number>1</number></desktops>
|
||||||
|
<keyboard>
|
||||||
|
<keybind key="A-F4"><action name="Close"/></keybind>
|
||||||
|
<keybind key="A-Tab"><action name="NextWindow"/></keybind>
|
||||||
|
</keyboard>
|
||||||
|
</openbox_config>`;
|
||||||
|
|
||||||
|
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" },
|
||||||
|
openboxConfig,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox"
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/rc.xml" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @rc.xml
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Autostart programs
|
||||||
|
|
||||||
|
Openbox runs scripts in `~/.config/openbox/autostart` on startup. Use this to launch applications, set the background, or configure the environment.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const autostart = `#!/bin/sh
|
||||||
|
# Set a solid background color
|
||||||
|
xsetroot -solid "#1e1e2e" &
|
||||||
|
|
||||||
|
# Launch a terminal
|
||||||
|
xterm -geometry 120x40+50+50 &
|
||||||
|
|
||||||
|
# Launch a browser
|
||||||
|
firefox --no-remote &
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" },
|
||||||
|
autostart,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox"
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @autostart.sh
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The autostart script runs when openbox starts, which happens during `startDesktop()`. Write the autostart file before calling `startDesktop()` for it to take effect.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
### Background
|
||||||
|
|
||||||
|
There is no wallpaper set by default (the background is the X root window default). You can set it using `xsetroot` in the autostart script (as shown above), or use `feh` if you need an image:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// Upload a wallpaper image
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const wallpaper = await fs.promises.readFile("./wallpaper.png");
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/wallpaper.png" },
|
||||||
|
wallpaper,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the autostart to apply it
|
||||||
|
const autostart = `#!/bin/sh
|
||||||
|
feh --bg-fill ~/wallpaper.png &
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox" });
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" },
|
||||||
|
autostart,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/wallpaper.png" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @wallpaper.png
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/openbox/autostart" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @autostart.sh
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
`feh` is not installed by default. Install it via the [Process API](/processes) before starting the desktop: `await sdk.runProcess({ command: "apt-get", args: ["install", "-y", "feh"] })`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
|
||||||
|
Only `fonts-dejavu-core` is installed by default. To add more fonts, install them with your system package manager or copy font files into the sandbox:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// Install a font package
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "fonts-noto", "fonts-liberation"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or copy a custom font file
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const font = await fs.promises.readFile("./CustomFont.ttf");
|
||||||
|
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts" });
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" },
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rebuild the font cache
|
||||||
|
await sdk.runProcess({ command: "fc-cache", args: ["-fv"] });
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","fonts-noto","fonts-liberation"]}'
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts"
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.local/share/fonts/CustomFont.ttf" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @CustomFont.ttf
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"fc-cache","args":["-fv"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Cursor theme
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.runProcess({
|
||||||
|
command: "apt-get",
|
||||||
|
args: ["install", "-y", "dmz-cursor-theme"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const xresources = `Xcursor.theme: DMZ-White\nXcursor.size: 24\n`;
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.Xresources" },
|
||||||
|
xresources,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"apt-get","args":["install","-y","dmz-cursor-theme"]}'
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.Xresources" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary 'Xcursor.theme: DMZ-White\nXcursor.size: 24'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Run `xrdb -merge ~/.Xresources` (via the autostart or process API) after writing the file for changes to take effect.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
### Shell and terminal
|
||||||
|
|
||||||
|
No terminal emulator or shell is launched by default. Add one to the openbox autostart:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# In ~/.config/openbox/autostart
|
||||||
|
xterm -geometry 120x40+50+50 &
|
||||||
|
```
|
||||||
|
|
||||||
|
To use a different shell, set the `SHELL` environment variable in your Dockerfile or install your preferred shell and configure the terminal to use it.
|
||||||
|
|
||||||
|
### GTK theme
|
||||||
|
|
||||||
|
Applications using GTK will pick up settings from `~/.config/gtk-3.0/settings.ini`:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const gtkSettings = `[Settings]
|
||||||
|
gtk-theme-name=Adwaita
|
||||||
|
gtk-icon-theme-name=Adwaita
|
||||||
|
gtk-font-name=DejaVu Sans 10
|
||||||
|
gtk-cursor-theme-name=DMZ-White
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sdk.mkdirFs({ path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0" });
|
||||||
|
await sdk.writeFsFile(
|
||||||
|
{ path: "~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" },
|
||||||
|
gtkSettings,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0"
|
||||||
|
|
||||||
|
curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=~/.local/state/sandbox-agent/desktop/home/.config/gtk-3.0/settings.ini" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @settings.ini
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Summary of configuration paths
|
||||||
|
|
||||||
|
All paths are relative to the desktop HOME directory (`~/.local/state/sandbox-agent/desktop/home`).
|
||||||
|
|
||||||
|
| What | Path | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| Openbox config | `.config/openbox/rc.xml` | Window manager theme, keybindings, behavior |
|
||||||
|
| Autostart | `.config/openbox/autostart` | Shell script run on desktop start |
|
||||||
|
| Custom fonts | `.local/share/fonts/` | TTF/OTF files, run `fc-cache -fv` after |
|
||||||
|
| Cursor theme | `.Xresources` | Requires `xrdb -merge` to apply |
|
||||||
|
| GTK 3 settings | `.config/gtk-3.0/settings.ini` | Theme, icons, fonts for GTK apps |
|
||||||
|
| Wallpaper | Any path, referenced from autostart | Requires `feh` or similar tool |
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "System",
|
"group": "System",
|
||||||
"pages": ["file-system", "processes"]
|
"pages": ["file-system", "processes", "computer-use", "common-software"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Orchestration",
|
"group": "Orchestration",
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,43 @@
|
||||||
import { Loader2, Monitor, Play, RefreshCw, Square, Camera } from "lucide-react";
|
import { Camera, Circle, Download, Loader2, Monitor, Play, RefreshCw, Square, Trash2, Video } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { SandboxAgentError } from "sandbox-agent";
|
import { SandboxAgentError } from "sandbox-agent";
|
||||||
import type { DesktopStatusResponse, SandboxAgent } from "sandbox-agent";
|
import type { DesktopRecordingInfo, DesktopStatusResponse, SandboxAgent } from "sandbox-agent";
|
||||||
|
import { DesktopViewer } from "@sandbox-agent/react";
|
||||||
|
import type { DesktopViewerClient } from "@sandbox-agent/react";
|
||||||
const MIN_SPIN_MS = 350;
|
const MIN_SPIN_MS = 350;
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown, fallback: string): string => {
|
const extractErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail;
|
if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail;
|
||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatStartedAt = (value: string | null | undefined): string => {
|
const formatStartedAt = (value: string | null | undefined): string => {
|
||||||
if (!value) {
|
if (!value) return "Not started";
|
||||||
return "Not started";
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||||
};
|
};
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
const formatDuration = (start: string, end?: string | null): string => {
|
||||||
|
const startMs = new Date(start).getTime();
|
||||||
|
const endMs = end ? new Date(end).getTime() : Date.now();
|
||||||
|
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Unknown";
|
||||||
|
const seconds = Math.round((endMs - startMs) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}m ${secs}s`;
|
||||||
|
};
|
||||||
const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
|
const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
|
||||||
const payload = new Uint8Array(bytes.byteLength);
|
const payload = new Uint8Array(bytes.byteLength);
|
||||||
payload.set(bytes);
|
payload.set(bytes);
|
||||||
const blob = new Blob([payload.buffer], { type: "image/png" });
|
const blob = new Blob([payload.buffer], { type: "image/png" });
|
||||||
|
|
||||||
if (typeof URL.createObjectURL === "function") {
|
if (typeof URL.createObjectURL === "function") {
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob."));
|
reader.onerror = () => reject(reader.error ?? new Error("Unable to read screenshot blob."));
|
||||||
|
|
@ -41,22 +51,42 @@ const createScreenshotUrl = async (bytes: Uint8Array): Promise<string> => {
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
const [status, setStatus] = useState<DesktopStatusResponse | null>(null);
|
const [status, setStatus] = useState<DesktopStatusResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [acting, setActing] = useState<"start" | "stop" | null>(null);
|
const [acting, setActing] = useState<"start" | "stop" | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [width, setWidth] = useState("1440");
|
const [width, setWidth] = useState("1440");
|
||||||
const [height, setHeight] = useState("900");
|
const [height, setHeight] = useState("900");
|
||||||
const [dpi, setDpi] = useState("96");
|
const [dpi, setDpi] = useState("96");
|
||||||
|
// Screenshot fallback
|
||||||
const [screenshotUrl, setScreenshotUrl] = useState<string | null>(null);
|
const [screenshotUrl, setScreenshotUrl] = useState<string | null>(null);
|
||||||
const [screenshotLoading, setScreenshotLoading] = useState(false);
|
const [screenshotLoading, setScreenshotLoading] = useState(false);
|
||||||
const [screenshotError, setScreenshotError] = useState<string | null>(null);
|
const [screenshotError, setScreenshotError] = useState<string | null>(null);
|
||||||
|
// Live view
|
||||||
|
const [liveViewActive, setLiveViewActive] = useState(false);
|
||||||
|
const [liveViewError, setLiveViewError] = useState<string | null>(null);
|
||||||
|
// Memoize the client as a DesktopViewerClient so the reference is stable
|
||||||
|
// across renders and doesn't cause the DesktopViewer effect to re-fire.
|
||||||
|
const viewerClient = useMemo<DesktopViewerClient>(() => {
|
||||||
|
const c = getClient();
|
||||||
|
return {
|
||||||
|
startDesktopStream: () => c.startDesktopStream(),
|
||||||
|
stopDesktopStream: () => c.stopDesktopStream(),
|
||||||
|
connectDesktopStream: (opts?: Parameters<SandboxAgent["connectDesktopStream"]>[0]) => c.connectDesktopStream(opts),
|
||||||
|
};
|
||||||
|
}, [getClient]);
|
||||||
|
// Recording
|
||||||
|
const [recordings, setRecordings] = useState<DesktopRecordingInfo[]>([]);
|
||||||
|
const [recordingLoading, setRecordingLoading] = useState(false);
|
||||||
|
const [recordingActing, setRecordingActing] = useState<"start" | "stop" | null>(null);
|
||||||
|
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||||
|
const [recordingFps, setRecordingFps] = useState("30");
|
||||||
|
const [deletingRecordingId, setDeletingRecordingId] = useState<string | null>(null);
|
||||||
|
const [downloadingRecordingId, setDownloadingRecordingId] = useState<string | null>(null);
|
||||||
|
// Active recording tracking
|
||||||
|
const activeRecording = useMemo(() => recordings.find((r) => r.status === "recording"), [recordings]);
|
||||||
const revokeScreenshotUrl = useCallback(() => {
|
const revokeScreenshotUrl = useCallback(() => {
|
||||||
setScreenshotUrl((current) => {
|
setScreenshotUrl((current) => {
|
||||||
if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") {
|
if (current?.startsWith("blob:") && typeof URL.revokeObjectURL === "function") {
|
||||||
|
|
@ -65,14 +95,10 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStatus = useCallback(
|
const loadStatus = useCallback(
|
||||||
async (mode: "initial" | "refresh" = "initial") => {
|
async (mode: "initial" | "refresh" = "initial") => {
|
||||||
if (mode === "initial") {
|
if (mode === "initial") setLoading(true);
|
||||||
setLoading(true);
|
else setRefreshing(true);
|
||||||
} else {
|
|
||||||
setRefreshing(true);
|
|
||||||
}
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const next = await getClient().getDesktopStatus();
|
const next = await getClient().getDesktopStatus();
|
||||||
|
|
@ -88,7 +114,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
},
|
},
|
||||||
[getClient],
|
[getClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshScreenshot = useCallback(async () => {
|
const refreshScreenshot = useCallback(async () => {
|
||||||
setScreenshotLoading(true);
|
setScreenshotLoading(true);
|
||||||
setScreenshotError(null);
|
setScreenshotError(null);
|
||||||
|
|
@ -103,25 +128,38 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
setScreenshotLoading(false);
|
setScreenshotLoading(false);
|
||||||
}
|
}
|
||||||
}, [getClient, revokeScreenshotUrl]);
|
}, [getClient, revokeScreenshotUrl]);
|
||||||
|
const loadRecordings = useCallback(async () => {
|
||||||
|
setRecordingLoading(true);
|
||||||
|
setRecordingError(null);
|
||||||
|
try {
|
||||||
|
const result = await getClient().listDesktopRecordings();
|
||||||
|
setRecordings(result.recordings);
|
||||||
|
} catch (loadError) {
|
||||||
|
setRecordingError(extractErrorMessage(loadError, "Unable to load recordings."));
|
||||||
|
} finally {
|
||||||
|
setRecordingLoading(false);
|
||||||
|
}
|
||||||
|
}, [getClient]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStatus();
|
void loadStatus();
|
||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.state === "active") {
|
if (status?.state === "active") {
|
||||||
void refreshScreenshot();
|
void loadRecordings();
|
||||||
} else {
|
} else {
|
||||||
revokeScreenshotUrl();
|
revokeScreenshotUrl();
|
||||||
|
setLiveViewActive(false);
|
||||||
}
|
}
|
||||||
}, [refreshScreenshot, revokeScreenshotUrl, status?.state]);
|
}, [status?.state, loadRecordings, revokeScreenshotUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => revokeScreenshotUrl();
|
||||||
revokeScreenshotUrl();
|
|
||||||
};
|
|
||||||
}, [revokeScreenshotUrl]);
|
}, [revokeScreenshotUrl]);
|
||||||
|
// Poll recording list while a recording is active
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeRecording) return;
|
||||||
|
const interval = setInterval(() => void loadRecordings(), 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [activeRecording, loadRecordings]);
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
const parsedWidth = Number.parseInt(width, 10);
|
const parsedWidth = Number.parseInt(width, 10);
|
||||||
const parsedHeight = Number.parseInt(height, 10);
|
const parsedHeight = Number.parseInt(height, 10);
|
||||||
|
|
@ -136,9 +174,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
dpi: Number.isFinite(parsedDpi) ? parsedDpi : undefined,
|
dpi: Number.isFinite(parsedDpi) ? parsedDpi : undefined,
|
||||||
});
|
});
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
if (next.state === "active") {
|
|
||||||
await refreshScreenshot();
|
|
||||||
}
|
|
||||||
} catch (startError) {
|
} catch (startError) {
|
||||||
setError(extractErrorMessage(startError, "Unable to start desktop runtime."));
|
setError(extractErrorMessage(startError, "Unable to start desktop runtime."));
|
||||||
await loadStatus("refresh");
|
await loadStatus("refresh");
|
||||||
|
|
@ -150,7 +185,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
setActing(null);
|
setActing(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
setActing("stop");
|
setActing("stop");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -159,6 +193,7 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
const next = await getClient().stopDesktop();
|
const next = await getClient().stopDesktop();
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
revokeScreenshotUrl();
|
revokeScreenshotUrl();
|
||||||
|
setLiveViewActive(false);
|
||||||
} catch (stopError) {
|
} catch (stopError) {
|
||||||
setError(extractErrorMessage(stopError, "Unable to stop desktop runtime."));
|
setError(extractErrorMessage(stopError, "Unable to stop desktop runtime."));
|
||||||
await loadStatus("refresh");
|
await loadStatus("refresh");
|
||||||
|
|
@ -170,15 +205,71 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
setActing(null);
|
setActing(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleStartRecording = async () => {
|
||||||
|
const fps = Number.parseInt(recordingFps, 10);
|
||||||
|
setRecordingActing("start");
|
||||||
|
setRecordingError(null);
|
||||||
|
try {
|
||||||
|
await getClient().startDesktopRecording({
|
||||||
|
fps: Number.isFinite(fps) && fps > 0 ? fps : undefined,
|
||||||
|
});
|
||||||
|
await loadRecordings();
|
||||||
|
} catch (err) {
|
||||||
|
setRecordingError(extractErrorMessage(err, "Unable to start recording."));
|
||||||
|
} finally {
|
||||||
|
setRecordingActing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleStopRecording = async () => {
|
||||||
|
setRecordingActing("stop");
|
||||||
|
setRecordingError(null);
|
||||||
|
try {
|
||||||
|
await getClient().stopDesktopRecording();
|
||||||
|
await loadRecordings();
|
||||||
|
} catch (err) {
|
||||||
|
setRecordingError(extractErrorMessage(err, "Unable to stop recording."));
|
||||||
|
} finally {
|
||||||
|
setRecordingActing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteRecording = async (id: string) => {
|
||||||
|
setDeletingRecordingId(id);
|
||||||
|
try {
|
||||||
|
await getClient().deleteDesktopRecording(id);
|
||||||
|
setRecordings((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setRecordingError(extractErrorMessage(err, "Unable to delete recording."));
|
||||||
|
} finally {
|
||||||
|
setDeletingRecordingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDownloadRecording = async (id: string, fileName: string) => {
|
||||||
|
setDownloadingRecordingId(id);
|
||||||
|
try {
|
||||||
|
const bytes = await getClient().downloadDesktopRecording(id);
|
||||||
|
const blob = new Blob([bytes], { type: "video/mp4" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
setRecordingError(extractErrorMessage(err, "Unable to download recording."));
|
||||||
|
} finally {
|
||||||
|
setDownloadingRecordingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
const canRefreshScreenshot = status?.state === "active";
|
const canRefreshScreenshot = status?.state === "active";
|
||||||
|
const isActive = status?.state === "active";
|
||||||
const resolutionLabel = useMemo(() => {
|
const resolutionLabel = useMemo(() => {
|
||||||
const resolution = status?.resolution;
|
const resolution = status?.resolution;
|
||||||
if (!resolution) return "Unknown";
|
if (!resolution) return "Unknown";
|
||||||
const dpiLabel = resolution.dpi ? ` @ ${resolution.dpi} DPI` : "";
|
const dpiLabel = resolution.dpi ? ` @ ${resolution.dpi} DPI` : "";
|
||||||
return `${resolution.width} x ${resolution.height}${dpiLabel}`;
|
return `${resolution.width} x ${resolution.height}${dpiLabel}`;
|
||||||
}, [status?.resolution]);
|
}, [status?.resolution]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="desktop-panel">
|
<div className="desktop-panel">
|
||||||
<div className="inline-row" style={{ marginBottom: 16 }}>
|
<div className="inline-row" style={{ marginBottom: 16 }}>
|
||||||
|
|
@ -186,15 +277,16 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
<RefreshCw className={`button-icon ${loading || refreshing ? "spinner-icon" : ""}`} />
|
<RefreshCw className={`button-icon ${loading || refreshing ? "spinner-icon" : ""}`} />
|
||||||
Refresh Status
|
Refresh Status
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary small" onClick={() => void refreshScreenshot()} disabled={!canRefreshScreenshot || screenshotLoading}>
|
{isActive && !liveViewActive && (
|
||||||
{screenshotLoading ? <Loader2 className="button-icon spinner-icon" /> : <Camera className="button-icon" />}
|
<button className="button secondary small" onClick={() => void refreshScreenshot()} disabled={!canRefreshScreenshot || screenshotLoading}>
|
||||||
Refresh Screenshot
|
{screenshotLoading ? <Loader2 className="button-icon spinner-icon" /> : <Camera className="button-icon" />}
|
||||||
</button>
|
Screenshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="banner error">{error}</div>}
|
{error && <div className="banner error">{error}</div>}
|
||||||
{screenshotError && <div className="banner error">{screenshotError}</div>}
|
{screenshotError && <div className="banner error">{screenshotError}</div>}
|
||||||
|
{/* ========== Runtime Section ========== */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<span className="card-title">
|
<span className="card-title">
|
||||||
|
|
@ -209,7 +301,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
{status?.state ?? "unknown"}
|
{status?.state ?? "unknown"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="desktop-state-grid">
|
<div className="desktop-state-grid">
|
||||||
<div>
|
<div>
|
||||||
<div className="card-meta">Display</div>
|
<div className="card-meta">Display</div>
|
||||||
|
|
@ -224,7 +315,6 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
<div>{formatStartedAt(status?.startedAt)}</div>
|
<div>{formatStartedAt(status?.startedAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="desktop-start-controls">
|
<div className="desktop-start-controls">
|
||||||
<div className="desktop-input-group">
|
<div className="desktop-input-group">
|
||||||
<label className="label">Width</label>
|
<label className="label">Width</label>
|
||||||
|
|
@ -239,19 +329,21 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
<input className="setup-input mono" value={dpi} onChange={(event) => setDpi(event.target.value)} inputMode="numeric" />
|
<input className="setup-input mono" value={dpi} onChange={(event) => setDpi(event.target.value)} inputMode="numeric" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button className="button success small" onClick={() => void handleStart()} disabled={acting === "start"}>
|
{isActive ? (
|
||||||
{acting === "start" ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
|
<button className="button danger small" onClick={() => void handleStop()} disabled={acting === "stop"}>
|
||||||
Start Desktop
|
{acting === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
|
||||||
</button>
|
Stop Desktop
|
||||||
<button className="button danger small" onClick={() => void handleStop()} disabled={acting === "stop"}>
|
</button>
|
||||||
{acting === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
|
) : (
|
||||||
Stop Desktop
|
<button className="button success small" onClick={() => void handleStart()} disabled={acting === "start"}>
|
||||||
</button>
|
{acting === "start" ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
|
||||||
|
Start Desktop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* ========== Missing Dependencies ========== */}
|
||||||
{status?.missingDependencies && status.missingDependencies.length > 0 && (
|
{status?.missingDependencies && status.missingDependencies.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|
@ -274,7 +366,179 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* ========== Live View Section ========== */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">
|
||||||
|
<Video size={14} style={{ marginRight: 6 }} />
|
||||||
|
Live View
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
className={`button small ${liveViewActive ? "danger" : "success"}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (liveViewActive) {
|
||||||
|
// Stop: close viewer then stop the stream process
|
||||||
|
setLiveViewActive(false);
|
||||||
|
void getClient()
|
||||||
|
.stopDesktopStream()
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
// Start stream first, then show viewer
|
||||||
|
void getClient()
|
||||||
|
.startDesktopStream()
|
||||||
|
.then(() => {
|
||||||
|
setLiveViewActive(true);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ padding: "4px 10px", fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{liveViewActive ? (
|
||||||
|
<>
|
||||||
|
<Square size={12} style={{ marginRight: 4 }} />
|
||||||
|
Stop Stream
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play size={12} style={{ marginRight: 4 }} />
|
||||||
|
Start Stream
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{liveViewError && (
|
||||||
|
<div className="banner error" style={{ marginBottom: 8 }}>
|
||||||
|
{liveViewError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isActive && <div className="desktop-screenshot-empty">Start the desktop runtime to enable live view.</div>}
|
||||||
|
{isActive && liveViewActive && <DesktopViewer client={viewerClient} autoStart={true} showStatusBar={true} />}
|
||||||
|
{isActive && !liveViewActive && (
|
||||||
|
<>
|
||||||
|
{screenshotUrl ? (
|
||||||
|
<div className="desktop-screenshot-frame">
|
||||||
|
<img src={screenshotUrl} alt="Desktop screenshot" className="desktop-screenshot-image" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="desktop-screenshot-empty">Click "Start Stream" for live desktop view, or use the Screenshot button above.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* ========== Recording Section ========== */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">
|
||||||
|
<Circle size={14} style={{ marginRight: 6, fill: activeRecording ? "#ff3b30" : "none" }} />
|
||||||
|
Recording
|
||||||
|
</span>
|
||||||
|
{activeRecording && <span className="pill danger">Recording</span>}
|
||||||
|
</div>
|
||||||
|
{recordingError && (
|
||||||
|
<div className="banner error" style={{ marginBottom: 8 }}>
|
||||||
|
{recordingError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isActive && <div className="desktop-screenshot-empty">Start the desktop runtime to enable recording.</div>}
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
<div className="desktop-start-controls" style={{ gridTemplateColumns: "1fr" }}>
|
||||||
|
<div className="desktop-input-group">
|
||||||
|
<label className="label">FPS</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={recordingFps}
|
||||||
|
onChange={(e) => setRecordingFps(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
style={{ maxWidth: 80 }}
|
||||||
|
disabled={!!activeRecording}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
{!activeRecording ? (
|
||||||
|
<button className="button danger small" onClick={() => void handleStartRecording()} disabled={recordingActing === "start"}>
|
||||||
|
{recordingActing === "start" ? (
|
||||||
|
<Loader2 className="button-icon spinner-icon" />
|
||||||
|
) : (
|
||||||
|
<Circle size={14} className="button-icon" style={{ fill: "#ff3b30" }} />
|
||||||
|
)}
|
||||||
|
Start Recording
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="button secondary small" onClick={() => void handleStopRecording()} disabled={recordingActing === "stop"}>
|
||||||
|
{recordingActing === "stop" ? <Loader2 className="button-icon spinner-icon" /> : <Square className="button-icon" />}
|
||||||
|
Stop Recording
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="button secondary small" onClick={() => void loadRecordings()} disabled={recordingLoading}>
|
||||||
|
<RefreshCw className={`button-icon ${recordingLoading ? "spinner-icon" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recordings.length > 0 && (
|
||||||
|
<div className="desktop-process-list" style={{ marginTop: 12 }}>
|
||||||
|
{recordings.map((rec) => (
|
||||||
|
<div key={rec.id} className="desktop-process-item">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<div>
|
||||||
|
<strong className="mono" style={{ fontSize: 12 }}>
|
||||||
|
{rec.fileName}
|
||||||
|
</strong>
|
||||||
|
<span
|
||||||
|
className={`pill ${rec.status === "recording" ? "danger" : rec.status === "completed" ? "success" : "warning"}`}
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
>
|
||||||
|
{rec.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rec.status === "completed" && (
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
title="Download"
|
||||||
|
onClick={() => void handleDownloadRecording(rec.id, rec.fileName)}
|
||||||
|
disabled={downloadingRecordingId === rec.id}
|
||||||
|
style={{ padding: "4px 6px" }}
|
||||||
|
>
|
||||||
|
{downloadingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Download size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => void handleDeleteRecording(rec.id)}
|
||||||
|
disabled={deletingRecordingId === rec.id}
|
||||||
|
style={{ padding: "4px 6px", color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
{deletingRecordingId === rec.id ? <Loader2 size={14} className="spinner-icon" /> : <Trash2 size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>
|
||||||
|
{formatBytes(rec.bytes)}
|
||||||
|
{" \u00b7 "}
|
||||||
|
{formatDuration(rec.startedAt, rec.endedAt)}
|
||||||
|
{" \u00b7 "}
|
||||||
|
{formatStartedAt(rec.startedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recordings.length === 0 && !recordingLoading && (
|
||||||
|
<div className="desktop-screenshot-empty" style={{ marginTop: 8 }}>
|
||||||
|
No recordings yet. Click "Start Recording" to begin.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* ========== Diagnostics Section ========== */}
|
||||||
{(status?.lastError || status?.runtimeLogPath || (status?.processes?.length ?? 0) > 0) && (
|
{(status?.lastError || status?.runtimeLogPath || (status?.processes?.length ?? 0) > 0) && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|
@ -314,27 +578,7 @@ const DesktopTab = ({ getClient }: { getClient: () => SandboxAgent }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<span className="card-title">Latest Screenshot</span>
|
|
||||||
{status?.state === "active" ? <span className="card-meta">Manual refresh only</span> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? <div className="card-meta">Loading...</div> : null}
|
|
||||||
{!loading && !screenshotUrl && (
|
|
||||||
<div className="desktop-screenshot-empty">
|
|
||||||
{status?.state === "active" ? "No screenshot loaded yet." : "Start the desktop runtime to capture a screenshot."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{screenshotUrl && (
|
|
||||||
<div className="desktop-screenshot-frame">
|
|
||||||
<img src={screenshotUrl} alt="Desktop screenshot" className="desktop-screenshot-image" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DesktopTab;
|
export default DesktopTab;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default defineConfig(({ command }) => ({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/v1": {
|
"/v1": {
|
||||||
target: "http://localhost:2468",
|
target: process.env.SANDBOX_AGENT_URL || "http://localhost:2468",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
414
pnpm-lock.yaml
generated
414
pnpm-lock.yaml
generated
|
|
@ -5,8 +5,8 @@ settings:
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
'@types/react-dom': ^18.3.0
|
'@types/react-dom': ^19.1.6
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/sandbox':
|
'@cloudflare/sandbox':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 0.7.17(@opencode-ai/sdk@1.2.24)
|
version: 0.7.18(@opencode-ai/sdk@1.2.24)
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.12.2
|
specifier: ^4.12.2
|
||||||
version: 4.12.2
|
version: 4.12.2
|
||||||
|
|
@ -73,16 +73,16 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/workers-types':
|
'@cloudflare/workers-types':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.20260313.1
|
version: 4.20260317.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 25.5.0
|
version: 25.5.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.3.0
|
specifier: ^19.1.6
|
||||||
version: 18.3.7(@types/react@18.3.27)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
|
|
@ -97,7 +97,7 @@ importers:
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
wrangler:
|
wrangler:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.73.0(@cloudflare/workers-types@4.20260313.1)
|
version: 4.74.0(@cloudflare/workers-types@4.20260317.1)
|
||||||
|
|
||||||
examples/computesdk:
|
examples/computesdk:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -106,7 +106,7 @@ importers:
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
computesdk:
|
computesdk:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 2.5.0
|
version: 2.5.1
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../sdks/typescript
|
version: link:../../sdks/typescript
|
||||||
|
|
@ -154,13 +154,13 @@ importers:
|
||||||
dockerode:
|
dockerode:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.0.9
|
version: 4.0.9
|
||||||
|
get-port:
|
||||||
|
specifier: latest
|
||||||
|
version: 7.1.0
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../sdks/typescript
|
version: link:../../sdks/typescript
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/dockerode':
|
|
||||||
specifier: latest
|
|
||||||
version: 4.0.1
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 25.5.0
|
version: 25.5.0
|
||||||
|
|
@ -345,9 +345,6 @@ importers:
|
||||||
'@sandbox-agent/example-shared':
|
'@sandbox-agent/example-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
'@sandbox-agent/persist-postgres':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../sdks/persist-postgres
|
|
||||||
pg:
|
pg:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
|
@ -373,13 +370,16 @@ importers:
|
||||||
'@sandbox-agent/example-shared':
|
'@sandbox-agent/example-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
'@sandbox-agent/persist-sqlite':
|
better-sqlite3:
|
||||||
specifier: workspace:*
|
specifier: ^11.0.0
|
||||||
version: link:../../sdks/persist-sqlite
|
version: 11.10.0
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../sdks/typescript
|
version: link:../../sdks/typescript
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/better-sqlite3':
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.6.13
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 25.5.0
|
version: 25.5.0
|
||||||
|
|
@ -492,12 +492,9 @@ importers:
|
||||||
'@sandbox-agent/foundry-shared':
|
'@sandbox-agent/foundry-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
'@sandbox-agent/persist-rivet':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../../sdks/persist-rivet
|
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.5.5
|
specifier: ^1.5.5
|
||||||
version: 1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.5.5(@cloudflare/workers-types@4.20260317.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
dockerode:
|
dockerode:
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
version: 4.0.9
|
version: 4.0.9
|
||||||
|
|
@ -506,7 +503,7 @@ importers:
|
||||||
version: 0.31.9
|
version: 0.31.9
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.5
|
specifier: ^0.44.5
|
||||||
version: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
version: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.11.9
|
specifier: ^4.11.9
|
||||||
version: 4.12.2
|
version: 4.12.2
|
||||||
|
|
@ -515,7 +512,7 @@ importers:
|
||||||
version: 10.3.1
|
version: 10.3.1
|
||||||
rivetkit:
|
rivetkit:
|
||||||
specifier: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a
|
specifier: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a
|
||||||
version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
|
version: https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../sdks/typescript
|
version: link:../../../sdks/typescript
|
||||||
|
|
@ -546,14 +543,14 @@ importers:
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
rivetkit:
|
rivetkit:
|
||||||
specifier: 2.1.6
|
specifier: 2.1.6
|
||||||
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
|
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0)
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../sdks/typescript
|
version: link:../../../sdks/typescript
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
|
|
@ -580,7 +577,7 @@ importers:
|
||||||
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
baseui:
|
baseui:
|
||||||
specifier: ^16.1.1
|
specifier: ^16.1.1
|
||||||
version: 16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
|
version: 16.1.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.542.0
|
specifier: ^0.542.0
|
||||||
version: 0.542.0(react@19.2.4)
|
version: 0.542.0(react@19.2.4)
|
||||||
|
|
@ -602,19 +599,19 @@ importers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@react-grab/mcp':
|
'@react-grab/mcp':
|
||||||
specifier: ^0.1.13
|
specifier: ^0.1.13
|
||||||
version: 0.1.27(@types/react@18.3.27)(react@19.2.4)
|
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.3.0
|
specifier: ^19.1.6
|
||||||
version: 18.3.7(@types/react@18.3.27)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
version: 5.1.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
version: 5.1.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
react-grab:
|
react-grab:
|
||||||
specifier: ^0.1.13
|
specifier: ^0.1.13
|
||||||
version: 0.1.27(@types/react@18.3.27)(react@19.2.4)
|
version: 0.1.27(@types/react@19.2.14)(react@19.2.4)
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
|
|
@ -640,9 +637,6 @@ importers:
|
||||||
|
|
||||||
frontend/packages/inspector:
|
frontend/packages/inspector:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sandbox-agent/persist-indexeddb':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../../sdks/persist-indexeddb
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@18.3.1)
|
version: 0.469.0(react@18.3.1)
|
||||||
|
|
@ -657,11 +651,11 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../sdks/react
|
version: link:../../../sdks/react
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.3.0
|
specifier: ^19.1.6
|
||||||
version: 18.3.7(@types/react@18.3.27)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
version: 4.7.0(vite@5.4.21(@types/node@25.5.0))
|
version: 4.7.0(vite@5.4.21(@types/node@25.5.0))
|
||||||
|
|
@ -688,7 +682,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@astrojs/react':
|
'@astrojs/react':
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
|
version: 4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
'@astrojs/sitemap':
|
'@astrojs/sitemap':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.7.0
|
version: 3.7.0
|
||||||
|
|
@ -715,11 +709,11 @@ importers:
|
||||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.3.0
|
specifier: ^19.1.6
|
||||||
version: 18.3.7(@types/react@18.3.27)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
@ -799,8 +793,8 @@ importers:
|
||||||
sdks/acp-http-client:
|
sdks/acp-http-client:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@agentclientprotocol/sdk':
|
'@agentclientprotocol/sdk':
|
||||||
specifier: ^0.14.1
|
specifier: ^0.16.1
|
||||||
version: 0.14.1(zod@4.3.6)
|
version: 0.16.1(zod@4.3.6)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
|
|
@ -900,57 +894,30 @@ importers:
|
||||||
sdks/gigacode/platforms/win32-x64: {}
|
sdks/gigacode/platforms/win32-x64: {}
|
||||||
|
|
||||||
sdks/persist-indexeddb:
|
sdks/persist-indexeddb:
|
||||||
dependencies:
|
|
||||||
sandbox-agent:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../typescript
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
fake-indexeddb:
|
|
||||||
specifier: ^6.2.4
|
|
||||||
version: 6.2.5
|
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
|
||||||
|
|
||||||
sdks/persist-postgres:
|
sdks/persist-postgres:
|
||||||
dependencies:
|
|
||||||
pg:
|
|
||||||
specifier: ^8.16.3
|
|
||||||
version: 8.18.0
|
|
||||||
sandbox-agent:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../typescript
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
'@types/pg':
|
|
||||||
specifier: ^8.15.6
|
|
||||||
version: 8.16.0
|
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
|
||||||
|
|
||||||
sdks/persist-rivet:
|
sdks/persist-rivet:
|
||||||
dependencies:
|
|
||||||
sandbox-agent:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../typescript
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
|
|
@ -961,22 +928,9 @@ importers:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
|
||||||
|
|
||||||
sdks/persist-sqlite:
|
sdks/persist-sqlite:
|
||||||
dependencies:
|
|
||||||
better-sqlite3:
|
|
||||||
specifier: ^11.0.0
|
|
||||||
version: 11.10.0
|
|
||||||
sandbox-agent:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../typescript
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/better-sqlite3':
|
|
||||||
specifier: ^7.0.0
|
|
||||||
version: 7.6.13
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
|
|
@ -986,25 +940,22 @@ importers:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)
|
|
||||||
|
|
||||||
sdks/react:
|
sdks/react:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: ^3.13.22
|
specifier: ^3.13.22
|
||||||
version: 3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)
|
version: 3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
ghostty-web:
|
ghostty-web:
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^19.1.12
|
||||||
version: 18.3.27
|
version: 19.2.14
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^19.1.1
|
||||||
version: 18.3.1
|
version: 19.2.4
|
||||||
sandbox-agent:
|
sandbox-agent:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../typescript
|
version: link:../typescript
|
||||||
|
|
@ -1028,12 +979,39 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../cli
|
version: link:../cli
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@cloudflare/sandbox':
|
||||||
|
specifier: '>=0.1.0'
|
||||||
|
version: 0.7.18(@opencode-ai/sdk@1.2.24)
|
||||||
|
'@daytonaio/sdk':
|
||||||
|
specifier: '>=0.12.0'
|
||||||
|
version: 0.151.0(ws@8.19.0)
|
||||||
|
'@e2b/code-interpreter':
|
||||||
|
specifier: '>=1.0.0'
|
||||||
|
version: 2.3.3
|
||||||
|
'@types/dockerode':
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.0.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.18.1
|
specifier: ^8.18.1
|
||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
|
'@vercel/sandbox':
|
||||||
|
specifier: '>=0.1.0'
|
||||||
|
version: 1.8.1
|
||||||
|
computesdk:
|
||||||
|
specifier: '>=0.1.0'
|
||||||
|
version: 2.5.1
|
||||||
|
dockerode:
|
||||||
|
specifier: '>=4.0.0'
|
||||||
|
version: 4.0.9
|
||||||
|
get-port:
|
||||||
|
specifier: '>=7.0.0'
|
||||||
|
version: 7.1.0
|
||||||
|
modal:
|
||||||
|
specifier: '>=0.1.0'
|
||||||
|
version: 0.7.3
|
||||||
openapi-typescript:
|
openapi-typescript:
|
||||||
specifier: ^6.7.0
|
specifier: ^6.7.0
|
||||||
version: 6.7.6
|
version: 6.7.6
|
||||||
|
|
@ -1073,6 +1051,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.0 || ^4.0.0
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
|
'@agentclientprotocol/sdk@0.16.1':
|
||||||
|
resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1106,8 +1089,8 @@ packages:
|
||||||
resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==}
|
resolution: {integrity: sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ==}
|
||||||
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
|
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
'@types/react-dom': ^18.3.0
|
'@types/react-dom': ^19.1.6
|
||||||
react: ^17.0.2 || ^18.0.0 || ^19.0.0
|
react: ^17.0.2 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0
|
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
|
@ -1614,8 +1597,8 @@ packages:
|
||||||
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
|
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@cloudflare/sandbox@0.7.17':
|
'@cloudflare/sandbox@0.7.18':
|
||||||
resolution: {integrity: sha512-dS6IdIq8OHg7jg08wz18BtP6TkBSaVrixq+kN4Zm5/6N9Vir+PbfebEFL23lM5UcaPoKx6Gttnhby8e79PSMOA==}
|
resolution: {integrity: sha512-v9hNXik88TfhLu83RRf1I8t98L86bt2RJVJB+EPIyZB7vBVwsx8w/veQ1BoJfbf/MSETtc/zLpQROgtbe5RYRw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@openai/agents': ^0.3.3
|
'@openai/agents': ^0.3.3
|
||||||
'@opencode-ai/sdk': ^1.1.40
|
'@opencode-ai/sdk': ^1.1.40
|
||||||
|
|
@ -1667,8 +1650,8 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20260313.1':
|
'@cloudflare/workers-types@4.20260317.1':
|
||||||
resolution: {integrity: sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ==}
|
resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==}
|
||||||
|
|
||||||
'@computesdk/cmd@0.4.1':
|
'@computesdk/cmd@0.4.1':
|
||||||
resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==}
|
resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==}
|
||||||
|
|
@ -3636,27 +3619,21 @@ packages:
|
||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
'@types/pg@8.16.0':
|
|
||||||
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
|
|
||||||
|
|
||||||
'@types/pg@8.18.0':
|
'@types/pg@8.18.0':
|
||||||
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
|
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7':
|
|
||||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
|
|
||||||
'@types/react-reconciler@0.28.9':
|
'@types/react-reconciler@0.28.9':
|
||||||
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
|
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
|
|
||||||
'@types/react@18.3.27':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
'@types/retry@0.12.2':
|
'@types/retry@0.12.2':
|
||||||
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
|
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
|
||||||
|
|
@ -4192,8 +4169,8 @@ packages:
|
||||||
compare-versions@6.1.1:
|
compare-versions@6.1.1:
|
||||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||||
|
|
||||||
computesdk@2.5.0:
|
computesdk@2.5.1:
|
||||||
resolution: {integrity: sha512-HDOSw1D6ogR7wwlBtr5ZZtRFhfNxdPBW1omLaApxDwAvdkBW4PGoIfYyMPX0N1LpaDl5NWwWALMvmtW2p4dafg==}
|
resolution: {integrity: sha512-zDzgdztvloTEm7cfVxSYX+kaQXlg+2UQBzmsisH61LTVcEPaZA7CCI/T8eC/eCxGzOjUjs5n68xo/ymK3w7Ebg==}
|
||||||
|
|
||||||
confbox@0.1.8:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
@ -5623,8 +5600,8 @@ packages:
|
||||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
miniflare@4.20260312.0:
|
miniflare@4.20260312.1:
|
||||||
resolution: {integrity: sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w==}
|
resolution: {integrity: sha512-YSWxec9ssisqkQgaCgcIQxZlB41E9hMiq1nxUgxXHRrE9NsfyC6ptSt8yfgBobsKIseAVKLTB/iEDpMumBv8oA==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
|
@ -5894,9 +5871,6 @@ packages:
|
||||||
pg-cloudflare@1.3.0:
|
pg-cloudflare@1.3.0:
|
||||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
pg-connection-string@2.11.0:
|
|
||||||
resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==}
|
|
||||||
|
|
||||||
pg-connection-string@2.12.0:
|
pg-connection-string@2.12.0:
|
||||||
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
|
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
|
||||||
|
|
||||||
|
|
@ -5904,11 +5878,6 @@ packages:
|
||||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
pg-pool@3.11.0:
|
|
||||||
resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
|
|
||||||
peerDependencies:
|
|
||||||
pg: '>=8.0'
|
|
||||||
|
|
||||||
pg-pool@3.13.0:
|
pg-pool@3.13.0:
|
||||||
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
|
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -5924,15 +5893,6 @@ packages:
|
||||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
pg@8.18.0:
|
|
||||||
resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==}
|
|
||||||
engines: {node: '>= 16.0.0'}
|
|
||||||
peerDependencies:
|
|
||||||
pg-native: '>=3.0.1'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
pg-native:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
pg@8.20.0:
|
pg@8.20.0:
|
||||||
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
|
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
|
||||||
engines: {node: '>= 16.0.0'}
|
engines: {node: '>= 16.0.0'}
|
||||||
|
|
@ -6193,7 +6153,7 @@ packages:
|
||||||
react-focus-lock@2.13.7:
|
react-focus-lock@2.13.7:
|
||||||
resolution: {integrity: sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==}
|
resolution: {integrity: sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
|
|
@ -6262,7 +6222,7 @@ packages:
|
||||||
resolution: {integrity: sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==}
|
resolution: {integrity: sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==}
|
||||||
engines: {node: '>=8.5.0'}
|
engines: {node: '>=8.5.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
react: ^16.8.0
|
react: ^16.8.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
|
|
@ -7122,7 +7082,7 @@ packages:
|
||||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
|
|
@ -7132,7 +7092,7 @@ packages:
|
||||||
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18.3.3
|
'@types/react': ^19.1.12
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
|
|
@ -7394,8 +7354,8 @@ packages:
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
wrangler@4.73.0:
|
wrangler@4.74.0:
|
||||||
resolution: {integrity: sha512-VJXsqKDFCp6OtFEHXITSOR5kh95JOknwPY8m7RyQuWJQguSybJy43m4vhoCSt42prutTef7eeuw7L4V4xiynGw==}
|
resolution: {integrity: sha512-3qprbhgdUyqYGHZ+Y1k0gsyHLMOlLrKL/HU0LDqLlCkbsKPprUA0/ThE4IZsxD84xAAXY6pv5JUuxS2+OnMa3A==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -7536,6 +7496,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
|
'@agentclientprotocol/sdk@0.16.1(zod@4.3.6)':
|
||||||
|
dependencies:
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@antfu/ni@0.23.2': {}
|
'@antfu/ni@0.23.2': {}
|
||||||
|
|
@ -7587,10 +7551,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
prismjs: 1.30.0
|
prismjs: 1.30.0
|
||||||
|
|
||||||
'@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)':
|
'@astrojs/react@4.4.2(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
'@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
@ -8372,7 +8336,7 @@ snapshots:
|
||||||
|
|
||||||
'@balena/dockerignore@1.0.2': {}
|
'@balena/dockerignore@1.0.2': {}
|
||||||
|
|
||||||
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
|
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
|
@ -8383,39 +8347,39 @@ snapshots:
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@cloudflare/workers-types': 4.20260313.1
|
'@cloudflare/workers-types': 4.20260317.1
|
||||||
|
|
||||||
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))':
|
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
||||||
|
|
||||||
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
|
|
||||||
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))':
|
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
|
||||||
|
|
@ -8497,7 +8461,7 @@ snapshots:
|
||||||
|
|
||||||
'@cloudflare/kv-asset-handler@0.4.2': {}
|
'@cloudflare/kv-asset-handler@0.4.2': {}
|
||||||
|
|
||||||
'@cloudflare/sandbox@0.7.17(@opencode-ai/sdk@1.2.24)':
|
'@cloudflare/sandbox@0.7.18(@opencode-ai/sdk@1.2.24)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/containers': 0.1.1
|
'@cloudflare/containers': 0.1.1
|
||||||
aws4fetch: 1.0.20
|
aws4fetch: 1.0.20
|
||||||
|
|
@ -8525,7 +8489,7 @@ snapshots:
|
||||||
'@cloudflare/workerd-windows-64@1.20260312.1':
|
'@cloudflare/workerd-windows-64@1.20260312.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20260313.1': {}
|
'@cloudflare/workers-types@4.20260317.1': {}
|
||||||
|
|
||||||
'@computesdk/cmd@0.4.1': {}
|
'@computesdk/cmd@0.4.1': {}
|
||||||
|
|
||||||
|
|
@ -9610,11 +9574,11 @@ snapshots:
|
||||||
prompts: 2.4.2
|
prompts: 2.4.2
|
||||||
smol-toml: 1.6.0
|
smol-toml: 1.6.0
|
||||||
|
|
||||||
'@react-grab/mcp@0.1.27(@types/react@18.3.27)(react@19.2.4)':
|
'@react-grab/mcp@0.1.27(@types/react@19.2.14)(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
|
'@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76)
|
||||||
fkill: 9.0.0
|
fkill: 9.0.0
|
||||||
react-grab: 0.1.27(@types/react@18.3.27)(react@19.2.4)
|
react-grab: 0.1.27(@types/react@19.2.14)(react@19.2.4)
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@cfworker/json-schema'
|
- '@cfworker/json-schema'
|
||||||
|
|
@ -10227,12 +10191,6 @@ snapshots:
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@18.3.1))(react@18.3.1)':
|
|
||||||
dependencies:
|
|
||||||
'@tanstack/virtual-core': 3.13.22
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 19.2.4(react@18.3.1)
|
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@tanstack/react-virtual@3.13.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.22
|
'@tanstack/virtual-core': 3.13.22
|
||||||
|
|
@ -10342,31 +10300,22 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@types/pg@8.16.0':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 24.10.9
|
|
||||||
pg-protocol: 1.11.0
|
|
||||||
pg-types: 2.2.0
|
|
||||||
|
|
||||||
'@types/pg@8.18.0':
|
'@types/pg@8.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.9
|
'@types/node': 24.10.9
|
||||||
pg-protocol: 1.11.0
|
pg-protocol: 1.11.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@types/react-reconciler@0.28.9(@types/react@18.3.27)':
|
'@types/react-reconciler@0.28.9(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@types/react@18.3.27':
|
'@types/react@19.2.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/prop-types': 15.7.15
|
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@types/retry@0.12.2': {}
|
'@types/retry@0.12.2': {}
|
||||||
|
|
@ -10725,7 +10674,7 @@ snapshots:
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.18: {}
|
baseline-browser-mapping@2.9.18: {}
|
||||||
|
|
||||||
baseui@16.1.1(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)):
|
baseui@16.1.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styletron-react@6.1.1(react@19.2.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@date-io/date-fns': 2.17.0(date-fns@2.30.0)
|
'@date-io/date-fns': 2.17.0(date-fns@2.30.0)
|
||||||
'@date-io/moment': 2.17.0(moment@2.30.1)
|
'@date-io/moment': 2.17.0(moment@2.30.1)
|
||||||
|
|
@ -10745,7 +10694,7 @@ snapshots:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
react-dropzone: 9.0.0(react@19.2.4)
|
react-dropzone: 9.0.0(react@19.2.4)
|
||||||
react-focus-lock: 2.13.7(@types/react@18.3.27)(react@19.2.4)
|
react-focus-lock: 2.13.7(@types/react@19.2.14)(react@19.2.4)
|
||||||
react-hook-form: 7.71.2(react@19.2.4)
|
react-hook-form: 7.71.2(react@19.2.4)
|
||||||
react-input-mask: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-input-mask: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
@ -10753,7 +10702,7 @@ snapshots:
|
||||||
react-movable: 3.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-movable: 3.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react-multi-ref: 1.0.2
|
react-multi-ref: 1.0.2
|
||||||
react-range: 1.10.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-range: 1.10.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react-uid: 2.3.0(@types/react@18.3.27)(react@19.2.4)
|
react-uid: 2.3.0(@types/react@19.2.14)(react@19.2.4)
|
||||||
react-virtualized: 9.22.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-virtualized: 9.22.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react-virtualized-auto-sizer: 1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-virtualized-auto-sizer: 1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react-window: 1.8.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
react-window: 1.8.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
|
@ -10767,15 +10716,15 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tweetnacl: 0.14.5
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
better-auth@1.5.5(@cloudflare/workers-types@4.20260313.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)):
|
better-auth@1.5.5(@cloudflare/workers-types@4.20260317.1)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@1.21.7)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))
|
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))
|
||||||
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
||||||
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260313.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
|
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@noble/ciphers': 2.1.1
|
'@noble/ciphers': 2.1.1
|
||||||
|
|
@ -10788,7 +10737,7 @@ snapshots:
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-kit: 0.31.9
|
drizzle-kit: 0.31.9
|
||||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
||||||
pg: 8.20.0
|
pg: 8.20.0
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
@ -10817,9 +10766,9 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
file-uri-to-path: 1.0.0
|
file-uri-to-path: 1.0.0
|
||||||
|
|
||||||
bippy@0.5.32(@types/react@18.3.27)(react@19.2.4):
|
bippy@0.5.32(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react-reconciler': 0.28.9(@types/react@18.3.27)
|
'@types/react-reconciler': 0.28.9(@types/react@19.2.14)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
@ -11041,7 +10990,7 @@ snapshots:
|
||||||
|
|
||||||
compare-versions@6.1.1: {}
|
compare-versions@6.1.1: {}
|
||||||
|
|
||||||
computesdk@2.5.0:
|
computesdk@2.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@computesdk/cmd': 0.4.1
|
'@computesdk/cmd': 0.4.1
|
||||||
|
|
||||||
|
|
@ -11409,9 +11358,9 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0):
|
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@cloudflare/workers-types': 4.20260313.1
|
'@cloudflare/workers-types': 4.20260317.1
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/better-sqlite3': 7.6.13
|
'@types/better-sqlite3': 7.6.13
|
||||||
'@types/pg': 8.18.0
|
'@types/pg': 8.18.0
|
||||||
|
|
@ -11439,7 +11388,7 @@ snapshots:
|
||||||
glob: 11.1.0
|
glob: 11.1.0
|
||||||
openapi-fetch: 0.14.1
|
openapi-fetch: 0.14.1
|
||||||
platform: 1.3.6
|
platform: 1.3.6
|
||||||
tar: 7.5.6
|
tar: 7.5.7
|
||||||
|
|
||||||
earcut@2.2.4: {}
|
earcut@2.2.4: {}
|
||||||
|
|
||||||
|
|
@ -12724,7 +12673,7 @@ snapshots:
|
||||||
|
|
||||||
mimic-response@3.1.0: {}
|
mimic-response@3.1.0: {}
|
||||||
|
|
||||||
miniflare@4.20260312.0:
|
miniflare@4.20260312.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cspotcode/source-map-support': 0.8.1
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
|
|
@ -12998,16 +12947,10 @@ snapshots:
|
||||||
pg-cloudflare@1.3.0:
|
pg-cloudflare@1.3.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
pg-connection-string@2.11.0: {}
|
|
||||||
|
|
||||||
pg-connection-string@2.12.0: {}
|
pg-connection-string@2.12.0: {}
|
||||||
|
|
||||||
pg-int8@1.0.1: {}
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
pg-pool@3.11.0(pg@8.18.0):
|
|
||||||
dependencies:
|
|
||||||
pg: 8.18.0
|
|
||||||
|
|
||||||
pg-pool@3.13.0(pg@8.20.0):
|
pg-pool@3.13.0(pg@8.20.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
pg: 8.20.0
|
pg: 8.20.0
|
||||||
|
|
@ -13024,16 +12967,6 @@ snapshots:
|
||||||
postgres-date: 1.0.7
|
postgres-date: 1.0.7
|
||||||
postgres-interval: 1.2.0
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
pg@8.18.0:
|
|
||||||
dependencies:
|
|
||||||
pg-connection-string: 2.11.0
|
|
||||||
pg-pool: 3.11.0(pg@8.18.0)
|
|
||||||
pg-protocol: 1.11.0
|
|
||||||
pg-types: 2.2.0
|
|
||||||
pgpass: 1.0.5
|
|
||||||
optionalDependencies:
|
|
||||||
pg-cloudflare: 1.3.0
|
|
||||||
|
|
||||||
pg@8.20.0:
|
pg@8.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
pg-connection-string: 2.12.0
|
pg-connection-string: 2.12.0
|
||||||
|
|
@ -13290,11 +13223,6 @@ snapshots:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
react-dom@19.2.4(react@18.3.1):
|
|
||||||
dependencies:
|
|
||||||
react: 18.3.1
|
|
||||||
scheduler: 0.27.0
|
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
@ -13308,23 +13236,23 @@ snapshots:
|
||||||
prop-types-extra: 1.1.1(react@19.2.4)
|
prop-types-extra: 1.1.1(react@19.2.4)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
react-focus-lock@2.13.7(@types/react@18.3.27)(react@19.2.4):
|
react-focus-lock@2.13.7(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
focus-lock: 1.3.6
|
focus-lock: 1.3.6
|
||||||
prop-types: 15.8.1
|
prop-types: 15.8.1
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-clientside-effect: 1.2.8(react@19.2.4)
|
react-clientside-effect: 1.2.8(react@19.2.4)
|
||||||
use-callback-ref: 1.3.3(@types/react@18.3.27)(react@19.2.4)
|
use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
|
||||||
use-sidecar: 1.1.3(@types/react@18.3.27)(react@19.2.4)
|
use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
react-grab@0.1.27(@types/react@18.3.27)(react@19.2.4):
|
react-grab@0.1.27(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@medv/finder': 4.0.2
|
'@medv/finder': 4.0.2
|
||||||
'@react-grab/cli': 0.1.27
|
'@react-grab/cli': 0.1.27
|
||||||
bippy: 0.5.32(@types/react@18.3.27)(react@19.2.4)
|
bippy: 0.5.32(@types/react@19.2.14)(react@19.2.4)
|
||||||
solid-js: 1.9.11
|
solid-js: 1.9.11
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
@ -13378,12 +13306,12 @@ snapshots:
|
||||||
|
|
||||||
react-refresh@0.18.0: {}
|
react-refresh@0.18.0: {}
|
||||||
|
|
||||||
react-uid@2.3.0(@types/react@18.3.27)(react@19.2.4):
|
react-uid@2.3.0(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
tslib: 1.14.1
|
tslib: 1.14.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
react-virtualized-auto-sizer@1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
react-virtualized-auto-sizer@1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -13571,7 +13499,7 @@ snapshots:
|
||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
|
rivetkit@2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
|
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
|
||||||
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
|
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
|
||||||
|
|
@ -13599,14 +13527,14 @@ snapshots:
|
||||||
'@hono/node-server': 1.19.9(hono@4.12.2)
|
'@hono/node-server': 1.19.9(hono@4.12.2)
|
||||||
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
|
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
|
||||||
drizzle-kit: 0.31.9
|
drizzle-kit: 0.31.9
|
||||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
||||||
ws: 8.19.0
|
ws: 8.19.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@standard-schema/spec'
|
- '@standard-schema/spec'
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
|
rivetkit@https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a(@e2b/code-interpreter@2.3.3)(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(@standard-schema/spec@1.1.0)(dockerode@4.0.9)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0))(ws@8.19.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
|
'@hono/standard-validator': 0.1.5(@standard-schema/spec@1.1.0)(hono@4.12.2)
|
||||||
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
|
'@hono/zod-openapi': 1.2.2(hono@4.12.2)(zod@4.3.6)
|
||||||
|
|
@ -13637,7 +13565,7 @@ snapshots:
|
||||||
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
|
'@hono/node-ws': 1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2)
|
||||||
dockerode: 4.0.9
|
dockerode: 4.0.9
|
||||||
drizzle-kit: 0.31.9
|
drizzle-kit: 0.31.9
|
||||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(kysely@0.28.11)(pg@8.20.0)
|
||||||
ws: 8.19.0
|
ws: 8.19.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@standard-schema/spec'
|
- '@standard-schema/spec'
|
||||||
|
|
@ -14371,20 +14299,20 @@ snapshots:
|
||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
use-callback-ref@1.3.3(@types/react@18.3.27)(react@19.2.4):
|
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
use-sidecar@1.1.3(@types/react@18.3.27)(react@19.2.4):
|
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-node-es: 1.1.0
|
detect-node-es: 1.1.0
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
use-sync-external-store@1.6.0(react@19.2.4):
|
use-sync-external-store@1.6.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -14744,18 +14672,18 @@ snapshots:
|
||||||
'@cloudflare/workerd-linux-arm64': 1.20260312.1
|
'@cloudflare/workerd-linux-arm64': 1.20260312.1
|
||||||
'@cloudflare/workerd-windows-64': 1.20260312.1
|
'@cloudflare/workerd-windows-64': 1.20260312.1
|
||||||
|
|
||||||
wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1):
|
wrangler@4.74.0(@cloudflare/workers-types@4.20260317.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/kv-asset-handler': 0.4.2
|
'@cloudflare/kv-asset-handler': 0.4.2
|
||||||
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260312.1)
|
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260312.1)
|
||||||
blake3-wasm: 2.1.5
|
blake3-wasm: 2.1.5
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
miniflare: 4.20260312.0
|
miniflare: 4.20260312.1
|
||||||
path-to-regexp: 6.3.0
|
path-to-regexp: 6.3.0
|
||||||
unenv: 2.0.0-rc.24
|
unenv: 2.0.0-rc.24
|
||||||
workerd: 1.20260312.1
|
workerd: 1.20260312.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@cloudflare/workers-types': 4.20260313.1
|
'@cloudflare/workers-types': 4.20260317.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
|
|
|
||||||
103
research/desktop-streaming-architecture.md
Normal file
103
research/desktop-streaming-architecture.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Desktop Streaming Architecture
|
||||||
|
|
||||||
|
## Decision: neko over GStreamer (direct) and VNC
|
||||||
|
|
||||||
|
We evaluated three approaches for streaming the virtual desktop to browser clients:
|
||||||
|
|
||||||
|
1. **VNC (noVNC/websockify)** - traditional remote desktop
|
||||||
|
2. **GStreamer WebRTC (direct)** - custom GStreamer pipeline in the sandbox agent process
|
||||||
|
3. **neko** - standalone WebRTC streaming server with its own GStreamer pipeline
|
||||||
|
|
||||||
|
We chose **neko**.
|
||||||
|
|
||||||
|
## Approach comparison
|
||||||
|
|
||||||
|
### VNC (noVNC)
|
||||||
|
|
||||||
|
- Uses RFB protocol, not WebRTC. Relies on pixel-diff framebuffer updates over WebSocket.
|
||||||
|
- Higher latency than WebRTC (no hardware-accelerated codec, no adaptive bitrate).
|
||||||
|
- Requires a VNC server (x11vnc or similar) plus websockify for browser access.
|
||||||
|
- Input handling is mature but tied to the RFB protocol.
|
||||||
|
- No audio support without additional plumbing.
|
||||||
|
|
||||||
|
**Rejected because:** Latency is noticeably worse than WebRTC-based approaches. The pixel-diff approach doesn't scale well at higher resolutions or frame rates. No native audio path.
|
||||||
|
|
||||||
|
### GStreamer WebRTC (direct)
|
||||||
|
|
||||||
|
- Custom pipeline: `ximagesrc -> videoconvert -> vp8enc -> rtpvp8pay -> webrtcbin`.
|
||||||
|
- Runs inside the sandbox agent Rust process using `gstreamer-rs` bindings.
|
||||||
|
- Requires feature-gating (`desktop-gstreamer` Cargo feature) and linking GStreamer at compile time.
|
||||||
|
- ICE candidate handling is complex: Docker-internal IPs (172.17.x.x) must be rewritten to 127.0.0.1 for host browser connectivity.
|
||||||
|
- UDP port range must be constrained via libnice NiceAgent properties to stay within Docker-forwarded ports.
|
||||||
|
- Input must be implemented separately (xdotool or custom X11 input injection).
|
||||||
|
- No built-in session management, authentication, or multi-client support.
|
||||||
|
|
||||||
|
**Rejected because:** Too much complexity for the sandbox agent to own directly. ICE/NAT traversal bugs are hard to debug. The GStreamer Rust bindings add significant compile-time dependencies. Input handling requires a separate implementation. We built and tested this approach (branch `desktop-computer-use`, PR #226) and found:
|
||||||
|
- Black screen issues due to GStreamer pipeline negotiation failures
|
||||||
|
- ICE candidate rewriting fragility across Docker networking modes
|
||||||
|
- libnice port range configuration requires accessing internal NiceAgent properties that vary across GStreamer versions
|
||||||
|
- No data channel for low-latency input (had to fall back to WebSocket-based input which adds a round trip)
|
||||||
|
|
||||||
|
### neko (chosen)
|
||||||
|
|
||||||
|
- Standalone Go binary extracted from `ghcr.io/m1k1o/neko/base`.
|
||||||
|
- Has its own GStreamer pipeline internally (same `ximagesrc -> vp8enc -> webrtcbin` approach, but battle-tested).
|
||||||
|
- Provides WebSocket signaling, WebRTC media, and a binary data channel for input, all out of the box.
|
||||||
|
- Input via data channel is low-latency (sub-frame, no HTTP round trip). Uses X11 XTEST extension.
|
||||||
|
- Multi-session support with `noauth` provider (each browser tab gets its own session).
|
||||||
|
- ICE-lite mode with `--webrtc.nat1to1 127.0.0.1` eliminates NAT traversal issues for Docker-to-host.
|
||||||
|
- EPR (ephemeral port range) flag constrains UDP ports cleanly.
|
||||||
|
- Sandbox agent acts as a thin WebSocket proxy: browser WS connects to sandbox agent, which creates a per-connection neko login session and relays signaling messages bidirectionally.
|
||||||
|
- Audio codec support (opus) included for free.
|
||||||
|
|
||||||
|
**Chosen because:** Neko encapsulates all the hard WebRTC/GStreamer/input complexity into a single binary. The sandbox agent only needs to:
|
||||||
|
1. Manage the neko process lifecycle (start/stop via the process runtime)
|
||||||
|
2. Proxy WebSocket signaling (bidirectional relay, ~60 lines of code)
|
||||||
|
3. Handle neko session creation (HTTP login to get a session cookie)
|
||||||
|
|
||||||
|
This keeps the sandbox agent's desktop streaming code simple (~300 lines for the manager, ~120 lines for the WS proxy) while delivering production-quality WebRTC streaming with data channel input.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Sandbox Agent neko (internal)
|
||||||
|
| | |
|
||||||
|
|-- WS /stream/signaling --> |-- WS ws://127.0.0.1:18100/api/ws -->|
|
||||||
|
| | (bidirectional relay) |
|
||||||
|
|<-- neko signaling ---------|<-- neko signaling -------|
|
||||||
|
| | |
|
||||||
|
|<========= WebRTC (UDP 59000-59100) ==================>|
|
||||||
|
| VP8 video, Opus audio, binary data channel |
|
||||||
|
| |
|
||||||
|
|-- data channel input (mouse/keyboard) --------------->|
|
||||||
|
| (binary protocol: opcode + payload, big-endian) |
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- neko listens on internal port 18100 (not exposed externally).
|
||||||
|
- UDP ports 59000-59100 are forwarded through Docker for WebRTC media.
|
||||||
|
- `--webrtc.icelite` + `--webrtc.nat1to1 127.0.0.1` means neko advertises 127.0.0.1 as its ICE candidate, so the browser connects to localhost UDP ports directly.
|
||||||
|
- `--desktop.input.enabled=false` disables neko's custom xf86-input driver (not available outside neko's official Docker images). Input falls back to XTEST.
|
||||||
|
- Each WebSocket proxy connection creates a fresh neko login session with a unique username to avoid session conflicts when multiple clients connect.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
| Concern | neko | GStreamer direct |
|
||||||
|
|---------|------|-----------------|
|
||||||
|
| Binary size | ~30MB additional binary | ~0 (uses system GStreamer libs) |
|
||||||
|
| Compile-time deps | None (external binary) | gstreamer-rs crate + GStreamer dev libs |
|
||||||
|
| Input latency | Sub-frame (data channel) | WebSocket round trip |
|
||||||
|
| ICE/NAT complexity | Handled by neko flags | Must implement in Rust |
|
||||||
|
| Multi-client | Built-in session management | Must implement |
|
||||||
|
| Maintenance | Upstream neko updates | Own all the code |
|
||||||
|
| Audio | Built-in (opus) | Must add audio pipeline |
|
||||||
|
|
||||||
|
The main trade-off is the additional ~30MB binary size from neko. This is acceptable for the Docker-based deployment model where image size is less critical than reliability and development velocity.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- neko v3: https://github.com/m1k1o/neko
|
||||||
|
- neko client reference: https://github.com/demodesk/neko-client
|
||||||
|
- neko data channel protocol: https://github.com/m1k1o/neko/blob/master/server/internal/webrtc/payload/receive.go
|
||||||
|
- GStreamer branch (closed): PR #226, branch `desktop-computer-use`
|
||||||
|
- Image digest: `ghcr.io/m1k1o/neko/base@sha256:0c384afa56268aaa2d5570211d284763d0840dcdd1a7d9a24be3081d94d3dfce`
|
||||||
|
|
@ -6,7 +6,7 @@ import type { DesktopMouseButton, DesktopStreamErrorStatus, DesktopStreamReadySt
|
||||||
|
|
||||||
type ConnectionState = "connecting" | "ready" | "closed" | "error";
|
type ConnectionState = "connecting" | "ready" | "closed" | "error";
|
||||||
|
|
||||||
export type DesktopViewerClient = Pick<SandboxAgent, "startDesktopStream" | "stopDesktopStream" | "connectDesktopStream">;
|
export type DesktopViewerClient = Pick<SandboxAgent, "connectDesktopStream">;
|
||||||
|
|
||||||
export interface DesktopViewerProps {
|
export interface DesktopViewerProps {
|
||||||
client: DesktopViewerClient;
|
client: DesktopViewerClient;
|
||||||
|
|
@ -51,7 +51,7 @@ const viewportStyle: CSSProperties = {
|
||||||
background: "radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 45%), linear-gradient(180deg, #0f172a 0%, #111827 100%)",
|
background: "radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 45%), linear-gradient(180deg, #0f172a 0%, #111827 100%)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageBaseStyle: CSSProperties = {
|
const videoBaseStyle: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
|
@ -78,112 +78,96 @@ const getStatusColor = (state: ConnectionState): string => {
|
||||||
|
|
||||||
export const DesktopViewer = ({ client, className, style, imageStyle, height = 480, onConnect, onDisconnect, onError }: DesktopViewerProps) => {
|
export const DesktopViewer = ({ client, className, style, imageStyle, height = 480, onConnect, onDisconnect, onError }: DesktopViewerProps) => {
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const sessionRef = useRef<ReturnType<DesktopViewerClient["connectDesktopStream"]> | null>(null);
|
const sessionRef = useRef<ReturnType<DesktopViewerClient["connectDesktopStream"]> | null>(null);
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
||||||
const [statusMessage, setStatusMessage] = useState("Starting desktop stream...");
|
const [statusMessage, setStatusMessage] = useState("Starting desktop stream...");
|
||||||
const [frameUrl, setFrameUrl] = useState<string | null>(null);
|
const [hasVideo, setHasVideo] = useState(false);
|
||||||
const [resolution, setResolution] = useState<{ width: number; height: number } | null>(null);
|
const [resolution, setResolution] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let lastObjectUrl: string | null = null;
|
|
||||||
let session: ReturnType<DesktopViewerClient["connectDesktopStream"]> | null = null;
|
|
||||||
|
|
||||||
setConnectionState("connecting");
|
setConnectionState("connecting");
|
||||||
setStatusMessage("Starting desktop stream...");
|
setStatusMessage("Connecting to desktop stream...");
|
||||||
setResolution(null);
|
setResolution(null);
|
||||||
|
setHasVideo(false);
|
||||||
|
|
||||||
const connect = async () => {
|
const session = client.connectDesktopStream();
|
||||||
try {
|
sessionRef.current = session;
|
||||||
await client.startDesktopStream();
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session = client.connectDesktopStream();
|
session.onReady((status) => {
|
||||||
sessionRef.current = session;
|
if (cancelled) return;
|
||||||
session.onReady((status) => {
|
setConnectionState("ready");
|
||||||
if (cancelled) {
|
setStatusMessage("Desktop stream connected.");
|
||||||
return;
|
setResolution({ width: status.width, height: status.height });
|
||||||
}
|
onConnect?.(status);
|
||||||
setConnectionState("ready");
|
});
|
||||||
setStatusMessage("Desktop stream connected.");
|
session.onTrack((stream) => {
|
||||||
setResolution({ width: status.width, height: status.height });
|
if (cancelled) return;
|
||||||
onConnect?.(status);
|
const video = videoRef.current;
|
||||||
});
|
if (video) {
|
||||||
session.onFrame((frame) => {
|
video.srcObject = stream;
|
||||||
if (cancelled) {
|
void video.play().catch(() => undefined);
|
||||||
return;
|
setHasVideo(true);
|
||||||
}
|
|
||||||
const nextUrl = URL.createObjectURL(new Blob([frame.slice().buffer], { type: "image/jpeg" }));
|
|
||||||
setFrameUrl((current) => {
|
|
||||||
if (current) {
|
|
||||||
URL.revokeObjectURL(current);
|
|
||||||
}
|
|
||||||
return nextUrl;
|
|
||||||
});
|
|
||||||
if (lastObjectUrl) {
|
|
||||||
URL.revokeObjectURL(lastObjectUrl);
|
|
||||||
}
|
|
||||||
lastObjectUrl = nextUrl;
|
|
||||||
});
|
|
||||||
session.onError((error) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConnectionState("error");
|
|
||||||
setStatusMessage(error instanceof Error ? error.message : error.message);
|
|
||||||
onError?.(error);
|
|
||||||
});
|
|
||||||
session.onClose(() => {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConnectionState((current) => (current === "error" ? current : "closed"));
|
|
||||||
setStatusMessage((current) => (current === "Desktop stream connected." ? "Desktop stream disconnected." : current));
|
|
||||||
onDisconnect?.();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextError = error instanceof Error ? error : new Error("Failed to initialize desktop stream.");
|
|
||||||
setConnectionState("error");
|
|
||||||
setStatusMessage(nextError.message);
|
|
||||||
onError?.(nextError);
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
session.onError((error) => {
|
||||||
void connect();
|
if (cancelled) return;
|
||||||
|
setConnectionState("error");
|
||||||
|
setStatusMessage(error instanceof Error ? error.message : error.message);
|
||||||
|
onError?.(error);
|
||||||
|
});
|
||||||
|
session.onDisconnect(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConnectionState((current) => (current === "error" ? current : "closed"));
|
||||||
|
setStatusMessage((current) => (current === "Desktop stream connected." ? "Desktop stream disconnected." : current));
|
||||||
|
onDisconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
session?.close();
|
session.close();
|
||||||
sessionRef.current = null;
|
sessionRef.current = null;
|
||||||
void client.stopDesktopStream().catch(() => undefined);
|
const video = videoRef.current;
|
||||||
setFrameUrl((current) => {
|
if (video) {
|
||||||
if (current) {
|
video.srcObject = null;
|
||||||
URL.revokeObjectURL(current);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
if (lastObjectUrl) {
|
|
||||||
URL.revokeObjectURL(lastObjectUrl);
|
|
||||||
}
|
}
|
||||||
|
setHasVideo(false);
|
||||||
};
|
};
|
||||||
}, [client, onConnect, onDisconnect, onError]);
|
}, [client, onConnect, onDisconnect, onError]);
|
||||||
|
|
||||||
const scalePoint = (clientX: number, clientY: number) => {
|
const scalePoint = (clientX: number, clientY: number) => {
|
||||||
const wrapper = wrapperRef.current;
|
const video = videoRef.current;
|
||||||
if (!wrapper || !resolution) {
|
if (!video || !resolution) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const rect = video.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) {
|
if (rect.width === 0 || rect.height === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const x = Math.max(0, Math.min(resolution.width, ((clientX - rect.left) / rect.width) * resolution.width));
|
// The video uses objectFit: "contain", so we need to compute the actual
|
||||||
const y = Math.max(0, Math.min(resolution.height, ((clientY - rect.top) / rect.height) * resolution.height));
|
// rendered content area within the <video> element to map coordinates
|
||||||
|
// accurately (ignoring letterbox bars).
|
||||||
|
const videoAspect = resolution.width / resolution.height;
|
||||||
|
const elemAspect = rect.width / rect.height;
|
||||||
|
let renderW: number;
|
||||||
|
let renderH: number;
|
||||||
|
if (elemAspect > videoAspect) {
|
||||||
|
// Pillarboxed (black bars on left/right)
|
||||||
|
renderH = rect.height;
|
||||||
|
renderW = rect.height * videoAspect;
|
||||||
|
} else {
|
||||||
|
// Letterboxed (black bars on top/bottom)
|
||||||
|
renderW = rect.width;
|
||||||
|
renderH = rect.width / videoAspect;
|
||||||
|
}
|
||||||
|
const offsetX = (rect.width - renderW) / 2;
|
||||||
|
const offsetY = (rect.height - renderH) / 2;
|
||||||
|
const relX = clientX - rect.left - offsetX;
|
||||||
|
const relY = clientY - rect.top - offsetY;
|
||||||
|
const x = Math.max(0, Math.min(resolution.width, (relX / renderW) * resolution.width));
|
||||||
|
const y = Math.max(0, Math.min(resolution.height, (relY / renderH) * resolution.height));
|
||||||
return {
|
return {
|
||||||
x: Math.round(x),
|
x: Math.round(x),
|
||||||
y: Math.round(y),
|
y: Math.round(y),
|
||||||
|
|
@ -212,7 +196,7 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
|
||||||
<div className={className} style={{ ...shellStyle, ...style }}>
|
<div className={className} style={{ ...shellStyle, ...style }}>
|
||||||
<div style={statusBarStyle}>
|
<div style={statusBarStyle}>
|
||||||
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
||||||
<span style={hintStyle}>{resolution ? `${resolution.width}×${resolution.height}` : "Awaiting frames"}</span>
|
<span style={hintStyle}>{resolution ? `${resolution.width}×${resolution.height}` : "Awaiting stream"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
|
|
@ -226,8 +210,14 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
|
||||||
}
|
}
|
||||||
withSession((session) => session.moveMouse(point.x, point.y));
|
withSession((session) => session.moveMouse(point.x, point.y));
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// preventDefault on mousedown suppresses the default focus behavior,
|
||||||
|
// so we must explicitly focus the wrapper to receive keyboard events.
|
||||||
|
wrapperRef.current?.focus();
|
||||||
const point = scalePoint(event.clientX, event.clientY);
|
const point = scalePoint(event.clientX, event.clientY);
|
||||||
withSession((session) => session.mouseDown(buttonFromMouseEvent(event), point?.x, point?.y));
|
withSession((session) => session.mouseDown(buttonFromMouseEvent(event), point?.x, point?.y));
|
||||||
}}
|
}}
|
||||||
|
|
@ -244,13 +234,25 @@ export const DesktopViewer = ({ client, className, style, imageStyle, height = 4
|
||||||
withSession((session) => session.scroll(point.x, point.y, Math.round(event.deltaX), Math.round(event.deltaY)));
|
withSession((session) => session.scroll(point.x, point.y, Math.round(event.deltaX), Math.round(event.deltaY)));
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
withSession((session) => session.keyDown(event.key));
|
withSession((session) => session.keyDown(event.key));
|
||||||
}}
|
}}
|
||||||
onKeyUp={(event) => {
|
onKeyUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
withSession((session) => session.keyUp(event.key));
|
withSession((session) => session.keyUp(event.key));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{frameUrl ? <img alt="Desktop stream" draggable={false} src={frameUrl} style={{ ...imageBaseStyle, ...imageStyle }} /> : null}
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
tabIndex={-1}
|
||||||
|
draggable={false}
|
||||||
|
style={{ ...videoBaseStyle, ...imageStyle, display: hasVideo ? "block" : "none", pointerEvents: "none" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1926,7 +1926,7 @@ export class SandboxAgent {
|
||||||
|
|
||||||
buildDesktopStreamWebSocketUrl(options: ProcessTerminalWebSocketUrlOptions = {}): string {
|
buildDesktopStreamWebSocketUrl(options: ProcessTerminalWebSocketUrlOptions = {}): string {
|
||||||
return toWebSocketUrl(
|
return toWebSocketUrl(
|
||||||
this.buildUrl(`${API_PREFIX}/desktop/stream/ws`, {
|
this.buildUrl(`${API_PREFIX}/desktop/stream/signaling`, {
|
||||||
access_token: options.accessToken ?? this.token,
|
access_token: options.accessToken ?? this.token,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { DesktopMouseButton } from "./types.ts";
|
import type { DesktopMouseButton } from "./types.ts";
|
||||||
|
|
||||||
const WS_READY_STATE_CONNECTING = 0;
|
|
||||||
const WS_READY_STATE_OPEN = 1;
|
const WS_READY_STATE_OPEN = 1;
|
||||||
const WS_READY_STATE_CLOSED = 3;
|
const WS_READY_STATE_CLOSED = 3;
|
||||||
|
|
||||||
|
|
@ -21,63 +20,140 @@ export interface DesktopStreamConnectOptions {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
WebSocket?: typeof WebSocket;
|
WebSocket?: typeof WebSocket;
|
||||||
protocols?: string | string[];
|
protocols?: string | string[];
|
||||||
|
RTCPeerConnection?: typeof RTCPeerConnection;
|
||||||
|
rtcConfig?: RTCConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DesktopStreamClientFrame =
|
/**
|
||||||
| {
|
* Neko data channel binary input protocol (Big Endian, v3).
|
||||||
type: "moveMouse";
|
*
|
||||||
x: number;
|
* Reference implementation:
|
||||||
y: number;
|
* https://github.com/demodesk/neko-client/blob/37f93eae6bd55b333c94bd009d7f2b079075a026/src/component/internal/webrtc.ts
|
||||||
}
|
*
|
||||||
| {
|
* Server-side protocol:
|
||||||
type: "mouseDown" | "mouseUp";
|
* https://github.com/m1k1o/neko/blob/master/server/internal/webrtc/payload/receive.go
|
||||||
x?: number;
|
*
|
||||||
y?: number;
|
* Pinned to neko server image: m1k1o/neko:base@sha256:14e4012bc361025f71205ffc2a9342a628f39168c0a1d855db033fb18590fcae
|
||||||
button?: DesktopMouseButton;
|
*/
|
||||||
}
|
const NEKO_OP_MOVE = 0x01;
|
||||||
| {
|
const NEKO_OP_SCROLL = 0x02;
|
||||||
type: "scroll";
|
const NEKO_OP_KEY_DOWN = 0x03;
|
||||||
x: number;
|
const NEKO_OP_KEY_UP = 0x04;
|
||||||
y: number;
|
const NEKO_OP_BTN_DOWN = 0x05;
|
||||||
deltaX?: number;
|
const NEKO_OP_BTN_UP = 0x06;
|
||||||
deltaY?: number;
|
|
||||||
}
|
function mouseButtonToX11(button?: DesktopMouseButton): number {
|
||||||
| {
|
switch (button) {
|
||||||
type: "keyDown" | "keyUp";
|
case "middle":
|
||||||
key: string;
|
return 2;
|
||||||
}
|
case "right":
|
||||||
| {
|
return 3;
|
||||||
type: "close";
|
default:
|
||||||
};
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyToX11Keysym(key: string): number {
|
||||||
|
if (key.length === 1) {
|
||||||
|
const cp = key.charCodeAt(0);
|
||||||
|
if (cp >= 0x20 && cp <= 0x7e) return cp;
|
||||||
|
return 0x01000000 + cp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
Backspace: 0xff08,
|
||||||
|
Tab: 0xff09,
|
||||||
|
Return: 0xff0d,
|
||||||
|
Enter: 0xff0d,
|
||||||
|
Escape: 0xff1b,
|
||||||
|
Delete: 0xffff,
|
||||||
|
Home: 0xff50,
|
||||||
|
Left: 0xff51,
|
||||||
|
ArrowLeft: 0xff51,
|
||||||
|
Up: 0xff52,
|
||||||
|
ArrowUp: 0xff52,
|
||||||
|
Right: 0xff53,
|
||||||
|
ArrowRight: 0xff53,
|
||||||
|
Down: 0xff54,
|
||||||
|
ArrowDown: 0xff54,
|
||||||
|
PageUp: 0xff55,
|
||||||
|
PageDown: 0xff56,
|
||||||
|
End: 0xff57,
|
||||||
|
Insert: 0xff63,
|
||||||
|
F1: 0xffbe,
|
||||||
|
F2: 0xffbf,
|
||||||
|
F3: 0xffc0,
|
||||||
|
F4: 0xffc1,
|
||||||
|
F5: 0xffc2,
|
||||||
|
F6: 0xffc3,
|
||||||
|
F7: 0xffc4,
|
||||||
|
F8: 0xffc5,
|
||||||
|
F9: 0xffc6,
|
||||||
|
F10: 0xffc7,
|
||||||
|
F11: 0xffc8,
|
||||||
|
F12: 0xffc9,
|
||||||
|
Shift: 0xffe1,
|
||||||
|
ShiftLeft: 0xffe1,
|
||||||
|
ShiftRight: 0xffe2,
|
||||||
|
Control: 0xffe3,
|
||||||
|
ControlLeft: 0xffe3,
|
||||||
|
ControlRight: 0xffe4,
|
||||||
|
Alt: 0xffe9,
|
||||||
|
AltLeft: 0xffe9,
|
||||||
|
AltRight: 0xffea,
|
||||||
|
Meta: 0xffeb,
|
||||||
|
MetaLeft: 0xffeb,
|
||||||
|
MetaRight: 0xffec,
|
||||||
|
CapsLock: 0xffe5,
|
||||||
|
NumLock: 0xff7f,
|
||||||
|
ScrollLock: 0xff14,
|
||||||
|
" ": 0x0020,
|
||||||
|
Space: 0x0020,
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[key] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
export class DesktopStreamSession {
|
export class DesktopStreamSession {
|
||||||
readonly socket: WebSocket;
|
readonly socket: WebSocket;
|
||||||
readonly closed: Promise<void>;
|
readonly closed: Promise<void>;
|
||||||
|
|
||||||
|
private pc: RTCPeerConnection | null = null;
|
||||||
|
private dataChannel: RTCDataChannel | null = null;
|
||||||
|
private mediaStream: MediaStream | null = null;
|
||||||
|
private connected = false;
|
||||||
|
private pendingCandidates: Record<string, unknown>[] = [];
|
||||||
|
private cachedReadyStatus: DesktopStreamReadyStatus | null = null;
|
||||||
|
|
||||||
private readonly readyListeners = new Set<(status: DesktopStreamReadyStatus) => void>();
|
private readonly readyListeners = new Set<(status: DesktopStreamReadyStatus) => void>();
|
||||||
private readonly frameListeners = new Set<(frame: Uint8Array) => void>();
|
private readonly trackListeners = new Set<(stream: MediaStream) => void>();
|
||||||
|
private readonly connectListeners = new Set<() => void>();
|
||||||
|
private readonly disconnectListeners = new Set<() => void>();
|
||||||
private readonly errorListeners = new Set<(error: DesktopStreamErrorStatus | Error) => void>();
|
private readonly errorListeners = new Set<(error: DesktopStreamErrorStatus | Error) => void>();
|
||||||
private readonly closeListeners = new Set<() => void>();
|
|
||||||
|
|
||||||
private closeSignalSent = false;
|
|
||||||
private closedResolve!: () => void;
|
private closedResolve!: () => void;
|
||||||
|
private readonly PeerConnection: typeof RTCPeerConnection;
|
||||||
|
private readonly rtcConfig: RTCConfiguration;
|
||||||
|
|
||||||
constructor(socket: WebSocket) {
|
constructor(socket: WebSocket, options: DesktopStreamConnectOptions = {}) {
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.socket.binaryType = "arraybuffer";
|
this.PeerConnection = options.RTCPeerConnection ?? globalThis.RTCPeerConnection;
|
||||||
|
this.rtcConfig = options.rtcConfig ?? {};
|
||||||
|
|
||||||
this.closed = new Promise<void>((resolve) => {
|
this.closed = new Promise<void>((resolve) => {
|
||||||
this.closedResolve = resolve;
|
this.closedResolve = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.addEventListener("message", (event) => {
|
this.socket.addEventListener("message", (event) => {
|
||||||
void this.handleMessage(event.data);
|
this.handleMessage(event.data as string);
|
||||||
});
|
});
|
||||||
this.socket.addEventListener("error", () => {
|
this.socket.addEventListener("error", () => {
|
||||||
this.emitError(new Error("Desktop stream websocket connection failed."));
|
this.emitError(new Error("Desktop stream signaling connection failed."));
|
||||||
});
|
});
|
||||||
this.socket.addEventListener("close", () => {
|
this.socket.addEventListener("close", () => {
|
||||||
|
this.teardownPeerConnection();
|
||||||
this.closedResolve();
|
this.closedResolve();
|
||||||
for (const listener of this.closeListeners) {
|
for (const listener of this.disconnectListeners) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -85,15 +161,37 @@ export class DesktopStreamSession {
|
||||||
|
|
||||||
onReady(listener: (status: DesktopStreamReadyStatus) => void): () => void {
|
onReady(listener: (status: DesktopStreamReadyStatus) => void): () => void {
|
||||||
this.readyListeners.add(listener);
|
this.readyListeners.add(listener);
|
||||||
|
// Deliver cached status to late listeners (handles race where system/init
|
||||||
|
// arrives before onReady is called).
|
||||||
|
if (this.cachedReadyStatus) {
|
||||||
|
listener(this.cachedReadyStatus);
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
this.readyListeners.delete(listener);
|
this.readyListeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onFrame(listener: (frame: Uint8Array) => void): () => void {
|
onTrack(listener: (stream: MediaStream) => void): () => void {
|
||||||
this.frameListeners.add(listener);
|
this.trackListeners.add(listener);
|
||||||
|
if (this.mediaStream) {
|
||||||
|
listener(this.mediaStream);
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
this.frameListeners.delete(listener);
|
this.trackListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnect(listener: () => void): () => void {
|
||||||
|
this.connectListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.connectListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect(listener: () => void): () => void {
|
||||||
|
this.disconnectListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.disconnectListeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,97 +202,335 @@ export class DesktopStreamSession {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use onDisconnect instead. */
|
||||||
onClose(listener: () => void): () => void {
|
onClose(listener: () => void): () => void {
|
||||||
this.closeListeners.add(listener);
|
return this.onDisconnect(listener);
|
||||||
return () => {
|
}
|
||||||
this.closeListeners.delete(listener);
|
|
||||||
};
|
/** @deprecated No longer emits JPEG frames. Use onTrack for WebRTC media. */
|
||||||
|
onFrame(_listener: (frame: Uint8Array) => void): () => void {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMediaStream(): MediaStream | null {
|
||||||
|
return this.mediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a neko data channel message with the 3-byte header (event + length). */
|
||||||
|
private buildNekoMsg(event: number, payloadSize: number): { buf: ArrayBuffer; view: DataView } {
|
||||||
|
const totalLen = 3 + payloadSize; // 1 byte event + 2 bytes length + payload
|
||||||
|
const buf = new ArrayBuffer(totalLen);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
view.setUint8(0, event);
|
||||||
|
view.setUint16(1, payloadSize, false);
|
||||||
|
return { buf, view };
|
||||||
}
|
}
|
||||||
|
|
||||||
moveMouse(x: number, y: number): void {
|
moveMouse(x: number, y: number): void {
|
||||||
this.sendFrame({ type: "moveMouse", x, y });
|
// Move payload: X(uint16) + Y(uint16) = 4 bytes
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_MOVE, 4);
|
||||||
|
view.setUint16(3, x, false);
|
||||||
|
view.setUint16(5, y, false);
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseDown(button?: DesktopMouseButton, x?: number, y?: number): void {
|
mouseDown(button?: DesktopMouseButton, x?: number, y?: number): void {
|
||||||
this.sendFrame({ type: "mouseDown", button, x, y });
|
if (x != null && y != null) {
|
||||||
|
this.moveMouse(x, y);
|
||||||
|
}
|
||||||
|
// Button payload: Key(uint32) = 4 bytes
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_BTN_DOWN, 4);
|
||||||
|
view.setUint32(3, mouseButtonToX11(button), false);
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseUp(button?: DesktopMouseButton, x?: number, y?: number): void {
|
mouseUp(button?: DesktopMouseButton, x?: number, y?: number): void {
|
||||||
this.sendFrame({ type: "mouseUp", button, x, y });
|
if (x != null && y != null) {
|
||||||
|
this.moveMouse(x, y);
|
||||||
|
}
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_BTN_UP, 4);
|
||||||
|
view.setUint32(3, mouseButtonToX11(button), false);
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll(x: number, y: number, deltaX?: number, deltaY?: number): void {
|
scroll(x: number, y: number, deltaX?: number, deltaY?: number): void {
|
||||||
this.sendFrame({ type: "scroll", x, y, deltaX, deltaY });
|
this.moveMouse(x, y);
|
||||||
|
// Scroll payload: DeltaX(int16) + DeltaY(int16) + ControlKey(uint8) = 5 bytes
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_SCROLL, 5);
|
||||||
|
view.setInt16(3, deltaX ?? 0, false);
|
||||||
|
view.setInt16(5, deltaY ?? 0, false);
|
||||||
|
view.setUint8(7, 0); // controlKey = false
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
keyDown(key: string): void {
|
keyDown(key: string): void {
|
||||||
this.sendFrame({ type: "keyDown", key });
|
const keysym = keyToX11Keysym(key);
|
||||||
|
if (keysym === 0) return;
|
||||||
|
// Key payload: Key(uint32) = 4 bytes
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_KEY_DOWN, 4);
|
||||||
|
view.setUint32(3, keysym, false);
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
keyUp(key: string): void {
|
keyUp(key: string): void {
|
||||||
this.sendFrame({ type: "keyUp", key });
|
const keysym = keyToX11Keysym(key);
|
||||||
|
if (keysym === 0) return;
|
||||||
|
const { buf, view } = this.buildNekoMsg(NEKO_OP_KEY_UP, 4);
|
||||||
|
view.setUint32(3, keysym, false);
|
||||||
|
this.sendDataChannel(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.socket.readyState === WS_READY_STATE_CONNECTING) {
|
this.teardownPeerConnection();
|
||||||
this.socket.addEventListener(
|
|
||||||
"open",
|
|
||||||
() => {
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.socket.readyState === WS_READY_STATE_OPEN) {
|
|
||||||
if (!this.closeSignalSent) {
|
|
||||||
this.closeSignalSent = true;
|
|
||||||
this.sendFrame({ type: "close" });
|
|
||||||
}
|
|
||||||
this.socket.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.socket.readyState !== WS_READY_STATE_CLOSED) {
|
if (this.socket.readyState !== WS_READY_STATE_CLOSED) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMessage(data: unknown): Promise<void> {
|
private handleMessage(data: string): void {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
if (typeof data === "string") {
|
msg = JSON.parse(data) as Record<string, unknown>;
|
||||||
const frame = parseStatusFrame(data);
|
} catch {
|
||||||
if (!frame) {
|
return;
|
||||||
this.emitError(new Error("Received invalid desktop stream control frame."));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frame.type === "ready") {
|
const event = (msg.event as string) ?? "";
|
||||||
|
|
||||||
|
// Neko uses "payload" for message data, not "data".
|
||||||
|
const payload = (msg.payload ?? msg.data) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "system/init": {
|
||||||
|
const screenData = payload?.screen_size as Record<string, unknown> | undefined;
|
||||||
|
if (screenData) {
|
||||||
|
const status: DesktopStreamReadyStatus = {
|
||||||
|
type: "ready",
|
||||||
|
width: Number(screenData.width) || 0,
|
||||||
|
height: Number(screenData.height) || 0,
|
||||||
|
};
|
||||||
|
this.cachedReadyStatus = status;
|
||||||
for (const listener of this.readyListeners) {
|
for (const listener of this.readyListeners) {
|
||||||
listener(frame);
|
listener(status);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// Request control so this session can send input.
|
||||||
this.emitError(frame);
|
this.sendSignaling("control/request", {});
|
||||||
return;
|
// Request WebRTC stream from neko. The server will respond with
|
||||||
|
// signal/provide containing the SDP offer.
|
||||||
|
this.sendSignaling("signal/request", { video: {}, audio: {} });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await decodeBinaryFrame(data);
|
case "signal/provide":
|
||||||
for (const listener of this.frameListeners) {
|
case "signal/offer": {
|
||||||
listener(bytes);
|
if (payload?.sdp) {
|
||||||
|
void this.handleNekoOffer(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "signal/restart": {
|
||||||
|
// Server-initiated renegotiation (treated as a new offer).
|
||||||
|
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/messages.ts#L190-L192
|
||||||
|
if (payload?.sdp) {
|
||||||
|
void this.handleNekoOffer(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "signal/candidate": {
|
||||||
|
if (payload) {
|
||||||
|
void this.handleNekoCandidate(payload);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "signal/close": {
|
||||||
|
// Server is closing the WebRTC connection.
|
||||||
|
this.teardownPeerConnection();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "system/disconnect": {
|
||||||
|
const message = (payload as Record<string, unknown>)?.message as string | undefined;
|
||||||
|
this.emitError(new Error(message ?? "Server disconnected."));
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleNekoOffer(data: Record<string, unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const iceServers: RTCIceServer[] = [];
|
||||||
|
const nekoIce = (data.iceservers ?? data.ice) as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (nekoIce) {
|
||||||
|
for (const server of nekoIce) {
|
||||||
|
if (server.urls) {
|
||||||
|
iceServers.push(server as unknown as RTCIceServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (iceServers.length === 0) {
|
||||||
|
iceServers.push({ urls: "stun:stun.l.google.com:19302" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: RTCConfiguration = { ...this.rtcConfig, iceServers };
|
||||||
|
const pc = new this.PeerConnection(config);
|
||||||
|
this.pc = pc;
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
const stream = event.streams[0] ?? new MediaStream([event.track]);
|
||||||
|
this.mediaStream = stream;
|
||||||
|
for (const listener of this.trackListeners) {
|
||||||
|
listener(stream);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this.sendSignaling("signal/candidate", event.candidate.toJSON());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L123-L173
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
switch (pc.connectionState) {
|
||||||
|
case "connected":
|
||||||
|
if (!this.connected) {
|
||||||
|
this.connected = true;
|
||||||
|
for (const listener of this.connectListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "closed":
|
||||||
|
case "failed":
|
||||||
|
this.emitError(new Error(`WebRTC connection ${pc.connectionState}.`));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
switch (pc.iceConnectionState) {
|
||||||
|
case "connected":
|
||||||
|
if (!this.connected) {
|
||||||
|
this.connected = true;
|
||||||
|
for (const listener of this.connectListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "closed":
|
||||||
|
case "failed":
|
||||||
|
this.emitError(new Error(`WebRTC ICE ${pc.iceConnectionState}.`));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Neko v3 creates data channels on the server side.
|
||||||
|
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L477-L486
|
||||||
|
pc.ondatachannel = (event) => {
|
||||||
|
this.dataChannel = event.channel;
|
||||||
|
this.dataChannel.binaryType = "arraybuffer";
|
||||||
|
this.dataChannel.onerror = () => {
|
||||||
|
this.emitError(new Error("WebRTC data channel error."));
|
||||||
|
};
|
||||||
|
this.dataChannel.onclose = () => {
|
||||||
|
this.dataChannel = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sdp = data.sdp as string;
|
||||||
|
await pc.setRemoteDescription({ type: "offer", sdp });
|
||||||
|
|
||||||
|
// Flush any ICE candidates that arrived before the PC was ready.
|
||||||
|
for (const pending of this.pendingCandidates) {
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate(pending as unknown as RTCIceCandidateInit);
|
||||||
|
} catch {
|
||||||
|
// ignore stale candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pendingCandidates = [];
|
||||||
|
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
// Enable stereo audio for Chromium.
|
||||||
|
// Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L262
|
||||||
|
if (answer.sdp) {
|
||||||
|
answer.sdp = answer.sdp.replace(/(stereo=1;)?useinbandfec=1/, "useinbandfec=1;stereo=1");
|
||||||
|
}
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
this.sendSignaling("signal/answer", { sdp: answer.sdp });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendFrame(frame: DesktopStreamClientFrame): void {
|
private async handleNekoCandidate(data: Record<string, unknown>): Promise<void> {
|
||||||
if (this.socket.readyState !== WS_READY_STATE_OPEN) {
|
// Buffer candidates that arrive before the peer connection is created.
|
||||||
|
if (!this.pc) {
|
||||||
|
this.pendingCandidates.push(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.socket.send(JSON.stringify(frame));
|
try {
|
||||||
|
const candidate = data as unknown as RTCIceCandidateInit;
|
||||||
|
await this.pc.addIceCandidate(candidate);
|
||||||
|
} catch (error) {
|
||||||
|
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendSignaling(event: string, payload: unknown): void {
|
||||||
|
if (this.socket.readyState !== WS_READY_STATE_OPEN) return;
|
||||||
|
this.socket.send(JSON.stringify({ event, payload }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendDataChannel(buf: ArrayBuffer): void {
|
||||||
|
if (this.dataChannel && this.dataChannel.readyState === "open") {
|
||||||
|
this.dataChannel.send(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down the peer connection, nullifying handlers first to prevent stale
|
||||||
|
* callbacks. Matches the reference disconnect() pattern.
|
||||||
|
* Ref: https://github.com/demodesk/neko-client/blob/37f93ea/src/component/internal/webrtc.ts#L321-L363 */
|
||||||
|
private teardownPeerConnection(): void {
|
||||||
|
if (this.dataChannel) {
|
||||||
|
this.dataChannel.onerror = null;
|
||||||
|
this.dataChannel.onmessage = null;
|
||||||
|
this.dataChannel.onopen = null;
|
||||||
|
this.dataChannel.onclose = null;
|
||||||
|
try {
|
||||||
|
this.dataChannel.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.dataChannel = null;
|
||||||
|
}
|
||||||
|
if (this.pc) {
|
||||||
|
this.pc.onicecandidate = null;
|
||||||
|
this.pc.onicecandidateerror = null;
|
||||||
|
this.pc.onconnectionstatechange = null;
|
||||||
|
this.pc.oniceconnectionstatechange = null;
|
||||||
|
this.pc.onsignalingstatechange = null;
|
||||||
|
this.pc.onnegotiationneeded = null;
|
||||||
|
this.pc.ontrack = null;
|
||||||
|
this.pc.ondatachannel = null;
|
||||||
|
try {
|
||||||
|
this.pc.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.pc = null;
|
||||||
|
}
|
||||||
|
this.mediaStream = null;
|
||||||
|
this.connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitError(error: DesktopStreamErrorStatus | Error): void {
|
private emitError(error: DesktopStreamErrorStatus | Error): void {
|
||||||
|
|
@ -203,34 +539,3 @@ export class DesktopStreamSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStatusFrame(payload: string): DesktopStreamStatusMessage | null {
|
|
||||||
const value = JSON.parse(payload) as Record<string, unknown>;
|
|
||||||
if (value.type === "ready" && typeof value.width === "number" && typeof value.height === "number") {
|
|
||||||
return {
|
|
||||||
type: "ready",
|
|
||||||
width: value.width,
|
|
||||||
height: value.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (value.type === "error" && typeof value.message === "string") {
|
|
||||||
return {
|
|
||||||
type: "error",
|
|
||||||
message: value.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decodeBinaryFrame(data: unknown): Promise<Uint8Array> {
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
return new Uint8Array(data);
|
|
||||||
}
|
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
||||||
return new Uint8Array(await data.arrayBuffer());
|
|
||||||
}
|
|
||||||
throw new Error("Unsupported desktop stream binary frame type.");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
42
server/compose.dev.yaml
Normal file
42
server/compose.dev.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: sandbox-agent-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/test-agent/Dockerfile
|
||||||
|
image: sandbox-agent-dev
|
||||||
|
command: ["server", "--host", "0.0.0.0", "--port", "3000", "--no-token"]
|
||||||
|
environment:
|
||||||
|
RUST_LOG: "${RUST_LOG:-info}"
|
||||||
|
ports:
|
||||||
|
- "2468:3000"
|
||||||
|
# UDP port range for WebRTC media transport (neko)
|
||||||
|
- "59050-59070:59050-59070/udp"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/inspector-dev/Dockerfile
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
SANDBOX_AGENT_URL: "http://backend:3000"
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- "..:/app"
|
||||||
|
# Keep Linux-native node_modules inside the container.
|
||||||
|
- "sa_root_node_modules:/app/node_modules"
|
||||||
|
- "sa_inspector_node_modules:/app/frontend/packages/inspector/node_modules"
|
||||||
|
- "sa_react_node_modules:/app/sdks/react/node_modules"
|
||||||
|
- "sa_typescript_node_modules:/app/sdks/typescript/node_modules"
|
||||||
|
- "sa_pnpm_store:/root/.local/share/pnpm/store"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sa_root_node_modules: {}
|
||||||
|
sa_inspector_node_modules: {}
|
||||||
|
sa_react_node_modules: {}
|
||||||
|
sa_typescript_node_modules: {}
|
||||||
|
sa_pnpm_store: {}
|
||||||
|
|
@ -41,6 +41,7 @@ base64.workspace = true
|
||||||
toml_edit.workspace = true
|
toml_edit.workspace = true
|
||||||
tar.workspace = true
|
tar.workspace = true
|
||||||
zip.workspace = true
|
zip.workspace = true
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
tempfile = { workspace = true, optional = true }
|
tempfile = { workspace = true, optional = true }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -172,9 +172,9 @@ impl DesktopRuntime {
|
||||||
let recording_manager =
|
let recording_manager =
|
||||||
DesktopRecordingManager::new(process_runtime.clone(), config.state_dir.clone());
|
DesktopRecordingManager::new(process_runtime.clone(), config.state_dir.clone());
|
||||||
Self {
|
Self {
|
||||||
|
streaming_manager: DesktopStreamingManager::new(process_runtime.clone()),
|
||||||
process_runtime,
|
process_runtime,
|
||||||
recording_manager,
|
recording_manager,
|
||||||
streaming_manager: DesktopStreamingManager::new(),
|
|
||||||
inner: Arc::new(Mutex::new(DesktopRuntimeStateData {
|
inner: Arc::new(Mutex::new(DesktopRuntimeStateData {
|
||||||
state: DesktopState::Inactive,
|
state: DesktopState::Inactive,
|
||||||
display_num: config.display_num,
|
display_num: config.display_num,
|
||||||
|
|
@ -197,7 +197,10 @@ impl DesktopRuntime {
|
||||||
pub async fn status(&self) -> DesktopStatusResponse {
|
pub async fn status(&self) -> DesktopStatusResponse {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
self.refresh_status_locked(&mut state).await;
|
self.refresh_status_locked(&mut state).await;
|
||||||
self.snapshot_locked(&state)
|
let mut response = self.snapshot_locked(&state);
|
||||||
|
drop(state);
|
||||||
|
self.append_neko_process(&mut response).await;
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(
|
pub async fn start(
|
||||||
|
|
@ -221,7 +224,10 @@ impl DesktopRuntime {
|
||||||
|
|
||||||
self.refresh_status_locked(&mut state).await;
|
self.refresh_status_locked(&mut state).await;
|
||||||
if state.state == DesktopState::Active {
|
if state.state == DesktopState::Active {
|
||||||
return Ok(self.snapshot_locked(&state));
|
let mut response = self.snapshot_locked(&state);
|
||||||
|
drop(state);
|
||||||
|
self.append_neko_process(&mut response).await;
|
||||||
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.missing_dependencies.is_empty() {
|
if !state.missing_dependencies.is_empty() {
|
||||||
|
|
@ -307,7 +313,10 @@ impl DesktopRuntime {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(self.snapshot_locked(&state))
|
let mut response = self.snapshot_locked(&state);
|
||||||
|
drop(state);
|
||||||
|
self.append_neko_process(&mut response).await;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<DesktopStatusResponse, DesktopProblem> {
|
pub async fn stop(&self) -> Result<DesktopStatusResponse, DesktopProblem> {
|
||||||
|
|
@ -336,7 +345,10 @@ impl DesktopRuntime {
|
||||||
state.install_command = self.install_command_for(&state.missing_dependencies);
|
state.install_command = self.install_command_for(&state.missing_dependencies);
|
||||||
state.environment.clear();
|
state.environment.clear();
|
||||||
|
|
||||||
Ok(self.snapshot_locked(&state))
|
let mut response = self.snapshot_locked(&state);
|
||||||
|
drop(state);
|
||||||
|
self.append_neko_process(&mut response).await;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
|
|
@ -630,8 +642,26 @@ impl DesktopRuntime {
|
||||||
self.recording_manager.delete(id).await
|
self.recording_manager.delete(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_streaming(&self) -> DesktopStreamStatusResponse {
|
pub async fn start_streaming(&self) -> Result<DesktopStreamStatusResponse, SandboxError> {
|
||||||
self.streaming_manager.start().await
|
let state = self.inner.lock().await;
|
||||||
|
let display = state
|
||||||
|
.display
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| SandboxError::Conflict {
|
||||||
|
message: "desktop runtime is not active".to_string(),
|
||||||
|
})?;
|
||||||
|
let resolution = state
|
||||||
|
.resolution
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| SandboxError::Conflict {
|
||||||
|
message: "desktop runtime is not active".to_string(),
|
||||||
|
})?;
|
||||||
|
let environment = state.environment.clone();
|
||||||
|
let display = display.to_string();
|
||||||
|
drop(state);
|
||||||
|
self.streaming_manager
|
||||||
|
.start(&display, resolution, &environment)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_streaming(&self) -> DesktopStreamStatusResponse {
|
pub async fn stop_streaming(&self) -> DesktopStreamStatusResponse {
|
||||||
|
|
@ -642,6 +672,10 @@ impl DesktopRuntime {
|
||||||
self.streaming_manager.ensure_active().await
|
self.streaming_manager.ensure_active().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn streaming_manager(&self) -> &DesktopStreamingManager {
|
||||||
|
&self.streaming_manager
|
||||||
|
}
|
||||||
|
|
||||||
async fn recording_context(&self) -> Result<DesktopRecordingContext, SandboxError> {
|
async fn recording_context(&self) -> Result<DesktopRecordingContext, SandboxError> {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
let ready = self
|
let ready = self
|
||||||
|
|
@ -1577,6 +1611,14 @@ impl DesktopRuntime {
|
||||||
processes
|
processes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append neko streaming process info to the response, if a neko process
|
||||||
|
/// has been started by the streaming manager.
|
||||||
|
async fn append_neko_process(&self, response: &mut DesktopStatusResponse) {
|
||||||
|
if let Some(neko_info) = self.streaming_manager.process_info().await {
|
||||||
|
response.processes.push(neko_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn record_problem_locked(&self, state: &mut DesktopRuntimeStateData, problem: &DesktopProblem) {
|
fn record_problem_locked(&self, state: &mut DesktopRuntimeStateData, problem: &DesktopProblem) {
|
||||||
state.last_error = Some(problem.to_error_info());
|
state.last_error = Some(problem.to_error_info());
|
||||||
self.write_runtime_log_locked(
|
self.write_runtime_log_locked(
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,205 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use sandbox_agent_error::SandboxError;
|
use sandbox_agent_error::SandboxError;
|
||||||
|
|
||||||
use crate::desktop_types::DesktopStreamStatusResponse;
|
use crate::desktop_types::{DesktopProcessInfo, DesktopResolution, DesktopStreamStatusResponse};
|
||||||
|
use crate::process_runtime::{ProcessOwner, ProcessRuntime, ProcessStartSpec};
|
||||||
|
|
||||||
|
/// Internal port where neko listens for HTTP/WS traffic.
|
||||||
|
const NEKO_INTERNAL_PORT: u16 = 18100;
|
||||||
|
|
||||||
|
/// UDP ephemeral port range for WebRTC media.
|
||||||
|
const NEKO_EPR: &str = "59050-59070";
|
||||||
|
|
||||||
|
/// How long to wait for neko to become ready.
|
||||||
|
const NEKO_READY_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// How long between readiness polls.
|
||||||
|
const NEKO_READY_POLL: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DesktopStreamingManager {
|
pub struct DesktopStreamingManager {
|
||||||
inner: Arc<Mutex<DesktopStreamingState>>,
|
inner: Arc<Mutex<DesktopStreamingState>>,
|
||||||
|
process_runtime: Arc<ProcessRuntime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct DesktopStreamingState {
|
struct DesktopStreamingState {
|
||||||
active: bool,
|
active: bool,
|
||||||
|
process_id: Option<String>,
|
||||||
|
/// Base URL for neko's internal HTTP server (e.g. "http://127.0.0.1:18100").
|
||||||
|
neko_base_url: Option<String>,
|
||||||
|
/// Session cookie obtained from neko login, used for WS auth.
|
||||||
|
neko_session_cookie: Option<String>,
|
||||||
|
display: Option<String>,
|
||||||
|
resolution: Option<DesktopResolution>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DesktopStreamingManager {
|
impl DesktopStreamingManager {
|
||||||
pub fn new() -> Self {
|
pub fn new(process_runtime: Arc<ProcessRuntime>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Mutex::new(DesktopStreamingState::default())),
|
inner: Arc::new(Mutex::new(DesktopStreamingState::default())),
|
||||||
|
process_runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> DesktopStreamStatusResponse {
|
/// Start the neko streaming subprocess targeting the given display.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
display: &str,
|
||||||
|
resolution: DesktopResolution,
|
||||||
|
environment: &HashMap<String, String>,
|
||||||
|
) -> Result<DesktopStreamStatusResponse, SandboxError> {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
|
|
||||||
|
if state.active {
|
||||||
|
return Ok(DesktopStreamStatusResponse { active: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any stale process.
|
||||||
|
if let Some(ref old_id) = state.process_id {
|
||||||
|
let _ = self.process_runtime.stop_process(old_id, Some(2000)).await;
|
||||||
|
state.process_id = None;
|
||||||
|
state.neko_base_url = None;
|
||||||
|
state.neko_session_cookie = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env = environment.clone();
|
||||||
|
env.insert("DISPLAY".to_string(), display.to_string());
|
||||||
|
|
||||||
|
let bind_addr = format!("0.0.0.0:{}", NEKO_INTERNAL_PORT);
|
||||||
|
let screen = format!("{}x{}@30", resolution.width, resolution.height);
|
||||||
|
|
||||||
|
let snapshot = self
|
||||||
|
.process_runtime
|
||||||
|
.start_process(ProcessStartSpec {
|
||||||
|
command: "neko".to_string(),
|
||||||
|
args: vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--server.bind".to_string(),
|
||||||
|
bind_addr,
|
||||||
|
"--desktop.screen".to_string(),
|
||||||
|
screen,
|
||||||
|
"--desktop.display".to_string(),
|
||||||
|
display.to_string(),
|
||||||
|
"--capture.video.display".to_string(),
|
||||||
|
display.to_string(),
|
||||||
|
"--capture.video.codec".to_string(),
|
||||||
|
"vp8".to_string(),
|
||||||
|
"--capture.audio.codec".to_string(),
|
||||||
|
"opus".to_string(),
|
||||||
|
"--webrtc.epr".to_string(),
|
||||||
|
NEKO_EPR.to_string(),
|
||||||
|
"--webrtc.icelite".to_string(),
|
||||||
|
"--webrtc.nat1to1".to_string(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"--member.provider".to_string(),
|
||||||
|
"noauth".to_string(),
|
||||||
|
// Disable the custom xf86-input-neko driver (defaults to true
|
||||||
|
// in neko v3). The driver socket is not available outside
|
||||||
|
// neko's official Docker images; XTEST is used instead.
|
||||||
|
"--desktop.input.enabled=false".to_string(),
|
||||||
|
],
|
||||||
|
cwd: None,
|
||||||
|
env,
|
||||||
|
tty: false,
|
||||||
|
interactive: false,
|
||||||
|
owner: ProcessOwner::Desktop,
|
||||||
|
restart_policy: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| SandboxError::Conflict {
|
||||||
|
message: format!("failed to start neko streaming process: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let neko_base = format!("http://127.0.0.1:{}", NEKO_INTERNAL_PORT);
|
||||||
|
state.process_id = Some(snapshot.id.clone());
|
||||||
|
state.neko_base_url = Some(neko_base.clone());
|
||||||
|
state.display = Some(display.to_string());
|
||||||
|
state.resolution = Some(resolution);
|
||||||
state.active = true;
|
state.active = true;
|
||||||
DesktopStreamStatusResponse { active: true }
|
|
||||||
|
// Drop the lock before waiting for readiness.
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
// Wait for neko to be ready by polling its login endpoint.
|
||||||
|
let deadline = tokio::time::Instant::now() + NEKO_READY_TIMEOUT;
|
||||||
|
let login_url = format!("{}/api/login", neko_base);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
|
||||||
|
let mut session_cookie = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match client
|
||||||
|
.post(&login_url)
|
||||||
|
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
// Extract NEKO_SESSION cookie from Set-Cookie header.
|
||||||
|
if let Some(set_cookie) = resp.headers().get("set-cookie") {
|
||||||
|
if let Ok(cookie_str) = set_cookie.to_str() {
|
||||||
|
// Extract just the cookie value (before the first ';').
|
||||||
|
if let Some(cookie_part) = cookie_str.split(';').next() {
|
||||||
|
session_cookie = Some(cookie_part.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("neko streaming process ready, session obtained");
|
||||||
|
|
||||||
|
// Take control so the connected client can send input.
|
||||||
|
let control_url = format!("{}/api/room/control/take", neko_base);
|
||||||
|
if let Some(ref cookie) = session_cookie {
|
||||||
|
let _ = client
|
||||||
|
.post(&control_url)
|
||||||
|
.header("Cookie", cookie.as_str())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
tracing::info!("neko control taken");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
tracing::warn!("neko did not become ready within timeout, proceeding anyway");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(NEKO_READY_POLL).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the session cookie.
|
||||||
|
if let Some(ref cookie) = session_cookie {
|
||||||
|
let mut state = self.inner.lock().await;
|
||||||
|
state.neko_session_cookie = Some(cookie.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DesktopStreamStatusResponse { active: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop streaming and tear down neko subprocess.
|
||||||
pub async fn stop(&self) -> DesktopStreamStatusResponse {
|
pub async fn stop(&self) -> DesktopStreamStatusResponse {
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
|
if let Some(ref process_id) = state.process_id.take() {
|
||||||
|
let _ = self
|
||||||
|
.process_runtime
|
||||||
|
.stop_process(process_id, Some(3000))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
state.active = false;
|
state.active = false;
|
||||||
|
state.neko_base_url = None;
|
||||||
|
state.neko_session_cookie = None;
|
||||||
|
state.display = None;
|
||||||
|
state.resolution = None;
|
||||||
DesktopStreamStatusResponse { active: false }
|
DesktopStreamStatusResponse { active: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,4 +212,109 @@ impl DesktopStreamingManager {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the neko WebSocket URL for signaling proxy, including session cookie.
|
||||||
|
pub async fn neko_ws_url(&self) -> Option<String> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.neko_base_url
|
||||||
|
.as_ref()
|
||||||
|
.map(|base| base.replace("http://", "ws://") + "/api/ws")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the neko base HTTP URL (e.g. `http://127.0.0.1:18100`).
|
||||||
|
pub async fn neko_base_url(&self) -> Option<String> {
|
||||||
|
self.inner.lock().await.neko_base_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh neko login session and return the session cookie.
|
||||||
|
/// Each WebSocket proxy connection should call this to get its own
|
||||||
|
/// session, avoiding conflicts when multiple clients connect.
|
||||||
|
/// Uses a unique username per connection so neko treats them as
|
||||||
|
/// separate members (noauth provider allows any credentials).
|
||||||
|
pub async fn create_neko_session(&self) -> Option<String> {
|
||||||
|
let base_url = self.neko_base_url().await?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let login_url = format!("{}/api/login", base_url);
|
||||||
|
let username = format!(
|
||||||
|
"user-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
);
|
||||||
|
tracing::debug!(
|
||||||
|
"creating neko session: username={}, url={}",
|
||||||
|
username,
|
||||||
|
login_url
|
||||||
|
);
|
||||||
|
let resp = match client
|
||||||
|
.post(&login_url)
|
||||||
|
.json(&serde_json::json!({"username": username, "password": "admin"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("neko login request failed: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
tracing::warn!("neko login returned status {}", resp.status());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cookie = resp
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|v| v.split(';').next().unwrap_or(v).to_string());
|
||||||
|
let cookie = match cookie {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("neko login response missing set-cookie header");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::debug!("neko session created: {}", username);
|
||||||
|
|
||||||
|
// Take control for this session.
|
||||||
|
let control_url = format!("{}/api/room/control/take", base_url);
|
||||||
|
let _ = client
|
||||||
|
.post(&control_url)
|
||||||
|
.header("Cookie", &cookie)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Some(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the shared neko session cookie (used during startup).
|
||||||
|
pub async fn neko_session_cookie(&self) -> Option<String> {
|
||||||
|
self.inner.lock().await.neko_session_cookie.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolution(&self) -> Option<DesktopResolution> {
|
||||||
|
self.inner.lock().await.resolution.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_active(&self) -> bool {
|
||||||
|
self.inner.lock().await.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return process diagnostics for the neko streaming subprocess, if one
|
||||||
|
/// has been started. The returned info mirrors the shape used by
|
||||||
|
/// `DesktopRuntime::processes_locked` for xvfb/openbox/dbus.
|
||||||
|
pub async fn process_info(&self) -> Option<DesktopProcessInfo> {
|
||||||
|
let state = self.inner.lock().await;
|
||||||
|
let process_id = state.process_id.as_ref()?;
|
||||||
|
let snapshot = self.process_runtime.snapshot(process_id).await.ok()?;
|
||||||
|
Some(DesktopProcessInfo {
|
||||||
|
name: "neko".to_string(),
|
||||||
|
pid: snapshot.pid,
|
||||||
|
running: snapshot.status == crate::process_runtime::ProcessStatus::Running,
|
||||||
|
log_path: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
||||||
)
|
)
|
||||||
.route("/desktop/stream/start", post(post_v1_desktop_stream_start))
|
.route("/desktop/stream/start", post(post_v1_desktop_stream_start))
|
||||||
.route("/desktop/stream/stop", post(post_v1_desktop_stream_stop))
|
.route("/desktop/stream/stop", post(post_v1_desktop_stream_stop))
|
||||||
.route("/desktop/stream/ws", get(get_v1_desktop_stream_ws))
|
.route("/desktop/stream/signaling", get(get_v1_desktop_stream_ws))
|
||||||
.route("/agents", get(get_v1_agents))
|
.route("/agents", get(get_v1_agents))
|
||||||
.route("/agents/:agent", get(get_v1_agent))
|
.route("/agents/:agent", get(get_v1_agent))
|
||||||
.route("/agents/:agent/install", post(post_v1_agent_install))
|
.route("/agents/:agent/install", post(post_v1_agent_install))
|
||||||
|
|
@ -1181,7 +1181,7 @@ async fn delete_v1_desktop_recording(
|
||||||
async fn post_v1_desktop_stream_start(
|
async fn post_v1_desktop_stream_start(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<DesktopStreamStatusResponse>, ApiError> {
|
) -> Result<Json<DesktopStreamStatusResponse>, ApiError> {
|
||||||
Ok(Json(state.desktop_runtime().start_streaming().await))
|
Ok(Json(state.desktop_runtime().start_streaming().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop desktop streaming.
|
/// Stop desktop streaming.
|
||||||
|
|
@ -1201,13 +1201,14 @@ async fn post_v1_desktop_stream_stop(
|
||||||
Ok(Json(state.desktop_runtime().stop_streaming().await))
|
Ok(Json(state.desktop_runtime().stop_streaming().await))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a desktop websocket streaming session.
|
/// Open a desktop WebRTC signaling session.
|
||||||
///
|
///
|
||||||
/// Upgrades the connection to a websocket that streams JPEG desktop frames and
|
/// Upgrades the connection to a WebSocket used for WebRTC signaling between
|
||||||
/// accepts mouse and keyboard control frames.
|
/// the browser client and the desktop streaming process. Also accepts mouse
|
||||||
|
/// and keyboard input frames as a fallback transport.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/v1/desktop/stream/ws",
|
path = "/v1/desktop/stream/signaling",
|
||||||
tag = "v1",
|
tag = "v1",
|
||||||
params(
|
params(
|
||||||
("access_token" = Option<String>, Query, description = "Bearer token alternative for WS auth")
|
("access_token" = Option<String>, Query, description = "Bearer token alternative for WS auth")
|
||||||
|
|
@ -2451,46 +2452,6 @@ enum TerminalClientFrame {
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
enum DesktopStreamClientFrame {
|
|
||||||
MoveMouse {
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
},
|
|
||||||
MouseDown {
|
|
||||||
#[serde(default)]
|
|
||||||
x: Option<i32>,
|
|
||||||
#[serde(default)]
|
|
||||||
y: Option<i32>,
|
|
||||||
#[serde(default)]
|
|
||||||
button: Option<DesktopMouseButton>,
|
|
||||||
},
|
|
||||||
MouseUp {
|
|
||||||
#[serde(default)]
|
|
||||||
x: Option<i32>,
|
|
||||||
#[serde(default)]
|
|
||||||
y: Option<i32>,
|
|
||||||
#[serde(default)]
|
|
||||||
button: Option<DesktopMouseButton>,
|
|
||||||
},
|
|
||||||
Scroll {
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
delta_x: Option<i32>,
|
|
||||||
#[serde(default)]
|
|
||||||
delta_y: Option<i32>,
|
|
||||||
},
|
|
||||||
KeyDown {
|
|
||||||
key: String,
|
|
||||||
},
|
|
||||||
KeyUp {
|
|
||||||
key: String,
|
|
||||||
},
|
|
||||||
Close,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_terminal_ws_session(
|
async fn process_terminal_ws_session(
|
||||||
mut socket: WebSocket,
|
mut socket: WebSocket,
|
||||||
runtime: Arc<ProcessRuntime>,
|
runtime: Arc<ProcessRuntime>,
|
||||||
|
|
@ -2603,131 +2564,115 @@ async fn process_terminal_ws_session(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn desktop_stream_ws_session(mut socket: WebSocket, desktop_runtime: Arc<DesktopRuntime>) {
|
/// WebRTC signaling proxy session.
|
||||||
let display_info = match desktop_runtime.display_info().await {
|
///
|
||||||
Ok(info) => info,
|
/// Proxies the WebSocket bidirectionally between the browser client and neko's
|
||||||
Err(err) => {
|
/// internal WebSocket endpoint. All neko signaling messages (SDP offers/answers,
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
/// ICE candidates, system events) are relayed transparently.
|
||||||
let _ = socket.close().await;
|
async fn desktop_stream_ws_session(mut client_ws: WebSocket, desktop_runtime: Arc<DesktopRuntime>) {
|
||||||
|
use futures::SinkExt;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
|
||||||
|
// Get neko's internal WS URL from the streaming manager.
|
||||||
|
let neko_ws_url = match desktop_runtime.streaming_manager().neko_ws_url().await {
|
||||||
|
Some(url) => url,
|
||||||
|
None => {
|
||||||
|
let _ = send_ws_error(&mut client_ws, "streaming process is not available").await;
|
||||||
|
let _ = client_ws.close().await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if send_ws_json(
|
// Create a fresh neko login session for this connection.
|
||||||
&mut socket,
|
// Each proxy connection gets its own neko session to avoid conflicts
|
||||||
json!({
|
// when multiple clients connect (neko sends signal/close to shared sessions).
|
||||||
"type": "ready",
|
let session_cookie = desktop_runtime
|
||||||
"width": display_info.resolution.width,
|
.streaming_manager()
|
||||||
"height": display_info.resolution.height,
|
.create_neko_session()
|
||||||
}),
|
.await;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut frame_tick = tokio::time::interval(Duration::from_millis(100));
|
// Build a WS request with the neko session cookie for authentication.
|
||||||
|
let ws_req = {
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
let mut req = neko_ws_url
|
||||||
|
.into_client_request()
|
||||||
|
.expect("valid neko WS URL");
|
||||||
|
if let Some(ref cookie) = session_cookie {
|
||||||
|
req.headers_mut()
|
||||||
|
.insert("Cookie", cookie.parse().expect("valid cookie header"));
|
||||||
|
}
|
||||||
|
req
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect to neko's internal WebSocket.
|
||||||
|
let (neko_ws, _) = match tokio_tungstenite::connect_async(ws_req).await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(err) => {
|
||||||
|
let _ = send_ws_error(
|
||||||
|
&mut client_ws,
|
||||||
|
&format!("failed to connect to streaming process: {err}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = client_ws.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut neko_sink, mut neko_stream) = neko_ws.split();
|
||||||
|
|
||||||
|
// Relay messages bidirectionally between client and neko.
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
ws_in = socket.recv() => {
|
// Client → Neko (signaling passthrough; input goes via WebRTC data channel)
|
||||||
match ws_in {
|
client_msg = client_ws.recv() => {
|
||||||
|
match client_msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
match serde_json::from_str::<DesktopStreamClientFrame>(&text) {
|
if neko_sink.send(TungsteniteMessage::Text(text.into())).await.is_err() {
|
||||||
Ok(DesktopStreamClientFrame::MoveMouse { x, y }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.move_mouse(DesktopMouseMoveRequest { x, y })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::MouseDown { x, y, button }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.mouse_down(DesktopMouseDownRequest { x, y, button })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::MouseUp { x, y, button }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.mouse_up(DesktopMouseUpRequest { x, y, button })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::Scroll { x, y, delta_x, delta_y }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.scroll_mouse(DesktopMouseScrollRequest {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
delta_x,
|
|
||||||
delta_y,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::KeyDown { key }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.key_down(DesktopKeyboardDownRequest { key })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::KeyUp { key }) => {
|
|
||||||
if let Err(err) = desktop_runtime
|
|
||||||
.key_up(DesktopKeyboardUpRequest { key })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DesktopStreamClientFrame::Close) => {
|
|
||||||
let _ = socket.close().await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
let _ = send_ws_error(&mut socket, &format!("invalid desktop stream frame: {err}")).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Ping(payload))) => {
|
|
||||||
let _ = socket.send(Message::Pong(payload)).await;
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None => break,
|
|
||||||
Some(Ok(Message::Binary(_))) | Some(Ok(Message::Pong(_))) => {}
|
|
||||||
Some(Err(_)) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = frame_tick.tick() => {
|
|
||||||
let frame = desktop_runtime
|
|
||||||
.screenshot(DesktopScreenshotQuery {
|
|
||||||
format: Some(DesktopScreenshotFormat::Jpeg),
|
|
||||||
quality: Some(60),
|
|
||||||
scale: Some(1.0),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match frame {
|
|
||||||
Ok(frame) => {
|
|
||||||
if socket.send(Message::Binary(frame.bytes.into())).await.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Some(Ok(Message::Binary(data))) => {
|
||||||
let _ = send_ws_error(&mut socket, &err.to_error_info().message).await;
|
if neko_sink.send(TungsteniteMessage::Binary(data.into())).await.is_err() {
|
||||||
let _ = socket.close().await;
|
break;
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
|
Some(Ok(Message::Ping(payload))) => {
|
||||||
|
let _ = client_ws.send(Message::Pong(payload)).await;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Ok(Message::Pong(_))) => {}
|
||||||
|
Some(Err(_)) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neko → Client
|
||||||
|
neko_msg = neko_stream.next() => {
|
||||||
|
match neko_msg {
|
||||||
|
Some(Ok(TungsteniteMessage::Text(text))) => {
|
||||||
|
if client_ws.send(Message::Text(text.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(TungsteniteMessage::Binary(data))) => {
|
||||||
|
if client_ws.send(Message::Binary(data.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(TungsteniteMessage::Ping(payload))) => {
|
||||||
|
if neko_sink.send(TungsteniteMessage::Pong(payload.clone())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(TungsteniteMessage::Close(_))) | None => break,
|
||||||
|
Some(Ok(TungsteniteMessage::Pong(_))) => {}
|
||||||
|
Some(Ok(TungsteniteMessage::Frame(_))) => {}
|
||||||
|
Some(Err(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = neko_sink.close().await;
|
||||||
|
let _ = client_ws.close().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> {
|
async fn send_ws_json(socket: &mut WebSocket, payload: Value) -> Result<(), ()> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue