Skip to content

Commit 82ed1c5

Browse files
committed
Bug fix
1 parent 9c53d98 commit 82ed1c5

2 files changed

Lines changed: 163 additions & 4 deletions

File tree

web/components/LiveUse.jsx

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
IconUsers,
2626
IconLock,
2727
IconMonitor,
28+
IconClipboard,
29+
IconCheck,
2830
} from "@/components/icons";
2931

3032
const 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 && (

web/components/icons.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ export function IconMonitor({ className }) {
158158
);
159159
}
160160

161+
export function IconClipboard({ className }) {
162+
return (
163+
<svg className={className} {...base}>
164+
<rect x="8" y="2" width="8" height="4" rx="1" />
165+
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
166+
</svg>
167+
);
168+
}
169+
170+
export function IconCheck({ className }) {
171+
return (
172+
<svg className={className} {...base} strokeWidth={1.8}>
173+
<polyline points="20 6 9 17 4 12" />
174+
</svg>
175+
);
176+
}
177+
161178
export function IconChevronLeft({ className }) {
162179
return (
163180
<svg className={className} {...base} strokeWidth={1.7}>

0 commit comments

Comments
 (0)