Repository navigation
feat: v3.0.4 — VNC UTF-8 clipboard + ClawKeep pair UX + bug fixes #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KrasimirKralev
merged 8 commits into
ID-Robots:beta
from
KrasimirKralev:feat/refresh-catalog-on-configure
May 19, 2026
Merged
Changes from 7 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0a7d515
feat: refresh model catalog when provider gets configured
KrasimirKralev 89e8e09
feat(picker): merge static catalog into live + filter deprecated Clau…
KrasimirKralev f9e231b
fix(remote-control): keep polling tunnel status so the URL appears wi…
KrasimirKralev bab0982
feat(clawkeep): redesign pair card to match the ClawAI subscription p…
KrasimirKralev ffea9c7
feat(vnc): bidirectional UTF-8 clipboard via xclip with toast fallback
KrasimirKralev 7a2cd3a
fix(clawai-tier): preserve last-known tier on transient failure
KrasimirKralev 05d0fbf
chore(release): bump version to 3.0.4
KrasimirKralev d2a05b3
fix: address CodeRabbit review on #139
KrasimirKralev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { spawn } from "child_process"; | ||
| import { promises as fs } from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
|
|
||
| // Bridges the host browser's clipboard with the guest X CLIPBOARD via | ||
| // `xclip`. Avoids the RFB ClientCutText Latin-1 limitation that mangles | ||
| // Cyrillic / CJK / emoji on the basic noVNC paste path, and works over | ||
| // plain HTTP (no `navigator.clipboard.readText()` permission required — | ||
| // the textarea in the paste modal captures the text natively). | ||
| const MAX_CLIPBOARD_BYTES = 1_048_576; | ||
| const XCLIP_TIMEOUT_MS = 5_000; | ||
|
|
||
| async function getVncDisplay(): Promise<string> { | ||
| if (process.env.CLAWBOX_VNC_DISPLAY) return process.env.CLAWBOX_VNC_DISPLAY; | ||
| try { | ||
| const envFile = path.join(os.homedir(), ".cache", "clawbox", "vnc-display.env"); | ||
| const raw = await fs.readFile(envFile, "utf8"); | ||
| const match = raw.match(/CLAWBOX_VNC_DISPLAY=(:\d+)/); | ||
| if (match) return match[1]; | ||
| } catch { | ||
| // No marker yet — fall through to the default x11vnc display. | ||
| } | ||
| return ":99"; | ||
| } | ||
|
|
||
| interface XclipResult { | ||
| stdout: string; | ||
| stderr: string; | ||
| code: number; | ||
| } | ||
|
|
||
| function runXclip(args: string[], display: string, input?: string): Promise<XclipResult> { | ||
| return new Promise((resolve, reject) => { | ||
| const isWrite = input !== undefined; | ||
| // For writes, xclip forks a daemon that keeps owning the X selection | ||
| // until another client claims it — if Node inherits its stdout/stderr, | ||
| // the `close` event never fires (the daemon holds those pipes open). | ||
| // Detach those streams so we observe only the parent's exit. | ||
| const proc = spawn("xclip", args, { | ||
| env: { ...process.env, DISPLAY: display }, | ||
| stdio: isWrite ? ["pipe", "ignore", "ignore"] : ["ignore", "pipe", "pipe"], | ||
| }); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| let settled = false; | ||
| const settle = (result: XclipResult) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(result); | ||
| }; | ||
| const timer = setTimeout(() => { | ||
| if (settled) return; | ||
| proc.kill("SIGTERM"); | ||
| settle({ stdout, stderr: `${stderr}\n[xclip] timed out`, code: -1 }); | ||
| }, XCLIP_TIMEOUT_MS); | ||
| if (proc.stdout) { | ||
| proc.stdout.on("data", (d: Buffer) => { stdout += d.toString("utf8"); }); | ||
| } | ||
| if (proc.stderr) { | ||
| proc.stderr.on("data", (d: Buffer) => { stderr += d.toString("utf8"); }); | ||
| } | ||
| proc.on("error", (err) => { | ||
| clearTimeout(timer); | ||
| if (settled) return; | ||
| settled = true; | ||
| reject(err); | ||
| }); | ||
| proc.on("close", (code) => { | ||
| clearTimeout(timer); | ||
| settle({ stdout, stderr, code: code ?? -1 }); | ||
| }); | ||
| if (isWrite && proc.stdin) { | ||
| proc.stdin.end(input, "utf8"); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| export async function GET() { | ||
| try { | ||
| const display = await getVncDisplay(); | ||
| const { stdout, code, stderr } = await runXclip( | ||
| ["-selection", "clipboard", "-out"], | ||
| display, | ||
| ); | ||
| if (code !== 0) { | ||
| // xclip returns non-zero when the selection is empty; treat that as | ||
| // an empty string rather than an error so the UI can render "(empty)". | ||
| const isEmpty = stderr.includes("There is no owner"); | ||
| if (isEmpty) { | ||
| return NextResponse.json({ text: "" }, { headers: { "Cache-Control": "no-store" } }); | ||
| } | ||
| return NextResponse.json( | ||
| { error: `xclip exit ${code}: ${stderr.slice(0, 200)}` }, | ||
| { status: 500, headers: { "Cache-Control": "no-store" } }, | ||
| ); | ||
| } | ||
| return NextResponse.json({ text: stdout }, { headers: { "Cache-Control": "no-store" } }); | ||
| } catch (err) { | ||
| return NextResponse.json( | ||
| { error: err instanceof Error ? err.message : "xclip read failed" }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: "request body must be JSON" }, { status: 400 }); | ||
| } | ||
| const body = (parsed ?? {}) as { text?: unknown }; | ||
| const text = typeof body.text === "string" ? body.text : ""; | ||
| if (text.length === 0) { | ||
| return NextResponse.json({ error: "text required" }, { status: 400 }); | ||
| } | ||
| if (Buffer.byteLength(text, "utf8") > MAX_CLIPBOARD_BYTES) { | ||
| return NextResponse.json({ error: "text exceeds 1 MiB cap" }, { status: 413 }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| try { | ||
| const display = await getVncDisplay(); | ||
| const { code, stderr } = await runXclip( | ||
| ["-selection", "clipboard", "-in"], | ||
| display, | ||
| text, | ||
| ); | ||
| if (code !== 0) { | ||
| return NextResponse.json( | ||
| { error: `xclip exit ${code}: ${stderr.slice(0, 200)}` }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| return NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); | ||
| } catch (err) { | ||
| return NextResponse.json( | ||
| { error: err instanceof Error ? err.message : "xclip write failed" }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.