From caaf7e6c79c8bfbce2ccfca6f38a0c160ccfdb00 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 6 May 2026 06:45:57 +0200 Subject: [PATCH] Integrate backend sidecar process and external binaries into the Tauri build --- backend/sidecar_main.py | 27 +++++ package.json | 3 + scripts/build-backend-sidecar.cjs | 177 ++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/binaries/.gitignore | 2 + src-tauri/tauri.conf.json | 10 +- 6 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 backend/sidecar_main.py create mode 100644 scripts/build-backend-sidecar.cjs create mode 100644 src-tauri/binaries/.gitignore diff --git a/backend/sidecar_main.py b/backend/sidecar_main.py new file mode 100644 index 0000000..5cba348 --- /dev/null +++ b/backend/sidecar_main.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import multiprocessing +import os + +import uvicorn + + +def main() -> None: + multiprocessing.freeze_support() + os.environ.setdefault("HEIMGEIST_BACKEND_SIDECAR", "1") + host = os.getenv("HEIMGEIST_BACKEND_HOST", "127.0.0.1") + port = int(os.getenv("HEIMGEIST_BACKEND_PORT", "8000")) + log_level = os.getenv("HEIMGEIST_BACKEND_LOG_LEVEL", "info") + + uvicorn.run( + "backend.main:app", + host=host, + port=port, + log_level=log_level, + reload=False, + workers=1, + ) + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json index ec51762..973c6c5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "dev:tauri:shell": "tauri dev", "tauri": "tauri", "build": "vite build", + "build:sidecar": "node scripts/build-backend-sidecar.cjs", + "package:mac": "npm run build:sidecar && tauri build --bundles app", + "build:app": "npm run package:mac", "start": "npm run dev" }, "dependencies": { diff --git a/scripts/build-backend-sidecar.cjs b/scripts/build-backend-sidecar.cjs new file mode 100644 index 0000000..2ce0b46 --- /dev/null +++ b/scripts/build-backend-sidecar.cjs @@ -0,0 +1,177 @@ +const { execFileSync, spawnSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const projectRoot = path.resolve(__dirname, '..') +const pythonBin = path.join(projectRoot, 'backend', '.venv', 'bin', 'python') +const binariesDir = path.join(projectRoot, 'src-tauri', 'binaries') +const targetTriple = resolveTargetTriple() +const executableExtension = process.platform === 'win32' ? '.exe' : '' +const backendOutputName = `heimgeist-backend-${targetTriple}${executableExtension}` +const backendOutputPath = path.join(binariesDir, backendOutputName) + +function resolveTargetTriple() { + const output = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }) + const match = output.match(/^host:\s+(\S+)/m) + if (!match) { + throw new Error('Could not determine Rust target triple from rustc -vV output.') + } + return match[1] +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: projectRoot, + stdio: 'inherit', + env: process.env, + ...options, + }) + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`) + } +} + +function ensurePython() { + if (!fs.existsSync(pythonBin)) { + throw new Error(`Missing backend Python virtualenv at ${pythonBin}. Run ./run.sh or create backend/.venv first.`) + } +} + +function ensurePyInstaller() { + const result = spawnSync(pythonBin, ['-c', 'import PyInstaller'], { + cwd: projectRoot, + stdio: 'ignore', + }) + if (result.status === 0) { + return + } + + console.log('Installing PyInstaller into backend/.venv for sidecar packaging.') + run(pythonBin, ['-m', 'pip', 'install', '--upgrade', 'pyinstaller']) +} + +function copyExecutable(sourcePath, outputBaseName) { + if (!sourcePath || !fs.existsSync(sourcePath)) { + return null + } + fs.mkdirSync(binariesDir, { recursive: true }) + const outputPath = path.join(binariesDir, `${outputBaseName}-${targetTriple}${executableExtension}`) + fs.copyFileSync(sourcePath, outputPath) + fs.chmodSync(outputPath, 0o755) + return outputPath +} + +function maybeCopyFfmpeg() { + try { + const ffmpegPath = require('ffmpeg-static') + const outputPath = copyExecutable(ffmpegPath, 'heimgeist-ffmpeg') + if (outputPath) { + console.log(`Bundled ffmpeg helper: ${outputPath}`) + } + } catch (error) { + console.warn(`Could not bundle ffmpeg-static: ${error.message}`) + } +} + +function maybeCopyFfprobe() { + try { + const ffprobeStatic = require('ffprobe-static') + const ffprobePath = ffprobeStatic && ffprobeStatic.path + if (!ffprobePath || !fs.existsSync(ffprobePath)) { + console.warn('Could not bundle ffprobe-static: package did not provide a binary path.') + return + } + + const probe = spawnSync(ffprobePath, ['-version'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (probe.status !== 0) { + console.warn( + `Skipping bundled ffprobe: ${ffprobePath} did not run successfully on this machine. Audio/video duration probing remains a release gap.`, + ) + return + } + + const outputPath = copyExecutable(ffprobePath, 'heimgeist-ffprobe') + if (outputPath) { + console.log(`Bundled ffprobe helper: ${outputPath}`) + } + } catch (error) { + console.warn(`Could not bundle ffprobe-static: ${error.message}`) + } +} + +function writeBinariesIgnore() { + fs.mkdirSync(binariesDir, { recursive: true }) + const ignorePath = path.join(binariesDir, '.gitignore') + if (!fs.existsSync(ignorePath)) { + fs.writeFileSync(ignorePath, '*\n!.gitignore\n', 'utf8') + } +} + +function buildBackendSidecar() { + const pyinstallerRoot = path.join('/tmp', `heimgeist-pyinstaller-${targetTriple}`) + const distPath = path.join(pyinstallerRoot, 'dist') + const workPath = path.join(pyinstallerRoot, 'build') + const specPath = path.join(pyinstallerRoot, 'spec') + + fs.rmSync(pyinstallerRoot, { recursive: true, force: true }) + fs.rmSync(backendOutputPath, { force: true }) + fs.mkdirSync(distPath, { recursive: true }) + fs.mkdirSync(workPath, { recursive: true }) + fs.mkdirSync(specPath, { recursive: true }) + + run(pythonBin, [ + '-m', + 'PyInstaller', + '--noconfirm', + '--clean', + '--onefile', + '--name', + 'heimgeist-backend', + '--distpath', + distPath, + '--workpath', + workPath, + '--specpath', + specPath, + '--paths', + projectRoot, + '--collect-submodules', + 'backend.rag', + '--hidden-import', + 'backend.main', + '--hidden-import', + 'backend.sidecar_main', + '--hidden-import', + 'uvicorn.logging', + '--hidden-import', + 'uvicorn.loops.auto', + '--hidden-import', + 'uvicorn.protocols.http.auto', + '--hidden-import', + 'uvicorn.protocols.websockets.auto', + '--hidden-import', + 'uvicorn.lifespan.on', + path.join(projectRoot, 'backend', 'sidecar_main.py'), + ]) + + const builtBinary = path.join(distPath, `heimgeist-backend${executableExtension}`) + if (!fs.existsSync(builtBinary)) { + throw new Error(`PyInstaller did not produce expected binary at ${builtBinary}`) + } + + fs.mkdirSync(binariesDir, { recursive: true }) + fs.copyFileSync(builtBinary, backendOutputPath) + fs.chmodSync(backendOutputPath, 0o755) + console.log(`Built backend sidecar: ${backendOutputPath}`) +} + +ensurePython() +ensurePyInstaller() +writeBinariesIgnore() +buildBackendSidecar() +maybeCopyFfmpeg() +maybeCopyFfprobe() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5b1d18c..c2d1731 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,3 +15,4 @@ serde_json = "1" tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-shell = "2" diff --git a/src-tauri/binaries/.gitignore b/src-tauri/binaries/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src-tauri/binaries/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5849228..d57721b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,8 +26,12 @@ } }, "bundle": { - "active": false, - "targets": "all", - "icon": [] + "active": true, + "targets": ["app"], + "icon": ["icons/icon.png"], + "externalBin": [ + "binaries/heimgeist-backend", + "binaries/heimgeist-ffmpeg" + ] } }