Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitattributes

This file was deleted.

2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 1 addition & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -47,8 +45,6 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true

- uses: actions/setup-node@v4
with:
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
136 changes: 100 additions & 36 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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);
});
}

Expand Down Expand Up @@ -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();
Expand Down
25 changes: 23 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -41,14 +41,18 @@
"target": [
{
"target": "dmg",
"arch": ["universal"]
"arch": [
"universal"
]
}
],
"icon": "build/icon.png",
"category": "public.app-category.board-games"
}
},
"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",
Expand Down
29 changes: 29 additions & 0 deletions progress.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 0 additions & 8 deletions public/stockfish.js

This file was deleted.

3 changes: 0 additions & 3 deletions public/stockfish.wasm

This file was deleted.

Loading
Loading