Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/hooks/useWebRtcStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,5 +467,6 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) {
errorHandle,
reconnect,
sendInputEvent,
activeSessionId,
}
}
117 changes: 111 additions & 6 deletions src/routes/trackpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,40 @@ import { ScreenMirror } from "../components/Trackpad/ScreenMirror"
import { ErrorComponent } from "../components/Trackpad/ErrorComponent"
import { useWebRtcStream } from "../hooks/useWebRtcStream"

const copyWithFallback = (text: string) => {
const textArea = document.createElement("textarea")
textArea.value = text
textArea.setAttribute("readonly", "")
textArea.style.position = "absolute"
textArea.style.left = "-9999px"
document.body.appendChild(textArea)
textArea.select()
textArea.setSelectionRange(0, text.length)
try {
return document.execCommand("copy")
} finally {
document.body.removeChild(textArea)
}
}
const writeClientClipboard = async (text: string) => {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
try {
await navigator.clipboard.writeText(text)
return
} catch (err) {
console.warn("navigator.clipboard.writeText failed, using fallback:", err)
}
}
const success = copyWithFallback(text)
if (!success) {
throw new Error("Fallback copy failed")
}
}

export const Route = createFileRoute("/trackpad")({
component: TrackpadPage,
})
Expand Down Expand Up @@ -43,10 +77,16 @@ function TrackpadPage() {
const [keyboardOpen, setKeyboardOpen] = useState(false)
const [extraKeysVisible, setExtraKeysVisible] = useState(true)
const { status, send, sendCombo } = useRemoteConnection()
const { trackActive, videoStream, error, errorHandle, reconnect } =
useWebRtcStream({
token,
})
const {
trackActive,
videoStream,
error,
errorHandle,
reconnect,
activeSessionId,
} = useWebRtcStream({
token,
})

// Send input actions safely over WebRTC DataChannels
const broadcastMessage = (payload: unknown) => {
Expand Down Expand Up @@ -75,8 +115,73 @@ function TrackpadPage() {
)
}

const handleCopy = () => broadcastMessage({ type: "copy" })
const handlePaste = async () => broadcastMessage({ type: "paste" })
const handleCopy = async () => {
try {
const headers: Record<string, string> = {}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const response = await fetch("/api/clipboard/copy", {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ sessionId: activeSessionId }),
})
Comment thread
Arbaaz123676 marked this conversation as resolved.
if (response.ok) {
const data = await response.json()
if (data && typeof data.text === "string") {
await writeClientClipboard(data.text)
} else {
throw new Error("Invalid copy response data")
}
Comment thread
Arbaaz123676 marked this conversation as resolved.
} else {
throw new Error(`Clipboard copy failed: ${response.statusText}`)
}
} catch (err) {
console.warn(
"Client clipboard copy failed, falling back to server copy:",
err,
)
broadcastMessage({ type: "copy" })
}
}
const handlePaste = async () => {
try {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
typeof navigator.clipboard.readText === "function"
) {
const text = await navigator.clipboard.readText()
if (text) {
const headers: Record<string, string> = {}
if (token) {
headers.Authorization = `Bearer ${token}`
}
const response = await fetch("/api/clipboard/paste", {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ sessionId: activeSessionId, text }),
})
if (response.ok) {
return
}
}
}
throw new Error("Client clipboard read returned empty or is unavailable")
} catch (err) {
console.warn(
"Client clipboard paste failed, falling back to server clipboard:",
err,
)
broadcastMessage({ type: "paste" })
}
}

const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as InputEvent
Expand Down
4 changes: 4 additions & 0 deletions src/server/api/InputPeerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export class InputPeerConnection {
this.inputHandler.updateConfig(config)
}

async handleMessage(msg: InputMessage): Promise<void> {
await this.inputHandler.handleMessage(msg)
}

close(): void {
try {
this.pc.close()
Expand Down
126 changes: 126 additions & 0 deletions src/server/api/apiHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/

import type { IncomingMessage, ServerResponse } from "node:http"
import { spawnSync } from "node:child_process"
import os from "node:os"
import fs from "node:fs"
import crypto from "node:crypto"
import logger from "../../utils/logger"
Expand Down Expand Up @@ -752,3 +754,127 @@ export async function handleWhipSignalingExchange(
}, 100)
req.on("close", () => clearInterval(answerCheckInterval))
}

