Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions src/components/Trackpad/TouchArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface TouchAreaProps {
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: (e: React.TouchEvent) => void
onTouchCancel: (e: React.TouchEvent) => void
}
}

Expand All @@ -32,6 +33,7 @@ export const TouchArea: React.FC<TouchAreaProps> = ({
onTouchStart={handleStart}
onTouchMove={handlers.onTouchMove}
onTouchEnd={handlers.onTouchEnd}
onTouchCancel={handlers.onTouchCancel}
onMouseDown={handlePreventFocus}
>
<div className="text-neutral-600 text-center pointer-events-none">
Expand Down
29 changes: 29 additions & 0 deletions src/hooks/useTrackpadGesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,41 @@ export const useTrackpadGesture = (
}
}

const handleTouchCancel = () => {
// Clear all active touches
ongoingTouches.current.clear()

// Reset gesture state
setIsTracking(false)
moved.current = false
releasedCount.current = 0

// Reset pinch state
lastPinchDist.current = null
pinching.current = false

// Clear dragging timeout if exists
if (draggingTimeout.current) {
clearTimeout(draggingTimeout.current)
draggingTimeout.current = null
}

// Release drag if active
if (dragging.current) {
dragging.current = false
}

// 🔥 Safety: ensure no stuck mouse state
send({ type: "click", button: "left", press: false })
}

return {
isTracking,
handlers: {
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
onTouchCancel: handleTouchCancel,
},
}
}
56 changes: 53 additions & 3 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 @@ -54,6 +59,31 @@ function TrackpadPage() {
}
}, [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 @@ -69,11 +99,31 @@ function TrackpadPage() {
}

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

const handlePaste = async () => {
send({ type: "paste" })
// 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)

// fallback to server-side paste
send({ type: "paste" })
}
}

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 +219
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.

⚠️ 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
8 changes: 7 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,11 @@ 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 +349,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