diff --git a/src/hooks/useWebRtcStream.ts b/src/hooks/useWebRtcStream.ts index 47fb51c..fd83251 100644 --- a/src/hooks/useWebRtcStream.ts +++ b/src/hooks/useWebRtcStream.ts @@ -14,6 +14,7 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { const [error, setError] = useState(null) const [errorHandle, setErrorHandle] = useState(null) const [connecting, setConnecting] = useState(false) + const [activeSessionId, setActiveSessionId] = useState(null) const [reconnectAttempt, setReconnectAttempt] = useState(0) const { registerDataChannel, send: sendInputEvent } = useConnection() const pcRef = useRef(null) @@ -121,6 +122,7 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { setConnecting(true) setTrackActive(false) setVideoStream(null) + setActiveSessionId(null) retryCountRef.current = 0 setReconnectAttempt((prev) => prev + 1) }, []) @@ -202,12 +204,16 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { try { const msg = JSON.parse(event.data) as { type: string + sessionId?: string sdp?: RTCSessionDescriptionInit candidate?: RTCIceCandidateInit errorType?: string message?: string } if (msg.type === "offer" && msg.sdp) { + if (msg.sessionId) { + setActiveSessionId(msg.sessionId) + } await pc.setRemoteDescription(msg.sdp) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -319,5 +325,6 @@ export function useWebRtcStream({ token }: UseWebRtcStreamOptions) { connecting, reconnect, sendInputEvent, + activeSessionId, } } diff --git a/src/routes/trackpad.tsx b/src/routes/trackpad.tsx index 86665ce..9dd936e 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -11,6 +11,44 @@ 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 = "fixed" + textArea.style.left = "-9999px" + textArea.style.top = "0" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + textArea.setSelectionRange(0, text.length) + try { + return document.execCommand("copy") + } catch { + return false + } 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, }) @@ -50,6 +88,7 @@ function TrackpadPage() { errorHandle, connecting, reconnect, + activeSessionId, } = useWebRtcStream({ token, }) @@ -81,8 +120,73 @@ function TrackpadPage() { ) } - const handleCopy = () => broadcastMessage({ type: "copy" }) - const handlePaste = async () => broadcastMessage({ type: "paste" }) + const handleCopy = async () => { + try { + const headers: Record = {} + 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 }), + }) + 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") + } + } 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 = {} + 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) => { const nativeEvent = e.nativeEvent as InputEvent @@ -114,6 +218,24 @@ function TrackpadPage() { if (textToSend) { if (modifier !== "Release") { handleModifier(textToSend) + } else if (textToSend.length > 50) { + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + fetch("/api/clipboard/paste", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sessionId: activeSessionId, + text: textToSend, + }), + }).catch(() => { + broadcastMessage({ type: "text", text: textToSend }) + }) } else { if (textToSend === " ") { broadcastMessage({ type: "key", key: "space" }) @@ -139,6 +261,24 @@ function TrackpadPage() { if (textToSend) { if (modifier !== "Release") { handleModifier(textToSend) + } else if (textToSend.length > 50) { + const headers: Record = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + fetch("/api/clipboard/paste", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sessionId: activeSessionId, + text: textToSend, + }), + }).catch(() => { + broadcastMessage({ type: "text", text: textToSend }) + }) } else { broadcastMessage({ type: "text", text: textToSend }) } diff --git a/src/server/clipboard.test.ts b/src/server/clipboard.test.ts new file mode 100644 index 0000000..70f6d6d --- /dev/null +++ b/src/server/clipboard.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as childProcess from "node:child_process" +import os from "node:os" +import { EventEmitter } from "node:events" +import { Writable } from "node:stream" +import { getSystemClipboard, setSystemClipboard } from "./clipboard.ts" +import { MAX_TEXT_LENGTH } from "./constants.ts" + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), + spawn: vi.fn(), +})) + +vi.mock("node:os", () => ({ + default: { + platform: vi.fn(), + }, +})) + +describe("Host Clipboard Module (Mocked Process Layer)", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("macOS (darwin)", () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue("darwin") + }) + + it("reads clipboard using pbpaste", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "macos-clipboard-text") + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("macos-clipboard-text") + expect(childProcess.execFile).toHaveBeenCalledWith( + "/usr/bin/pbpaste", + [], + expect.objectContaining({ encoding: "utf8" }), + expect.any(Function), + ) + }) + + it("writes clipboard using pbcopy with stdin", async () => { + let writtenData = "" + const fakeProc = new EventEmitter() as { + stdin: Writable + emit: (event: string, ...args: unknown[]) => boolean + on: (event: string, listener: (...args: unknown[]) => void) => void + } + fakeProc.stdin = new Writable({ + write(chunk, _enc, cb) { + writtenData += chunk.toString() + cb() + }, + }) + vi.mocked(childProcess.spawn).mockImplementation((() => { + process.nextTick(() => fakeProc.emit("close", 0)) + return fakeProc + }) as unknown as typeof childProcess.spawn) + + await setSystemClipboard("Hello\nWorld 🚀") + expect(writtenData).toBe("Hello\nWorld 🚀") + expect(childProcess.spawn).toHaveBeenCalledWith( + "/usr/bin/pbcopy", + [], + expect.objectContaining({ stdio: ["pipe", "ignore", "ignore"] }), + ) + }) + }) + + describe("Windows (win32)", () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue("win32") + }) + + it("reads clipboard using PowerShell Get-Clipboard", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(null, "windows-clipboard-text\r\n") + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("windows-clipboard-text") + expect(childProcess.execFile).toHaveBeenCalledWith( + "powershell.exe", + expect.arrayContaining(["-NoProfile", "-NonInteractive"]), + expect.objectContaining({ encoding: "utf8" }), + expect.any(Function), + ) + }) + + it("writes clipboard using PowerShell Set-Clipboard via stdin", async () => { + let writtenData = "" + const fakeProc = new EventEmitter() as { + stdin: Writable + emit: (event: string, ...args: unknown[]) => boolean + on: (event: string, listener: (...args: unknown[]) => void) => void + } + fakeProc.stdin = new Writable({ + write(chunk, _enc, cb) { + writtenData += chunk.toString() + cb() + }, + }) + vi.mocked(childProcess.spawn).mockImplementation((() => { + process.nextTick(() => fakeProc.emit("close", 0)) + return fakeProc + }) as unknown as typeof childProcess.spawn) + + await setSystemClipboard("windows-paste-data") + expect(writtenData).toBe("windows-paste-data") + expect(childProcess.spawn).toHaveBeenCalledWith( + "powershell.exe", + expect.arrayContaining(["-NoProfile", "-NonInteractive"]), + expect.objectContaining({ stdio: ["pipe", "ignore", "ignore"] }), + ) + }) + }) + + describe("Linux (linux)", () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue("linux") + }) + + it("reads clipboard using wl-paste if available", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + if (cmd === "wl-paste") { + cb(null, "wayland-clipboard-text") + } + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("wayland-clipboard-text") + }) + + it("falls back to xclip if wl-paste fails", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + if (cmd === "wl-paste") { + cb(new Error("wl-paste not found"), "") + } else if (cmd === "xclip") { + cb(null, "xclip-clipboard-text") + } + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("xclip-clipboard-text") + }) + + it("falls back to xsel if wl-paste and xclip both fail", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + if (cmd === "wl-paste") { + cb(new Error("wl-paste not found"), "") + } else if (cmd === "xclip") { + cb(new Error("xclip not found"), "") + } else if (cmd === "xsel") { + cb(null, "xsel-clipboard-text") + } + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("xsel-clipboard-text") + }) + + it("returns empty string and does not throw when all linux tools fail", async () => { + vi.mocked(childProcess.execFile).mockImplementation((( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string) => void, + ) => { + cb(new Error("tool not found"), "") + }) as unknown as typeof childProcess.execFile) + + const text = await getSystemClipboard() + expect(text).toBe("") + }) + }) + + describe("Capacity Boundaries (10,000 to 100,000 characters)", () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue("darwin") + }) + + it("verifies MAX_TEXT_LENGTH is set to 100,000", () => { + expect(MAX_TEXT_LENGTH).toBe(100000) + }) + + const boundarySizes = [9999, 10000, 10001, 99999, 100000] + + for (const size of boundarySizes) { + it(`handles writing and reading ${size} characters of realistic text`, async () => { + // Construct realistic string with spaces, newlines, tabs, emojis, and special symbols + const sample = "Line with emoji 🚀 and symbols <>&\"'\t\n" + const repeats = Math.ceil(size / sample.length) + const fullText = sample.repeat(repeats).slice(0, size) + expect(fullText.length).toBe(size) + + let capturedStdin = "" + const fakeProc = new EventEmitter() as { + stdin: Writable + emit: (event: string, ...args: unknown[]) => boolean + on: (event: string, listener: (...args: unknown[]) => void) => void + } + fakeProc.stdin = new Writable({ + write(chunk, _enc, cb) { + capturedStdin += chunk.toString() + cb() + }, + }) + vi.mocked(childProcess.spawn).mockImplementation((() => { + process.nextTick(() => fakeProc.emit("close", 0)) + return fakeProc + }) as unknown as typeof childProcess.spawn) + + await setSystemClipboard(fullText) + expect(capturedStdin.length).toBe(size) + expect(capturedStdin).toBe(fullText) + }) + } + + it("handles 100,001 characters with truncation at MAX_TEXT_LENGTH boundary", async () => { + const size = 100001 + const sample = "A" + const oversizedText = sample.repeat(size) + expect(oversizedText.length).toBe(100001) + + const clamped = oversizedText.slice(0, MAX_TEXT_LENGTH) + expect(clamped.length).toBe(100000) + + let capturedStdin = "" + const fakeProc = new EventEmitter() as { + stdin: Writable + emit: (event: string, ...args: unknown[]) => boolean + on: (event: string, listener: (...args: unknown[]) => void) => void + } + fakeProc.stdin = new Writable({ + write(chunk, _enc, cb) { + capturedStdin += chunk.toString() + cb() + }, + }) + vi.mocked(childProcess.spawn).mockImplementation((() => { + process.nextTick(() => fakeProc.emit("close", 0)) + return fakeProc + }) as unknown as typeof childProcess.spawn) + + await setSystemClipboard(clamped) + expect(capturedStdin.length).toBe(100000) + }) + }) +}) diff --git a/src/server/clipboard.ts b/src/server/clipboard.ts new file mode 100644 index 0000000..afacda3 --- /dev/null +++ b/src/server/clipboard.ts @@ -0,0 +1,220 @@ +import os from "node:os" +import { execFile, spawn } from "node:child_process" +import logger from "../utils/logger.ts" + +/** + * Reads plain text from the host OS system clipboard. + */ +export async function getSystemClipboard(): Promise { + const platform = os.platform() + try { + if (platform === "darwin") { + return await readMacClipboard() + } + if (platform === "win32") { + return await readWindowsClipboard() + } + if (platform === "linux") { + return await readLinuxClipboard() + } + } catch (err) { + logger.warn( + `Failed to read system clipboard on ${platform}: ${String(err)}`, + ) + } + return "" +} + +/** + * Writes plain text to the host OS system clipboard. + */ +export async function setSystemClipboard(text: string): Promise { + const platform = os.platform() + try { + if (platform === "darwin") { + await writeMacClipboard(text) + return + } + if (platform === "win32") { + await writeWindowsClipboard(text) + return + } + if (platform === "linux") { + await writeLinuxClipboard(text) + return + } + } catch (err) { + logger.warn( + `Failed to write system clipboard on ${platform}: ${String(err)}`, + ) + } +} + +// ── macOS Implementation ── +function readMacClipboard(): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/pbpaste", + [], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (err, stdout) => { + if (err) { + execFile( + "pbpaste", + [], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (err2, stdout2) => { + if (err2) return reject(err2) + resolve(stdout2) + }, + ) + return + } + resolve(stdout) + }, + ) + }) +} + +function writeMacClipboard(text: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("/usr/bin/pbcopy", [], { + stdio: ["pipe", "ignore", "ignore"], + }) + proc.on("error", () => { + const fallback = spawn("pbcopy", [], { + stdio: ["pipe", "ignore", "ignore"], + }) + fallback.on("error", reject) + fallback.on("close", (code) => { + if (code === 0) resolve() + else reject(new Error(`pbcopy exited with code ${code}`)) + }) + fallback.stdin.end(text, "utf8") + }) + proc.on("close", (code) => { + if (code === 0) resolve() + else reject(new Error(`pbcopy exited with code ${code}`)) + }) + proc.stdin.end(text, "utf8") + }) +} + +// ── Windows Implementation ── +function readWindowsClipboard(): Promise { + return new Promise((resolve, reject) => { + execFile( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard", + ], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (err, stdout) => { + if (err) return reject(err) + resolve(stdout.replace(/\r\n$/, "").replace(/\n$/, "")) + }, + ) + }) +} + +function writeWindowsClipboard(text: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$val = [Console]::In.ReadToEnd(); Set-Clipboard -Value $val", + ], + { stdio: ["pipe", "ignore", "ignore"] }, + ) + proc.on("error", reject) + proc.on("close", (code) => { + if (code === 0) resolve() + else + reject(new Error(`powershell Set-Clipboard exited with code ${code}`)) + }) + proc.stdin.end(text, "utf8") + }) +} + +// ── Linux Implementation ── +function readLinuxClipboard(): Promise { + return new Promise((resolve, reject) => { + execFile( + "wl-paste", + ["--no-newline"], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (wlErr, wlOut) => { + if (!wlErr) return resolve(wlOut) + execFile( + "xclip", + ["-selection", "clipboard", "-o"], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (xcErr, xcOut) => { + if (!xcErr) return resolve(xcOut) + execFile( + "xsel", + ["--clipboard", "--output"], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + (xsErr, xsOut) => { + if (!xsErr) return resolve(xsOut) + reject( + new Error( + "No Linux clipboard tool available (wl-paste, xclip, xsel)", + ), + ) + }, + ) + }, + ) + }, + ) + }) +} + +function writeLinuxClipboard(text: string): Promise { + return new Promise((resolve, reject) => { + const tryWl = () => { + const proc = spawn("wl-copy", [], { + stdio: ["pipe", "ignore", "ignore"], + }) + proc.on("error", () => tryXclip()) + proc.on("close", (code) => { + if (code === 0) resolve() + else tryXclip() + }) + proc.stdin.end(text, "utf8") + } + + const tryXclip = () => { + const proc = spawn("xclip", ["-selection", "clipboard"], { + stdio: ["pipe", "ignore", "ignore"], + }) + proc.on("error", () => tryXsel()) + proc.on("close", (code) => { + if (code === 0) resolve() + else tryXsel() + }) + proc.stdin.end(text, "utf8") + } + + const tryXsel = () => { + const proc = spawn("xsel", ["--clipboard", "--input"], { + stdio: ["pipe", "ignore", "ignore"], + }) + proc.on("error", reject) + proc.on("close", (code) => { + if (code === 0) resolve() + else reject(new Error(`xsel exited with code ${code}`)) + }) + proc.stdin.end(text, "utf8") + } + + tryWl() + }) +} diff --git a/src/server/constants.ts b/src/server/constants.ts index 19b36b6..9b79e57 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -13,7 +13,7 @@ export const DEFAULT_CONFIG: InputConfig = { screenWidth: DEFAULT_SCREEN_WIDTH, screenHeight: DEFAULT_SCREEN_HEIGHT, } -export const MAX_TEXT_LENGTH = 10000 +export const MAX_TEXT_LENGTH = 100000 export const MAX_COORD = 2000 export const MAX_COMBO_KEYS = 10 export const MAX_KEY_LENGTH = 50 diff --git a/src/server/drivers/linux/keyboard.ts b/src/server/drivers/linux/keyboard.ts index 7f5aa2a..d1d2754 100644 --- a/src/server/drivers/linux/keyboard.ts +++ b/src/server/drivers/linux/keyboard.ts @@ -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) diff --git a/src/server/drivers/mac/keyboard.ts b/src/server/drivers/mac/keyboard.ts index 3198b9d..f6c93bf 100644 --- a/src/server/drivers/mac/keyboard.ts +++ b/src/server/drivers/mac/keyboard.ts @@ -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, @@ -24,6 +18,17 @@ const MEDIA_KEY_MAP: Record = { audiostop: NX_KEYTYPE_PLAY, } +const MODIFIER_FLAGS: Record = { + meta: 0x00100000, + command: 0x00100000, + cmd: 0x00100000, + shift: 0x00020000, + control: 0x00040000, + ctrl: 0x00040000, + alt: 0x00080000, + option: 0x00080000, +} + export class MacKeyboard { injectKey(key: string, pos: string): void { const lowerKey = key.toLowerCase() @@ -39,7 +44,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) @@ -48,8 +53,14 @@ export class MacKeyboard { injectCombo(keys: string[]): void { const codes: number[] = [] + let flags = 0 for (const k of keys) { - const code = MAC_KEY_MAP[k.toLowerCase()] + const lower = k.toLowerCase() + const modFlag = MODIFIER_FLAGS[lower] + if (modFlag !== undefined) { + flags |= modFlag + } + const code = MAC_KEY_MAP[lower] if (code !== undefined) { codes.push(code) } else { @@ -58,13 +69,13 @@ export class MacKeyboard { } if (codes.length === 0) return - // Press all keys down + // Press all keys down with modifier flags for (const code of codes) { - postKeyEvent(code, true) + postKeyEvent(code, true, flags) } // Release in reverse order for (let i = codes.length - 1; i >= 0; i--) { - postKeyEvent(codes[i], false) + postKeyEvent(codes[i], false, flags) } } @@ -72,20 +83,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 { @@ -108,7 +112,6 @@ function ensureUnicode() { if (_unicodeInjectorLoaded) return _unicodeInjectorLoaded = true try { - const koffi = require("koffi") const lib = koffi.load( "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", ) @@ -148,7 +151,6 @@ function injectUnicode(ch: string): void { const upRef = _CGEventCreateKeyboardEvent(null, 0, 0) if (!upRef) return - _CGEventKeyboardSetUnicodeString(upRef, charCount, buf) _CGEventPost(0, upRef) _CFRelease(upRef) } diff --git a/src/server/drivers/mac/structs.ts b/src/server/drivers/mac/structs.ts index 39ef652..453f2b9 100644 --- a/src/server/drivers/mac/structs.ts +++ b/src/server/drivers/mac/structs.ts @@ -13,10 +13,16 @@ function cg() { } // ── CGPoint ──────────────────────────────────────────────────────────────── -export const CGPoint = koffi.struct("CGPoint", { - x: "double", - y: "double", -}) +export const CGPoint = (() => { + try { + return koffi.struct("CGPoint", { + x: "double", + y: "double", + }) + } catch { + return koffi.resolve("CGPoint") + } +})() let _CGEventCreateMouseEvent: koffi.KoffiFunction | null = null let _CGEventCreateKeyboardEvent: koffi.KoffiFunction | null = null @@ -27,6 +33,8 @@ let _CGEventSetIntegerValueField: koffi.KoffiFunction | null = null export let _CGEventSetDoubleValueField: koffi.KoffiFunction | null = null export let _CGEventGetLocation: koffi.KoffiFunction | null = null +let _CGEventSetFlags: koffi.KoffiFunction | null = null + function ensureFunctions() { const lib = cg() if (!_CGEventCreateMouseEvent) { @@ -36,6 +44,7 @@ function ensureFunctions() { _CGEventCreateKeyboardEvent = lib.func( "void * CGEventCreateKeyboardEvent(void *, uint16, uint8)", ) + _CGEventSetFlags = lib.func("void CGEventSetFlags(void *, uint64)") // koffi variadic: declare only fixed args; pass extras manually. _CGEventCreateScrollWheelEvent = lib.func( "void * CGEventCreateScrollWheelEvent(void *, uint32, uint32, int32, int32)", @@ -80,13 +89,20 @@ export function postMouseEvent( _CFRelease?.(ref) } -export function postKeyEvent(keyCode: number, keyDown: boolean): void { +export function postKeyEvent( + keyCode: number, + keyDown: boolean, + flags?: number, +): void { ensureFunctions() const ref = _CGEventCreateKeyboardEvent?.(null, keyCode, keyDown ? 1 : 0) as | bigint | number | null if (!ref) return + if (flags !== undefined && _CGEventSetFlags) { + _CGEventSetFlags(ref, BigInt(flags)) + } _CGEventPost?.(0, ref) _CFRelease?.(ref) } diff --git a/src/server/drivers/windows/keyboard.ts b/src/server/drivers/windows/keyboard.ts index cae39c8..786da57 100644 --- a/src/server/drivers/windows/keyboard.ts +++ b/src/server/drivers/windows/keyboard.ts @@ -40,7 +40,7 @@ export class WindowsKeyboard { }) } this.sendInput(events.length, events) - } else if (key.length === 1) { + } else if (key.length > 0) { this.injectText(key) } else { console.warn("[Keyboard] Unknown key and not a single character:", key) diff --git a/src/server/server.ts b/src/server/server.ts index 91cda40..a5b2677 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -5,6 +5,8 @@ import winston from "winston" import { getOrCreateActiveToken, isKnownToken } from "./tokenStore.ts" import { GstManager } from "./gstreamer/gstManager.ts" import { WebRTCManager } from "./webRTC.ts" +import { getSystemClipboard, setSystemClipboard } from "./clipboard.ts" +import { MAX_TEXT_LENGTH } from "./constants.ts" import type { InputConfig } from "./types.ts" import { getLanIp, isLoopbackAddress } from "../utils/net.ts" @@ -242,6 +244,74 @@ export function attachSignalingRoutes(server: any): void { return } + if (pathname === "/api/clipboard/copy" && req.method === "POST") { + if (!requireAuth(req, res)) return + parseJsonBody<{ sessionId?: string }>(req) + .catch(() => ({}) as { sessionId?: string }) + .then(async (body) => { + try { + const handler = webrtcManager?.getInputHandler(body.sessionId) + if (!handler) { + json(res, 400, { error: "Active session required" }) + return + } + + const before = await getSystemClipboard() + await handler.handleMessage({ type: "copy" }) + + let current = before + const startTime = Date.now() + const maxWaitMs = 120 + const pollIntervalMs = 15 + while (Date.now() - startTime < maxWaitMs) { + await new Promise((resolve) => + setTimeout(resolve, pollIntervalMs), + ) + current = await getSystemClipboard() + if (current !== before) break + } + + json(res, 200, { text: current }) + } catch (err) { + logger.error(`Error in /api/clipboard/copy: ${String(err)}`) + json(res, 500, { error: "Failed to copy clipboard" }) + } + }) + return + } + + if (pathname === "/api/clipboard/paste" && req.method === "POST") { + if (!requireAuth(req, res)) return + parseJsonBody<{ sessionId?: string; text?: string }>(req) + .then(async (body) => { + try { + const handler = webrtcManager?.getInputHandler(body.sessionId) + if (!handler) { + json(res, 400, { error: "Active session required" }) + return + } + + if (typeof body.text === "string" && body.text.length > 0) { + const textToSet = + body.text.length > MAX_TEXT_LENGTH + ? body.text.slice(0, MAX_TEXT_LENGTH) + : body.text + await setSystemClipboard(textToSet) + } + + await handler.handleMessage({ type: "paste" }) + json(res, 200, { ok: true }) + } catch (err) { + logger.error(`Error in /api/clipboard/paste: ${String(err)}`) + json(res, 500, { error: "Failed to paste clipboard" }) + } + }) + .catch((err) => { + json(res, 400, { ok: false, error: String(err) }) + }) + return + } + if (pathname === "/api/debug/sessions" && req.method === "GET") { if (!requireAuth(req, res)) return const sessions = webrtcManager?.getSessions() ?? [] diff --git a/src/server/webRTC.ts b/src/server/webRTC.ts index 930c8a0..ba6bcd5 100644 --- a/src/server/webRTC.ts +++ b/src/server/webRTC.ts @@ -337,7 +337,7 @@ export class WebRTCManager { const offer = await pc.createOffer() await pc.setLocalDescription(offer) if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "offer", sdp: offer })) + ws.send(JSON.stringify({ type: "offer", sdp: offer, sessionId })) } } catch (err) { logger.error(`Failed to create offer: ${String(err)}`) @@ -382,6 +382,13 @@ export class WebRTCManager { return snapshots } + public getInputHandler(sessionId?: string): InputHandler | null { + if (sessionId && this.clients.has(sessionId)) { + return this.clients.get(sessionId)?.inputHandler ?? null + } + return null + } + public updateConfig(config: Partial) { for (const client of this.clients.values()) { client.inputHandler.updateConfig(config)