1818
1919import { dbg } from './diag.js' ;
2020import { nowMs } from './env.js' ;
21+ import { ALL_MODIFIER_KEYSYMS } from './keys.js' ;
2122
2223export function createClipboard ( {
2324 getRfb, getProxy, getHints, getFocusKey, sendText, sendSpecialKey,
@@ -26,6 +27,16 @@ export function createClipboard({
2627 let remoteClipboardText = null ; // latest text the remote copied
2728 let pendingLocalWrite = false ; // remote text awaiting a user-gesture write
2829 let pendingSince = 0 ;
30+ let pasteGeneration = 0 ; // cancels stale/made-redundant fallback reads
31+
32+ // Prevent stale modifiers from turning injected text into shortcuts.
33+ function releaseRemoteModifiers ( ) {
34+ const rfb = getRfb ( ) ;
35+ if ( ! rfb ) return ;
36+ for ( const keysym of ALL_MODIFIER_KEYSYMS ) {
37+ try { rfb . sendKey ( keysym , null , false ) ; } catch ( _ ) { }
38+ }
39+ }
2940
3041 // Ctrl+V on the remote — used to paste a long insert we staged on the remote
3142 // clipboard in one shot instead of N per-char keysym round-trips.
@@ -38,12 +49,15 @@ export function createClipboard({
3849 // 'v' and the paste chord silently does the wrong thing. Sending the bare
3950 // keysym lets Xvnc pick a keycode that actually yields 'v' — deterministic,
4051 // layout-independent, and consistent with the rest of the text-injection path.
52+ // Always release Control, even if a send fails mid-chord.
4153 try {
4254 rfb . sendKey ( 0xffe3 , null , true ) ; // Control down
4355 rfb . sendKey ( 0x0076 , null , true ) ; // v down
4456 rfb . sendKey ( 0x0076 , null , false ) ; // v up
45- rfb . sendKey ( 0xffe3 , null , false ) ; // Control up
46- } catch ( _ ) { }
57+ } catch ( _ ) {
58+ } finally {
59+ try { rfb . sendKey ( 0xffe3 , null , false ) ; } catch ( _ ) { } // Control up
60+ }
4761 }
4862
4963 // Shift+Tab on the remote — moves to the PREVIOUS focusable field (plain Tab
@@ -104,15 +118,30 @@ export function createClipboard({
104118 } catch ( _ ) { return false ; }
105119 }
106120
121+ function normalizePastedText ( text ) {
122+ if ( ! text ) return '' ;
123+ const hints = getHints ( ) ;
124+ if ( hints && hints . tag === 'INPUT' ) return text . replace ( / [ \r \n ] + / g, '' ) ;
125+ return text ;
126+ }
127+
128+ function canStageRemoteClipboard ( text , rfb ) {
129+ if ( typeof rfb . clipboardPasteFrom !== 'function' ) return false ;
130+ const encodingSupported = / ^ [ \x00 - \xff ] * $ / . test ( text ) || serverExtendedClipboard ( ) ;
131+ if ( ! encodingSupported ) return false ;
132+ return text . length > 32 || ! getFocusKey ( ) ;
133+ }
134+
107135 function insertPastedText ( text ) {
108136 const rfb = getRfb ( ) ;
109- if ( ! text || ! rfb ) return ;
137+ if ( ! rfb ) return ;
110138 // Single-line field: strip newlines so a pasted trailing \n doesn't fire
111139 // Enter and instantly submit/navigate. INPUT is the positive test — TEXTAREA
112140 // and contenteditable report other tags and legitimately keep their newlines.
113- const hints = getHints ( ) ;
114- if ( hints && hints . tag === 'INPUT' ) text = text . replace ( / [ \r \n ] + / g, '' ) ;
141+ text = normalizePastedText ( text ) ;
115142 if ( ! text ) return ;
143+ // Ensure the active paste modifier cannot affect injected text.
144+ releaseRemoteModifiers ( ) ;
116145 // Stage on the remote clipboard + Ctrl+V (one round-trip) instead of per-char
117146 // keysyms when either:
118147 // long text — a big win over N keysyms on a 3G link;
@@ -125,24 +154,49 @@ export function createClipboard({
125154 // lossless UTF-8 path instead of the '?'-corrupting ISO-8859-1 fallback.
126155 // Otherwise fall back to per-char sendText — that text is non-Latin-1, so it
127156 // cannot trigger an ASCII shortcut either.
128- if ( ( text . length > 32 || ! getFocusKey ( ) ) && typeof rfb . clipboardPasteFrom === 'function' &&
129- ( / ^ [ \x00 - \xff ] * $ / . test ( text ) || serverExtendedClipboard ( ) ) ) {
157+ if ( canStageRemoteClipboard ( text , rfb ) ) {
130158 try { rfb . clipboardPasteFrom ( text ) ; remoteCtrlV ( ) ; return ; } catch ( _ ) { }
131159 }
132160 sendText ( text ) ;
133161 }
134162
135163 function onProxyPaste ( e ) {
164+ // Any native paste event supersedes the keydown fallback.
165+ pasteGeneration ++ ;
136166 if ( ! getRfb ( ) ) return ;
137167 let text = '' ;
138- try { text = ( e . clipboardData || window . clipboardData ) . getData ( 'text/plain' ) || '' ; } catch ( _ ) { }
168+ try {
169+ const data = e . clipboardData || window . clipboardData ;
170+ text = data . getData ( 'text/plain' ) || data . getData ( 'text' ) || '' ;
171+ } catch ( _ ) { }
139172 if ( ! text ) return ;
140173 e . preventDefault ( ) ;
141174 dbg ( 'paste len=' + text . length ) ;
142175 insertPastedText ( text ) ;
143176 clearProxy ( ) ;
144177 }
145178
179+ // Recover when Chromium omits paste during a proxy/canvas focus handoff.
180+ // Deferring gives the native event priority and prevents duplicate insertion.
181+ function requestClipboardPasteFallback ( ) {
182+ const generation = ++ pasteGeneration ;
183+ const focusAtRequest = getFocusKey ( ) ;
184+ setTimeout ( async ( ) => {
185+ if ( generation !== pasteGeneration ) return ;
186+ let text = '' ;
187+ try {
188+ if ( ! navigator . clipboard || ! navigator . clipboard . readText ) return ;
189+ text = await navigator . clipboard . readText ( ) ;
190+ } catch ( _ ) { return ; }
191+ if ( generation !== pasteGeneration || ! text ) return ;
192+ // A known field must still own focus after the asynchronous read.
193+ if ( focusAtRequest && getFocusKey ( ) !== focusAtRequest ) return ;
194+ dbg ( 'paste fallback len=' + text . length ) ;
195+ insertPastedText ( text ) ;
196+ clearProxy ( ) ;
197+ } , 0 ) ;
198+ }
199+
146200 // writeText needs transient activation on iOS Safari, so a write triggered by
147201 // the remote's copy (no local gesture) can reject — buffer it and retry on the
148202 // next tap (onTouchEnd calls flushLocalClipboard).
@@ -174,5 +228,6 @@ export function createClipboard({
174228 tryWriteLocalClipboard ( text ) ; // best-effort now; retried on next gesture
175229 }
176230
177- return { sendActionKey, insertPastedText, navRemoteField, onProxyPaste, flushLocalClipboard, onRemoteClipboard } ;
231+ return { sendActionKey, insertPastedText, navRemoteField, onProxyPaste, flushLocalClipboard,
232+ onRemoteClipboard, releaseRemoteModifiers, requestClipboardPasteFallback } ;
178233}
0 commit comments