function writeHostClipboard(text: string): void {
const platform = os.platform()
if (platform === "darwin") {
spawnSync("pbcopy", { input: text })
} else if (platform === "win32") {
spawnSync("clip", { input: text })
} else {
const procWl = spawnSync("wl-copy", { input: text })
if (procWl.status !== 0) {
const procXclip = spawnSync("xclip", ["-selection", "clipboard"], {
input: text,
})
if (procXclip.status !== 0) {
spawnSync("xsel", ["--clipboard", "--input"], { input: text })
}
}
}
}
// 100 MiB — far beyond any realistic clipboard content; prevents spawnSync
// from silently truncating stdout when the default 1 MiB maxBuffer is exceeded.
const CLIPBOARD_MAX_BUFFER = 100 * 1024 * 1024
function readHostClipboard(): string {
const platform = os.platform()
if (platform === "darwin") {
const proc = spawnSync("pbpaste", {
encoding: "utf-8",
maxBuffer: CLIPBOARD_MAX_BUFFER,
})
if (proc.error) throw proc.error
return proc.stdout || ""
} else if (platform === "win32") {
const proc = spawnSync(
"powershell",
["-NoProfile", "-Command", "Get-Clipboard"],
{ encoding: "utf-8", maxBuffer: CLIPBOARD_MAX_BUFFER },
)
if (proc.error) throw proc.error
return (proc.stdout || "").replace(/\r\n$/, "").replace(/\n$/, "")
} else {
const procWl = spawnSync("wl-paste", ["--no-newline"], {
encoding: "utf-8",
maxBuffer: CLIPBOARD_MAX_BUFFER,
})
if (procWl.status === 0 && !procWl.error) {
return procWl.stdout || ""
}
const procXclip = spawnSync("xclip", ["-selection", "clipboard", "-o"], {
encoding: "utf-8",
maxBuffer: CLIPBOARD_MAX_BUFFER,
})
if (procXclip.status === 0 && !procXclip.error) {
return procXclip.stdout || ""
}
const procXsel = spawnSync("xsel", ["--clipboard", "--output"], {
encoding: "utf-8",
maxBuffer: CLIPBOARD_MAX_BUFFER,
})
if (procXsel.status === 0 && !procXsel.error) {
return procXsel.stdout || ""
}
}
return ""
}
export async function handleClipboardCopy(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (!requireAuth(req, res)) return
const bodyText = await readBody(req)
const { sessionId } = JSON.parse(bodyText || "{}") as {
sessionId?: string
}
Comment thread
Arbaaz123676 marked this conversation as resolved.
if (!sessionId) {
json(res, 400, { error: "sessionId is required" })
return
}
const inputPc = inputConnections.get(sessionId)
if (!inputPc) {
json(res, 404, { error: "Input connection not active" })
return
}
await inputPc.handleMessage({ type: "copy" })
await new Promise((resolve) => setTimeout(resolve, 100))
Comment on lines +839 to +840

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unguarded copy-trigger + fixed 100ms delay race before reading clipboard.

Two related concerns here:

  1. await inputPc.handleMessage({ type: "copy" }) is not wrapped in try/catch. If it throws (e.g. an unsupported-utility path the PR explicitly calls out), the error skips the handler's structured 500 response and falls through to the generic route-level catch in server.ts, unlike handleClipboardPaste, which wraps its equivalent inputPc.handleMessage({type:"text", text}) call (lines 862-868).
  2. A fixed 100ms sleep is used to "wait" for the host OS to populate the clipboard after the injected copy keystroke. This is inherently racy: on a slower app/host, readHostClipboard() can return stale (previous) clipboard content silently, with no indication to the caller that the read may be wrong.
🛠️ Proposed fix
-	await inputPc.handleMessage({ type: "copy" })
-	await new Promise((resolve) => setTimeout(resolve, 100))
-	try {
-		const text = readHostClipboard()
-		json(res, 200, { text })
-	} catch (err) {
-		logger.error(`Failed to read host clipboard: ${String(err)}`)
-		json(res, 500, { error: "Failed to read host clipboard" })
-	}
+	try {
+		await inputPc.handleMessage({ type: "copy" })
+	} catch (err) {
+		logger.error(`Failed to trigger host copy: ${String(err)}`)
+		json(res, 500, { error: "Failed to trigger host copy" })
+		return
+	}
+	try {
+		let text = ""
+		for (let i = 0; i < 5; i++) {
+			await new Promise((resolve) => setTimeout(resolve, 50))
+			text = readHostClipboard()
+			if (text) break
+		}
+		json(res, 200, { text })
+	} catch (err) {
+		logger.error(`Failed to read host clipboard: ${String(err)}`)
+		json(res, 500, { error: "Failed to read host clipboard" })
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await inputPc.handleMessage({ type: "copy" })
await new Promise((resolve) => setTimeout(resolve, 100))
try {
await inputPc.handleMessage({ type: "copy" })
} catch (err) {
logger.error(`Failed to trigger host copy: ${String(err)}`)
json(res, 500, { error: "Failed to trigger host copy" })
return
}
try {
let text = ""
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
text = readHostClipboard()
if (text) break
}
json(res, 200, { text })
} catch (err) {
logger.error(`Failed to read host clipboard: ${String(err)}`)
json(res, 500, { error: "Failed to read host clipboard" })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/api/apiHandlers.ts` around lines 828 - 829, Update the
clipboard-copy flow around inputPc.handleMessage({ type: "copy" }) to catch and
handle errors through the handler’s structured 500 response, matching
handleClipboardPaste. Replace the fixed 100ms delay with a reliable
synchronization or polling mechanism that waits until the host clipboard
reflects the injected copy operation before calling readHostClipboard(), while
preserving the existing success and error response behavior.

try {
const text = readHostClipboard()
json(res, 200, { text })
} catch (err) {
logger.error(`Failed to read host clipboard: ${String(err)}`)
json(res, 500, { error: "Failed to read host clipboard" })
}
}
export async function handleClipboardPaste(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (!requireAuth(req, res)) return
const bodyText = await readBody(req)
const { sessionId, text } = JSON.parse(bodyText || "{}") as {
sessionId?: string
text?: string
}
if (!sessionId || typeof text !== "string") {
json(res, 400, { error: "sessionId and text are required" })
return
}
const inputPc = inputConnections.get(sessionId)
if (!inputPc) {
json(res, 404, { error: "Input connection not active" })
return
}
try {
writeHostClipboard(text)
} catch (err) {
logger.error(`Failed to write host clipboard (non-fatal): ${String(err)}`)
}
try {
await inputPc.handleMessage({ type: "text", text })
json(res, 200, { ok: true })
} catch (err) {
logger.error(`Failed to inject text: ${String(err)}`)
json(res, 500, { error: "Failed to inject pasted text" })
}
}
2 changes: 1 addition & 1 deletion src/server/drivers/linux/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class LinuxKeyboard {
this.sendKeyEvent(code, KEY_RELEASE)
}
this.sync()
} else if (key.length === 1) {
} else if (key.length > 0) {
this.injectText(key)
} else {
console.warn("[LinuxKeyboard] Unknown key:", key)
Expand Down
23 changes: 4 additions & 19 deletions src/server/drivers/mac/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
/**
* macOS virtual keyboard implementation.
*
* Handles key, key-combination, and text injection through CoreGraphics
* keyboard events. Supports both key-code based input and Unicode
* character injection for characters not present in the standard key map.
*/
import koffi from "koffi"
import {
postKeyEvent,
postMediaKeyEvent,
Expand Down Expand Up @@ -39,7 +33,7 @@ export class MacKeyboard {
if (code !== undefined) {
if (pos !== "RELEASE") postKeyEvent(code, true)
if (pos !== "HOLD") postKeyEvent(code, false)
} else if (key.length === 1) {
} else if (key.length > 0) {
this.injectText(key)
} else {
console.warn("[MacKeyboard] Unknown key:", key)
Expand Down Expand Up @@ -72,20 +66,13 @@ export class MacKeyboard {
if (!text) return
for (const ch of text) {
const { code, shifted } = resolveChar(ch, MAC_KEY_MAP)
const shiftCode = MAC_KEY_MAP.shift
if (code === undefined) {
// Fall back to Unicode injection for unmapped characters.
if (code === undefined || shifted) {
// Fall back to Unicode injection for unmapped or shifted characters.
this.injectUnicodeChar(ch)
continue
}
if (shiftCode === undefined) {
console.warn("[MacKeyboard] Shift key code not defined in key map")
continue
}
if (shifted) postKeyEvent(shiftCode, true)
postKeyEvent(code, true)
postKeyEvent(code, false)
if (shifted) postKeyEvent(shiftCode, false)
}
}
private injectUnicodeChar(ch: string): void {
Expand All @@ -108,7 +95,6 @@ function ensureUnicode() {
if (_unicodeInjectorLoaded) return
_unicodeInjectorLoaded = true
try {
const koffi = require("koffi")
const lib = koffi.load(
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
)
Expand Down Expand Up @@ -148,7 +134,6 @@ function injectUnicode(ch: string): void {

const upRef = _CGEventCreateKeyboardEvent(null, 0, 0)
if (!upRef) return
_CGEventKeyboardSetUnicodeString(upRef, charCount, buf)
_CGEventPost(0, upRef)
_CFRelease(upRef)
}
Loading
Loading