mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-11 03:13:40 +00:00
fix(web): service-worker registration, base-path handling, PWA verification, multi-tab protocol, update prompt, focus trap, iOS audio unlock, checkJs fixes
This commit is contained in:
1 parent
75bcbfeff0
commit
0593672afd
26 files changed
+1241
-222
No files matched your search
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* SvelteKit `paths.base` resolution, shared between `svelte.config.js`
|
||||
* (page routing / prerendering) and `vite.config.js` (PWA precache
|
||||
* manifest). Both consumers MUST agree on the base or the service
|
||||
* worker's `additionalManifestEntries` end up origin-rooted while the
|
||||
* app itself serves under a sub-path (e.g. GitHub Pages `/loto`),
|
||||
* causing every precached URL to 404 and the SW install to fail.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. NEXT_BASE_PATH — explicit override (escape hatch)
|
||||
* 2. codeserver dev → /absproxy/{port}
|
||||
* 3. BUILD_PROFILE=gh → /loto
|
||||
* 4. default (local dev / generic static host) → ""
|
||||
*
|
||||
* @module base-path
|
||||
*/
|
||||
import { loadEnv } from "vite";
|
||||
|
||||
/** @returns {string} */
|
||||
export function resolveBase() {
|
||||
if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
|
||||
if (process.env.VITE_DEV_PROFILE === "codeserver") {
|
||||
// .env.local lives outside process.env at config-eval time; loadEnv reads it.
|
||||
const env = loadEnv(
|
||||
process.env.NODE_ENV ?? "development",
|
||||
process.cwd(),
|
||||
"",
|
||||
);
|
||||
const port = env.CODESERVER_PORT ?? "3000";
|
||||
return `/absproxy/${port}`;
|
||||
}
|
||||
if (process.env.BUILD_PROFILE === "gh") return "/loto";
|
||||
return "";
|
||||
}
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
"checkJs": true
|
||||
}
|
||||
}
|
||||
Generated
+104
-112
@@ -20,6 +20,7 @@
|
||||
"globals": "^17",
|
||||
"happy-dom": "20.11.0",
|
||||
"svelte": "^5",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
@@ -1903,9 +1904,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2025,9 +2023,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2045,9 +2040,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2065,9 +2057,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2085,9 +2074,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2105,9 +2091,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2125,9 +2108,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2414,9 +2394,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2431,9 +2408,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2448,9 +2422,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2465,9 +2436,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2482,9 +2450,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2499,9 +2464,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2516,9 +2478,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2533,9 +2492,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2550,9 +2506,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2567,9 +2520,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2584,9 +2534,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2601,9 +2548,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2618,9 +2562,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2780,6 +2721,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/load-config": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
|
||||
"integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz",
|
||||
@@ -2943,9 +2894,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2963,9 +2911,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2983,9 +2928,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3003,9 +2945,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3712,6 +3651,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -5822,9 +5777,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5846,9 +5798,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5869,9 +5818,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5893,9 +5839,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5959,9 +5902,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6071,6 +6011,16 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -6519,6 +6469,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6732,6 +6696,19 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mri": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||
@@ -7233,6 +7210,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check": {
|
||||
"version": "4.7.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
|
||||
"integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@sveltejs/load-config": "^0.2.3",
|
||||
"chokidar": "^4.0.1",
|
||||
"fdir": "^6.2.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sade": "^1.7.4"
|
||||
},
|
||||
"bin": {
|
||||
"svelte-check": "bin/svelte-check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"typescript": "^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-eslint-parser": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.1.tgz",
|
||||
@@ -7539,6 +7541,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -7943,9 +7960,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7967,9 +7981,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7991,9 +8002,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8591,22 +8599,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"extraneous": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"build:gh": "BUILD_PROFILE=gh vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"check": "svelte-check --tsconfig ./jsconfig.json",
|
||||
"verify:pwa": "node scripts/verify-pwa-build.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
@@ -34,6 +36,7 @@
|
||||
"globals": "^17",
|
||||
"happy-dom": "20.11.0",
|
||||
"svelte": "^5",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build-time regression guard for the PWA layer.
|
||||
*
|
||||
* `adapter-static` renders page HTML during `adapt()`, which runs AFTER the
|
||||
* PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets to inject
|
||||
* its own registration tag into the built HTML, and a plain grep on
|
||||
* `build/index.html` proves nothing either way. `+layout.svelte` registers
|
||||
* the service worker itself via a dynamically-imported `virtual:pwa-register`
|
||||
* chunk instead; this script asserts that wiring survived the build rather
|
||||
* than trusting a future refactor not to quietly drop it.
|
||||
*
|
||||
* Also asserts the audio precache manifest in `build/sw.js` is prefixed
|
||||
* with the SAME base path SvelteKit itself resolved for this build — a
|
||||
* mismatch here 404s every precached clip and fails the SW `install` event
|
||||
* on that origin (this exact bug shipped once already).
|
||||
*
|
||||
* Usage: run AFTER `npm run build` / `npm run build:gh` (see package.json's
|
||||
* `verify:pwa` script), with the same `BUILD_PROFILE`/`NEXT_BASE_PATH` env
|
||||
* the build used.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveBase } from "../base-path.js";
|
||||
|
||||
const BUILD_DIR = "build";
|
||||
let ok = true;
|
||||
|
||||
/** @param {string} msg */
|
||||
function fail(msg) {
|
||||
console.error(`✗ ${msg}`);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
/** @param {string} dir */
|
||||
function walkJsFiles(dir) {
|
||||
/** @type {string[]} */
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) files.push(...walkJsFiles(full));
|
||||
else if (entry.endsWith(".js")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// --- sw.js was actually generated ---------------------------------------
|
||||
const swPath = join(BUILD_DIR, "sw.js");
|
||||
let swSrc = "";
|
||||
try {
|
||||
swSrc = readFileSync(swPath, "utf8");
|
||||
} catch {
|
||||
fail(`${swPath} not found — did the PWA plugin run?`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- C1: some built client chunk actually registers it -------------------
|
||||
const appDir = join(BUILD_DIR, "_app");
|
||||
const jsFiles = walkJsFiles(appDir);
|
||||
const registersSW = jsFiles.some((f) => {
|
||||
const src = readFileSync(f, "utf8");
|
||||
return src.includes("serviceWorker") && src.includes("sw.js");
|
||||
});
|
||||
if (!registersSW) {
|
||||
fail(
|
||||
`No built JS chunk under ${appDir} registers the service worker ` +
|
||||
'(expected a "serviceWorker" + "sw.js" reference reachable from the ' +
|
||||
"virtual:pwa-register import in +layout.svelte).",
|
||||
);
|
||||
}
|
||||
|
||||
// --- H1: precached audio entries carry the resolved base path -----------
|
||||
const base = resolveBase();
|
||||
const audioUrls = [...swSrc.matchAll(/"([^"]*\/audio\/[^"]*\.mp3)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
if (audioUrls.length === 0) {
|
||||
fail(`${swPath} has no precached audio entries — expected the default voice's clips.`);
|
||||
} else {
|
||||
const badUrls = audioUrls.filter((u) => !u.startsWith(`${base}/audio/`));
|
||||
if (badUrls.length > 0) {
|
||||
fail(
|
||||
`${badUrls.length}/${audioUrls.length} precached audio URL(s) don't ` +
|
||||
`start with "${base}/audio/" (resolved base "${base}") — e.g. "${badUrls[0]}". ` +
|
||||
"additionalManifestEntries in vite.config.js and svelte.config.js's " +
|
||||
"paths.base have drifted apart again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) process.exit(1);
|
||||
console.log(
|
||||
`✓ PWA build check passed — base "${base}", ${jsFiles.length} client JS files, ` +
|
||||
`${audioUrls.length} precached audio entries.`,
|
||||
);
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts for what these interfaces
|
||||
// are for. None are used yet — this file's real job right now is the
|
||||
// triple-slash reference above, which supplies the `virtual:pwa-register`
|
||||
// module types that `+layout.svelte` imports.
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -9,6 +9,44 @@
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<!-- Blocking pre-paint theme script. `loadSettings()` in +layout.svelte
|
||||
(client-only, runs in onMount) is the authoritative pass and keeps
|
||||
its own validation; this duplicate, defensively-guarded read only
|
||||
has to get the FIRST paint right so dark-mode users don't see a
|
||||
full-page light flash before hydration. Runs before app.css's
|
||||
<link> (injected by %sveltekit.head% further down) so `.dark`/the
|
||||
custom property are already set by the time styles apply. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem("loto_settings");
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var theme =
|
||||
parsed && typeof parsed.theme === "string" ? parsed.theme : "auto";
|
||||
var dark =
|
||||
theme === "dark" ||
|
||||
(theme !== "light" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
var color =
|
||||
parsed &&
|
||||
typeof parsed.emptyCellColor === "string" &&
|
||||
/^#[0-9a-fA-F]{6}$/.test(parsed.emptyCellColor)
|
||||
? parsed.emptyCellColor
|
||||
: null;
|
||||
if (color) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--empty-cell-bg",
|
||||
color,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
/* private mode / corrupt JSON — fall back to light + default
|
||||
color, same as loadSettings()'s own catch. */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<title>Lô tô — Hội chợ TN1</title>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
} from "$lib/master-store.svelte.js";
|
||||
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||
import { cancelPlayback, playNumber, unlockAudio } from "$lib/voice.js";
|
||||
import { setWakeLock } from "$lib/wake-lock.js";
|
||||
|
||||
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
|
||||
@@ -67,10 +67,13 @@
|
||||
// against an empty `masterState.called` and replay the back-history
|
||||
// when MasterPanel later loads.
|
||||
$effect(() => {
|
||||
// Subscribe to both arrays so any mutation re-saves.
|
||||
// Subscribe to both arrays so any mutation re-saves. Gated on
|
||||
// `hydrated` so a mount can never persist stale in-memory state before
|
||||
// `loadMaster()` has (re-)run — see `claimActiveTab()` in
|
||||
// active-tab.svelte.js for the reclaim-a-frozen-tab case this guards.
|
||||
masterState.called;
|
||||
masterState.remaining;
|
||||
saveMaster();
|
||||
if (masterState.hydrated) saveMaster();
|
||||
});
|
||||
|
||||
// Map number -> 1-based draw order for fast Kinh! verification.
|
||||
@@ -150,6 +153,11 @@
|
||||
}
|
||||
|
||||
function handleDrawNext() {
|
||||
// "Xổ số" is a real click — prime the shared <audio> element here so
|
||||
// later auto-call playback (fired from a setInterval callback, which
|
||||
// iOS does not treat as a user gesture) is already unlocked. No-op
|
||||
// after the first call.
|
||||
unlockAudio();
|
||||
const next = drawNext();
|
||||
if (next === null) return;
|
||||
scrollOnNextDraw = true;
|
||||
@@ -159,6 +167,8 @@
|
||||
|
||||
function toggleAuto() {
|
||||
if (masterState.remaining.length === 0) return;
|
||||
// "Bắt đầu" is a real click — see handleDrawNext's comment.
|
||||
unlockAudio();
|
||||
autoRunning = !autoRunning;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
saveGrid,
|
||||
saveManualUnticks,
|
||||
} from "$lib/game-logic.js";
|
||||
import { focusTrap } from "$lib/focus-trap.js";
|
||||
import { masterState } from "$lib/master-store.svelte.js";
|
||||
import { pushOverlay } from "$lib/overlay-history.js";
|
||||
import { applyMasterCalls } from "$lib/player-auto-cross.js";
|
||||
@@ -39,6 +40,11 @@
|
||||
let congratsRow = $state(-1);
|
||||
let celebrationTier = $state(/** @type {1 | 2} */ (1));
|
||||
let toast = $state(/** @type {string | null} */ (null));
|
||||
// Bumped on every showToast() call so `{#key toastId}` force-remounts the
|
||||
// toast node — otherwise a 2nd "Chờ" within 5s reuses the same DOM node,
|
||||
// the CSS fade-out animation never restarts, and the node (still
|
||||
// pointer-events-auto) sits invisible-but-clickable over the board.
|
||||
let toastId = $state(0);
|
||||
|
||||
// 12 confetti emoji indices. Stable per-render — values don't matter,
|
||||
// only the count drives the {#each}.
|
||||
@@ -68,24 +74,29 @@
|
||||
// player crossed in both mode per locked product decision.
|
||||
let prevCalledLen = $state(0);
|
||||
|
||||
// Memoized per-row completeness — avoid 81×/render isRowComplete calls
|
||||
const rowCompleteness = $derived(
|
||||
grid && crossed.length
|
||||
? grid.map((_, r) => isRowComplete(grid, crossed, r))
|
||||
: []
|
||||
);
|
||||
// Memoized per-row completeness — avoid 81×/render isRowComplete calls.
|
||||
// Reads `grid` into a local `g` first: TS can't carry the null-narrowing
|
||||
// from the ternary condition into the `.map()` callback closure for a
|
||||
// mutable outer binding, even though nothing reassigns `grid` mid-eval.
|
||||
const rowCompleteness = $derived.by(() => {
|
||||
const g = grid;
|
||||
return g && crossed.length
|
||||
? g.map((_, r) => isRowComplete(g, crossed, r))
|
||||
: [];
|
||||
});
|
||||
|
||||
// Per-row "Chờ" flag — one cell away AND not already complete. Reuses
|
||||
// rowCompleteness so we don't re-walk the row twice.
|
||||
const waitingRows = $derived(
|
||||
grid && crossed.length
|
||||
? grid.map(
|
||||
const waitingRows = $derived.by(() => {
|
||||
const g = grid;
|
||||
return g && crossed.length
|
||||
? g.map(
|
||||
(_, r) =>
|
||||
!rowCompleteness[r] &&
|
||||
getWaitingNumber(grid, crossed, r) !== null,
|
||||
getWaitingNumber(g, crossed, r) !== null,
|
||||
)
|
||||
: []
|
||||
);
|
||||
: [];
|
||||
});
|
||||
|
||||
// "row,col" keys of cells holding the awaited number for any waiting
|
||||
// row. Drives the per-cell pulse animation so the user can spot which
|
||||
@@ -117,6 +128,7 @@
|
||||
function showToast(msg) {
|
||||
dismissToast();
|
||||
toast = msg;
|
||||
toastId++;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
@@ -160,7 +172,11 @@
|
||||
|
||||
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
|
||||
$effect(() => {
|
||||
if (!grid || crossed.length === 0) return;
|
||||
// Captured into `g` so the null-narrowing survives the `.some()`
|
||||
// callback below (TS drops narrowing of a mutable outer binding once
|
||||
// it's referenced inside a nested function expression).
|
||||
const g = grid;
|
||||
if (!g || crossed.length === 0) return;
|
||||
|
||||
// The master takes over announcer duties in "both" mode, so its
|
||||
// voice flag also drives Chờ/Kinh. Solo players keep their own flag.
|
||||
@@ -168,9 +184,16 @@
|
||||
settings.voiceEnabledPlayer ||
|
||||
(settings.voiceEnabledMaster && settings.mode === "both");
|
||||
|
||||
// Set when pass 1 fires playBingo() this run, so pass 2 knows not to
|
||||
// cut it off — playWaiting()/playBingo() both start with
|
||||
// cancelPlayback(), so an unconditional pass-2 call would silence the
|
||||
// win announcement in the exact "completed one row, one away on
|
||||
// another" run where it matters most.
|
||||
let announcedBingo = false;
|
||||
|
||||
// Pass 1: at most one bingo popup per render
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
|
||||
for (let i = 0; i < g.length; i++) {
|
||||
if (!celebratedRows.has(i) && isRowComplete(g, crossed, i)) {
|
||||
celebratedRows.add(i);
|
||||
notifiedWaitingRows.add(i);
|
||||
congratsRow = i + 1;
|
||||
@@ -178,29 +201,32 @@
|
||||
// Tier 2 confetti: 2nd bingo, OR 1st bingo while another row
|
||||
// is one cell away. The previous "3+ bingos" threshold rarely
|
||||
// fired on a 9-row card so most wins felt under-celebrated.
|
||||
const hasActiveCho = grid.some(
|
||||
const hasActiveCho = g.some(
|
||||
(_, r) =>
|
||||
!celebratedRows.has(r) &&
|
||||
getWaitingNumber(grid, crossed, r) !== null,
|
||||
getWaitingNumber(g, crossed, r) !== null,
|
||||
);
|
||||
celebrationTier =
|
||||
celebratedRows.size >= 2 ||
|
||||
(celebratedRows.size >= 1 && hasActiveCho)
|
||||
? 2
|
||||
: 1;
|
||||
if (announce) playBingo();
|
||||
if (announce) {
|
||||
playBingo();
|
||||
announcedBingo = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: update waiting state for every non-celebrated row
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
for (let i = 0; i < g.length; i++) {
|
||||
if (celebratedRows.has(i)) continue;
|
||||
const waitNum = getWaitingNumber(grid, crossed, i);
|
||||
const waitNum = getWaitingNumber(g, crossed, i);
|
||||
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
|
||||
notifiedWaitingRows.add(i);
|
||||
showToast(`Chờ ${waitNum}`);
|
||||
if (announce) playWaiting(waitNum);
|
||||
if (announce && !announcedBingo) playWaiting(waitNum);
|
||||
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
|
||||
notifiedWaitingRows.delete(i);
|
||||
}
|
||||
@@ -494,7 +520,11 @@
|
||||
<!-- Centered overlay over the card. Lower opacity + pointer-events
|
||||
routing keeps cell taps unblocked while the chip is visible.
|
||||
Cell-pulse on the awaited number is the persistent visual
|
||||
anchor; this chip is the textual fresh-event cue. -->
|
||||
anchor; this chip is the textual fresh-event cue.
|
||||
{#key toastId} forces a full remount on every showToast() call
|
||||
so the 5s CSS fade-out always restarts from a fresh node — see
|
||||
toastId's declaration for why a reused node was a real bug. -->
|
||||
{#key toastId}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -509,6 +539,7 @@
|
||||
{toast}
|
||||
</button>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -568,6 +599,7 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="congrats-title"
|
||||
use:focusTrap
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { VOICES } from "$lib/audio-manifest.js";
|
||||
import { focusTrap } from "$lib/focus-trap.js";
|
||||
import { pushOverlay } from "$lib/overlay-history.js";
|
||||
import {
|
||||
BOARD_TEXT_SCALES,
|
||||
@@ -177,6 +178,7 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
use:focusTrap
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<!-- Backdrop: click-to-close, hidden from a11y tree. Window-level
|
||||
@@ -261,7 +263,11 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#snippet switchRow(label, isOn, onToggle)}
|
||||
{#snippet switchRow(
|
||||
/** @type {string} */ label,
|
||||
/** @type {boolean} */ isOn,
|
||||
/** @type {() => void} */ onToggle,
|
||||
)}
|
||||
<!-- Whole row is the switch — role/tabindex/click here, not on the
|
||||
inner pill, so tapping the label text also toggles. -->
|
||||
<div
|
||||
|
||||
@@ -27,6 +27,24 @@ export const activeTab = $state({
|
||||
/** @type {BroadcastChannel | null} */
|
||||
let bc = null;
|
||||
|
||||
/**
|
||||
* `Date.now()` of this tab's most recent claim (mount or {@link claimActiveTab}).
|
||||
* Used to resolve simultaneous claims: whichever tab claimed last stays
|
||||
* active, so two tabs opened in the same instant don't both freeze each
|
||||
* other. Equal timestamps (clock-resolution collisions) tie-break on
|
||||
* {@link TAB_ID} so exactly one tab wins deterministically.
|
||||
* @type {number}
|
||||
*/
|
||||
let myClaimTs = 0;
|
||||
|
||||
/**
|
||||
* Broadcast a claim carrying this tab's id and current claim timestamp.
|
||||
*/
|
||||
function postClaim() {
|
||||
myClaimTs = Date.now();
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID, ts: myClaimTs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the channel, listen for peer claims, and announce that THIS tab
|
||||
* is now the active one. Returns a cleanup that closes the channel.
|
||||
@@ -37,11 +55,29 @@ export function watchActiveTab() {
|
||||
if (bc) return () => {};
|
||||
bc = new BroadcastChannel(CHANNEL);
|
||||
bc.onmessage = (e) => {
|
||||
if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
|
||||
if (e.data?.type !== "claim" || e.data.id === TAB_ID) return;
|
||||
// A legacy peer (pre-echo build) broadcasts {type, id} with no `ts`.
|
||||
// Treat that as beating any real timestamp so this tab freezes instead
|
||||
// of silently ignoring it (matches the old always-freeze behavior).
|
||||
const peerTs = typeof e.data.ts === "number" ? e.data.ts : Infinity;
|
||||
// Newest claim wins. Ignore a peer claim that is older than this tab's
|
||||
// own claim; on an exact tie (same millisecond) the lexicographically
|
||||
// greater id wins so exactly one side freezes.
|
||||
const peerWins =
|
||||
peerTs > myClaimTs || (peerTs === myClaimTs && e.data.id > TAB_ID);
|
||||
if (peerWins) {
|
||||
activeTab.inactive = true;
|
||||
} else if (!activeTab.inactive && bc) {
|
||||
// This tab wins but the peer doesn't know it lost yet — it set
|
||||
// itself active locally before broadcasting. Echo this tab's own
|
||||
// winning claim so the peer re-evaluates and freezes. This can't
|
||||
// loop: the echo carries this tab's unchanged (ts, id), so the peer
|
||||
// strictly loses it and freezes without ever echoing back (a frozen
|
||||
// tab never claims).
|
||||
bc.postMessage({ type: "claim", id: TAB_ID, ts: myClaimTs });
|
||||
}
|
||||
};
|
||||
bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
postClaim();
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.close();
|
||||
@@ -52,10 +88,11 @@ export function watchActiveTab() {
|
||||
|
||||
/**
|
||||
* Take active status back from the inactive banner. Marks this tab
|
||||
* active locally and broadcasts a claim — any peer freezes itself in
|
||||
* turn so the handover is symmetric.
|
||||
* active locally, records a fresh claim timestamp (so a stale peer claim
|
||||
* can't re-freeze it), and broadcasts the claim — any peer freezes itself
|
||||
* in turn so the handover is symmetric.
|
||||
*/
|
||||
export function claimActiveTab() {
|
||||
activeTab.inactive = false;
|
||||
if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
|
||||
postClaim();
|
||||
}
|
||||
@@ -9,20 +9,33 @@ class FakeBC {
|
||||
/** @param {string} name */
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
/** @type {((e: { data: any }) => void) | null} */
|
||||
this.onmessage = null;
|
||||
this.closed = false;
|
||||
const peers = FakeBC.channels.get(name) ?? [];
|
||||
peers.push(this);
|
||||
FakeBC.channels.set(name, peers);
|
||||
}
|
||||
/** @param {any} data */
|
||||
/**
|
||||
* Defers delivery to a microtask and re-reads the peer list at delivery
|
||||
* time (not at call time), so two channels opened in the same tick — the
|
||||
* "two tabs mount simultaneously" case under test — both see each other's
|
||||
* claim. This matches real `BroadcastChannel`, which queues a task and
|
||||
* resolves the receiving set when that task runs, not when postMessage
|
||||
* is called.
|
||||
* @param {any} data
|
||||
*/
|
||||
postMessage(data) {
|
||||
if (this.closed) return;
|
||||
const peers = FakeBC.channels.get(this.name) ?? [];
|
||||
const name = this.name;
|
||||
const sender = this;
|
||||
queueMicrotask(() => {
|
||||
const peers = FakeBC.channels.get(name) ?? [];
|
||||
for (const p of peers) {
|
||||
if (p === this || p.closed || !p.onmessage) continue;
|
||||
if (p === sender || p.closed || !p.onmessage) continue;
|
||||
p.onmessage({ data });
|
||||
}
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
@@ -34,19 +47,37 @@ class FakeBC {
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush FakeBC's queued microtask message deliveries. */
|
||||
async function deliverBcMessages() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBC.channels.clear();
|
||||
vi.stubGlobal("BroadcastChannel", /** @type {any} */ (FakeBC));
|
||||
vi.resetModules();
|
||||
// Default: a strictly increasing "clock" so claim order matches call
|
||||
// order without every test having to stub a value. Individual tests
|
||||
// override a single call with `mockReturnValueOnce` when they need an
|
||||
// exact (e.g. equal or stale) timestamp.
|
||||
let clock = 0;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => {
|
||||
clock += 1;
|
||||
return clock;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("active-tab", () => {
|
||||
it("watchActiveTab returns no-op cleanup when BroadcastChannel is missing", async () => {
|
||||
vi.stubGlobal("BroadcastChannel", undefined);
|
||||
// @ts-expect-error - "?tag" is a real relative import at runtime (Vite/
|
||||
// Vitest cache-busting for module-singleton isolation between tests);
|
||||
// TS can't resolve the literal query-string specifier.
|
||||
const mod = await import("./active-tab.svelte.js?nobc");
|
||||
const cleanup = mod.watchActiveTab();
|
||||
expect(typeof cleanup).toBe("function");
|
||||
@@ -55,27 +86,35 @@ describe("active-tab", () => {
|
||||
});
|
||||
|
||||
it("second tab's claim marks the first tab inactive", async () => {
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabA = await import("./active-tab.svelte.js?a");
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabB = await import("./active-tab.svelte.js?b");
|
||||
tabA.watchActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
tabB.watchActiveTab();
|
||||
await deliverBcMessages();
|
||||
expect(tabA.activeTab.inactive).toBe(true);
|
||||
expect(tabB.activeTab.inactive).toBe(false);
|
||||
});
|
||||
|
||||
it("claimActiveTab reactivates self and inactivates the other", async () => {
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabA = await import("./active-tab.svelte.js?ra-a");
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabB = await import("./active-tab.svelte.js?ra-b");
|
||||
tabA.watchActiveTab();
|
||||
tabB.watchActiveTab();
|
||||
await deliverBcMessages();
|
||||
expect(tabA.activeTab.inactive).toBe(true);
|
||||
tabA.claimActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
await deliverBcMessages();
|
||||
expect(tabB.activeTab.inactive).toBe(true);
|
||||
});
|
||||
|
||||
it("watchActiveTab is idempotent within a single module", async () => {
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tab = await import("./active-tab.svelte.js?idem");
|
||||
const c1 = tab.watchActiveTab();
|
||||
const c2 = tab.watchActiveTab();
|
||||
@@ -84,4 +123,168 @@ describe("active-tab", () => {
|
||||
c1();
|
||||
c2();
|
||||
});
|
||||
|
||||
it("simultaneous mount with unequal ts: older claim freezes, newer stays active", async () => {
|
||||
vi.mocked(Date.now).mockReturnValueOnce(1000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabOlder = await import("./active-tab.svelte.js?ts-older");
|
||||
tabOlder.watchActiveTab();
|
||||
|
||||
vi.mocked(Date.now).mockReturnValueOnce(2000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabNewer = await import("./active-tab.svelte.js?ts-newer");
|
||||
tabNewer.watchActiveTab();
|
||||
await deliverBcMessages();
|
||||
|
||||
expect(tabOlder.activeTab.inactive).toBe(true);
|
||||
expect(tabNewer.activeTab.inactive).toBe(false);
|
||||
});
|
||||
|
||||
it("simultaneous mount with equal ts: exactly one tab wins via id tie-break", async () => {
|
||||
const idLow = "11111111-1111-4111-8111-111111111111";
|
||||
const idHigh = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
|
||||
/** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
|
||||
idLow
|
||||
),
|
||||
);
|
||||
vi.mocked(Date.now).mockReturnValueOnce(5000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabLow = await import("./active-tab.svelte.js?tie-low");
|
||||
tabLow.watchActiveTab();
|
||||
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
|
||||
/** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
|
||||
idHigh
|
||||
),
|
||||
);
|
||||
vi.mocked(Date.now).mockReturnValueOnce(5000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabHigh = await import("./active-tab.svelte.js?tie-high");
|
||||
tabHigh.watchActiveTab();
|
||||
await deliverBcMessages();
|
||||
|
||||
// Equal claim ts: the lexicographically greater id wins, regardless of
|
||||
// mount order — both tabs receive each other's claim and independently
|
||||
// reach the same conclusion.
|
||||
expect(tabLow.activeTab.inactive).toBe(true);
|
||||
expect(tabHigh.activeTab.inactive).toBe(false);
|
||||
});
|
||||
|
||||
it("reclaim updates myClaimTs so a stale peer claim can't re-freeze the reclaimed tab", async () => {
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabA = await import("./active-tab.svelte.js?stale-a");
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabB = await import("./active-tab.svelte.js?stale-b");
|
||||
tabA.watchActiveTab();
|
||||
tabB.watchActiveTab();
|
||||
await deliverBcMessages();
|
||||
expect(tabA.activeTab.inactive).toBe(true);
|
||||
|
||||
// A reclaims — this records a fresh, later claim ts and reactivates
|
||||
// immediately.
|
||||
tabA.claimActiveTab();
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
await deliverBcMessages();
|
||||
expect(tabB.activeTab.inactive).toBe(true);
|
||||
|
||||
// A stale claim from B (older than A's reclaim — e.g. a delayed
|
||||
// message) must not re-freeze A. A ignores it as a loss for B and
|
||||
// echoes its own winning claim back, so B (the stale claimer) freezes
|
||||
// instead of staying active locally — exactly one tab ends up active.
|
||||
vi.mocked(Date.now).mockReturnValueOnce(1);
|
||||
tabB.claimActiveTab();
|
||||
await deliverBcMessages(); // B's stale claim reaches A; A echoes
|
||||
await deliverBcMessages(); // A's echo reaches B; B freezes
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
expect(tabB.activeTab.inactive).toBe(true);
|
||||
});
|
||||
|
||||
it("a losing claimer is echoed at and freezes instead of leaving both tabs active", async () => {
|
||||
// Reproduces the asymmetric-delivery race: tabA's own initial claim is
|
||||
// flushed (delivered to nobody) before tabB even mounts, so tabB never
|
||||
// sees it — matching real BroadcastChannel, which doesn't replay past
|
||||
// messages to a channel opened later. Both claims then carry the same
|
||||
// ts, so only the id tie-break decides the winner, and only tabA (the
|
||||
// one still open when tabB's claim arrives) evaluates it.
|
||||
const idLow = "11111111-1111-4111-8111-111111111111";
|
||||
const idHigh = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
|
||||
/** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
|
||||
idHigh
|
||||
),
|
||||
);
|
||||
vi.mocked(Date.now).mockReturnValueOnce(3000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabA = await import("./active-tab.svelte.js?echo-a");
|
||||
tabA.watchActiveTab();
|
||||
await deliverBcMessages(); // tabA's own claim is flushed with no peers
|
||||
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
|
||||
/** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
|
||||
idLow
|
||||
),
|
||||
);
|
||||
vi.mocked(Date.now).mockReturnValueOnce(3000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabB = await import("./active-tab.svelte.js?echo-b");
|
||||
tabB.watchActiveTab();
|
||||
await deliverBcMessages(); // tabB's claim reaches only tabA
|
||||
await deliverBcMessages(); // tabA's echo (it strictly wins the tie) reaches tabB
|
||||
|
||||
// tabA has the greater id, so on the tie it wins and never freezes;
|
||||
// without the echo fix tabB would also stay active (both active).
|
||||
expect(tabA.activeTab.inactive).toBe(false);
|
||||
expect(tabB.activeTab.inactive).toBe(true);
|
||||
});
|
||||
|
||||
it("the echo does not loop: message traffic settles after one echo round", async () => {
|
||||
const postSpy = vi.spyOn(FakeBC.prototype, "postMessage");
|
||||
|
||||
vi.mocked(Date.now).mockReturnValueOnce(2000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabWinner = await import("./active-tab.svelte.js?loop-winner");
|
||||
tabWinner.watchActiveTab();
|
||||
await deliverBcMessages(); // winner's own claim, delivered to nobody yet
|
||||
|
||||
vi.mocked(Date.now).mockReturnValueOnce(1000);
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tabLoser = await import("./active-tab.svelte.js?loop-loser");
|
||||
tabLoser.watchActiveTab();
|
||||
await deliverBcMessages(); // loser's stale claim reaches winner; winner echoes
|
||||
await deliverBcMessages(); // winner's echo reaches loser; loser freezes
|
||||
|
||||
expect(tabWinner.activeTab.inactive).toBe(false);
|
||||
expect(tabLoser.activeTab.inactive).toBe(true);
|
||||
|
||||
// Winner's initial claim + loser's stale claim + one echo from the
|
||||
// winner. The loser never echoes back (it's frozen and always
|
||||
// strictly loses the winner's unchanged ts), so traffic must stop here.
|
||||
const settledCount = postSpy.mock.calls.length;
|
||||
expect(settledCount).toBe(3);
|
||||
|
||||
// Flushing further must not produce any more messages.
|
||||
await deliverBcMessages();
|
||||
await deliverBcMessages();
|
||||
expect(postSpy.mock.calls.length).toBe(settledCount);
|
||||
});
|
||||
|
||||
it("a peer claim with no ts (legacy tab) always wins, matching old always-freeze behavior", async () => {
|
||||
// @ts-expect-error - "?tag" cache-busting import, see the note above.
|
||||
const tab = await import("./active-tab.svelte.js?legacy");
|
||||
tab.watchActiveTab();
|
||||
await deliverBcMessages(); // this tab's own claim, delivered to nobody
|
||||
|
||||
// A pre-fix peer broadcasts {type, id} with no `ts` at all.
|
||||
const legacyPeer = new /** @type {any} */ (BroadcastChannel)(
|
||||
"loto_active_tab",
|
||||
);
|
||||
legacyPeer.postMessage({ type: "claim", id: "legacy-peer" });
|
||||
await deliverBcMessages();
|
||||
|
||||
expect(tab.activeTab.inactive).toBe(true);
|
||||
legacyPeer.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Svelte action for a modal `role="dialog" aria-modal="true"` container:
|
||||
* moves focus into the dialog on mount, traps Tab/Shift+Tab among its
|
||||
* focusable descendants, restores focus to whatever was focused before
|
||||
* the dialog opened, and locks body scroll for as long as any trap is
|
||||
* active (reference-counted so two stacked dialogs can't fight over the
|
||||
* lock or restore the wrong `overflow` value).
|
||||
*
|
||||
* Usage: `<div role="dialog" aria-modal="true" use:focusTrap>…</div>`.
|
||||
* Escape-to-close is NOT handled here — callers already wire their own
|
||||
* window-level Escape listener (see PlayerBoard/SettingsButton) so the
|
||||
* Android back-gesture sentinel keeps working unchanged.
|
||||
*
|
||||
* @module lib/focus-trap
|
||||
*/
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
let lockCount = 0;
|
||||
let previousBodyOverflow = "";
|
||||
|
||||
function lockBodyScroll() {
|
||||
if (lockCount === 0) {
|
||||
previousBodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
lockCount++;
|
||||
}
|
||||
|
||||
function unlockBodyScroll() {
|
||||
lockCount = Math.max(0, lockCount - 1);
|
||||
if (lockCount === 0) document.body.style.overflow = previousBodyOverflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
function focusables(container) {
|
||||
return /** @type {HTMLElement[]} */ (
|
||||
Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => el instanceof HTMLElement && el.offsetParent !== null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @returns {{ destroy(): void }}
|
||||
*/
|
||||
export function focusTrap(node) {
|
||||
const previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1");
|
||||
// Focus the container itself rather than a descendant — predictable
|
||||
// regardless of dialog layout, and screen readers still announce
|
||||
// `aria-labelledby`/`aria-label` on focus.
|
||||
node.focus();
|
||||
|
||||
lockBodyScroll();
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
function onKeydown(e) {
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables(node);
|
||||
if (items.length === 0) {
|
||||
// Nothing tabbable inside — keep focus pinned on the dialog so Tab
|
||||
// can't escape to the (visually covered but still-in-DOM) page.
|
||||
e.preventDefault();
|
||||
node.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || !node.contains(active))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !node.contains(active))) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
node.addEventListener("keydown", onKeydown);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("keydown", onKeydown);
|
||||
unlockBodyScroll();
|
||||
previouslyFocused?.focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { focusTrap } from "./focus-trap.js";
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
let outsideButton;
|
||||
/** @type {HTMLDivElement} */
|
||||
let dialog;
|
||||
/** @type {HTMLButtonElement} */
|
||||
let first;
|
||||
/** @type {HTMLButtonElement} */
|
||||
let last;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
document.body.style.overflow = "";
|
||||
|
||||
outsideButton = document.createElement("button");
|
||||
outsideButton.textContent = "outside";
|
||||
document.body.appendChild(outsideButton);
|
||||
outsideButton.focus();
|
||||
|
||||
dialog = document.createElement("div");
|
||||
first = document.createElement("button");
|
||||
first.textContent = "first";
|
||||
const middle = document.createElement("button");
|
||||
middle.textContent = "middle";
|
||||
last = document.createElement("button");
|
||||
last.textContent = "last";
|
||||
dialog.append(first, middle, last);
|
||||
document.body.appendChild(dialog);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
/** @param {HTMLElement} target @param {boolean} [shiftKey] */
|
||||
function tab(target, shiftKey = false) {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
shiftKey,
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("focus-trap (M4)", () => {
|
||||
it("focuses the dialog container on mount", () => {
|
||||
const trap = focusTrap(dialog);
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
expect(dialog.getAttribute("tabindex")).toBe("-1");
|
||||
trap.destroy();
|
||||
});
|
||||
|
||||
it("wraps Tab from the last focusable back to the first", () => {
|
||||
const trap = focusTrap(dialog);
|
||||
last.focus();
|
||||
const event = tab(dialog);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(first);
|
||||
trap.destroy();
|
||||
});
|
||||
|
||||
it("wraps Shift+Tab from the first focusable back to the last", () => {
|
||||
const trap = focusTrap(dialog);
|
||||
first.focus();
|
||||
const event = tab(dialog, true);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(last);
|
||||
trap.destroy();
|
||||
});
|
||||
|
||||
it("locks body scroll while active and restores it on destroy", () => {
|
||||
document.body.style.overflow = "auto";
|
||||
const trap = focusTrap(dialog);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
trap.destroy();
|
||||
expect(document.body.style.overflow).toBe("auto");
|
||||
});
|
||||
|
||||
it("restores focus to the previously-focused element on destroy", () => {
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
const trap = focusTrap(dialog);
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
trap.destroy();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
});
|
||||
|
||||
it("does not fight over the body scroll lock across two stacked traps", () => {
|
||||
const outer = focusTrap(dialog);
|
||||
const dialog2 = document.createElement("div");
|
||||
const btn2 = document.createElement("button");
|
||||
dialog2.appendChild(btn2);
|
||||
document.body.appendChild(dialog2);
|
||||
const inner = focusTrap(dialog2);
|
||||
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
inner.destroy();
|
||||
// Outer trap is still active — lock must stay held.
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
outer.destroy();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ export const masterState = $state({
|
||||
called: [],
|
||||
/** @type {number[]} */
|
||||
remaining: [],
|
||||
/**
|
||||
* True once `loadMaster()` has run at least once in this tab's current
|
||||
* active session. Consumers should gate `saveMaster()` on this so a
|
||||
* component that mounts (or remounts after reclaiming a frozen tab)
|
||||
* never persists stale in-memory state over a peer tab's newer writes
|
||||
* before this tab has re-read localStorage.
|
||||
*/
|
||||
hydrated: false,
|
||||
});
|
||||
|
||||
function shuffled1to90() {
|
||||
@@ -56,6 +64,8 @@ export function loadMaster() {
|
||||
masterState.remaining = parsed.remaining;
|
||||
} catch {
|
||||
/* private mode / corrupt JSON — leave defaults */
|
||||
} finally {
|
||||
masterState.hydrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ beforeEach(() => {
|
||||
localStorage.clear();
|
||||
masterState.called = [];
|
||||
masterState.remaining = [];
|
||||
masterState.hydrated = false;
|
||||
});
|
||||
|
||||
describe("master-store", () => {
|
||||
@@ -85,4 +86,53 @@ describe("master-store", () => {
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual([]);
|
||||
});
|
||||
|
||||
describe("hydrated flag (reclaim-a-frozen-tab fix, H2)", () => {
|
||||
it("starts false and flips true after the first loadMaster()", () => {
|
||||
expect(masterState.hydrated).toBe(false);
|
||||
loadMaster();
|
||||
expect(masterState.hydrated).toBe(true);
|
||||
});
|
||||
|
||||
it("flips true even when there is nothing to load", () => {
|
||||
// No localStorage entry at all — still counts as "a load happened",
|
||||
// so a consumer's save-effect gate (MasterPanel) is safe to write.
|
||||
loadMaster();
|
||||
expect(masterState.hydrated).toBe(true);
|
||||
});
|
||||
|
||||
it("flips true even when the stored payload is corrupt", () => {
|
||||
localStorage.setItem("loto_master", "{not valid");
|
||||
loadMaster();
|
||||
expect(masterState.hydrated).toBe(true);
|
||||
});
|
||||
|
||||
it("re-loading picks up a peer tab's newer writes instead of this tab's stale copy", () => {
|
||||
// Simulates the reclaim sequence: this tab drew a couple of numbers,
|
||||
// froze (its in-memory masterState is untouched but stale), a peer
|
||||
// tab drew more and persisted them, then this tab reclaims and must
|
||||
// re-read localStorage before anything re-saves.
|
||||
startNewGame();
|
||||
drawNext();
|
||||
drawNext();
|
||||
saveMaster();
|
||||
const staleCalled = [...masterState.called];
|
||||
const thirdDraw = masterState.remaining[0];
|
||||
|
||||
// Peer tab: independently draws one more and persists.
|
||||
const peerCalled = [...staleCalled, thirdDraw];
|
||||
localStorage.setItem(
|
||||
"loto_master",
|
||||
JSON.stringify({
|
||||
called: peerCalled,
|
||||
remaining: masterState.remaining.slice(1),
|
||||
}),
|
||||
);
|
||||
|
||||
// Reclaim: claimActiveTab()'s fix re-hydrates before re-enabling.
|
||||
loadMaster();
|
||||
expect(masterState.called).toEqual(peerCalled);
|
||||
expect(masterState.called).not.toEqual(staleCalled);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@ function firePopState() {
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof vi.spyOn>} */
|
||||
let back;
|
||||
/** @type {ReturnType<typeof vi.spyOn>} */
|
||||
let pushState;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -23,13 +23,34 @@ export const BOARD_TEXT_SCALES = /** @type {const} */ ([0.9, 1, 1.15, 1.3]);
|
||||
* stalling the UI on mount. Real settings serialize to ~200 bytes. */
|
||||
const MAX_STORAGE_BYTES = 8_192;
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
/**
|
||||
* Field types for `settings`/`DEFAULT_SETTINGS`. Declared explicitly (rather
|
||||
* than left to inference) so `Object.freeze()` below doesn't narrow each
|
||||
* field to its literal default value (e.g. `autoCallSpeed: 5` inferred as
|
||||
* the type `5`, not `number`) — that literal type would otherwise leak into
|
||||
* `settings = $state({ ...DEFAULT_SETTINGS })` and reject every later
|
||||
* assignment of a different, still-valid value for the field.
|
||||
* @typedef {Object} Settings
|
||||
* @property {string} emptyCellColor
|
||||
* @property {"auto"|"light"|"dark"} theme
|
||||
* @property {"player"|"master"|"both"} mode
|
||||
* @property {boolean} autoCallEnabled
|
||||
* @property {number} autoCallSpeed
|
||||
* @property {boolean} voiceEnabledMaster
|
||||
* @property {boolean} voiceEnabledPlayer
|
||||
* @property {boolean} voiceWaitingNumber
|
||||
* @property {string} voice
|
||||
* @property {number} boardTextScale
|
||||
*/
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze(
|
||||
/** @type {Settings} */ ({
|
||||
/** Excel "Standard Color: Purple". */
|
||||
emptyCellColor: "#7030A0",
|
||||
/** "auto" follows OS prefers-color-scheme; "light"/"dark" overrides it. */
|
||||
theme: /** @type {"auto"|"light"|"dark"} */ ("auto"),
|
||||
theme: "auto",
|
||||
/** Which panels are visible: player only, master only, or both inline. */
|
||||
mode: /** @type {"player"|"master"|"both"} */ ("player"),
|
||||
mode: "player",
|
||||
/** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
|
||||
autoCallEnabled: false,
|
||||
/** Auto-call interval, seconds per number. Integer 1..10. */
|
||||
@@ -44,7 +65,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
||||
voice: DEFAULT_VOICE,
|
||||
/** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
|
||||
boardTextScale: 1,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const settings = $state({ ...DEFAULT_SETTINGS });
|
||||
|
||||
|
||||
@@ -18,8 +18,14 @@ function mockMatchMedia(matches = false) {
|
||||
const mql = {
|
||||
matches,
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
addEventListener: (/** @type {string} */ _t, fn) => listeners.add(fn),
|
||||
removeEventListener: (/** @type {string} */ _t, fn) => listeners.delete(fn),
|
||||
addEventListener: (
|
||||
/** @type {string} */ _t,
|
||||
/** @type {(e: MediaQueryListEvent) => void} */ fn,
|
||||
) => listeners.add(fn),
|
||||
removeEventListener: (
|
||||
/** @type {string} */ _t,
|
||||
/** @type {(e: MediaQueryListEvent) => void} */ fn,
|
||||
) => listeners.delete(fn),
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
onchange: null,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Update-prompt coordinator for the PWA service worker.
|
||||
*
|
||||
* `vite.config.js` sets `registerType: "prompt"` so vite-plugin-pwa NEVER
|
||||
* force-reloads a live tab when a new build activates — an unattended
|
||||
* reload would drop in-memory round state (`autoRunning`, `showCongrats`,
|
||||
* the auto-call countdown) mid-fairground. Instead `+layout.svelte` calls
|
||||
* `registerSW({ onNeedRefresh: showUpdatePrompt })`, which routes here so a
|
||||
* small dismissible banner can ask the host before reloading. The actual
|
||||
* reload only ever happens from an explicit tap on the banner's button.
|
||||
*
|
||||
* @module lib/update-prompt
|
||||
*/
|
||||
|
||||
export const updatePrompt = $state({
|
||||
/** True once vite-plugin-pwa's `onNeedRefresh` has fired this session. */
|
||||
visible: false,
|
||||
});
|
||||
|
||||
/** @type {((reloadPage?: boolean) => Promise<void>) | null} */
|
||||
let updateSW = null;
|
||||
|
||||
/**
|
||||
* Store the function `registerSW()` returns so `applyUpdate()` can trigger
|
||||
* the actual reload later without `+layout.svelte` holding its own copy.
|
||||
* @param {(reloadPage?: boolean) => Promise<void>} fn
|
||||
*/
|
||||
export function setUpdateSW(fn) {
|
||||
updateSW = fn;
|
||||
}
|
||||
|
||||
/** Wired to `registerSW`'s `onNeedRefresh` callback. */
|
||||
export function showUpdatePrompt() {
|
||||
updatePrompt.visible = true;
|
||||
}
|
||||
|
||||
/** Host tapped "Tải lại" — activate the waiting worker and reload. */
|
||||
export function applyUpdate() {
|
||||
updatePrompt.visible = false;
|
||||
if (updateSW) void updateSW(true);
|
||||
}
|
||||
|
||||
/** Host tapped dismiss — keep the current version running for this visit. */
|
||||
export function dismissUpdate() {
|
||||
updatePrompt.visible = false;
|
||||
}
|
||||
|
||||
/** Test-only reset so module-singleton state doesn't leak between cases. */
|
||||
export function _resetUpdatePromptForTest() {
|
||||
updatePrompt.visible = false;
|
||||
updateSW = null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
_resetUpdatePromptForTest,
|
||||
applyUpdate,
|
||||
dismissUpdate,
|
||||
setUpdateSW,
|
||||
showUpdatePrompt,
|
||||
updatePrompt,
|
||||
} from "./update-prompt.svelte.js";
|
||||
|
||||
beforeEach(() => {
|
||||
_resetUpdatePromptForTest();
|
||||
});
|
||||
|
||||
describe("update-prompt", () => {
|
||||
it("starts hidden", () => {
|
||||
expect(updatePrompt.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("showUpdatePrompt (onNeedRefresh) reveals the banner", () => {
|
||||
showUpdatePrompt();
|
||||
expect(updatePrompt.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("dismissUpdate hides the banner without calling updateSW", () => {
|
||||
const updateSW = vi.fn().mockResolvedValue(undefined);
|
||||
setUpdateSW(updateSW);
|
||||
showUpdatePrompt();
|
||||
dismissUpdate();
|
||||
expect(updatePrompt.visible).toBe(false);
|
||||
expect(updateSW).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applyUpdate hides the banner and reloads via updateSW(true)", () => {
|
||||
const updateSW = vi.fn().mockResolvedValue(undefined);
|
||||
setUpdateSW(updateSW);
|
||||
showUpdatePrompt();
|
||||
applyUpdate();
|
||||
expect(updatePrompt.visible).toBe(false);
|
||||
expect(updateSW).toHaveBeenCalledTimes(1);
|
||||
expect(updateSW).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("applyUpdate before registerSW resolves is a safe no-op (no throw)", () => {
|
||||
showUpdatePrompt();
|
||||
expect(() => applyUpdate()).not.toThrow();
|
||||
expect(updatePrompt.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
+53
-15
@@ -7,8 +7,22 @@
|
||||
import { base } from "$app/paths";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
|
||||
/** @type {Map<string, HTMLAudioElement>} */
|
||||
const cache = new Map();
|
||||
/**
|
||||
* Single reusable <audio> element, `.src` swapped per clip. iOS Safari
|
||||
* only allows programmatic `play()` on an element that was actually
|
||||
* play()'d inside a user gesture; auto-call plays clips from a
|
||||
* `setInterval` callback, which is never a gesture. A single element
|
||||
* unlocked once via `unlockAudio()` (called from the "Bắt đầu"/"Xổ số"
|
||||
* click) stays unlocked for the rest of the page's life — the previous
|
||||
* per-URL-cached-element design created a fresh, never-unlocked element
|
||||
* for every clip and was silent on iOS under auto-call with no
|
||||
* diagnostic (also retained up to 92 `preload="auto"` elements per voice).
|
||||
* @type {HTMLAudioElement | null}
|
||||
*/
|
||||
let audio = null;
|
||||
|
||||
/** True once `unlockAudio()` has primed `audio` inside a user gesture. */
|
||||
let unlocked = false;
|
||||
|
||||
/** @type {HTMLAudioElement | null} */
|
||||
let activeClip = null;
|
||||
@@ -23,15 +37,34 @@ function isBrowser() {
|
||||
return typeof window !== "undefined" && typeof Audio !== "undefined";
|
||||
}
|
||||
|
||||
/** @param {string} url */
|
||||
function getAudio(url) {
|
||||
let a = cache.get(url);
|
||||
if (!a) {
|
||||
a = new Audio(url);
|
||||
a.preload = "auto";
|
||||
cache.set(url, a);
|
||||
function getAudio() {
|
||||
if (!audio) {
|
||||
audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock the shared `<audio>` element for later programmatic playback on
|
||||
* iOS Safari. MUST be called synchronously from inside a real user
|
||||
* gesture (a click handler) — see the module doc comment on `audio`.
|
||||
* Idempotent and safe to call from every relevant click handler.
|
||||
*/
|
||||
export function unlockAudio() {
|
||||
if (!isBrowser() || unlocked) return;
|
||||
unlocked = true;
|
||||
const a = getAudio();
|
||||
const played = a.play();
|
||||
if (played && typeof played.then === "function") {
|
||||
played.then(() => a.pause()).catch(() => {
|
||||
/* The priming play() itself may reject (e.g. no src yet) — that's
|
||||
fine, the attempt still happened synchronously inside the
|
||||
gesture, which is what iOS actually checks. */
|
||||
});
|
||||
} else {
|
||||
a.pause();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/** @param {string} name — clip basename without extension */
|
||||
@@ -42,12 +75,13 @@ function clipUrl(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all cached <audio> elements. Call when the active voice changes
|
||||
* so the old voice's clips don't leak across a long session.
|
||||
* Stop and reset the shared element. Kept as its own export (rather than
|
||||
* folded into `cancelPlayback`) so callers that change `settings.voice`
|
||||
* keep a stable "drop anything voice-specific" hook even though there is
|
||||
* now only one element to reset.
|
||||
*/
|
||||
export function clearAudioCache() {
|
||||
cancelPlayback();
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function cancelPlayback() {
|
||||
@@ -73,12 +107,12 @@ export function cancelPlayback() {
|
||||
* Play one clip; resolve when it ends, errors, or is canceled.
|
||||
* @param {string} url
|
||||
* @param {symbol} token — caller's session marker; mismatch = canceled
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function playClip(url, token) {
|
||||
return new Promise((resolve) => {
|
||||
if (!isBrowser() || activeToken !== token) return resolve();
|
||||
const a = getAudio(url);
|
||||
a.currentTime = 0;
|
||||
const a = getAudio();
|
||||
activeClip = a;
|
||||
activeResolver = resolve;
|
||||
const done = () => {
|
||||
@@ -90,6 +124,10 @@ function playClip(url, token) {
|
||||
};
|
||||
a.onended = done;
|
||||
a.onerror = done;
|
||||
// Reassigning `.src` (even to the same URL) reloads and resets
|
||||
// playback to 0 — required because this element is reused across
|
||||
// every clip, unlike the old one-element-per-URL cache.
|
||||
a.src = url;
|
||||
a.play().catch(done);
|
||||
});
|
||||
}
|
||||
|
||||
+65
-27
@@ -1,17 +1,17 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { settings } from "./settings-store.svelte.js";
|
||||
import { cancelPlayback, clearAudioCache, playNumber, playWaiting } from "./voice.js";
|
||||
|
||||
// Track every Audio instance the module constructs so we can drive
|
||||
// onended/onerror by hand and verify cancelPlayback's bookkeeping.
|
||||
// Track every Audio instance the module constructs. Since voice.js now
|
||||
// keeps a single reusable <audio> element for the module's lifetime (M6
|
||||
// fix — one element, `.src` swapped per clip, so it stays iOS-unlocked
|
||||
// across every subsequent play()), this array should never grow past 1
|
||||
// within a test.
|
||||
/** @type {any[]} */
|
||||
let audios = [];
|
||||
|
||||
class FakeAudio {
|
||||
/** @param {string} url */
|
||||
constructor(url) {
|
||||
this.src = url;
|
||||
constructor() {
|
||||
this.src = "";
|
||||
this.preload = "";
|
||||
this.currentTime = 0;
|
||||
this.onended = null;
|
||||
@@ -28,47 +28,58 @@ class FakeAudio {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
/** @type {typeof import("./voice.js")} */
|
||||
let voice;
|
||||
/** @type {typeof import("./settings-store.svelte.js").settings} */
|
||||
let settings;
|
||||
|
||||
beforeEach(async () => {
|
||||
audios = [];
|
||||
vi.stubGlobal("Audio", /** @type {any} */ (FakeAudio));
|
||||
// Reset cache + active state so each test starts cleanly.
|
||||
clearAudioCache();
|
||||
// voice.js keeps module-level singleton state (the shared element, the
|
||||
// unlock flag) by design — reset the module registry so each test gets
|
||||
// a fresh instance instead of reusing a prior test's primed element.
|
||||
vi.resetModules();
|
||||
({ settings } = await import("./settings-store.svelte.js"));
|
||||
voice = await import("./voice.js");
|
||||
settings.voice = "test-voice";
|
||||
settings.voiceWaitingNumber = false;
|
||||
settings.mode = "player";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cancelPlayback();
|
||||
voice.cancelPlayback();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("voice — playback cancellation", () => {
|
||||
it("cancelPlayback pauses and rewinds the active clip", () => {
|
||||
playNumber(7);
|
||||
voice.playNumber(7);
|
||||
const a = audios[audios.length - 1];
|
||||
a.currentTime = 0.42;
|
||||
a.paused = false;
|
||||
cancelPlayback();
|
||||
voice.cancelPlayback();
|
||||
expect(a.paused).toBe(true);
|
||||
expect(a.currentTime).toBe(0);
|
||||
expect(a.onended).toBeNull();
|
||||
expect(a.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("a second playNumber cancels the first", () => {
|
||||
playNumber(3);
|
||||
const first = audios[audios.length - 1];
|
||||
playNumber(5);
|
||||
const second = audios[audios.length - 1];
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.paused).toBe(true);
|
||||
expect(second.paused).toBe(false);
|
||||
it("a second playNumber cancels the first and reuses the same element", () => {
|
||||
voice.playNumber(3);
|
||||
const a = audios[audios.length - 1];
|
||||
expect(a.src).toMatch(/\/3\.mp3$/);
|
||||
voice.playNumber(5);
|
||||
// Single shared element (M6 fix) — src swapped in place, no 2nd
|
||||
// Audio instance constructed.
|
||||
expect(audios.length).toBe(1);
|
||||
expect(a.src).toMatch(/\/5\.mp3$/);
|
||||
expect(a.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("playWaiting plays only 'cho' when voiceWaitingNumber is off", async () => {
|
||||
settings.voiceWaitingNumber = false;
|
||||
playWaiting(42);
|
||||
voice.playWaiting(42);
|
||||
// Let the async chain schedule + start the cho clip.
|
||||
await Promise.resolve();
|
||||
expect(audios.length).toBe(1);
|
||||
@@ -76,26 +87,27 @@ describe("voice — playback cancellation", () => {
|
||||
audios[0].onended?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Number clip should NOT be queued.
|
||||
// Number clip should NOT be queued — src stays on "cho".
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
});
|
||||
|
||||
it("playWaiting chains 'cho' → number when voiceWaitingNumber is on", async () => {
|
||||
settings.voiceWaitingNumber = true;
|
||||
playWaiting(42);
|
||||
voice.playWaiting(42);
|
||||
await Promise.resolve();
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
audios[0].onended?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(audios.length).toBe(2);
|
||||
expect(audios[1].src).toMatch(/\/42\.mp3$/);
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0].src).toMatch(/\/42\.mp3$/);
|
||||
});
|
||||
|
||||
it("playWaiting suppresses trailing number in both mode even when flag is on", async () => {
|
||||
settings.voiceWaitingNumber = true;
|
||||
settings.mode = "both";
|
||||
playWaiting(42);
|
||||
voice.playWaiting(42);
|
||||
await Promise.resolve();
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
audios[0].onended?.();
|
||||
@@ -104,5 +116,31 @@ describe("voice — playback cancellation", () => {
|
||||
// Master is the announcer in both mode — overlapping "Chờ N" right
|
||||
// after a master call confuses listeners, so we drop it.
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice — iOS unlock (M6)", () => {
|
||||
it("unlockAudio primes the shared element exactly once", () => {
|
||||
voice.unlockAudio();
|
||||
expect(audios.length).toBe(1);
|
||||
const primed = audios[0];
|
||||
voice.unlockAudio();
|
||||
// Idempotent — no 2nd element created on a repeat call (e.g. both
|
||||
// "Bắt đầu" and a later "Xổ số" click in the same session).
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0]).toBe(primed);
|
||||
});
|
||||
|
||||
it("playback after unlockAudio reuses the already-primed element", () => {
|
||||
voice.unlockAudio();
|
||||
const primed = audios[0];
|
||||
// Simulates auto-call's setInterval callback, which iOS does not
|
||||
// treat as a user gesture — playback must land on the element that
|
||||
// was unlocked earlier inside a real click, not a fresh one.
|
||||
voice.playNumber(9);
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0]).toBe(primed);
|
||||
expect(audios[0].src).toMatch(/\/9\.mp3$/);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,13 @@
|
||||
claimActiveTab,
|
||||
watchActiveTab,
|
||||
} from "$lib/active-tab.svelte.js";
|
||||
import {
|
||||
applyUpdate,
|
||||
dismissUpdate,
|
||||
setUpdateSW,
|
||||
showUpdatePrompt,
|
||||
updatePrompt,
|
||||
} from "$lib/update-prompt.svelte.js";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -19,14 +26,53 @@
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
loadMaster();
|
||||
registerServiceWorker();
|
||||
return watchActiveTab();
|
||||
});
|
||||
|
||||
// `adapter-static` renders page HTML during `adapt()`, which runs AFTER
|
||||
// the PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets a
|
||||
// chance to inject its own registration tag into the built HTML. We have
|
||||
// to call the plugin's registration entry point ourselves. Dynamic import
|
||||
// (not a static one) so this module is never evaluated during SSR/
|
||||
// prerendering — `virtual:pwa-register` reaches into `navigator` and
|
||||
// `workbox-window`, both browser-only.
|
||||
//
|
||||
// `registerType: "prompt"` (vite.config.js) means the new SW installs and
|
||||
// waits rather than force-reloading a live round; `onNeedRefresh` only
|
||||
// flips the banner's visibility (lib/update-prompt.svelte.js) — the
|
||||
// reload itself happens exclusively from that banner's explicit tap.
|
||||
function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD) return;
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator))
|
||||
return;
|
||||
import("virtual:pwa-register")
|
||||
.then(({ registerSW }) => {
|
||||
const updateSW = registerSW({ onNeedRefresh: showUpdatePrompt });
|
||||
setUpdateSW(updateSW);
|
||||
})
|
||||
.catch(() => {
|
||||
/* SW unsupported/blocked (e.g. private mode) — app still works online */
|
||||
});
|
||||
}
|
||||
|
||||
// Reclaiming a frozen tab: while this tab was frozen, the peer tab may
|
||||
// have drawn more numbers and persisted them. Re-read localStorage BEFORE
|
||||
// flipping `inactive` back off, so the children remount with the peer's
|
||||
// authoritative state instead of this tab's stale in-memory snapshot —
|
||||
// otherwise MasterPanel's save effect would immediately roll the round
|
||||
// back to whatever this tab last held.
|
||||
function reclaimTab() {
|
||||
loadSettings();
|
||||
loadMaster();
|
||||
claimActiveTab();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if activeTab.inactive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={claimActiveTab}
|
||||
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"
|
||||
@@ -41,4 +87,41 @@
|
||||
</button>
|
||||
{:else}
|
||||
{@render children()}
|
||||
|
||||
{#if updatePrompt.visible}
|
||||
<!-- Small corner banner, never a full-screen overlay — must never sit
|
||||
over the board or the master "Số vừa xổ" hero mid-round. Reload
|
||||
only ever happens from the explicit "Tải lại" tap (see
|
||||
lib/update-prompt.svelte.js's module doc for why). -->
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed z-40 bottom-4 inset-x-4 sm:inset-x-auto sm:right-4 sm:left-auto sm:max-w-xs
|
||||
flex items-center gap-3 rounded-2xl bg-white dark:bg-slate-800
|
||||
border border-slate-200 dark:border-slate-600
|
||||
px-4 py-3 shadow-2xl animate-fade-in"
|
||||
>
|
||||
<p class="flex-1 text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
Có bản mới. Tải lại?
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={applyUpdate}
|
||||
class="shrink-0 px-4 py-1.5 rounded-full font-semibold text-white text-sm
|
||||
bg-rose-600 hover:bg-rose-700 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
Tải lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismissUpdate}
|
||||
aria-label="Đóng thông báo cập nhật"
|
||||
class="shrink-0 px-2 py-1.5 rounded-full text-sm font-medium
|
||||
text-slate-500 dark:text-slate-400
|
||||
hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Để sau
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
+1
-22
@@ -1,28 +1,7 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
import { loadEnv } from "vite";
|
||||
|
||||
const profile = process.env.BUILD_PROFILE;
|
||||
const isCodeserver = process.env.VITE_DEV_PROFILE === "codeserver";
|
||||
|
||||
// Same env-loading concern as vite.config.js — .env.local isn't in
|
||||
// process.env at config-eval time. loadEnv reads it.
|
||||
const env = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), "");
|
||||
|
||||
// basePath resolution:
|
||||
// 1. NEXT_BASE_PATH — explicit override (escape hatch)
|
||||
// 2. codeserver dev → /absproxy/{port}
|
||||
// 3. BUILD_PROFILE=gh → /loto
|
||||
// 4. default (local dev / generic static host) → ""
|
||||
function resolveBase() {
|
||||
if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
|
||||
if (isCodeserver) {
|
||||
const port = env.CODESERVER_PORT ?? "3000";
|
||||
return `/absproxy/${port}`;
|
||||
}
|
||||
if (profile === "gh") return "/loto";
|
||||
return "";
|
||||
}
|
||||
import { resolveBase } from "./base-path.js";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
+17
-4
@@ -4,6 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
import { resolveBase } from "./base-path.js";
|
||||
|
||||
// Precache the default voice's clips so the app is fully offline-capable
|
||||
// on first install (without bloating the install with every voice).
|
||||
// Alternate voices fall through to runtime CacheFirst on first play.
|
||||
@@ -18,8 +20,14 @@ const clipNames = [
|
||||
"cho",
|
||||
"kinh",
|
||||
];
|
||||
// `additionalManifestEntries` are passed to workbox-build verbatim — the
|
||||
// plugin only base-prefixes entries it generates itself from globs. Without
|
||||
// this, the GitHub Pages build (base "/loto") precaches origin-rooted
|
||||
// "/audio/..." URLs that 404 under "/loto/", and Workbox aborts the SW
|
||||
// `install` event on the first failed precache request.
|
||||
const base = resolveBase();
|
||||
const defaultVoicePrecacheEntries = clipNames.map((n) => ({
|
||||
url: `/audio/${defaultVoiceId}/${n}.mp3`,
|
||||
url: `${base}/audio/${defaultVoiceId}/${n}.mp3`,
|
||||
// Workbox needs a revision string to invalidate stale clips.
|
||||
// Bump the prefix when audio is regenerated.
|
||||
revision: `audio-v1-${defaultVoiceId}-${n}`,
|
||||
@@ -37,9 +45,14 @@ export default defineConfig(({ mode }) => {
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA({
|
||||
// Do NOT add `skipWaiting` without a reload-prompt UI — it would
|
||||
// swap the SW mid-game and lose state.
|
||||
registerType: "autoUpdate",
|
||||
// "prompt" (not "autoUpdate"): a deploy must never force-reload a
|
||||
// live tab mid-round — that would drop in-memory state
|
||||
// (autoRunning, showCongrats, the auto-call countdown). The new SW
|
||||
// installs and waits; `+layout.svelte` registers
|
||||
// `onNeedRefresh: showUpdatePrompt` (see lib/update-prompt.svelte.js)
|
||||
// so the host sees a small "Có bản mới. Tải lại?" banner and the
|
||||
// reload only happens on an explicit tap.
|
||||
registerType: "prompt",
|
||||
// Ship a hand-written manifest so the icons/theme stay aligned
|
||||
// with /static; the plugin can also generate one but mixing is
|
||||
// confusing.
|
||||
|
||||
Reference in new issue
Block a user