diff --git a/src/components/Trackpad/TouchArea.tsx b/src/components/Trackpad/TouchArea.tsx index 03c367e8..16062095 100644 --- a/src/components/Trackpad/TouchArea.tsx +++ b/src/components/Trackpad/TouchArea.tsx @@ -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 } } @@ -32,6 +33,7 @@ export const TouchArea: React.FC = ({ onTouchStart={handleStart} onTouchMove={handlers.onTouchMove} onTouchEnd={handlers.onTouchEnd} + onTouchCancel={handlers.onTouchCancel} onMouseDown={handlePreventFocus} >
diff --git a/src/hooks/useTrackpadGesture.ts b/src/hooks/useTrackpadGesture.ts index 66f801d8..16d467d2 100644 --- a/src/hooks/useTrackpadGesture.ts +++ b/src/hooks/useTrackpadGesture.ts @@ -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, }, } } diff --git a/src/routes/trackpad.tsx b/src/routes/trackpad.tsx index f2f31461..c24ff456 100644 --- a/src/routes/trackpad.tsx +++ b/src/routes/trackpad.tsx @@ -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("Release") @@ -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, @@ -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) } @@ -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) => { diff --git a/src/server/InputHandler.ts b/src/server/InputHandler.ts index 63f9b26e..3659f9ab 100644 --- a/src/server/InputHandler.ts +++ b/src/server/InputHandler.ts @@ -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" @@ -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 @@ -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 + } + + 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[] = [] diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 0388a12a..e53369dd 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -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() @@ -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}`) @@ -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}`)