From 760eec5f478a648724691177283601c1a33e0220 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 15:27:40 +0200 Subject: [PATCH 01/16] fix(app): ship checksum worker for windows builds --- app/build.bat | 26 ++- backend/build.bat | 6 +- backend/build.sh | 8 +- backend/src/app.ts | 7 +- backend/src/lish/lish.ts | 40 ++++- backend/tests/compiled-worker-smoke.test.ts | 186 ++++++++++++++++++++ 6 files changed, 245 insertions(+), 28 deletions(-) create mode 100644 backend/tests/compiled-worker-smoke.test.ts diff --git a/app/build.bat b/app/build.bat index 4d80d3b0..069d03ff 100644 --- a/app/build.bat +++ b/app/build.bat @@ -732,8 +732,11 @@ set "_fe_start=0" call :get_timestamp _fe_start echo === Building frontend === cd /d "!ROOT_DIR!\frontend" -call build.bat +call bun i --frozen-lockfile if errorlevel 1 exit /b 1 +call bun --bun run build +if errorlevel 1 exit /b 1 +cd /d "!SCRIPT_DIR!" set "_fe_elapsed=0" call :elapsed_since !_fe_start! _fe_elapsed echo === Frontend done ^(!_fe_elapsed!^) === @@ -749,10 +752,13 @@ call :get_timestamp _be_start echo === Building backend ^(target: !BUN_TGT!^) === cd /d "!ROOT_DIR!\backend" if exist build rmdir /s /q build -bun i --frozen-lockfile +call bun i --frozen-lockfile if errorlevel 1 ( endlocal & exit /b 1 ) mkdir build -bun build --compile --target !BUN_TGT! src/app.ts --outfile build\lish-backend.exe +call bun build --compile --target !BUN_TGT! ./src/app.ts --outfile build\lish-backend.exe +if errorlevel 1 ( endlocal & exit /b 1 ) +mkdir build\lish +call bun build ./src/lish/checksum-worker.ts --target bun --outfile build\lish\checksum-worker.js if errorlevel 1 ( endlocal & exit /b 1 ) set "_be_elapsed=0" call :elapsed_since !_be_start! _be_elapsed @@ -764,19 +770,19 @@ rem ─── sync_product_info ──────────────── :sync_product_info set "PRODUCT_JSON=!ROOT_DIR!\shared\src\product.json" -for /f "tokens=*" %%v in ('bun -e "process.stdout.write(require(process.argv[1]).version)" "!PRODUCT_JSON!"') do set "PRODUCT_VERSION=%%v" -for /f "tokens=*" %%n in ('bun -e "process.stdout.write(require(process.argv[1]).name)" "!PRODUCT_JSON!"') do set "PRODUCT_NAME=%%n" -for /f "tokens=*" %%d in ('bun -e "process.stdout.write(require(process.argv[1]).identifier)" "!PRODUCT_JSON!"') do set "PRODUCT_IDENTIFIER=%%d" +for /f "tokens=*" %%v in ('call bun -e "process.stdout.write(require(process.argv[1]).version)" "!PRODUCT_JSON!"') do set "PRODUCT_VERSION=%%v" +for /f "tokens=*" %%n in ('call bun -e "process.stdout.write(require(process.argv[1]).name)" "!PRODUCT_JSON!"') do set "PRODUCT_NAME=%%n" +for /f "tokens=*" %%d in ('call bun -e "process.stdout.write(require(process.argv[1]).identifier)" "!PRODUCT_JSON!"') do set "PRODUCT_IDENTIFIER=%%d" echo Product: !PRODUCT_NAME! v!PRODUCT_VERSION! (!PRODUCT_IDENTIFIER!) rem Sync tauri.conf.json -bun -e "var f=require('fs'),p=require(process.argv[1]),t=process.argv[2],c=JSON.parse(f.readFileSync(t,'utf8'));c.productName=p.name;c.mainBinaryName=p.name;c.version=p.version;c.identifier=p.identifier;c.bundle.windows.nsis.startMenuFolder=p.name;f.writeFileSync(t,JSON.stringify(c,null,'\t')+'\n')" "!PRODUCT_JSON!" "!SCRIPT_DIR!tauri.conf.json" +call bun -e "var f=require('fs'),p=require(process.argv[1]),t=process.argv[2],c=JSON.parse(f.readFileSync(t,'utf8'));c.productName=p.name;c.mainBinaryName=p.name;c.version=p.version;c.identifier=p.identifier;c.bundle.windows.nsis.startMenuFolder=p.name;f.writeFileSync(t,JSON.stringify(c,null,'\t')+'\n')" "!PRODUCT_JSON!" "!SCRIPT_DIR!tauri.conf.json" rem Sync Cargo.toml version -bun -e "var f=require('fs'),v=process.argv[1],t=process.argv[2],s=f.readFileSync(t,'utf8').replace(/^version = \"[^\"]*\"/m,'version = \"'+v+'\"');f.writeFileSync(t,s)" "!PRODUCT_VERSION!" "!SCRIPT_DIR!Cargo.toml" +call bun -e "var f=require('fs'),v=process.argv[1],t=process.argv[2],s=f.readFileSync(t,'utf8').replace(/^version = \"[^\"]*\"/m,'version = \"'+v+'\"');f.writeFileSync(t,s)" "!PRODUCT_VERSION!" "!SCRIPT_DIR!Cargo.toml" rem Sync wix-fragment-debug.wxs -bun -e "var f=require('fs'),n=process.argv[1],s=f.readFileSync(process.argv[2],'utf8').replace(/\{\{product_name\}\}/g,n);f.writeFileSync(process.argv[2],s)" "!PRODUCT_NAME!" "!SCRIPT_DIR!wix-fragment-debug.wxs" +call bun -e "var f=require('fs'),n=process.argv[1],s=f.readFileSync(process.argv[2],'utf8').replace(/\{\{product_name\}\}/g,n);f.writeFileSync(process.argv[2],s)" "!PRODUCT_NAME!" "!SCRIPT_DIR!wix-fragment-debug.wxs" exit /b 0 rem ─── build_zip ──────────────────────────────────────────────────────────── @@ -788,6 +794,8 @@ if exist "!ZIP_STAGING!" rmdir /s /q "!ZIP_STAGING!" mkdir "!ZIP_STAGING!" copy /y "!BUILD_RELEASE_DIR!\!PRODUCT_NAME!.exe" "!ZIP_STAGING!\!PRODUCT_NAME!.exe" >nul copy /y "!ROOT_DIR!\backend\build\lish-backend.exe" "!ZIP_STAGING!\lish-backend.exe" >nul +mkdir "!ZIP_STAGING!\lish" +xcopy /e /i /y "!ROOT_DIR!\backend\build\lish" "!ZIP_STAGING!\lish" >nul rem Create Debug.bat from template powershell -Command "(Get-Content '!SCRIPT_DIR!bundle-scripts\Debug.bat' -Raw) -replace '\{\{product_name\}\}','!PRODUCT_NAME!' | Set-Content '!ZIP_STAGING!\Debug.bat' -NoNewline" powershell -Command "Compress-Archive -Path '!ZIP_STAGING!\*' -DestinationPath '!FINAL_DIR!\!PRODUCT_NAME!_!PRODUCT_VERSION!_windows_!_arch!.zip' -CompressionLevel !ZIP_PS_LEVEL! -Force" diff --git a/backend/build.bat b/backend/build.bat index 21eb300b..6ae6a619 100644 --- a/backend/build.bat +++ b/backend/build.bat @@ -1,8 +1,10 @@ @echo off if exist build rmdir /s /q build -bun i --frozen-lockfile +call bun i --frozen-lockfile mkdir build -bun build --compile src/app.ts --outfile build\lish-backend.exe +call bun build --compile ./src/app.ts --outfile build\lish-backend.exe +mkdir build\lish +call bun build ./src/lish/checksum-worker.ts --target bun --outfile build\lish\checksum-worker.js rem Patch PE subsystem from CONSOLE (3) to WINDOWS_GUI (2) to prevent console window powershell -Command "$f='%~dp0build\lish-backend.exe'; $b=[IO.File]::ReadAllBytes($f); $pe=[BitConverter]::ToInt32($b,0x3C); $b[$pe+0x5C]=2; $b[$pe+0x5D]=0; [IO.File]::WriteAllBytes($f,$b)" diff --git a/backend/build.sh b/backend/build.sh index c4911a39..563faee2 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -25,13 +25,15 @@ done [ -d "./build/" ] && rm -r build mkdir -p build bun i --frozen-lockfile +mkdir -p build/lish if [ -n "$BUN_TARGET" ]; then echo "Building backend for target: $BUN_TARGET" case "$BUN_TARGET" in - *windows*) bun build --compile --target "$BUN_TARGET" src/app.ts --outfile build/lish-backend.exe ;; - *) bun build --compile --target "$BUN_TARGET" src/app.ts --outfile build/lish-backend ;; + *windows*) bun build --compile --target "$BUN_TARGET" ./src/app.ts --outfile build/lish-backend.exe ;; + *) bun build --compile --target "$BUN_TARGET" ./src/app.ts --outfile build/lish-backend ;; esac else - bun build --compile src/app.ts --outfile build/lish-backend + bun build --compile ./src/app.ts --outfile build/lish-backend fi +bun build ./src/lish/checksum-worker.ts --target bun --outfile build/lish/checksum-worker.js diff --git a/backend/src/app.ts b/backend/src/app.ts index 869ee64d..c6e7384b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,5 @@ import { dirname, join } from 'path'; +import { pathToFileURL } from 'url'; import { productName, productVersion } from '@shared'; import { setupLogger, type LogLevel } from './logger.ts'; import { Networks } from './lishnet/lishnets.ts'; @@ -15,11 +16,7 @@ const args = process.argv.slice(2); // Default dataDir: next to binary if compiled, otherwise ./data (relative to CWD) const isCompiledBinary = process.execPath !== Bun.which('bun'); let dataDir = isCompiledBinary ? join(dirname(process.execPath), 'data') : './data'; - -// In compiled binaries, import.meta.url is always the binary path (/$bunfs/root/), -// so the worker is at ./lish/checksum-worker.js relative to it. -// In dev mode the default in lish.ts (./checksum-worker.ts relative to lish.ts) is correct. -if (isCompiledBinary) setWorkerUrl(new URL('./lish/checksum-worker.js', import.meta.url).href); +if (isCompiledBinary) setWorkerUrl(pathToFileURL(join(dirname(process.execPath), 'lish', 'checksum-worker.js')).href); let logLevel: LogLevel = isCompiledBinary ? 'info' : 'debug'; let apiHost = 'localhost'; diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index ff25f60b..a1a4e2c1 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -7,16 +7,18 @@ import { calculateChecksum } from './checksum.ts'; import { Utils } from '../utils.ts'; import { type DataServer } from './data-server.ts'; -// Worker URL for checksum-worker. Default works in dev mode (import.meta.url is the actual file URL). -// In compiled binaries, import.meta.url is always the binary path, so app.ts must call setWorkerUrl() -// with new URL('./lish/checksum-worker.js', import.meta.url).href before any LISH creation. -let _workerUrl: string = new URL('./checksum-worker.ts', import.meta.url).href; +let _workerUrl: string | null = null; -/** Override the checksum worker URL. Must be called from the main entrypoint (app.ts) in compiled mode. */ +/** Override the checksum worker URL for tests or external launchers. */ export function setWorkerUrl(url: string): void { _workerUrl = url; } +function createChecksumWorker(): Worker { + if (_workerUrl) return new Worker(_workerUrl); + return new Worker(new URL('./checksum-worker.ts', import.meta.url)); +} + // Helper to normalize paths to forward slashes function normalizePath(p: string): string { return p.replace(/\\/g, '/'); @@ -74,9 +76,16 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch const totalChunks = Math.ceil(fileSize / chunkSize); const cpuCount = maxWorkers > 0 ? maxWorkers : navigator.hardwareConcurrency || 1; const workerCount = Math.min(cpuCount, totalChunks); + const canTerminateBusyWorkers = !(process.platform === 'win32' && process.execPath !== Bun.which('bun')); + const releaseWorkers = (): void => { + for (const worker of workers) { + if (canTerminateBusyWorkers) worker.terminate(); + else (worker as any).unref?.(); + } + }; // Create workers const workers: Worker[] = []; - for (let i = 0; i < workerCount; i++) workers.push(new Worker(_workerUrl)); + for (let i = 0; i < workerCount; i++) workers.push(createChecksumWorker()); let completedChunks = 0; const results: string[] = new Array(totalChunks); let nextChunk = 0; @@ -87,14 +96,28 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch function abortHandler(): void { if (finished) return; finished = true; - workers.forEach(w => w.terminate()); + releaseWorkers(); rejectAll(new CodedError(ErrorCodes.LISH_CREATE_CANCELLED)); } + function failWorker(error: unknown): void { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', abortHandler); + releaseWorkers(); + rejectAll(error instanceof Error ? error : new Error(String(error))); + } if (signal?.aborted) { abortHandler(); return; } signal?.addEventListener('abort', abortHandler, { once: true }); + for (const worker of workers) { + worker.addEventListener('error', event => { + const message = event instanceof ErrorEvent ? event.message : 'checksum worker failed'; + failWorker(new Error(message)); + }); + worker.addEventListener('messageerror', event => failWorker(new Error(`checksum worker message error: ${event.type}`))); + } function feedWorker(workerIndex: number): void { if (finished) return; if (nextChunk >= totalChunks) return; @@ -106,8 +129,7 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch worker.removeEventListener('message', handler); if (finished) return; if (event.data.error) { - finished = true; - rejectAll(new Error(event.data.error)); + failWorker(new Error(event.data.error)); return; } results[chunkIndex] = event.data.checksum; diff --git a/backend/tests/compiled-worker-smoke.test.ts b/backend/tests/compiled-worker-smoke.test.ts new file mode 100644 index 00000000..b1f066ba --- /dev/null +++ b/backend/tests/compiled-worker-smoke.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from 'bun:test'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn, type Subprocess } from 'bun'; + +async function findFreePort(): Promise { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }); + const port = server.port; + server.stop(true); + if (port === undefined) throw new Error('failed to allocate a free port'); + return port; +} + +async function waitForBackend(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const ws = new WebSocket(`ws://localhost:${port}/ws`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close(); + reject(new Error('websocket open timeout')); + }, 500); + ws.addEventListener('open', () => { + clearTimeout(timer); + ws.close(); + resolve(); + }); + ws.addEventListener('error', error => { + clearTimeout(timer); + reject(error); + }); + }); + return; + } catch (error) { + lastError = error; + await Bun.sleep(250); + } + } + throw new Error(`backend did not open ws://localhost:${port}/ws: ${String(lastError)}`); +} + +function rpc(ws: WebSocket, method: string, params: Record, timeoutMs = 15_000): Promise { + const id = crypto.randomUUID(); + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.removeEventListener('message', onMessage); + reject(new Error(`RPC timeout: ${method}`)); + }, timeoutMs); + function onMessage(event: MessageEvent): void { + const msg = JSON.parse(event.data as string); + if (msg.id !== id) return; + clearTimeout(timer); + ws.removeEventListener('message', onMessage); + if (msg.error) reject(new Error(`${method}: ${msg.error}${msg.errorDetail ? `: ${msg.errorDetail}` : ''}`)); + else resolve(msg.result); + } + ws.addEventListener('message', onMessage); + }); +} + +async function openWs(port: number): Promise { + const ws = new WebSocket(`ws://localhost:${port}/ws`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('websocket open timeout')), 5_000); + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.addEventListener('error', error => { + clearTimeout(timer); + reject(error); + }); + }); + return ws; +} + +async function stopProcess(proc: Subprocess<'ignore', 'pipe', 'pipe'>): Promise { + try { + proc.kill(); + await Promise.race([proc.exited, Bun.sleep(5_000)]); + } catch {} +} + +test( + 'compiled backend can create a LISH with the default checksum settings outside the source tree', + async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'libershare-compiled-worker-')); + const sourceDir = join(tempRoot, 'source'); + const outputDir = join(tempRoot, 'out'); + const dataDir = join(tempRoot, 'data'); + const buildDir = join(tempRoot, 'build'); + const runDir = join(tempRoot, 'run'); + const exePath = join(buildDir, process.platform === 'win32' ? 'lish-backend.exe' : 'lish-backend'); + let backend: Subprocess<'ignore', 'pipe', 'pipe'> | undefined; + + try { + await mkdir(sourceDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); + await mkdir(dataDir, { recursive: true }); + await mkdir(buildDir, { recursive: true }); + await mkdir(join(buildDir, 'lish'), { recursive: true }); + await mkdir(runDir, { recursive: true }); + await writeFile(join(sourceDir, 'a.txt'), 'a'.repeat(10_000)); + await writeFile(join(sourceDir, 'b.txt'), 'b'.repeat(10_000)); + await writeFile( + join(dataDir, 'settings.json'), + JSON.stringify({ + network: { + incomingPort: 0, + allowRelay: false, + maxRelayClients: 0, + mdnsEnabled: false, + }, + }) + ); + + const build = spawn({ + cmd: ['bun', 'build', '--compile', './src/app.ts', '--outfile', exePath], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const buildExit = await build.exited; + const buildOutput = `${await new Response(build.stdout).text()}${await new Response(build.stderr).text()}`; + expect(buildExit, buildOutput).toBe(0); + const workerBuild = spawn({ + cmd: ['bun', 'build', './src/lish/checksum-worker.ts', '--target', 'bun', '--outfile', join(buildDir, 'lish', 'checksum-worker.js')], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const workerBuildExit = await workerBuild.exited; + const workerBuildOutput = `${await new Response(workerBuild.stdout).text()}${await new Response(workerBuild.stderr).text()}`; + expect(workerBuildExit, workerBuildOutput).toBe(0); + + const apiPort = await findFreePort(); + backend = spawn({ + cmd: [exePath, '--datadir', dataDir, '--host', 'localhost', '--port', String(apiPort), '--loglevel', 'debug'], + cwd: runDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await waitForBackend(apiPort, 20_000); + + const ws = await openWs(apiPort); + const progressEvents: string[] = []; + ws.addEventListener('message', event => { + const msg = JSON.parse(event.data as string); + if (msg.event === 'lishs.create:progress') progressEvents.push(msg.data.type); + }); + + await rpc(ws, 'events.subscribe', { events: ['lishs.create:progress'] }); + const result = await rpc( + ws, + 'lishs.create', + { + dataPath: sourceDir, + lishFile: join(outputDir, 'compiled-worker-smoke.lish'), + addToSharing: false, + addToDownloading: false, + name: 'Compiled worker smoke', + chunkSize: 1024, + minifyJSON: false, + compress: false, + }, + 30_000 + ); + ws.close(); + + expect(result).toHaveProperty('lishID'); + expect(progressEvents).toContain('chunk'); + expect(progressEvents.filter(type => type === 'file')).toHaveLength(2); + } finally { + if (backend) await stopProcess(backend); + await rm(tempRoot, { recursive: true, force: true }); + } + }, + 120_000 +); From 4bd3606a449e892501eef2c2c1ae09d31ba00bd3 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:07:28 +0200 Subject: [PATCH 02/16] fix(app): bundle checksum worker as Tauri resource on all platforms --- app/tauri.linux.conf.json | 3 ++- app/tauri.macos.conf.json | 3 ++- app/tauri.windows.conf.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/tauri.linux.conf.json b/app/tauri.linux.conf.json index 24588083..cf02b277 100644 --- a/app/tauri.linux.conf.json +++ b/app/tauri.linux.conf.json @@ -3,7 +3,8 @@ "mainBinaryName": "libershare", "bundle": { "resources": { - "../backend/build/lish-backend": "./lish-backend" + "../backend/build/lish-backend": "./lish-backend", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" }, "linux": { "deb": { diff --git a/app/tauri.macos.conf.json b/app/tauri.macos.conf.json index 95037331..2909776c 100644 --- a/app/tauri.macos.conf.json +++ b/app/tauri.macos.conf.json @@ -1,7 +1,8 @@ { "bundle": { "resources": { - "../backend/build/lish-backend": "./lish-backend" + "../backend/build/lish-backend": "./lish-backend", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" } } } diff --git a/app/tauri.windows.conf.json b/app/tauri.windows.conf.json index b39b620d..eb66a49d 100644 --- a/app/tauri.windows.conf.json +++ b/app/tauri.windows.conf.json @@ -1,7 +1,8 @@ { "bundle": { "resources": { - "../backend/build/lish-backend.exe": "./lish-backend.exe" + "../backend/build/lish-backend.exe": "./lish-backend.exe", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" }, "windows": { "wix": { From 03e09a6d80f05734a066ea7df07ea653cc6bb9e7 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:07:59 +0200 Subject: [PATCH 03/16] fix(app): include checksum worker in zip and linux package staging --- app/build.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/build.sh b/app/build.sh index b7640009..d7567566 100755 --- a/app/build.sh +++ b/app/build.sh @@ -563,6 +563,7 @@ ${PRODUCT_NAME} - peer-to-peer file sharing application %files /usr/bin/${PRODUCT_NAME_LOWER} /usr/bin/lish-backend +/usr/bin/lish /usr/share/applications/${PRODUCT_NAME_LOWER}.desktop /usr/share/applications/${PRODUCT_NAME_LOWER}-debug.desktop /usr/share/icons/hicolor/256x256/apps/${PRODUCT_NAME_LOWER}.png @@ -646,6 +647,8 @@ APPRUN_EOF _stage_zip_linux() { cp "$BUILD_RELEASE_DIR/$PRODUCT_NAME_LOWER" "$ZIP_STAGING/" cp "$ROOT_DIR/backend/build/lish-backend" "$ZIP_STAGING/lish-backend" + mkdir -p "$ZIP_STAGING/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$ZIP_STAGING/lish/" _copy_debug_script chmod +x "$ZIP_STAGING/$PRODUCT_NAME_LOWER" "$ZIP_STAGING/lish-backend" } @@ -653,6 +656,8 @@ _stage_zip_linux() { _stage_zip_windows() { cp "$BUILD_RELEASE_DIR/${PRODUCT_NAME}.exe" "$ZIP_STAGING/" cp "$ROOT_DIR/backend/build/lish-backend.exe" "$ZIP_STAGING/lish-backend.exe" + mkdir -p "$ZIP_STAGING/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$ZIP_STAGING/lish/" sed "s/{{product_name}}/$PRODUCT_NAME/g" \ "$SCRIPT_DIR/bundle-scripts/Debug.bat" >"$ZIP_STAGING/Debug.bat" } @@ -694,6 +699,8 @@ build_linux_packages() { chmod +x "$PKG_STAGING/usr/bin/$PRODUCT_NAME_LOWER" cp "$ROOT_DIR/backend/build/lish-backend" "$PKG_STAGING/usr/bin/" chmod +x "$PKG_STAGING/usr/bin/lish-backend" + mkdir -p "$PKG_STAGING/usr/bin/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$PKG_STAGING/usr/bin/lish/" generate_desktop_entry "$PKG_STAGING/usr/share/applications/${PRODUCT_NAME_LOWER}.desktop" generate_desktop_entry "$PKG_STAGING/usr/share/applications/${PRODUCT_NAME_LOWER}-debug.desktop" --debug From 98ef61f3d699d04382c94b06ae72ca5b61f5c0f0 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:08:17 +0200 Subject: [PATCH 04/16] fix(backend): exit on first build step failure on windows --- backend/build.bat | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/build.bat b/backend/build.bat index 6ae6a619..ca59c270 100644 --- a/backend/build.bat +++ b/backend/build.bat @@ -1,10 +1,14 @@ @echo off if exist build rmdir /s /q build call bun i --frozen-lockfile +if errorlevel 1 exit /b 1 mkdir build call bun build --compile ./src/app.ts --outfile build\lish-backend.exe +if errorlevel 1 exit /b 1 mkdir build\lish call bun build ./src/lish/checksum-worker.ts --target bun --outfile build\lish\checksum-worker.js +if errorlevel 1 exit /b 1 rem Patch PE subsystem from CONSOLE (3) to WINDOWS_GUI (2) to prevent console window powershell -Command "$f='%~dp0build\lish-backend.exe'; $b=[IO.File]::ReadAllBytes($f); $pe=[BitConverter]::ToInt32($b,0x3C); $b[$pe+0x5C]=2; $b[$pe+0x5D]=0; [IO.File]::WriteAllBytes($f,$b)" +if errorlevel 1 exit /b 1 From 07f26e69da4d55ba01c9bd7f70289b4fa6f0305e Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:08:35 +0200 Subject: [PATCH 05/16] refactor(lish): use releaseWorkers helper on parallel checksum success path --- backend/src/lish/lish.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index a1a4e2c1..8634b365 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -147,8 +147,8 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch // Start one chunk per worker for (let i = 0; i < workerCount; i++) feedWorker(i); }); - // Terminate workers - workers.forEach(w => w.terminate()); + // Release workers via the platform-aware helper so the success path matches abort/error cleanup. + releaseWorkers(); return results; } From 4320673ee4bb14550076ed829d377bbbac4542a4 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:08:54 +0200 Subject: [PATCH 06/16] perf(lish): cache compiled-binary detection at module load --- backend/src/lish/lish.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index 8634b365..333c8914 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -7,6 +7,11 @@ import { calculateChecksum } from './checksum.ts'; import { Utils } from '../utils.ts'; import { type DataServer } from './data-server.ts'; +// Cached at module load: Bun.which() walks PATH on every call, and this flag is consulted on +// every parallel-checksum invocation. Compiled binaries always have execPath !== Bun.which('bun'). +const _isCompiledBinary = process.execPath !== Bun.which('bun'); +const _canTerminateBusyWorkers = !(process.platform === 'win32' && _isCompiledBinary); + let _workerUrl: string | null = null; /** Override the checksum worker URL for tests or external launchers. */ @@ -76,10 +81,9 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch const totalChunks = Math.ceil(fileSize / chunkSize); const cpuCount = maxWorkers > 0 ? maxWorkers : navigator.hardwareConcurrency || 1; const workerCount = Math.min(cpuCount, totalChunks); - const canTerminateBusyWorkers = !(process.platform === 'win32' && process.execPath !== Bun.which('bun')); const releaseWorkers = (): void => { for (const worker of workers) { - if (canTerminateBusyWorkers) worker.terminate(); + if (_canTerminateBusyWorkers) worker.terminate(); else (worker as any).unref?.(); } }; From a5de9f1fd5def6b80c8f739d0aa0a1f84fd93525 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:09:06 +0200 Subject: [PATCH 07/16] style(lish): replace any cast with typed worker.unref intersection --- backend/src/lish/lish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index 333c8914..2d45bef7 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -84,7 +84,7 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch const releaseWorkers = (): void => { for (const worker of workers) { if (_canTerminateBusyWorkers) worker.terminate(); - else (worker as any).unref?.(); + else (worker as Worker & { unref?: () => void }).unref?.(); } }; // Create workers From c76e9ce56c52bfd384a874df9927ad1dbba2d098 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:09:16 +0200 Subject: [PATCH 08/16] style(lish): clarify messageerror description --- backend/src/lish/lish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index 2d45bef7..a7ea6441 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -120,7 +120,7 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch const message = event instanceof ErrorEvent ? event.message : 'checksum worker failed'; failWorker(new Error(message)); }); - worker.addEventListener('messageerror', event => failWorker(new Error(`checksum worker message error: ${event.type}`))); + worker.addEventListener('messageerror', () => failWorker(new Error('checksum worker message could not be deserialized'))); } function feedWorker(workerIndex: number): void { if (finished) return; From 642da99a879172436ab3e9f24cfa3586dd4559cf Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 4 May 2026 22:44:35 +0200 Subject: [PATCH 09/16] chore(installer): drop nsis languages without multiuser plugin coverage --- app/tauri.conf.json | 62 ++------------------------------------------- 1 file changed, 2 insertions(+), 60 deletions(-) diff --git a/app/tauri.conf.json b/app/tauri.conf.json index 3fe27b20..5a332efe 100644 --- a/app/tauri.conf.json +++ b/app/tauri.conf.json @@ -34,56 +34,31 @@ "startMenuFolder": "LiberShare", "installerHooks": "nsis-hooks.nsh", "languages": [ - "Afrikaans", "Albanian", "Arabic", "Asturian", "Basque", "Belarusian", - "Bosnian", - "Breton", - "Bulgarian", - "Catalan", "Corsican", - "Croatian", "Czech", "Danish", "Dutch", "English", "Esperanto", - "Estonian", - "Farsi", - "Finnish", "French", - "Galician", "German", - "Greek", "Hebrew", - "Hungarian", - "Icelandic", "Indonesian", - "Irish", "Italian", "Japanese", - "Korean", - "Kurdish", - "Latvian", - "Lithuanian", - "Luxembourgish", - "Macedonian", - "Malay", "Mongolian", "Norwegian", "NorwegianNynorsk", - "Pashto", "Polish", "Portuguese", "PortugueseBR", - "Romanian", "Russian", "ScotsGaelic", - "Serbian", - "SerbianLatin", "SimpChinese", "Slovak", "Slovenian", @@ -91,62 +66,29 @@ "SpanishInternational", "Swedish", "Tatar", - "Thai", "TradChinese", - "Turkish", "Ukrainian", - "Uzbek", - "Vietnamese", - "Welsh" + "Vietnamese" ], "customLanguageFiles": { - "Afrikaans": "nsis-langs/Afrikaans.nsh", "Albanian": "nsis-langs/Albanian.nsh", "Asturian": "nsis-langs/Asturian.nsh", "Basque": "nsis-langs/Basque.nsh", "Belarusian": "nsis-langs/Belarusian.nsh", - "Bosnian": "nsis-langs/Bosnian.nsh", - "Breton": "nsis-langs/Breton.nsh", - "Bulgarian": "nsis-langs/Bulgarian.nsh", - "Catalan": "nsis-langs/Catalan.nsh", "Corsican": "nsis-langs/Corsican.nsh", - "Croatian": "nsis-langs/Croatian.nsh", "Czech": "nsis-langs/Czech.nsh", "Danish": "nsis-langs/Danish.nsh", "Esperanto": "nsis-langs/Esperanto.nsh", - "Estonian": "nsis-langs/Estonian.nsh", - "Farsi": "nsis-langs/Farsi.nsh", - "Finnish": "nsis-langs/Finnish.nsh", - "Galician": "nsis-langs/Galician.nsh", - "Greek": "nsis-langs/Greek.nsh", "Hebrew": "nsis-langs/Hebrew.nsh", - "Hungarian": "nsis-langs/Hungarian.nsh", - "Icelandic": "nsis-langs/Icelandic.nsh", "Indonesian": "nsis-langs/Indonesian.nsh", - "Irish": "nsis-langs/Irish.nsh", - "Korean": "nsis-langs/Korean.nsh", - "Kurdish": "nsis-langs/Kurdish.nsh", - "Latvian": "nsis-langs/Latvian.nsh", - "Lithuanian": "nsis-langs/Lithuanian.nsh", - "Luxembourgish": "nsis-langs/Luxembourgish.nsh", - "Macedonian": "nsis-langs/Macedonian.nsh", - "Malay": "nsis-langs/Malay.nsh", "Mongolian": "nsis-langs/Mongolian.nsh", "NorwegianNynorsk": "nsis-langs/NorwegianNynorsk.nsh", - "Pashto": "nsis-langs/Pashto.nsh", "Polish": "nsis-langs/Polish.nsh", - "Romanian": "nsis-langs/Romanian.nsh", "ScotsGaelic": "nsis-langs/ScotsGaelic.nsh", - "Serbian": "nsis-langs/Serbian.nsh", - "SerbianLatin": "nsis-langs/SerbianLatin.nsh", "Slovak": "nsis-langs/Slovak.nsh", "Slovenian": "nsis-langs/Slovenian.nsh", "Tatar": "nsis-langs/Tatar.nsh", - "Thai": "nsis-langs/Thai.nsh", - "Turkish": "nsis-langs/Turkish.nsh", - "Uzbek": "nsis-langs/Uzbek.nsh", - "Vietnamese": "nsis-langs/Vietnamese.nsh", - "Welsh": "nsis-langs/Welsh.nsh" + "Vietnamese": "nsis-langs/Vietnamese.nsh" } } } From ebdffc77a43a539c230568428969447aa73ad6d3 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 5 May 2026 09:23:56 +0200 Subject: [PATCH 10/16] test(lish): add stopCreate regression test for compiled worker abort path --- backend/tests/compiled-worker-smoke.test.ts | 214 ++++++++++++++------ 1 file changed, 153 insertions(+), 61 deletions(-) diff --git a/backend/tests/compiled-worker-smoke.test.ts b/backend/tests/compiled-worker-smoke.test.ts index b1f066ba..4a01ac2c 100644 --- a/backend/tests/compiled-worker-smoke.test.ts +++ b/backend/tests/compiled-worker-smoke.test.ts @@ -88,68 +88,90 @@ async function stopProcess(proc: Subprocess<'ignore', 'pipe', 'pipe'>): Promise< } catch {} } +interface CompiledBackend { + tempRoot: string; + sourceDir: string; + outputDir: string; + exePath: string; + apiPort: number; + backend: Subprocess<'ignore', 'pipe', 'pipe'>; +} + +async function buildAndSpawnCompiledBackend(prefix: string): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), `libershare-${prefix}-`)); + const sourceDir = join(tempRoot, 'source'); + const outputDir = join(tempRoot, 'out'); + const dataDir = join(tempRoot, 'data'); + const buildDir = join(tempRoot, 'build'); + const runDir = join(tempRoot, 'run'); + const exePath = join(buildDir, process.platform === 'win32' ? 'lish-backend.exe' : 'lish-backend'); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); + await mkdir(dataDir, { recursive: true }); + await mkdir(buildDir, { recursive: true }); + await mkdir(join(buildDir, 'lish'), { recursive: true }); + await mkdir(runDir, { recursive: true }); + await writeFile( + join(dataDir, 'settings.json'), + JSON.stringify({ + network: { + incomingPort: 0, + allowRelay: false, + maxRelayClients: 0, + mdnsEnabled: false, + }, + }) + ); + + const build = spawn({ + cmd: ['bun', 'build', '--compile', './src/app.ts', '--outfile', exePath], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const buildExit = await build.exited; + const buildOutput = `${await new Response(build.stdout).text()}${await new Response(build.stderr).text()}`; + expect(buildExit, buildOutput).toBe(0); + + const workerBuild = spawn({ + cmd: ['bun', 'build', './src/lish/checksum-worker.ts', '--target', 'bun', '--outfile', join(buildDir, 'lish', 'checksum-worker.js')], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const workerBuildExit = await workerBuild.exited; + const workerBuildOutput = `${await new Response(workerBuild.stdout).text()}${await new Response(workerBuild.stderr).text()}`; + expect(workerBuildExit, workerBuildOutput).toBe(0); + + const apiPort = await findFreePort(); + const backend = spawn({ + cmd: [exePath, '--datadir', dataDir, '--host', 'localhost', '--port', String(apiPort), '--loglevel', 'debug'], + cwd: runDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await waitForBackend(apiPort, 20_000); + + return { tempRoot, sourceDir, outputDir, exePath, apiPort, backend }; +} + +async function teardownCompiledBackend(ctx: CompiledBackend | undefined): Promise { + if (!ctx) return; + await stopProcess(ctx.backend); + await rm(ctx.tempRoot, { recursive: true, force: true }); +} + test( 'compiled backend can create a LISH with the default checksum settings outside the source tree', async () => { - const tempRoot = await mkdtemp(join(tmpdir(), 'libershare-compiled-worker-')); - const sourceDir = join(tempRoot, 'source'); - const outputDir = join(tempRoot, 'out'); - const dataDir = join(tempRoot, 'data'); - const buildDir = join(tempRoot, 'build'); - const runDir = join(tempRoot, 'run'); - const exePath = join(buildDir, process.platform === 'win32' ? 'lish-backend.exe' : 'lish-backend'); - let backend: Subprocess<'ignore', 'pipe', 'pipe'> | undefined; - + let ctx: CompiledBackend | undefined; try { - await mkdir(sourceDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - await mkdir(dataDir, { recursive: true }); - await mkdir(buildDir, { recursive: true }); - await mkdir(join(buildDir, 'lish'), { recursive: true }); - await mkdir(runDir, { recursive: true }); - await writeFile(join(sourceDir, 'a.txt'), 'a'.repeat(10_000)); - await writeFile(join(sourceDir, 'b.txt'), 'b'.repeat(10_000)); - await writeFile( - join(dataDir, 'settings.json'), - JSON.stringify({ - network: { - incomingPort: 0, - allowRelay: false, - maxRelayClients: 0, - mdnsEnabled: false, - }, - }) - ); - - const build = spawn({ - cmd: ['bun', 'build', '--compile', './src/app.ts', '--outfile', exePath], - cwd: import.meta.dir + '/..', - stdout: 'pipe', - stderr: 'pipe', - }); - const buildExit = await build.exited; - const buildOutput = `${await new Response(build.stdout).text()}${await new Response(build.stderr).text()}`; - expect(buildExit, buildOutput).toBe(0); - const workerBuild = spawn({ - cmd: ['bun', 'build', './src/lish/checksum-worker.ts', '--target', 'bun', '--outfile', join(buildDir, 'lish', 'checksum-worker.js')], - cwd: import.meta.dir + '/..', - stdout: 'pipe', - stderr: 'pipe', - }); - const workerBuildExit = await workerBuild.exited; - const workerBuildOutput = `${await new Response(workerBuild.stdout).text()}${await new Response(workerBuild.stderr).text()}`; - expect(workerBuildExit, workerBuildOutput).toBe(0); - - const apiPort = await findFreePort(); - backend = spawn({ - cmd: [exePath, '--datadir', dataDir, '--host', 'localhost', '--port', String(apiPort), '--loglevel', 'debug'], - cwd: runDir, - stdout: 'pipe', - stderr: 'pipe', - }); - await waitForBackend(apiPort, 20_000); + ctx = await buildAndSpawnCompiledBackend('compiled-worker-smoke'); + await writeFile(join(ctx.sourceDir, 'a.txt'), 'a'.repeat(10_000)); + await writeFile(join(ctx.sourceDir, 'b.txt'), 'b'.repeat(10_000)); - const ws = await openWs(apiPort); + const ws = await openWs(ctx.apiPort); const progressEvents: string[] = []; ws.addEventListener('message', event => { const msg = JSON.parse(event.data as string); @@ -161,8 +183,8 @@ test( ws, 'lishs.create', { - dataPath: sourceDir, - lishFile: join(outputDir, 'compiled-worker-smoke.lish'), + dataPath: ctx.sourceDir, + lishFile: join(ctx.outputDir, 'compiled-worker-smoke.lish'), addToSharing: false, addToDownloading: false, name: 'Compiled worker smoke', @@ -178,9 +200,79 @@ test( expect(progressEvents).toContain('chunk'); expect(progressEvents.filter(type => type === 'file')).toHaveLength(2); } finally { - if (backend) await stopProcess(backend); - await rm(tempRoot, { recursive: true, force: true }); + await teardownCompiledBackend(ctx); } }, 120_000 ); + +// Regression guard for the Windows compiled `worker.terminate()` segfault path. The fix in +// lish.ts swaps terminate() for unref() on busy workers when running as a compiled binary on +// Windows. We prove the abort cleanup no longer crashes by mid-flight cancelling a multi-file +// LISH creation and verifying the backend stays responsive afterwards. +test( + 'compiled backend handles stopCreate mid-flight without crashing', + async () => { + let ctx: CompiledBackend | undefined; + try { + ctx = await buildAndSpawnCompiledBackend('compiled-worker-stop'); + // 3 × 8 MB random files keep workers busy long enough to abort during chunk hashing + // without finishing the run instantly. + const fileBytes = 8 * 1024 * 1024; + for (const name of ['a.bin', 'b.bin', 'c.bin']) { + const buf = new Uint8Array(fileBytes); + crypto.getRandomValues(buf.subarray(0, 65_536)); + await writeFile(join(ctx.sourceDir, name), buf); + } + + const ws = await openWs(ctx.apiPort); + let progressCount = 0; + ws.addEventListener('message', event => { + const msg = JSON.parse(event.data as string); + if (msg.event === 'lishs.create:progress') progressCount++; + }); + await rpc(ws, 'events.subscribe', { events: ['lishs.create:progress'] }); + + const createPromise = rpc( + ws, + 'lishs.create', + { + dataPath: ctx.sourceDir, + lishFile: join(ctx.outputDir, 'stop-create.lish'), + addToSharing: false, + addToDownloading: false, + name: 'Stop create regression', + chunkSize: 65_536, + minifyJSON: false, + compress: false, + }, + 60_000 + ); + + // Wait until at least one chunk has progressed so we know workers are active. + const startWait = Date.now(); + while (progressCount === 0 && Date.now() - startWait < 30_000) await Bun.sleep(50); + expect(progressCount).toBeGreaterThan(0); + + await rpc(ws, 'lishs.stopCreate', {}); + + let createError: unknown; + try { + await createPromise; + } catch (e) { + createError = e; + } + expect(String(createError)).toContain('LISH_CREATE_CANCELLED'); + + // Backend must still be alive: a follow-up RPC should succeed. + expect(ctx.backend.exitCode).toBeNull(); + const list = await rpc(ws, 'lishs.list', {}); + expect(list).toBeDefined(); + + ws.close(); + } finally { + await teardownCompiledBackend(ctx); + } + }, + 180_000 +); From 03e48d5f6a0ec90122db75bd37f64a1a08985dad Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 5 May 2026 09:27:04 +0200 Subject: [PATCH 11/16] test(backend): run typecheck and unit tests as pre-build gate --- backend/build.bat | 10 ++++++++++ backend/build.sh | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/backend/build.bat b/backend/build.bat index ca59c270..e1ed20cc 100644 --- a/backend/build.bat +++ b/backend/build.bat @@ -2,6 +2,16 @@ if exist build rmdir /s /q build call bun i --frozen-lockfile if errorlevel 1 exit /b 1 + +rem Pre-build verification: typecheck + unit tests must pass before producing artifacts. +rem Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +if not "%SKIP_TESTS%"=="1" ( + call bun run typecheck + if errorlevel 1 exit /b 1 + call bun run test + if errorlevel 1 exit /b 1 +) + mkdir build call bun build --compile ./src/app.ts --outfile build\lish-backend.exe if errorlevel 1 exit /b 1 diff --git a/backend/build.sh b/backend/build.sh index 563faee2..c35a8533 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -25,6 +25,14 @@ done [ -d "./build/" ] && rm -r build mkdir -p build bun i --frozen-lockfile + +# Pre-build verification: typecheck + unit tests must pass before producing artifacts. +# Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +if [ "${SKIP_TESTS:-0}" != "1" ]; then + bun run typecheck + bun run test +fi + mkdir -p build/lish if [ -n "$BUN_TARGET" ]; then From 8758bed410ec5c11639398b6910e052905e33150 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 5 May 2026 09:28:14 +0200 Subject: [PATCH 12/16] test(app): run cross-package verification as pre-build gate --- app/build.bat | 41 +++++++++++++++++++++++++++++++++++++++++ app/build.sh | 17 +++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/app/build.bat b/app/build.bat index 069d03ff..46762498 100644 --- a/app/build.bat +++ b/app/build.bat @@ -610,6 +610,13 @@ if errorlevel 1 ( endlocal & exit /b 1 ) +rem ── Pre-build verification (typecheck + tests across packages) ── +call :run_pre_build_tests +if errorlevel 1 ( + for %%f in (!_all_expected!) do echo FAIL %%f>>"!BUILD_RESULTS_FILE!" + endlocal & exit /b 1 +) + rem ── Build frontend ── call :build_frontend if errorlevel 1 ( @@ -721,6 +728,40 @@ call :elapsed_since !_icons_start! _icons_elapsed echo === Icons done ^(!_icons_elapsed!^) === exit /b 0 +rem ─── run_pre_build_tests ───────────────────────────────────────────────── +rem Pre-build verification: ensure source quality across packages before producing artifacts. +rem Backend unit tests run inside backend\build.bat, here we cover the rest. +rem Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. + +:run_pre_build_tests +if "%SKIP_TESTS%"=="1" ( + echo === Pre-build tests skipped ^(SKIP_TESTS=1^) === + exit /b 0 +) +set "_test_start=0" +call :get_timestamp _test_start +echo === Running pre-build verification === +cd /d "!ROOT_DIR!\shared" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run typecheck +if errorlevel 1 exit /b 1 +cd /d "!ROOT_DIR!\cli" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run typecheck +if errorlevel 1 exit /b 1 +cd /d "!ROOT_DIR!\frontend" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run check +if errorlevel 1 exit /b 1 +cd /d "!SCRIPT_DIR!" +set "_test_elapsed=0" +call :elapsed_since !_test_start! _test_elapsed +echo === Pre-build verification done ^(!_test_elapsed!^) === +exit /b 0 + rem ─── build_frontend ────────────────────────────────────────────────────── :build_frontend diff --git a/app/build.sh b/app/build.sh index d7567566..620ca197 100755 --- a/app/build.sh +++ b/app/build.sh @@ -334,6 +334,22 @@ build_frontend() { echo "=== Frontend done ($(elapsed_since $_t)) ===" } +# Pre-build verification: ensure source quality across packages before producing artifacts. +# Backend unit tests run inside backend/build.sh, here we cover the rest. +# Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +run_pre_build_tests() { + if [ "${SKIP_TESTS:-0}" = "1" ]; then + echo "=== Pre-build tests skipped (SKIP_TESTS=1) ===" + return 0 + fi + _t=$(date +%s) + echo "=== Running pre-build verification ===" + (cd "$ROOT_DIR/shared" && bun install --frozen-lockfile && bun run typecheck) + (cd "$ROOT_DIR/cli" && bun install --frozen-lockfile && bun run typecheck) + (cd "$ROOT_DIR/frontend" && bun install --frozen-lockfile && bun run check) + echo "=== Pre-build verification done ($(elapsed_since $_t)) ===" +} + build_backend() { if [ "$BUILD_OS" = "macos" ] && [ "$BUILD_ARCH" = "universal" ]; then _t=$(date +%s) @@ -921,6 +937,7 @@ docker_inner_build() { _inner_fail=0 build_icons + run_pre_build_tests build_frontend build_backend sync_product_info From 9ed247147993e93be702e015e563cd701ba70bc8 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 5 May 2026 10:02:20 +0200 Subject: [PATCH 13/16] test(downloader): skip windows-specific backslash traversal cases on posix --- backend/tests/unit/protocol/downloader.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/tests/unit/protocol/downloader.test.ts b/backend/tests/unit/protocol/downloader.test.ts index f3c2eaa5..222b6ba7 100644 --- a/backend/tests/unit/protocol/downloader.test.ts +++ b/backend/tests/unit/protocol/downloader.test.ts @@ -736,12 +736,16 @@ describe('Downloader – path traversal protection', () => { // --- Encoded/obfuscated traversal attempts (must block) --- - it('blocks backslash traversal on Windows', () => { + // Backslash is only a path separator on Windows. On POSIX it is a literal filename character, + // so safePath cannot — and should not — interpret it as traversal. + const itWindows = process.platform === 'win32' ? it : it.skip; + + itWindows('blocks backslash traversal on Windows', () => { const dl = makeDownloader(); expect(() => callSafePath(dl, '..\\evil.txt')).toThrow('Path traversal blocked'); }); - it('blocks mixed slash traversal', () => { + itWindows('blocks mixed slash traversal on Windows', () => { const dl = makeDownloader(); expect(() => callSafePath(dl, '..\\..\\evil.txt')).toThrow('Path traversal blocked'); }); From c12f7516f81fa9c2a8acdad3c536c32ac3a68402 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 5 May 2026 18:54:54 +0200 Subject: [PATCH 14/16] test(app): run backend tests in app/build.bat backend phase too --- app/build.bat | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/build.bat b/app/build.bat index 46762498..c67e97d8 100644 --- a/app/build.bat +++ b/app/build.bat @@ -795,6 +795,17 @@ cd /d "!ROOT_DIR!\backend" if exist build rmdir /s /q build call bun i --frozen-lockfile if errorlevel 1 ( endlocal & exit /b 1 ) + +rem Pre-build verification: typecheck + unit tests must pass before producing artifacts. +rem Mirrors the gate inside backend\build.bat (used when invoking it directly) so Tauri +rem builds don't bypass tests. Skip with SKIP_TESTS=1 only in emergencies; CI must never set it. +if not "%SKIP_TESTS%"=="1" ( + call bun run typecheck + if errorlevel 1 ( endlocal & exit /b 1 ) + call bun run test + if errorlevel 1 ( endlocal & exit /b 1 ) +) + mkdir build call bun build --compile --target !BUN_TGT! ./src/app.ts --outfile build\lish-backend.exe if errorlevel 1 ( endlocal & exit /b 1 ) From 38e8674e756888ff5922c8a4d71b6e1febfd6b89 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 6 May 2026 17:27:32 +0200 Subject: [PATCH 15/16] Revert "test(app): run backend tests in app/build.bat backend phase too" This reverts commit c12f7516f81fa9c2a8acdad3c536c32ac3a68402. --- app/build.bat | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/build.bat b/app/build.bat index c67e97d8..46762498 100644 --- a/app/build.bat +++ b/app/build.bat @@ -795,17 +795,6 @@ cd /d "!ROOT_DIR!\backend" if exist build rmdir /s /q build call bun i --frozen-lockfile if errorlevel 1 ( endlocal & exit /b 1 ) - -rem Pre-build verification: typecheck + unit tests must pass before producing artifacts. -rem Mirrors the gate inside backend\build.bat (used when invoking it directly) so Tauri -rem builds don't bypass tests. Skip with SKIP_TESTS=1 only in emergencies; CI must never set it. -if not "%SKIP_TESTS%"=="1" ( - call bun run typecheck - if errorlevel 1 ( endlocal & exit /b 1 ) - call bun run test - if errorlevel 1 ( endlocal & exit /b 1 ) -) - mkdir build call bun build --compile --target !BUN_TGT! ./src/app.ts --outfile build\lish-backend.exe if errorlevel 1 ( endlocal & exit /b 1 ) From 9d656a2032fa96d95a81d438cc774d9efc11604a Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 6 May 2026 17:28:09 +0200 Subject: [PATCH 16/16] Revert "chore(installer): drop nsis languages without multiuser plugin coverage" This reverts commit 642da99a879172436ab3e9f24cfa3586dd4559cf. --- app/tauri.conf.json | 62 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/app/tauri.conf.json b/app/tauri.conf.json index 5a332efe..3fe27b20 100644 --- a/app/tauri.conf.json +++ b/app/tauri.conf.json @@ -34,31 +34,56 @@ "startMenuFolder": "LiberShare", "installerHooks": "nsis-hooks.nsh", "languages": [ + "Afrikaans", "Albanian", "Arabic", "Asturian", "Basque", "Belarusian", + "Bosnian", + "Breton", + "Bulgarian", + "Catalan", "Corsican", + "Croatian", "Czech", "Danish", "Dutch", "English", "Esperanto", + "Estonian", + "Farsi", + "Finnish", "French", + "Galician", "German", + "Greek", "Hebrew", + "Hungarian", + "Icelandic", "Indonesian", + "Irish", "Italian", "Japanese", + "Korean", + "Kurdish", + "Latvian", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Malay", "Mongolian", "Norwegian", "NorwegianNynorsk", + "Pashto", "Polish", "Portuguese", "PortugueseBR", + "Romanian", "Russian", "ScotsGaelic", + "Serbian", + "SerbianLatin", "SimpChinese", "Slovak", "Slovenian", @@ -66,29 +91,62 @@ "SpanishInternational", "Swedish", "Tatar", + "Thai", "TradChinese", + "Turkish", "Ukrainian", - "Vietnamese" + "Uzbek", + "Vietnamese", + "Welsh" ], "customLanguageFiles": { + "Afrikaans": "nsis-langs/Afrikaans.nsh", "Albanian": "nsis-langs/Albanian.nsh", "Asturian": "nsis-langs/Asturian.nsh", "Basque": "nsis-langs/Basque.nsh", "Belarusian": "nsis-langs/Belarusian.nsh", + "Bosnian": "nsis-langs/Bosnian.nsh", + "Breton": "nsis-langs/Breton.nsh", + "Bulgarian": "nsis-langs/Bulgarian.nsh", + "Catalan": "nsis-langs/Catalan.nsh", "Corsican": "nsis-langs/Corsican.nsh", + "Croatian": "nsis-langs/Croatian.nsh", "Czech": "nsis-langs/Czech.nsh", "Danish": "nsis-langs/Danish.nsh", "Esperanto": "nsis-langs/Esperanto.nsh", + "Estonian": "nsis-langs/Estonian.nsh", + "Farsi": "nsis-langs/Farsi.nsh", + "Finnish": "nsis-langs/Finnish.nsh", + "Galician": "nsis-langs/Galician.nsh", + "Greek": "nsis-langs/Greek.nsh", "Hebrew": "nsis-langs/Hebrew.nsh", + "Hungarian": "nsis-langs/Hungarian.nsh", + "Icelandic": "nsis-langs/Icelandic.nsh", "Indonesian": "nsis-langs/Indonesian.nsh", + "Irish": "nsis-langs/Irish.nsh", + "Korean": "nsis-langs/Korean.nsh", + "Kurdish": "nsis-langs/Kurdish.nsh", + "Latvian": "nsis-langs/Latvian.nsh", + "Lithuanian": "nsis-langs/Lithuanian.nsh", + "Luxembourgish": "nsis-langs/Luxembourgish.nsh", + "Macedonian": "nsis-langs/Macedonian.nsh", + "Malay": "nsis-langs/Malay.nsh", "Mongolian": "nsis-langs/Mongolian.nsh", "NorwegianNynorsk": "nsis-langs/NorwegianNynorsk.nsh", + "Pashto": "nsis-langs/Pashto.nsh", "Polish": "nsis-langs/Polish.nsh", + "Romanian": "nsis-langs/Romanian.nsh", "ScotsGaelic": "nsis-langs/ScotsGaelic.nsh", + "Serbian": "nsis-langs/Serbian.nsh", + "SerbianLatin": "nsis-langs/SerbianLatin.nsh", "Slovak": "nsis-langs/Slovak.nsh", "Slovenian": "nsis-langs/Slovenian.nsh", "Tatar": "nsis-langs/Tatar.nsh", - "Vietnamese": "nsis-langs/Vietnamese.nsh" + "Thai": "nsis-langs/Thai.nsh", + "Turkish": "nsis-langs/Turkish.nsh", + "Uzbek": "nsis-langs/Uzbek.nsh", + "Vietnamese": "nsis-langs/Vietnamese.nsh", + "Welsh": "nsis-langs/Welsh.nsh" } } }