Files
OpenFrontIO/src/client/MultiTabDetector.ts
icslucas 13abdc30ef Update MultiTabDetector.ts (#588)
## Description:
Replaced the original focus/visibility-based MultiTabDetector
implementation with a lock-based system using localStorage to reliably
prevent multitabbing in OpenFront. The new system enforces a single
active tab per browser instance by using a unique tabId and a shared
lock key with periodic heartbeats. This eliminates race conditions,
prevents event-hook tampering, and avoids fingerprinting while
maintaining compatibility with existing punishment callbacks and UI
warnings.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [X] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Lucas

---------

Co-authored-by: evan <openfrontio@gmail.com>
2025-04-24 11:51:17 -07:00

109 lines
2.8 KiB
TypeScript

export class MultiTabDetector {
private readonly tabId = `${Date.now()}-${Math.random()}`;
private readonly lockKey = "multi-tab-lock";
private readonly heartbeatIntervalMs = 1_000;
private readonly staleThresholdMs = 3_000;
private heartbeatTimer: number | null = null;
private isPunished = false;
private punishmentCount = 0;
private startPenaltyCallback: (duration: number) => void = () => {};
constructor() {
window.addEventListener("storage", this.onStorageEvent.bind(this));
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
public startMonitoring(startPenalty: (duration: number) => void): void {
this.startPenaltyCallback = startPenalty;
this.writeLock();
this.heartbeatTimer = window.setInterval(
() => this.heartbeat(),
this.heartbeatIntervalMs,
);
}
public stopMonitoring(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
const lock = this.readLock();
if (lock?.owner === this.tabId) {
localStorage.removeItem(this.lockKey);
}
window.removeEventListener("storage", this.onStorageEvent.bind(this));
window.removeEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
private heartbeat(): void {
const now = Date.now();
const lock = this.readLock();
if (
!lock ||
lock.owner === this.tabId ||
now - lock.timestamp > this.staleThresholdMs
) {
this.writeLock();
this.isPunished = false;
return;
}
if (!this.isPunished) {
this.applyPunishment();
}
}
private onStorageEvent(e: StorageEvent): void {
if (e.key === this.lockKey && e.newValue) {
let other: { owner: string; timestamp: number };
try {
other = JSON.parse(e.newValue);
} catch (e) {
console.error("Failed to parse lock", e);
return;
}
if (other.owner !== this.tabId && !this.isPunished) {
this.applyPunishment();
}
}
}
private onBeforeUnload(): void {
const lock = this.readLock();
if (lock?.owner === this.tabId) {
localStorage.removeItem(this.lockKey);
}
}
private applyPunishment(): void {
this.isPunished = true;
this.punishmentCount++;
const delay = 10_000;
this.startPenaltyCallback(delay);
setTimeout(() => {
this.isPunished = false;
}, delay);
}
private writeLock(): void {
localStorage.setItem(
this.lockKey,
JSON.stringify({ owner: this.tabId, timestamp: Date.now() }),
);
}
private readLock(): { owner: string; timestamp: number } | null {
const raw = localStorage.getItem(this.lockKey);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (e) {
console.error("Failed to parse lock", raw, e);
return null;
}
}
}