File size: 3,977 Bytes
05ffe31 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | import { useCallback, useEffect, useRef, useState, ReactNode } from "react";
import { Button } from "@/components/ui/button";
type Peer = { id: string; openedAt: number; lastSeen: number };
const CHANNEL = "lelab-tabs-v1";
const HEARTBEAT_MS = 1000;
const PEER_TIMEOUT_MS = 3000;
const SingleTabGuard = ({ children }: { children: ReactNode }) => {
const [isPrimary, setIsPrimary] = useState(true);
const peersRef = useRef<Map<string, Peer>>(new Map());
const myIdRef = useRef<string>("");
const myOpenedAtRef = useRef<number>(0);
const channelRef = useRef<BroadcastChannel | null>(null);
const recompute = useCallback(() => {
const peers = peersRef.current;
const cutoff = Date.now() - PEER_TIMEOUT_MS;
for (const [id, peer] of peers) {
if (peer.lastSeen < cutoff) peers.delete(id);
}
let winnerId = myIdRef.current;
let winnerOpenedAt = myOpenedAtRef.current;
for (const peer of peers.values()) {
if (
peer.openedAt < winnerOpenedAt ||
(peer.openedAt === winnerOpenedAt && peer.id < winnerId)
) {
winnerId = peer.id;
winnerOpenedAt = peer.openedAt;
}
}
setIsPrimary(winnerId === myIdRef.current);
}, []);
useEffect(() => {
if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") {
return;
}
myIdRef.current = crypto.randomUUID();
myOpenedAtRef.current = Date.now();
const channel = new BroadcastChannel(CHANNEL);
channelRef.current = channel;
const send = (type: string) => {
channel.postMessage({
type,
id: myIdRef.current,
openedAt: myOpenedAtRef.current,
});
};
channel.onmessage = (e) => {
const msg = e.data;
if (!msg || msg.id === myIdRef.current) return;
const peers = peersRef.current;
if (msg.type === "HEARTBEAT") {
peers.set(msg.id, {
id: msg.id,
openedAt: msg.openedAt,
lastSeen: Date.now(),
});
} else if (msg.type === "RELEASE") {
peers.delete(msg.id);
} else if (msg.type === "TAKEOVER") {
peers.set(msg.id, {
id: msg.id,
openedAt: msg.openedAt,
lastSeen: Date.now(),
});
// Move ourselves behind the taker so the election flips.
if (myOpenedAtRef.current <= msg.openedAt) {
myOpenedAtRef.current = msg.openedAt + 1;
}
}
recompute();
};
send("HEARTBEAT");
const interval = setInterval(() => {
send("HEARTBEAT");
recompute();
}, HEARTBEAT_MS);
const onUnload = () => send("RELEASE");
window.addEventListener("beforeunload", onUnload);
return () => {
window.removeEventListener("beforeunload", onUnload);
clearInterval(interval);
send("RELEASE");
channel.close();
channelRef.current = null;
};
}, [recompute]);
const takeOver = useCallback(() => {
myOpenedAtRef.current = 0;
channelRef.current?.postMessage({
type: "TAKEOVER",
id: myIdRef.current,
openedAt: 0,
});
recompute();
}, [recompute]);
return (
<>
{children}
{!isPrimary && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div className="mx-4 max-w-md space-y-4 rounded-lg border bg-background p-6 text-center shadow-lg">
<h2 className="text-lg font-semibold">
LeLab is already open in another tab
</h2>
<p className="text-sm text-muted-foreground">
Only one tab can control the robot at a time. Switch back to the
original tab, or take over here — the other tab will lock.
</p>
<Button onClick={takeOver}>Use this tab</Button>
</div>
</div>
)}
</>
);
};
export default SingleTabGuard;
|