From 032dc7dfb93bfcfdb2cf31880406ee67f413e7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:06:10 +0000 Subject: [PATCH] Multi-threaded engine, fix lost-stop timeouts, auto update check (v1.1.0) Engine speed and strength: - The app was shipping the single-threaded Stockfish build (the committed public/stockfish.* matched stockfish-18-single), so Threads was ignored. Switch to the multi-threaded NNUE build: ~4x nodes/sec on 4 cores. - Serve/copy the engine from the stockfish npm package via a Vite plugin instead of committing a 113MB LFS blob. - Wait for readyok after Threads/Hash at startup and drop the redundant ucinewgame that re-cleared the 512MB hash (warm start ~3.5s). Reliability (analysis failing after moving quickly): - The worker queues `go` while a search unwinds but runs `stop` at once, so a stop could be lost; the 3s stop watchdog then restarted the engine and analysis failed. Re-send `stop` every 100ms until its bestmove and raise the stop watchdog to 8s. Regression tests with a fake worker that mirrors the real command queueing. - A final result whose lines all fail the legality filter now surfaces a retry warning instead of spinning forever. App: - Think time setting (1/3/5/10s); shorter analysis debounce. - Automatic update check at startup (toggleable), with a banner and a dot on the settings gear when a newer release exists. - Electron: fixed port so settings persist across launches and the WASM code cache works; streamed files with ETag; single-instance lock. - Bundle fonts locally (Google Fonts was blocked by the app's CSP/COEP). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017ZHKbLKcpoa8QJGzqkbDTJ --- .gitattributes | 1 - .github/workflows/ci.yml | 2 - .github/workflows/release.yml | 6 +- README.md | 11 +- electron/main.cjs | 136 ++++++++++++++++++------- package-lock.json | 25 ++++- package.json | 8 +- progress.txt | 29 ++++++ public/stockfish.js | 8 -- public/stockfish.wasm | 3 - src/App.css | 82 +++++++++++++++ src/App.tsx | 49 +++++++-- src/components/SettingsMenu.tsx | 114 ++++++++++++++------- src/engine/stockfish.test.ts | 174 ++++++++++++++++++++++++++++++++ src/engine/stockfish.ts | 62 +++++++++--- src/index.css | 2 - src/main.tsx | 8 ++ src/useUpdateCheck.ts | 73 ++++++++++++++ vite.config.ts | 48 ++++++++- 19 files changed, 712 insertions(+), 129 deletions(-) delete mode 100644 .gitattributes delete mode 100644 public/stockfish.js delete mode 100644 public/stockfish.wasm create mode 100644 src/engine/stockfish.test.ts create mode 100644 src/useUpdateCheck.ts diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a9336a0..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.wasm filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e771d6..8c25c88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,6 @@ jobs: name: Lint, test, and build runs-on: ubuntu-latest steps: - # LFS is deliberately not fetched: the Stockfish WASM binary is not - # needed for lint/tests, and Vite copies the LFS pointer file harmlessly. - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec78301..d5199a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,8 @@ jobs: name: Windows portable .exe runs-on: windows-latest steps: - # lfs: true — packaging needs the real Stockfish WASM binary, not the pointer. + # The Stockfish engine comes from the `stockfish` npm package (npm ci). - uses: actions/checkout@v4 - with: - lfs: true - uses: actions/setup-node@v4 with: @@ -47,8 +45,6 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 - with: - lfs: true - uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index da96ba7..58ba60d 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,15 @@ ## Features - Drag-and-drop piece movement with click-to-place editing -- Stockfish 18 analysis with streaming results (moves appear instantly) +- Stockfish 18 analysis on all your CPU cores (multi-threaded NNUE build), streaming results so the best move so far appears within milliseconds of each move - 1 / 3 / 5 candidate lines (fewer lines = deeper search), with eval bars and depth info +- Adjustable think time per position (1s / 3s / 5s / 10s) - Tactical motif detection (forks, pins, skewers, checks, etc.) - Opening name recognition - Checkmate, stalemate, and draw detection - Light/dark theme toggle - Board flip and turn switching -- Built-in update check (Settings → Check for updates) +- Update check against GitHub releases: automatic at startup (a banner appears when a new version is out) and on demand from Settings → Check for updates ## Quick Start (Development) @@ -61,7 +62,7 @@ npm run dev Opens the app in your browser at `http://localhost:5173`. -> **Note:** cloning requires [Git LFS](https://git-lfs.com/) — the Stockfish WASM binary (~108 MB) is stored with LFS. +The Stockfish engine (`stockfish-18.js` + its ~113 MB `.wasm`) comes from the `stockfish` npm package: the dev server serves it straight from `node_modules`, and `npm run build` copies it into `dist/`. Nothing engine-related is committed to the repo. ## Testing @@ -155,13 +156,13 @@ Both scripts use GDI+ with no external dependencies. Rerun `npm run dist` afterw 6. Click any analysis line to play that move on the board. 7. Hover over a line to highlight the move on the board. 8. Use **Reset** to restore the starting position or **Clear** to empty the board. -9. Open the **⚙ settings menu** (top left) to see the app version or check for updates. +9. Open the **⚙ settings menu** (top left) to change the engine's think time, see the app version, or check for updates. A dot on the gear and a banner under the header mean a new release is available. ## Tech Stack - React 19 + TypeScript - Vite (dev server serves COOP/COEP headers so `SharedArrayBuffer` is available for the threaded Stockfish build) -- Stockfish 18 WASM (multi-threaded build, pthread workers) +- Stockfish 18 WASM (multi-threaded NNUE build, pthread workers), from the `stockfish` npm package - chess.js for move validation - react-chessboard for the board UI - Electron for desktop runtime, electron-builder for packaging diff --git a/electron/main.cjs b/electron/main.cjs index fa3325e..6b4402d 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -13,8 +13,16 @@ const MIME_TYPES = { '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', + '.woff': 'font/woff', + '.woff2': 'font/woff2', }; +// A fixed port keeps the app's origin stable between launches. localStorage +// (theme, line count, think time) and the HTTP cache are keyed by origin, so a +// random port silently reset settings every launch and forced a full +// recompile of the 100MB+ engine. Falls back to any free port if taken. +const PREFERRED_PORT = 47813; + // 'wasm-unsafe-eval' and blob: workers are required by the multi-threaded // Stockfish build; connect-src allows the GitHub update check. const CSP = [ @@ -29,45 +37,85 @@ const CSP = [ "frame-ancestors 'none'", ].join('; '); -function startServer() { - return new Promise((resolve) => { - const server = http.createServer((req, res) => { - const urlPath = decodeURIComponent((req.url || '/').split('?')[0]); - const filePath = path.normalize( - path.join(DIST_PATH, urlPath === '/' ? '/index.html' : urlPath) - ); - - // Resolve must stay inside dist/ — the separator suffix prevents - // sibling-directory bypasses like "dist-evil". - if (filePath !== DIST_PATH && !filePath.startsWith(DIST_PATH + path.sep)) { - res.writeHead(403); - res.end(); - return; - } +function handleRequest(req, res) { + let urlPath; + try { + urlPath = decodeURIComponent((req.url || '/').split('?')[0]); + } catch { + res.writeHead(400); + res.end(); + return; + } + const filePath = path.normalize( + path.join(DIST_PATH, urlPath === '/' ? '/index.html' : urlPath) + ); + + // Resolve must stay inside dist/ — the separator suffix prevents + // sibling-directory bypasses like "dist-evil". + if (filePath !== DIST_PATH && !filePath.startsWith(DIST_PATH + path.sep)) { + res.writeHead(403); + res.end(); + return; + } - try { - const data = fs.readFileSync(filePath); - const ext = path.extname(filePath).toLowerCase(); - res.writeHead(200, { - 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', - // Required for SharedArrayBuffer, which the multi-threaded - // Stockfish build needs to spawn its pthread workers. - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Resource-Policy': 'same-origin', - 'Content-Security-Policy': CSP, - 'X-Content-Type-Options': 'nosniff', - }); - res.end(data); - } catch { - res.writeHead(404); - res.end(); - } - }); + fs.stat(filePath, (err, stat) => { + if (err || !stat.isFile()) { + res.writeHead(404); + res.end(); + return; + } + + // Validators let Chromium keep responses in its HTTP cache, which is what + // enables its compiled-WebAssembly code cache: engine restarts and later + // launches skip recompiling the 100MB+ binary. + const etag = `"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`; + const ext = path.extname(filePath).toLowerCase(); + const headers = { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + ETag: etag, + // Required for SharedArrayBuffer, which the multi-threaded + // Stockfish build needs to spawn its pthread workers. + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': CSP, + 'X-Content-Type-Options': 'nosniff', + }; + + if (req.headers['if-none-match'] === etag) { + res.writeHead(304, headers); + res.end(); + return; + } - server.listen(0, '127.0.0.1', () => { - resolve(server.address().port); + headers['Content-Length'] = stat.size; + res.writeHead(200, headers); + if (req.method === 'HEAD') { + res.end(); + return; + } + // Stream rather than readFileSync: the engine binary is 100MB+ and a + // synchronous read would stall the main process. + const stream = fs.createReadStream(filePath); + stream.on('error', () => res.destroy()); + stream.pipe(res); + }); +} + +function startServer() { + return new Promise((resolve, reject) => { + const server = http.createServer(handleRequest); + const listen = (port) => server.listen(port, '127.0.0.1'); + server.once('listening', () => resolve(server.address().port)); + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && server.address() === null) { + listen(0); + } else { + reject(err); + } }); + listen(PREFERRED_PORT); }); } @@ -112,7 +160,23 @@ function createWindow() { } } +// One instance at a time: a second launch would start a second engine +// competing for the same CPU cores. Focus the existing window instead. +const gotInstanceLock = app.requestSingleInstanceLock(); +if (!gotInstanceLock) { + app.quit(); +} else { + app.on('second-instance', () => { + const [win] = BrowserWindow.getAllWindows(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); +} + app.whenReady().then(async () => { + if (!gotInstanceLock) return; const port = await startServer(); appOrigin = `http://127.0.0.1:${port}`; createWindow(); diff --git a/package-lock.json b/package-lock.json index 958a8a5..b7ebf6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "chess-solver", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chess-solver", - "version": "1.0.0", + "version": "1.1.0", + "license": "MIT", "dependencies": { + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "chess.js": "^1.4.0", "react": "^19.2.4", "react-chessboard": "^5.10.0", @@ -945,6 +948,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/dm-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz", + "integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index 81bfdeb..e5e8252 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chess-solver", "private": true, - "version": "1.0.0", + "version": "1.1.0", "description": "Desktop chess analysis tool powered by Stockfish 18 — set up any position and get engine-recommended moves with evaluations, tactical motifs, and principal variations.", "author": "ccharafeddine", "license": "MIT", @@ -41,7 +41,9 @@ "target": [ { "target": "dmg", - "arch": ["universal"] + "arch": [ + "universal" + ] } ], "icon": "build/icon.png", @@ -49,6 +51,8 @@ } }, "dependencies": { + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "chess.js": "^1.4.0", "react": "^19.2.4", "react-chessboard": "^5.10.0", diff --git a/progress.txt b/progress.txt index 9505173..731f441 100644 --- a/progress.txt +++ b/progress.txt @@ -1,5 +1,34 @@ # Chess Solver — Progress +## v1.1.0 (2026-09-24): engine speed/strength + reliability audit +- The app had been shipping the SINGLE-threaded Stockfish build + (public/stockfish.* byte-matched stockfish-18-single), so the Threads + option was silently ignored. Now uses the multi-threaded NNUE build + (stockfish-18.js/.wasm), ~4x nodes/sec on a 4-core machine. +- Engine files are no longer committed (no more Git LFS): vite.config.ts's + stockfish-engine plugin serves them from node_modules/stockfish/bin in dev + and copies them into dist/ on build. +- Root cause of "no move after moving quickly": the worker queues `go` while + a search is still unwinding but runs `stop` immediately, so the stop could + be lost; the 3s stop watchdog then restarted the engine (recompiling + 113MB) and analysis failed. Reproduced in Chromium (16 of 35 rapid + positions got no analysis). Fix: `stop` is re-sent every 100ms until its + bestmove; stop watchdog raised to 8s; startup waits for readyok after + Threads/Hash; redundant ucinewgame dropped (warm start ~3.5s to first + move). Regression test in src/engine/stockfish.test.ts with a fake worker + that mirrors the real queueing. +- Hash stays 512MB: 1GB intermittently crashes the 2GB WASM heap. Threads + capped at 8 (32 hangs the build). +- Think time setting (1/3/5/10s) in the settings menu. +- Update check now also runs automatically at startup (toggle in settings); + a banner + gear dot show when a newer release exists, dismissible per + version (src/useUpdateCheck.ts). +- Electron: fixed port 47813 (random fallback) so localStorage settings + persist across launches and Chromium's WASM code cache works; files are + streamed with ETag/no-cache; single-instance lock. +- Fonts bundled via @fontsource (Google Fonts was blocked by the app's CSP + and COEP in Electron). + ## Current state (2026-07-08): v1.0.0 release prep - Engine layer (src/engine/stockfish.ts) rewritten around bestmove accounting: no readyok/isready synchronization, pending analysis survives engine diff --git a/public/stockfish.js b/public/stockfish.js deleted file mode 100644 index b998b72..0000000 --- a/public/stockfish.js +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Stockfish.js 18 (c) 2026, Chess.com, LLC - * https://github.com/nmrugg/stockfish.js - * License: GPLv3 - * - * Based on Stockfish (c) T. Romstad, M. Costalba, J. Kiiski, G. Linscott and other contributors. - * https://github.com/official-stockfish/Stockfish - */!function(){var a,u,s,e,r,o,n;function t(){function e(e){e=e||{},(l=l||(void 0!==e?e:{})).ready=new Promise(function(e,n){T=e,i=n}),"undefined"!=typeof global&&"[object process]"===Object.prototype.toString.call(global.process)&&"undefined"!=typeof fetch&&("undefined"==typeof XMLHttpRequest&&(global.XMLHttpRequest=function(){var t,r={open:function(e,n){t=n},send:function(){require("fs").readFile(t,function(e,n){r.readyState=4,e?(console.error(e),r.status=404,r.onerror(e)):(r.status=200,r.response=n,r.onreadystatechange(),r.onload())})}};return r}),fetch=null),l.print=function(e){l.listener?l.listener(e):console.log(e)},l.printErr=function(e){l.listener?l.listener(e):console.error(e)},l.terminate=function(){"undefined"!=typeof PThread&&PThread.Z()};var l,T,i,n,t,U,r,H,a,o=Object.assign({},l),u=[],s="./this.program",c=(e,n)=>{throw n},k="object"==typeof window,f="function"==typeof importScripts,L="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,p="",q=(L?(p=f?require("path").dirname(p)+"/":__dirname+"/",H=()=>{r||(U=require("fs"),r=require("path"))},n=function(e,n){return H(),e=r.normalize(e),U.readFileSync(e,n?void 0:"utf8")},t=e=>e=(e=n(e,!0)).buffer?e:new Uint8Array(e),1{if(d||0<_)throw process.exitCode=e,n;n instanceof j||m("exiting due to exception: "+n),process.exit(e)},l.inspect=function(){return"[Emscripten Module object]"}):(k||f)&&(f?p=self.location.href:"undefined"!=typeof document&&document.currentScript&&(p=document.currentScript.src),p=0!==(p=Te?Te:p).indexOf("blob:")?p.substr(0,p.replace(/[?#].*/,"").lastIndexOf("/")+1):"",n=e=>{var n=new XMLHttpRequest;return n.open("GET",e,!1),n.send(null),n.responseText},f)&&(t=e=>{var n=new XMLHttpRequest;return n.open("GET",e,!1),n.responseType="arraybuffer",n.send(null),new Uint8Array(n.response)}),l.print||console.log.bind(console)),m=l.printErr||console.warn.bind(console),d=(Object.assign(l,o),l.arguments&&(u=l.arguments),l.thisProgram&&(s=l.thisProgram),l.quit&&(c=l.quit),l.wasmBinary&&(a=l.wasmBinary),l.noExitRuntime||!0);"object"!=typeof WebAssembly&&A("no native wasm support detected");var W,B,h,y,g,N,v=!1,K="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;function X(e,n,t){var r=n+t;for(t=n;e[t]&&!(r<=t);)++t;if(16>10,56320|1023&a)))):r+=String.fromCharCode(a)}return r}function z(e){return e?X(y,e,void 0):""}function G(e,n,t,r){if(0>6}else{if(i<=65535){if(r<=t+2)break;n[t++]=224|i>>12}else{if(r<=t+3)break;n[t++]=240|i>>18,n[t++]=128|i>>12&63}n[t++]=128|i>>6&63}n[t++]=128|63&i}}n[t]=0}}function V(e){for(var n=0,t=0;t{var e=process.hrtime();return 1e3*e[0]+e[1]/1e6}:()=>performance.now(),le=[];function pe(e){if(!te&&!v)try{e()}catch(e){ue(e)}}var me,de={};function he(){if(!me){var e,n={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:s||"./this.program"};for(e in de)void 0===de[e]?delete n[e]:n[e]=de[e];var t=[];for(e in n)t.push(e+"="+n[e]);me=t}return me}function C(e){return 0==e%4&&(0!=e%100||0==e%400)}function ye(e,n){for(var t=0,r=0;r<=n;t+=e[r++]);return t}var M=[31,29,31,30,31,30,31,31,30,31,30,31],R=[31,28,31,30,31,30,31,31,30,31,30,31];function x(e,n){for(e=new Date(e.getTime());0r-e.getDate())){e.setDate(e.getDate()+n);break}n-=r-e.getDate()+1,e.setDate(1),t<11?e.setMonth(t+1):(e.setMonth(0),e.setFullYear(e.getFullYear()+1))}return e}function ge(e,n,t,r){function o(e,n,t){for(e="number"==typeof e?e.toString():e||"";e.length>2];for(c in r={V:g[r>>2],U:g[r+4>>2],M:g[r+8>>2],L:g[r+12>>2],K:g[r+16>>2],A:g[r+20>>2],N:g[r+24>>2],O:g[r+28>>2],$:g[r+32>>2],T:g[r+36>>2],W:f?z(f):""},t=z(t),f={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"})t=t.replace(new RegExp(c,"g"),f[c]);var l,p,m="Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),d="January February March April May June July August September October November December".split(" "),f={"%a":function(e){return m[e.N].substring(0,3)},"%A":function(e){return m[e.N]},"%b":function(e){return d[e.K].substring(0,3)},"%B":function(e){return d[e.K]},"%C":function(e){return i((e.A+1900)/100|0,2)},"%d":function(e){return i(e.L,2)},"%e":function(e){return o(e.L,2," ")},"%g":function(e){return s(e).toString().substring(2)},"%G":s,"%H":function(e){return i(e.M,2)},"%I":function(e){return 0==(e=e.M)?e=12:12n?0:(h.set(c,e),c.length-1)}function F(e){try{e()}catch(e){A(e)}}var O=0,E=null,I=[],ve={},we={},_e=0,be=null,Se=[];function Ae(t){var e,r={};for(e in t)!function(e){var n=t[e];r[e]="function"==typeof n?function(){I.push(e);try{return n.apply(null,arguments)}finally{v||(I.pop()!==e&&A(void 0),E&&1===O&&0===I.length&&(O=0,F(l._asyncify_stop_unwind),"undefined"!=typeof Fibers)&&Fibers.aa())}}:n}(e);return r}function De(e){var o,i,n,t;v||(0===O?(i=o=!1,e(()=>{if(!v&&(o=!0,i)){O=2,F(()=>l._asyncify_start_rewind(E)),"undefined"!=typeof Browser&&Browser.R.S&&Browser.R.resume();var n=!1;try{var t=(0,l.asm[we[g[E+8>>2]]])()}catch(e){t=e,n=!0}var e,r=!1;if(E||(e=be)&&(be=null,(n?e.reject:e.resolve)(t),r=!0),n&&!r)throw t}}),i=!0,o||(O=1,e=xe(10485772),n=e+12,g[e>>2]=n,g[e+4>>2]=n+10485760,n=I[0],void 0===(t=ve[n])&&(t=_e++,ve[n]=t,we[t]=n),g[e+8>>2]=t,E=e,F(()=>l._asyncify_start_unwind(E)),"undefined"!=typeof Browser&&Browser.R.S&&Browser.R.pause())):2===O?(O=0,F(l._asyncify_stop_rewind),Me(E),E=null,Se.forEach(e=>pe(e))):A("invalid state: "+O))}var P,Ce={d:function(){return 0},i:function(){},r:function(){return 0},f:function(){},a:function(){A("")},g:function(e,n){if(0===e)e=Date.now();else{if(1!==e&&4!==e)return g[Re()>>2]=28,-1;e=fe()}return g[n>>2]=e/1e3|0,g[n+4>>2]=e%1e3*1e6|0,0},j:function(e,n,t){var r;for(le.length=0,t>>=2;r=y[n++];)(r=r<105)&&1&t&&t++,le.push(r?N[t++>>1]:g[t]),++t;return ae[e].apply(null,le)},h:function(e,n,t){y.copyWithin(e,n,n+t)},c:function(e){var n=y.length;if(!(2147483648<(e>>>=0)))for(var t=1;t<=4;t*=2){var r=n*(1+.2/t),r=Math.min(r,e+100663296),o=Math;r=Math.max(e,r),o=o.min.call(o,2147483648,r+(65536-r%65536)%65536);e:{try{W.grow(o-B.byteLength+65535>>>16),Z();var i=1;break e}catch(e){}i=void 0}if(i)return!0}return!1},k:function(t){De(e=>{return n=e,setTimeout(function(){pe(n)},t);var n})},n:function(r,o){var i=0;return he().forEach(function(e,n){var t=o+i;for(n=g[r+4*n>>2]=t,t=0;t>0]=e.charCodeAt(t);h[n>>0]=0,i+=e.length+1}),0},o:function(e,n){var t=he(),r=(g[e>>2]=t.length,0);return t.forEach(function(e){r+=e.length+1}),g[n>>2]=r,0},b:function(e){Ye(e)},e:function(){return 0},q:function(e,n,t,r){return e=ce.Y(e),n=ce.X(e,n,t),g[r>>2]=n,0},l:function(){},p:function(e,n,t,r){for(var o=0,i=0;i>2],u=g[n+4>>2];n+=8;for(var s=0;s>2]=o,0},m:ge},Me=(!function(){function n(e){e=Ae(e=e.exports),l.asm=e,W=l.asm.s,Z(),Q.unshift(l.asm.t),b--,l.monitorRunDependencies&&l.monitorRunDependencies(b),0==b&&(null!==re&&(clearInterval(re),re=null),S)&&(e=S,S=null,e())}function t(e){n(e.instance)}function r(e){return(a||!k&&!f||"function"!=typeof fetch?Promise.resolve().then(ie):fetch(w,{credentials:"same-origin"}).then(function(e){if(e.ok)return e.arrayBuffer();throw"failed to load wasm binary file at '"+w+"'"}).catch(ie)).then(function(e){return WebAssembly.instantiate(e,o)}).then(function(e){return e}).then(e,function(e){m("failed to asynchronously prepare wasm: "+e),A(e)})}var o={a:Ce};if(b++,l.monitorRunDependencies&&l.monitorRunDependencies(b),l.instantiateWasm)try{var e=l.instantiateWasm(o,n);return Ae(e)}catch(e){return m("Module.instantiateWasm callback failed with error: "+e)}(a||"function"!=typeof WebAssembly.instantiateStreaming||oe()||"function"!=typeof fetch?r(t):fetch(w,{credentials:"same-origin"}).then(function(e){return WebAssembly.instantiateStreaming(e,o).then(t,function(e){return m("wasm streaming compile failed: "+e),m("falling back to ArrayBuffer instantiation"),r(t)})})).catch(i)}(),l.___wasm_call_ctors=function(){return(l.___wasm_call_ctors=l.asm.t).apply(null,arguments)},l._main=function(){return(l._main=l.asm.u).apply(null,arguments)},l._command=function(){return(l._command=l.asm.v).apply(null,arguments)},l._isSearching=function(){return(l._isSearching=l.asm.w).apply(null,arguments)},l._free=function(){return(Me=l._free=l.asm.x).apply(null,arguments)}),Re=l.___errno_location=function(){return(Re=l.___errno_location=l.asm.y).apply(null,arguments)},xe=l._malloc=function(){return(xe=l._malloc=l.asm.z).apply(null,arguments)},Fe=l.stackSave=function(){return(Fe=l.stackSave=l.asm.B).apply(null,arguments)},Oe=l.stackRestore=function(){return(Oe=l.stackRestore=l.asm.C).apply(null,arguments)},Y=l.stackAlloc=function(){return(Y=l.stackAlloc=l.asm.D).apply(null,arguments)},Ee=l.dynCall_vi=function(){return(Ee=l.dynCall_vi=l.asm.E).apply(null,arguments)},Ie=l.dynCall_v=function(){return(Ie=l.dynCall_v=l.asm.F).apply(null,arguments)};function j(e){this.name="ExitStatus",this.message="Program terminated with exit("+e+")",this.status=e}function Pe(i){function e(){if(!P&&(P=!0,l.calledRun=!0,!v)){if(D(Q),D(ee),T(l),l.onRuntimeInitialized&&l.onRuntimeInitialized(),je){var e=i,n=l._main,t=(e=e||[]).length+1,r=Y(4*(t+1));g[r>>2]=J(s);for(var o=1;o>2)+o]=J(e[o-1]);g[(r>>2)+t]=0;try{Ye(n(t,r))}catch(e){ue(e)}}if(l.postRun)for("function"==typeof l.postRun&&(l.postRun=[l.postRun]);l.postRun.length;)e=l.postRun.shift(),ne.unshift(e);D(ne)}}if(i=i||u,!(0{be={resolve:e,reject:n}}).then(i):(r=i(r),o?Promise.resolve(r):r)},S=function e(){P||Pe(),P||(S=e)},l.run=Pe,l.preInit)for("function"==typeof l.preInit&&(l.preInit=[l.preInit]);0>>0)),i=a.slice(0,-n.length);for(e=0;e(() => { + const saved = parseInt(localStorage.getItem('chess-solver-think-time') ?? '3', 10); + return THINK_TIME_CHOICES.includes(saved) ? saved : 3; + }); + const updates = useUpdateCheck(); const engineRef = useRef(null); @@ -143,6 +149,10 @@ export default function App() { localStorage.setItem('chess-solver-multipv', String(multiPV)); }, [multiPV]); + useEffect(() => { + localStorage.setItem('chess-solver-think-time', String(thinkTimeSec)); + }, [thinkTimeSec]); + useEffect(() => { const handleResize = () => setBoardWidth(computeBoardWidth()); window.addEventListener('resize', handleResize); @@ -174,7 +184,7 @@ export default function App() { }); } - if (lines.length > 0 && legal.length === 0) { + if (!isFinal && lines.length > 0 && legal.length === 0) { // Everything the engine sent was stale garbage; wait for the next // stream instead of rendering it or claiming failure. return; @@ -203,15 +213,17 @@ export default function App() { })); setAnalysisResult({ fen: analysisFen, lines: displayLines, final: isFinal }); - }, { multiPV }); + }, { multiPV, movetimeMs: thinkTimeSec * 1000 }); }, - [engineReady, multiPV] + [engineReady, multiPV, thinkTimeSec] ); - // Debounced analysis of the current position. + // Debounced analysis of the current position. The debounce only coalesces + // bursts of editor clicks; the engine itself switches positions in + // milliseconds, so keep it short. useEffect(() => { if (!analyzable || gameEndMessage || illegalWarning) return; - const timer = setTimeout(() => runAnalysis(fen), 150); + const timer = setTimeout(() => runAnalysis(fen), 40); return () => clearTimeout(timer); }, [fen, analyzable, gameEndMessage, illegalWarning, runAnalysis]); @@ -311,7 +323,15 @@ export default function App() {
- +

Chess Solver

+ {updates.showBanner && updates.available && ( +
+ + Chess Solver v{updates.available.latestVersion} is available + (you have v{__APP_VERSION__}). + + + + Download + + + +
+ )} +
void; + updateState: UpdateCheckState; + updateAvailable: boolean; + onCheckForUpdates: () => void; + autoCheck: boolean; + onAutoCheckChange: (on: boolean) => void; +} + +function describeUpdate(state: UpdateCheckState): { message: string; url?: string; error?: boolean } | null { + if (state.phase === 'error') { + return state.manual ? { message: 'Could not reach GitHub. Check your connection.', error: true } : null; + } + if (state.phase !== 'done') return null; + const { status } = state; + if (status.state === 'update-available') { + return { message: `Update available: v${status.latestVersion}`, url: status.url }; + } + if (status.state === 'up-to-date') return { message: `You're up to date (v${__APP_VERSION__})` }; + return { message: 'No releases published yet.' }; +} -export default function SettingsMenu() { +export default function SettingsMenu({ + thinkTimeSec, + onThinkTimeChange, + updateState, + updateAvailable, + onCheckForUpdates, + autoCheck, + onAutoCheckChange, +}: SettingsMenuProps) { const [open, setOpen] = useState(false); - const [check, setCheck] = useState({ phase: 'idle' }); const rootRef = useRef(null); + const result = describeUpdate(updateState); useEffect(() => { if (!open) return; @@ -30,39 +56,20 @@ export default function SettingsMenu() { }; }, [open]); - const handleCheckForUpdates = async () => { - setCheck({ phase: 'checking' }); - try { - const status = await checkForUpdates(__APP_VERSION__); - if (status.state === 'update-available') { - setCheck({ - phase: 'done', - message: `Update available: v${status.latestVersion}`, - url: status.url, - }); - } else if (status.state === 'up-to-date') { - setCheck({ phase: 'done', message: `You're up to date (v${__APP_VERSION__})` }); - } else { - setCheck({ phase: 'done', message: 'No releases published yet.' }); - } - } catch { - setCheck({ phase: 'error', message: 'Could not reach GitHub. Check your connection.' }); - } - }; - return (
{open && ( @@ -72,26 +79,57 @@ export default function SettingsMenu() { v{__APP_VERSION__}
+
+ + Think time + +
+ {THINK_TIME_CHOICES.map((sec) => ( + + ))} +
+
+ +
+ - {(check.phase === 'done' || check.phase === 'error') && ( -
- {check.message} - {check.phase === 'done' && check.url && ( - + {result && ( + )} + + void) | null = null; + received: string[] = []; + private listeners = new Set<(e: MessageEvent) => void>(); + private queue: string[] = []; + private searching = false; + private searchTimer: ReturnType | undefined; + private fen = ''; + + constructor() { + FakeEngineWorker.instances.push(this); + } + + addEventListener(_type: string, fn: (e: MessageEvent) => void) { + this.listeners.add(fn); + } + + removeEventListener(_type: string, fn: (e: MessageEvent) => void) { + this.listeners.delete(fn); + } + + terminate() { + clearTimeout(this.searchTimer); + } + + postMessage(cmd: string) { + this.received.push(cmd); + if (cmd.startsWith('go') || cmd.startsWith('setoption')) { + this.queue.push(cmd); + this.drain(); + } else { + this.exec(cmd); + } + } + + /** Simulate the engine being busy so the next `go` gets queued. */ + holdSearchFlag() { + this.searching = true; + } + + releaseSearchFlag() { + this.searching = false; + this.drain(); + } + + private emit(msg: string) { + // Worker messages arrive asynchronously. + setTimeout(() => this.listeners.forEach((fn) => fn({ data: msg } as MessageEvent)), 0); + } + + private drain() { + while (this.queue.length && !this.searching) this.exec(this.queue.shift()!); + } + + private exec(cmd: string) { + if (cmd === 'uci') this.emit('uciok'); + else if (cmd === 'isready') this.emit('readyok'); + else if (cmd.startsWith('position fen ')) this.fen = cmd.slice('position fen '.length); + else if (cmd === 'stop') { + if (this.searching && this.searchTimer !== undefined) this.finish(1); + } else if (cmd.startsWith('go movetime ')) { + const ms = parseInt(cmd.slice('go movetime '.length), 10); + this.searching = true; + const move = this.fen.includes(' w ') ? 'e2e4' : 'e7e5'; + this.emit(`info depth 12 multipv 1 score cp 30 nodes 1000 nps 500000 pv ${move}`); + this.searchTimer = setTimeout(() => this.finish(0), ms); + } + } + + private finish(delay: number) { + clearTimeout(this.searchTimer); + this.searchTimer = undefined; + const move = this.fen.includes(' w ') ? 'e2e4' : 'e7e5'; + setTimeout(() => { + this.emit(`bestmove ${move}`); + this.searching = false; + this.drain(); + }, delay); + } +} + +const WHITE = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; +const BLACK = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1'; + +describe('StockfishEngine', () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeEngineWorker.instances = []; + vi.stubGlobal('Worker', FakeEngineWorker); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function startEngine() { + const engine = new StockfishEngine(); + const ready = engine.init(); + await vi.advanceTimersByTimeAsync(10); + await ready; + return engine; + } + + it('configures threads and hash before reporting ready', async () => { + const engine = await startEngine(); + const [worker] = FakeEngineWorker.instances; + const readyAt = worker.received.indexOf('isready'); + expect(worker.received.slice(0, readyAt).some((c) => c.startsWith('setoption name Hash'))).toBe(true); + expect(worker.received.slice(0, readyAt).some((c) => c.startsWith('setoption name Threads'))).toBe(true); + engine.destroy(); + }); + + it('switches to a new position promptly when stop lands before a queued go', async () => { + const engine = await startEngine(); + const [worker] = FakeEngineWorker.instances; + const results: { fen: string; lines: AnalysisLine[]; final: boolean }[] = []; + + // The worker still considers itself busy, so this `go` is queued and the + // first `stop` (sent when the user moves) is a no-op. + worker.holdSearchFlag(); + engine.analyze(WHITE, (lines, _m, final) => results.push({ fen: WHITE, lines, final }), { movetimeMs: 3000 }); + engine.analyze(BLACK, (lines, _m, final) => results.push({ fen: BLACK, lines, final }), { movetimeMs: 3000 }); + await vi.advanceTimersByTimeAsync(5); + worker.releaseSearchFlag(); // the queued go for WHITE starts now + + // A repeated `stop` must cut the WHITE search short, well before its + // 3s movetime, and without restarting the engine. + await vi.advanceTimersByTimeAsync(300); + const black = results.filter((r) => r.fen === BLACK && r.lines.length > 0); + expect(black.length).toBeGreaterThan(0); + expect(black[0].lines[0].move).toBe('e7e5'); + expect(results.some((r) => r.fen === WHITE)).toBe(false); + expect(FakeEngineWorker.instances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(3000); + expect(results.at(-1)).toMatchObject({ fen: BLACK, final: true }); + engine.destroy(); + }); + + it('never restarts the engine across a burst of rapid position changes', async () => { + const engine = await startEngine(); + let finals = 0; + let lastFinal: AnalysisLine[] = []; + for (let i = 0; i < 40; i++) { + const fen = i % 2 ? BLACK : WHITE; + engine.analyze(fen, (lines, _m, final) => { + if (final) { + finals += 1; + lastFinal = lines; + } + }, { movetimeMs: 1000 }); + await vi.advanceTimersByTimeAsync(i % 5 === 0 ? 400 : 3); + } + await vi.advanceTimersByTimeAsync(2000); + expect(FakeEngineWorker.instances).toHaveLength(1); + expect(finals).toBe(1); // only the latest position completes + expect(lastFinal[0].move).toBe('e7e5'); + engine.destroy(); + }); +}); diff --git a/src/engine/stockfish.ts b/src/engine/stockfish.ts index 852f3e7..2d24736 100644 --- a/src/engine/stockfish.ts +++ b/src/engine/stockfish.ts @@ -28,12 +28,24 @@ export interface AnalyzeOptions { // reaching ~30-50% deeper at the same movetime. Bump in the UI for review. const DEFAULT_MULTIPV = 1; const DEFAULT_MOVETIME_MS = 3000; +/** Per-position search time choices offered in the UI, in seconds. */ +export const THINK_TIME_CHOICES = [1, 3, 5, 10]; +// 512MB is the largest table that is reliably stable in the 2GB WASM heap +// alongside the thread stacks; 1GB intermittently crashes the worker. const HASH_MB = 512; -// The 100MB+ WASM binary can take a while to compile on first load. -const UCIOK_TIMEOUT_MS = 20000; +// The 100MB+ WASM binary can take a while to compile on first load, +// especially on slower machines; waiting longer beats failing startup. +const UCIOK_TIMEOUT_MS = 60000; +// `stop` is re-sent at this interval until its bestmove arrives. The worker +// queues `go` while a previous search is still unwinding but executes `stop` +// immediately, so a single `stop` can land before the `go` it was meant for +// and be lost; the repeat catches that search as soon as it starts. +const STOP_RESEND_MS = 100; // How long `stop` may take to produce its bestmove before we assume the -// worker is wedged and rebuild it. -const STOP_TIMEOUT_MS = 3000; +// worker is wedged and rebuild it. Rebuilding recompiles the 100MB+ engine +// and throws away the hash table, so this errs well on the side of patience; +// a healthy engine answers `stop` within a few milliseconds. +const STOP_TIMEOUT_MS = 8000; // Extra headroom past `go movetime` before the search is considered stuck. const GO_GRACE_MS = 4000; // A stuck search is retried this many times on a fresh worker before the @@ -92,6 +104,7 @@ export class StockfishEngine { private goWatchdogId: ReturnType | undefined = undefined; private stopWatchdogId: ReturnType | undefined = undefined; + private stopResendId: ReturnType | undefined = undefined; async init(): Promise { await this.ensureWorker(); @@ -153,6 +166,9 @@ export class StockfishEngine { if (!this.stopRequested) { this.stopRequested = true; this.send('stop'); + // Every `stop` sent here precedes the next `go` in the worker's inbox, + // so a repeat can never cancel the search that replaces this one. + this.stopResendId = setInterval(() => this.send('stop'), STOP_RESEND_MS); } // If the bestmove acknowledging the stop never arrives, the worker is // wedged — rebuild it. `pending` survives the restart and runs after. @@ -256,10 +272,10 @@ export class StockfishEngine { this.worker = worker; const timeoutId = setTimeout(() => { - worker.removeEventListener('message', onReady); + worker.removeEventListener('message', onHandshake); try { worker.terminate(); } catch { /* ignore */ } if (this.worker === worker) this.worker = null; - reject(new Error('Engine did not answer uci in time')); + reject(new Error('Engine did not finish starting in time')); }, UCIOK_TIMEOUT_MS); worker.onerror = (e) => { @@ -267,25 +283,37 @@ export class StockfishEngine { e.preventDefault(); }; - const onReady = (e: MessageEvent) => { + const threads = pickThreadCount(); + // uci -> uciok, then configure and wait for readyok. Spawning the + // search threads and allocating the hash table happen here, so the + // first `go` starts immediately instead of queueing behind them (and + // racing its watchdog). + const onHandshake = (e: MessageEvent) => { const msg: string = typeof e.data === 'string' ? e.data : ''; - if (!msg.includes('uciok')) return; + if (msg.startsWith('uciok')) { + worker.postMessage(`setoption name Threads value ${threads}`); + // Resizing the hash clears it (using the threads just set), so a + // fresh worker needs no `ucinewgame` — that would clear it again. + worker.postMessage(`setoption name Hash value ${HASH_MB}`); + worker.postMessage('isready'); + return; + } + if (!msg.startsWith('readyok')) return; clearTimeout(timeoutId); - worker.removeEventListener('message', onReady); + worker.removeEventListener('message', onHandshake); + if (this.worker !== worker) { + reject(new Error('Engine was shut down during startup')); + return; + } worker.addEventListener('message', this.onMessage); - const threads = pickThreadCount(); this.threads = threads; - this.send(`setoption name Threads value ${threads}`); - this.send(`setoption name Hash value ${HASH_MB}`); - this.send('ucinewgame'); - this.ready = true; console.log(`[Stockfish] Engine ready (Threads=${threads}, Hash=${HASH_MB}MB)`); resolve(); }; - worker.addEventListener('message', onReady); + worker.addEventListener('message', onHandshake); worker.postMessage('uci'); }); } @@ -402,5 +430,9 @@ export class StockfishEngine { clearTimeout(this.stopWatchdogId); this.stopWatchdogId = undefined; } + if (this.stopResendId !== undefined) { + clearInterval(this.stopResendId); + this.stopResendId = undefined; + } } } diff --git a/src/index.css b/src/index.css index 6e992ce..1c6bb13 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); - *, *::before, *::after { diff --git a/src/main.tsx b/src/main.tsx index bef5202..4b3bf51 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +// Fonts are bundled rather than pulled from Google Fonts: the desktop app's +// CSP and cross-origin isolation block third-party stylesheets and fonts. +import '@fontsource/dm-sans/latin-400.css' +import '@fontsource/dm-sans/latin-500.css' +import '@fontsource/dm-sans/latin-600.css' +import '@fontsource/dm-sans/latin-700.css' +import '@fontsource/jetbrains-mono/latin-400.css' +import '@fontsource/jetbrains-mono/latin-500.css' import './index.css' import App from './App.tsx' diff --git a/src/useUpdateCheck.ts b/src/useUpdateCheck.ts new file mode 100644 index 0000000..0407d3f --- /dev/null +++ b/src/useUpdateCheck.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from 'react'; +import { checkForUpdates, type UpdateStatus } from './engine/updates'; + +export type UpdateCheckState = + | { phase: 'idle' } + | { phase: 'checking'; manual: boolean } + | { phase: 'done'; status: UpdateStatus; manual: boolean } + | { phase: 'error'; manual: boolean }; + +const AUTO_CHECK_KEY = 'chess-solver-auto-update-check'; +const DISMISSED_KEY = 'chess-solver-dismissed-update'; +// Give the engine a head start on its (CPU-heavy) startup before touching the +// network. +const STARTUP_CHECK_DELAY_MS = 4000; + +/** + * Update checking against GitHub releases: once automatically at startup + * (unless turned off) and on demand from the settings menu. A found update + * surfaces as a banner until dismissed for that specific version. + */ +export function useUpdateCheck() { + const [state, setState] = useState({ phase: 'idle' }); + const [autoCheck, setAutoCheckState] = useState( + () => localStorage.getItem(AUTO_CHECK_KEY) !== 'off' + ); + const [dismissedVersion, setDismissedVersion] = useState( + () => localStorage.getItem(DISMISSED_KEY) + ); + + const runCheck = useCallback(async (manual: boolean) => { + setState({ phase: 'checking', manual }); + try { + const status = await checkForUpdates(__APP_VERSION__); + setState({ phase: 'done', status, manual }); + } catch { + setState({ phase: 'error', manual }); + } + }, []); + + useEffect(() => { + if (!autoCheck) return; + const timer = setTimeout(() => runCheck(false), STARTUP_CHECK_DELAY_MS); + return () => clearTimeout(timer); + // Only the startup check is automatic; toggling the setting on later + // doesn't need to fire one. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setAutoCheck = useCallback((on: boolean) => { + localStorage.setItem(AUTO_CHECK_KEY, on ? 'on' : 'off'); + setAutoCheckState(on); + }, []); + + const available = + state.phase === 'done' && state.status.state === 'update-available' ? state.status : null; + + const dismiss = useCallback(() => { + if (!available?.latestVersion) return; + localStorage.setItem(DISMISSED_KEY, available.latestVersion); + setDismissedVersion(available.latestVersion); + }, [available]); + + return { + state, + available, + // The settings menu still shows a dismissed update after a manual check. + showBanner: available !== null && available.latestVersion !== dismissedVersion, + check: () => runCheck(true), + dismiss, + autoCheck, + setAutoCheck, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 1437c88..848358d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,8 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, type Plugin } from 'vitest/config' import react from '@vitejs/plugin-react' -import { readFileSync } from 'node:fs' +import { createReadStream, copyFileSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' const pkg = JSON.parse( readFileSync(new URL('./package.json', import.meta.url), 'utf-8') @@ -13,9 +15,49 @@ const crossOriginIsolationHeaders = { 'Cross-Origin-Resource-Policy': 'same-origin', } +// The engine ships straight from the `stockfish` npm package instead of being +// committed to the repo: the large multi-threaded NNUE build (strongest +// flavor; needs cross-origin isolation, which both the dev server and the +// Electron shell provide). The worker loads `./stockfish.js`, which in turn +// fetches `./stockfish.wasm` and spawns its pthreads from the same script. +const ENGINE_DIR = fileURLToPath(new URL('./node_modules/stockfish/bin/', import.meta.url)) +const ENGINE_FILES: Record = { + 'stockfish.js': { source: 'stockfish-18.js', type: 'application/javascript' }, + 'stockfish.wasm': { source: 'stockfish-18.wasm', type: 'application/wasm' }, +} + +function stockfishEngine(): Plugin { + let outDir = 'dist' + const serve: Plugin['configureServer'] = (server) => { + server.middlewares.use((req, res, next) => { + const name = (req.url ?? '').split(/[?#]/)[0].split('/').pop() ?? '' + const file = ENGINE_FILES[name] + if (!file) return next() + const path = join(ENGINE_DIR, file.source) + res.setHeader('Content-Type', file.type) + res.setHeader('Content-Length', statSync(path).size) + for (const [k, v] of Object.entries(crossOriginIsolationHeaders)) res.setHeader(k, v) + createReadStream(path).pipe(res) + }) + } + return { + name: 'stockfish-engine', + configResolved(config) { + outDir = join(config.root, config.build.outDir) + }, + configureServer: serve, + configurePreviewServer: serve as Plugin['configurePreviewServer'], + writeBundle() { + for (const [name, file] of Object.entries(ENGINE_FILES)) { + copyFileSync(join(ENGINE_DIR, file.source), join(outDir, name)) + } + }, + } +} + // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), stockfishEngine()], base: './', define: { __APP_VERSION__: JSON.stringify(pkg.version),