Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
63 changes: 56 additions & 7 deletions src/routes/trackpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const Route = createFileRoute("/trackpad")({
component: TrackpadPage,
})

type ClipboardMessage = {
type: "clipboard-text"
text: string
}

function TrackpadPage() {
const [scrollMode, setScrollMode] = useState(false)
const [modifier, setModifier] = useState<ModifierState>("Release")
Expand All @@ -36,7 +41,7 @@ function TrackpadPage() {
return s ? JSON.parse(s) : false
})

const { send, sendCombo } = useRemoteConnection()
const { send, sendCombo, subscribe } = useRemoteConnection()
// Pass sensitivity and invertScroll to the gesture hook
const { isTracking, handlers } = useTrackpadGesture(
send,
Expand All @@ -53,6 +58,33 @@ function TrackpadPage() {
hiddenInputRef.current?.blur()
}
}, [keyboardOpen])


useEffect(() => {
const unsubscribe = subscribe("clipboard-text", async (msg) => {
const data = msg as ClipboardMessage

try {
const text = data.text || ""

if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement("textarea")
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
}
} catch (err) {
console.error("Clipboard write failed", err)
}
}
)

return () => unsubscribe()
}, [subscribe])

const toggleKeyboard = () => {
setKeyboardOpen((prev) => !prev)
Expand All @@ -67,13 +99,30 @@ function TrackpadPage() {
// Release after short delay to simulate click
setTimeout(() => send({ type: "click", button, press: false }), 50)
}

const handleCopy = () => {
send({ type: "copy" })
}

const handlePaste = async () => {
send({ type: "paste" })
// copy from SERVER → CLIENT
send({ type: "clipboard-pull" })
}

const handlePaste = async () => {
// paste from CLIENT → SERVER
try {
let text = ""

if (navigator.clipboard && window.isSecureContext) {
text = await navigator.clipboard.readText()
} else {
text = window.getSelection()?.toString() || ""
}

send({
type: "clipboard-push",
text,
})
} catch (err) {
console.error("Paste failed", err)
}
}

const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down
46 changes: 45 additions & 1 deletion src/server/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { KEY_MAP } from "./KeyMap"
import { moveRelative } from "./ydotool"
import os from "node:os"

type ServerToClientMessage = {
type: "clipboard-text"
text: string
}

export interface InputMessage {
type:
| "move"
| "paste"
| "copy"
| "clipboard-push"
| "clipboard-pull"
| "click"
| "scroll"
| "key"
Expand All @@ -34,7 +41,10 @@ export class InputHandler {
private throttleMs: number
private modifier: Key

constructor(throttleMs = 8) {
constructor(
private sendToClient: (msg: ServerToClientMessage) => void,
throttleMs = 8,
) {
mouse.config.mouseSpeed = 1000
this.modifier = os.platform() === "darwin" ? Key.LeftSuper : Key.LeftControl
this.throttleMs = throttleMs
Expand Down Expand Up @@ -196,6 +206,40 @@ export class InputHandler {
break
}

case "clipboard-push": {
if (msg.text) {
// TEMP: fallback using typing instead of real clipboard
try{
await keyboard.type(msg.text)
}catch(err) {
console.error("Clipboard push failed:", err)
}
}
break
Comment on lines +209 to +218
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

clipboard-push is not a one-operation paste path.

keyboard.type(msg.text) replays keystrokes character-by-character, which does not satisfy the one-shot paste requirement and can alter behavior (shortcuts/IME/focus-sensitive fields).

🧰 Tools
🪛 GitHub Actions: CI

[error] 210-216: Biome formatting detected mismatch: File content differs from formatting output. Run the formatter and re-run the CI checks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/InputHandler.ts` around lines 209 - 218, The current
"clipboard-push" case uses keyboard.type(msg.text) which types characters
one-by-one; replace it with a real one-shot paste: write msg.text to the
system/renderer clipboard (e.g., navigator.clipboard.writeText or a Node
clipboard helper like clipboardy) and then perform a single paste action (one
keyboard.press of the platform modifier + "V" or use the renderer's paste API)
instead of keyboard.type; update the "clipboard-push" handler to call clipboard
write with msg.text and then trigger a single paste keystroke (taking into
account Ctrl vs Meta for macOS) so paste is atomic and IME/shortcut-safe.

}

case "clipboard-pull": {
// simulate Ctrl+C to get current clipboard
try {
await keyboard.pressKey(this.modifier, Key.C)
} finally {
await Promise.allSettled([
keyboard.releaseKey(Key.C),
keyboard.releaseKey(this.modifier),
])
}

// small delay to allow clipboard update
await new Promise((r) => setTimeout(r, 100))

// ❗ send back to client (IMPORTANT)
this.sendToClient({
type: "clipboard-text",
text: "CLIPBOARD_DATA_UNAVAILABLE",
})
break
}

case "scroll": {
const MAX_SCROLL = 100
const promises: Promise<unknown>[] = []
Expand Down
11 changes: 10 additions & 1 deletion src/server/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export async function createWsServer(
: 8

const wss = new WebSocketServer({ noServer: true })
const inputHandler = new InputHandler(inputThrottleMs)
let LAN_IP = "127.0.0.1"
try {
LAN_IP = await getLocalIp()
Expand Down Expand Up @@ -118,6 +117,14 @@ export async function createWsServer(
token: string | null,
isLocal: boolean,
) => {
const inputHandler = new InputHandler(
(msg) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
}
},
inputThrottleMs
)
// Localhost: only store token if it's already known (trusted scan)
// Remote: token is already validated in the upgrade handler
logger.info(`Client connected from ${request.socket.remoteAddress}`)
Expand Down Expand Up @@ -345,6 +352,8 @@ export async function createWsServer(
"combo",
"copy",
"paste",
"clipboard-push",
"clipboard-pull",
]
if (!msg.type || !VALID_INPUT_TYPES.includes(msg.type)) {
logger.warn(`Unknown message type: ${msg.type}`)
Expand Down
Loading