mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 18:15:53 +00:00
feat(tab-lock): single-tab guard via BroadcastChannel
Two tabs of the app on the same origin both running auto-call would
double-draw, double-write `loto_master`, and overlap audio. New tab
now broadcasts a claim; old tab freezes itself with a fullscreen
overlay ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") and the
user can tap to take it back — handover broadcasts a fresh claim,
freezing the other tab in turn.
Mounted in +layout.svelte's onMount so the cleanup closes the channel
on HMR / route changes. No-op in legacy iOS Safari (≤15.4) without
BroadcastChannel — silently falls through to the prior behavior.
This commit is contained in:
1 parent
9e9c68bff6
commit
2f4efd1cab
4 files changed
+173
-1
No files matched your search
@@ -23,6 +23,7 @@
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
|
||||
| `src/lib/master-store.svelte.js` | Shared reactive `{called, remaining}` $state for the master deck, persisted to `loto_master`. Exports `masterState`, `loadMaster`, `saveMaster`, `startNewGame`, `drawNext`, `resetMaster`. Hydrated once via `+layout.svelte`'s `onMount` so player-side reads see the full history regardless of mount order. Replaced the single-slot `call-bus` to fix history-loss bugs (regen, reload, multi-tab) — see `plans/reports/code-reviewer-260430-2024-both-mode-consistency.md`. |
|
||||
| `src/lib/tab-lock.svelte.js` | Single-tab guard via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `tabLock.frozen = true`. `+layout.svelte` mounts it on boot and renders a fullscreen overlay banner ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") when frozen — tap calls `reclaimTab` which broadcasts back, freezing the other tab in turn. No-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
|
||||
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
|
||||
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
|
||||
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Single-tab guard for the Lô tô app. When the user opens the app in a
|
||||
* second tab, the second tab broadcasts a claim and the first tab
|
||||
* freezes itself until the user explicitly takes it back. Prevents
|
||||
* double auto-call intervals, double localStorage writers, and
|
||||
* overlapping audio across tabs of the same origin.
|
||||
*
|
||||
* No-op in browsers without `BroadcastChannel` (legacy iOS Safari).
|
||||
*
|
||||
* @module lib/tab-lock
|
||||
*/
|
||||
|
||||
const CHANNEL = "loto_tab_lock";
|
||||
|
||||
const TAB_ID =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `tab-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
||||
|
||||
export const tabLock = $state({
|
||||
/** True when another tab has taken over and this tab should pause. */
|
||||
frozen: false,
|
||||
});
|
||||
|
||||
/** @type {BroadcastChannel | null} */
|
||||
let bc = null;
|
||||
|
||||
/**
|
||||
* Start listening for tab-claim messages and immediately announce that
|
||||
* THIS tab is now active. Returns a cleanup that closes the channel.
|
||||
* Calling more than once is a no-op so layout HMR doesn't double-bind.
|
||||
*/
|
||||
export function startTabLock() {
|
||||
if (typeof BroadcastChannel === "undefined") return () => {};
|
||||
if (bc) return () => {};
|
||||
bc = new BroadcastChannel(CHANNEL);
|
||||
bc.onmessage = (e) => {
|
||||
if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
|
||||
tabLock.frozen = true;
|
||||
}
|
||||
};
|
||||
bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.close();
|
||||
bc = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-claim the lock from the frozen banner. Unfreezes this tab and
|
||||
* broadcasts a new claim — any other live tab freezes itself in turn.
|
||||
*/
|
||||
export function reclaimTab() {
|
||||
tabLock.frozen = false;
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** Minimal stub matching the BroadcastChannel surface we use. */
|
||||
class FakeBC {
|
||||
/** @type {Map<string, FakeBC[]>} */
|
||||
static channels = new Map();
|
||||
/** @param {string} name */
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.onmessage = null;
|
||||
this.closed = false;
|
||||
const peers = FakeBC.channels.get(name) ?? [];
|
||||
peers.push(this);
|
||||
FakeBC.channels.set(name, peers);
|
||||
}
|
||||
/** @param {any} data */
|
||||
postMessage(data) {
|
||||
if (this.closed) return;
|
||||
const peers = FakeBC.channels.get(this.name) ?? [];
|
||||
for (const p of peers) {
|
||||
if (p === this || p.closed || !p.onmessage) continue;
|
||||
p.onmessage({ data });
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
const peers = FakeBC.channels.get(this.name) ?? [];
|
||||
FakeBC.channels.set(
|
||||
this.name,
|
||||
peers.filter((p) => p !== this),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBC.channels.clear();
|
||||
vi.stubGlobal("BroadcastChannel", /** @type {any} */ (FakeBC));
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("tab-lock", () => {
|
||||
it("startTabLock returns no-op cleanup when BroadcastChannel is missing", async () => {
|
||||
vi.stubGlobal("BroadcastChannel", undefined);
|
||||
const mod = await import("./tab-lock.svelte.js?nobc");
|
||||
const cleanup = mod.startTabLock();
|
||||
expect(typeof cleanup).toBe("function");
|
||||
expect(mod.tabLock.frozen).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("second tab's claim freezes the first tab", async () => {
|
||||
const tabA = await import("./tab-lock.svelte.js?a");
|
||||
const tabB = await import("./tab-lock.svelte.js?b");
|
||||
tabA.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(false);
|
||||
tabB.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(true);
|
||||
expect(tabB.tabLock.frozen).toBe(false);
|
||||
});
|
||||
|
||||
it("reclaimTab unfreezes self and freezes the other", async () => {
|
||||
const tabA = await import("./tab-lock.svelte.js?ra-a");
|
||||
const tabB = await import("./tab-lock.svelte.js?ra-b");
|
||||
tabA.startTabLock();
|
||||
tabB.startTabLock();
|
||||
expect(tabA.tabLock.frozen).toBe(true);
|
||||
tabA.reclaimTab();
|
||||
expect(tabA.tabLock.frozen).toBe(false);
|
||||
expect(tabB.tabLock.frozen).toBe(true);
|
||||
});
|
||||
|
||||
it("startTabLock is idempotent within a single module", async () => {
|
||||
const tab = await import("./tab-lock.svelte.js?idem");
|
||||
const c1 = tab.startTabLock();
|
||||
const c2 = tab.startTabLock();
|
||||
expect(typeof c1).toBe("function");
|
||||
expect(typeof c2).toBe("function");
|
||||
c1();
|
||||
c2();
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,42 @@
|
||||
import "../app.css";
|
||||
import { loadMaster } from "$lib/master-store.svelte.js";
|
||||
import { loadSettings } from "$lib/settings-store.svelte.js";
|
||||
import {
|
||||
reclaimTab,
|
||||
startTabLock,
|
||||
tabLock,
|
||||
} from "$lib/tab-lock.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Hydrate global stores once so children mount with consistent state.
|
||||
// Master state must hydrate before PlayerBoard reads `masterState.called`
|
||||
// length — see the mode-mount race fix in the consistency refactor.
|
||||
// The tab-lock cleanup is returned so HMR / route changes close the
|
||||
// BroadcastChannel cleanly.
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
loadMaster();
|
||||
return startTabLock();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
{#if tabLock.frozen}
|
||||
<button
|
||||
type="button"
|
||||
onclick={reclaimTab}
|
||||
class="fixed inset-0 z-50 flex flex-col items-center justify-center
|
||||
bg-slate-900/95 text-white text-center px-6
|
||||
cursor-pointer focus:outline-none"
|
||||
aria-label="Loto đã mở ở tab khác. Nhấn để chuyển về tab này."
|
||||
>
|
||||
<p class="text-2xl sm:text-3xl font-bold mb-3">
|
||||
🏮 Loto đã mở ở tab khác
|
||||
</p>
|
||||
<p class="text-base text-slate-300 max-w-sm">
|
||||
Tap để chuyển về tab này. Tab kia sẽ tự dừng.
|
||||
</p>
|
||||
</button>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
Reference in new issue
Block a user