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