@@ -25,6 +25,8 @@ import {
2525 IconUsers ,
2626 IconLock ,
2727 IconMonitor ,
28+ IconClipboard ,
29+ IconCheck ,
2830} from "@/components/icons" ;
2931
3032const MIN = 1 ;
@@ -199,6 +201,41 @@ const splitComboKeys = (value) => {
199201 return parts . map ( normalizeKeyName ) . filter ( Boolean ) ;
200202} ;
201203
204+ // Copy text to the device clipboard, returning true on success. Tries the async
205+ // Clipboard API first (works on desktop and on Android Chrome while the page is
206+ // focused), then falls back to a hidden <textarea> + execCommand("copy") for
207+ // contexts that reject the async API (notably iOS Safari outside a user
208+ // gesture). Both paths can still be blocked when there's no user gesture — the
209+ // caller surfaces a tap-to-copy banner when this returns false.
210+ async function copyTextToClipboard ( text ) {
211+ if ( ! text ) return false ;
212+ try {
213+ if ( navigator . clipboard ?. writeText ) {
214+ await navigator . clipboard . writeText ( text ) ;
215+ return true ;
216+ }
217+ } catch {
218+ /* fall through to the legacy path */
219+ }
220+ try {
221+ const ta = document . createElement ( "textarea" ) ;
222+ ta . value = text ;
223+ ta . setAttribute ( "readonly" , "" ) ;
224+ ta . style . position = "fixed" ;
225+ ta . style . top = "-1000px" ;
226+ ta . style . opacity = "0" ;
227+ document . body . appendChild ( ta ) ;
228+ ta . focus ( ) ;
229+ ta . select ( ) ;
230+ ta . setSelectionRange ( 0 , text . length ) ;
231+ const ok = document . execCommand ( "copy" ) ;
232+ document . body . removeChild ( ta ) ;
233+ return ok ;
234+ } catch {
235+ return false ;
236+ }
237+ }
238+
202239/**
203240 * Live mode — remote control of the PC from the phone.
204241 *
@@ -254,6 +291,9 @@ export default function LiveUse({
254291 const monitorIdRef = useRef ( initialShot ?. monitorId || 1 ) ;
255292 const [ status , setStatus ] = useState ( { text : "Ready." , state : "idle" } ) ;
256293 const [ showHint , setShowHint ] = useState ( true ) ;
294+ // Latest text copied on the PC that we're relaying to the phone clipboard.
295+ // `copied` is true once it's actually on the phone (auto or after a tap).
296+ const [ pcClip , setPcClip ] = useState ( null ) ;
257297
258298 // Controls
259299 const [ tab , setTab ] = useState ( "mouse" ) ; // mouse | keyboard | special
@@ -707,15 +747,24 @@ export default function LiveUse({
707747 if ( ! data . enabled ) {
708748 lastHash = "" ;
709749 primed = false ;
750+ setPcClip ( null ) ;
710751 return ;
711752 }
712753 if ( data . hash && data . hash !== lastHash && data . text ) {
713754 const shouldOffer = primed ;
714755 lastHash = data . hash ;
715756 primed = true ;
716- if ( shouldOffer && navigator . clipboard ?. writeText ) {
717- await navigator . clipboard . writeText ( data . text ) ;
718- }
757+ // Skip whatever was already on the PC clipboard when we opened.
758+ if ( ! shouldOffer ) return ;
759+ // Try a silent copy — works on desktop and Android Chrome while the
760+ // tab is focused. iOS Safari (and any backgrounded tab) blocks
761+ // clipboard writes that aren't tied to a tap, so when this fails we
762+ // surface a tap-to-copy banner instead of failing silently (the old
763+ // behavior — calling writeText from this background poll just threw,
764+ // which is why PC→phone copy "did nothing").
765+ const auto = document . hasFocus ?. ( ) ? await copyTextToClipboard ( data . text ) : false ;
766+ if ( ! active ) return ;
767+ setPcClip ( { text : data . text , copied : auto } ) ;
719768 }
720769 } catch ( err ) {
721770 if ( err . unauthorized ) onLogout ( ) ;
@@ -729,6 +778,26 @@ export default function LiveUse({
729778 } ;
730779 } , [ onLogout ] ) ;
731780
781+ // Finish (or retry) the copy on tap — a user gesture always satisfies the
782+ // browser's clipboard-write requirement.
783+ const copyPcClip = async ( ) => {
784+ if ( ! pcClip ) return ;
785+ const ok = await copyTextToClipboard ( pcClip . text ) ;
786+ setPcClip ( ( prev ) => ( prev ? { ...prev , copied : ok } : prev ) ) ;
787+ setStatus (
788+ ok
789+ ? { text : "Copied from PC to phone." , state : "idle" }
790+ : { text : "Couldn't copy — try again." , state : "error" } ,
791+ ) ;
792+ } ;
793+
794+ // Auto-dismiss the clipboard banner once the text is on the phone.
795+ useEffect ( ( ) => {
796+ if ( ! pcClip ?. copied ) return ;
797+ const t = setTimeout ( ( ) => setPcClip ( null ) , 2600 ) ;
798+ return ( ) => clearTimeout ( t ) ;
799+ } , [ pcClip ] ) ;
800+
732801 // ---- Gestures (touch + mouse for desktop testing) ----------------------
733802 useEffect ( ( ) => {
734803 const cont = containerRef . current ;
@@ -843,6 +912,51 @@ export default function LiveUse({
843912 // eslint-disable-next-line react-hooks/exhaustive-deps
844913 } , [ ] ) ;
845914
915+ // Reconnect the stream after the phone returns from the background. Mobile
916+ // browsers freeze or drop the MJPEG connection while the tab is hidden (the
917+ // user switches apps), and the <img> keeps a dead socket — showing a black
918+ // frame with no error event — until streaming is toggled off/on by hand.
919+ // Re-issuing the stream URL with a fresh nonce forces a clean reconnect; when
920+ // not streaming we just pull one fresh frame so a stale shot isn't left up.
921+ useEffect ( ( ) => {
922+ let wasHidden = typeof document !== "undefined" && document . visibilityState === "hidden" ;
923+ const resume = ( ) => {
924+ if ( typeof document !== "undefined" && document . visibilityState !== "visible" ) return ;
925+ if ( ! wasHidden ) return ;
926+ wasHidden = false ;
927+ if ( autoRef . current ) {
928+ setStreamUrl (
929+ screenshotStreamUrl ( {
930+ profile : "live" ,
931+ monitor : monitorIdRef . current ,
932+ fps : STREAM_FPS ,
933+ nonce : Date . now ( ) ,
934+ } ) ,
935+ ) ;
936+ setStatus ( { text : `Streaming at ${ STREAM_FPS } fps.` , state : "idle" } ) ;
937+ } else {
938+ doRefresh ( ) ;
939+ }
940+ } ;
941+ const onVisibility = ( ) => {
942+ if ( document . visibilityState === "hidden" ) wasHidden = true ;
943+ else resume ( ) ;
944+ } ;
945+ const onPageShow = ( e ) => {
946+ if ( e . persisted ) {
947+ wasHidden = true ;
948+ resume ( ) ;
949+ }
950+ } ;
951+ document . addEventListener ( "visibilitychange" , onVisibility ) ;
952+ window . addEventListener ( "pageshow" , onPageShow ) ;
953+ return ( ) => {
954+ document . removeEventListener ( "visibilitychange" , onVisibility ) ;
955+ window . removeEventListener ( "pageshow" , onPageShow ) ;
956+ } ;
957+ // eslint-disable-next-line react-hooks/exhaustive-deps
958+ } , [ ] ) ;
959+
846960 // ---- Lifecycle ---------------------------------------------------------
847961 useEffect ( ( ) => {
848962 mountedRef . current = true ;
@@ -1048,14 +1162,42 @@ export default function LiveUse({
10481162 </ div >
10491163
10501164 { /* First-run gesture hint — fades out, never overlaps controls for long. */ }
1051- { showHint && ! dockHidden && (
1165+ { showHint && ! dockHidden && ! pcClip && (
10521166 < div
10531167 className = "pointer-events-none absolute left-1/2 top-[calc(env(safe-area-inset-top)+5.2rem)] z-10 -translate-x-1/2 animate-fadein whitespace-nowrap rounded-full border border-line bg-black/70 px-3 py-1.5 font-mono text-[0.58rem] uppercase tracking-stamp text-silver"
10541168 >
10551169 1 finger: move aim · 2 fingers: zoom
10561170 </ div >
10571171 ) }
10581172
1173+ { /* Clipboard relay — text just copied on the PC. We try to push it to the
1174+ phone clipboard automatically; when the browser blocks that (common on
1175+ mobile, where writes need a tap), this banner finishes the copy with a
1176+ single touch. */ }
1177+ { pcClip && (
1178+ < div className = "pointer-events-none absolute inset-x-0 top-[calc(env(safe-area-inset-top)+5.6rem)] z-30 flex justify-center px-3" >
1179+ < button
1180+ onClick = { copyPcClip }
1181+ className = { `pointer-events-auto flex max-w-[min(92vw,420px)] items-center gap-2.5 rounded-full border bg-panel-solid px-4 py-2.5 text-left shadow-[0_10px_30px_rgba(0,0,0,0.5)] transition-colors ${
1182+ pcClip . copied
1183+ ? "border-ink/40 text-ink"
1184+ : "border-line-strong text-silver hover:text-ink active:scale-[0.98]"
1185+ } `}
1186+ aria-label = { pcClip . copied ? "Copied from PC" : "Copy text from PC to phone" }
1187+ >
1188+ < span className = "grid h-7 w-7 shrink-0 place-items-center rounded-full border border-line bg-black/40" >
1189+ { pcClip . copied ? < IconCheck className = "h-4 w-4" /> : < IconClipboard className = "h-4 w-4" /> }
1190+ </ span >
1191+ < span className = "flex min-w-0 flex-col font-mono" >
1192+ < span className = "text-[0.56rem] uppercase tracking-stamp text-faint" >
1193+ { pcClip . copied ? "Copied from PC" : "From PC · tap to copy" }
1194+ </ span >
1195+ < span className = "truncate text-[0.78rem] text-silver" > { pcClip . text } </ span >
1196+ </ span >
1197+ </ button >
1198+ </ div >
1199+ ) }
1200+
10591201 { /* Show-controls pill — appears when the dock is hidden so you can see
10601202 the whole remote screen (including its taskbar). */ }
10611203 { dockHidden && (
0 commit comments