Skip to content

Commit 43a10fc

Browse files
committed
feat(desktop): scaffold Tauri desktop shell + slim API sidecar packaging
Foundation for packaging OpenKB as a desktop app for non-technical users. The architecture is a thin Tauri (Rust) shell that spawns the openkb-api server (from #150) frozen into a slim, self-contained PyInstaller sidecar, then points the system WebView at it over localhost. desktop/packaging/ — freezes openkb.api:main into a slim onedir binary: - pyi_rthook_magika.py: runtime hook stubbing magika so markitdown converts Office/HTML by file extension, letting the build exclude magika + onnxruntime entirely. - prune_litellm_proxy.py: post-build removal of the unused LiteLLM Proxy Server (static/data assets no client code path imports). - build_sidecar.sh: the PyInstaller recipe wiring both together. - sidecar_entry.py: frozen entry mirroring the openkb-api console script. desktop/src-tauri/ — reference Tauri v2 shell: spawns the sidecar, shows a splash while polling its health, navigates the WebView to it, kills it on exit. PyInstaller onedir output is bundled as a resource directory (not externalBin, which expects a single file). Verified (x86_64 Linux): the frozen slim sidecar boots uvicorn and serves GET /api/v1/kbs -> 200; ≈134 MB compressed / 338 MB on disk. The src-tauri shell is a skeleton, not compiled here (the dev container lacks webkit2gtk); build it where the platform WebView libraries are present. See desktop/README.md. Claude-Session: https://claude.ai/code/session_01KZyUSGAzVL9yxpsWWPv6Y2
1 parent 715a623 commit 43a10fc

11 files changed

Lines changed: 415 additions & 0 deletions

File tree

desktop/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# PyInstaller sidecar build artifacts
2+
packaging/build/
3+
packaging/dist/
4+
packaging/*.spec
5+
6+
# Tauri / Rust build artifacts
7+
src-tauri/target/
8+
src-tauri/gen/

desktop/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# OpenKB Desktop (Tauri)
2+
3+
A desktop app that wraps OpenKB for non-technical users: no `pip`, no terminal.
4+
It is a thin [Tauri](https://tauri.app) (Rust) shell around two things OpenKB
5+
already produces:
6+
7+
1. **The API sidecar**`openkb-api` (the FastAPI server from the Knowledge
8+
Workbench, `openkb.api:main`) frozen into a self-contained binary with
9+
PyInstaller. It serves both the JSON/SSE API and the built web UI.
10+
2. **The web UI** — the `frontend/` Vite SPA, served by the sidecar at `/`.
11+
12+
```
13+
┌─────────────────────────────────────────────┐
14+
│ Tauri shell (Rust + system WebView) │
15+
│ │
16+
│ spawns ──▶ openkb-api-sidecar (frozen) │
17+
│ ├─ FastAPI /api/v1/* │
18+
│ └─ web UI / │
19+
│ WebView ──▶ http://127.0.0.1:<port>/ │
20+
└─────────────────────────────────────────────┘
21+
```
22+
23+
The user double-clicks the app; the Rust shell starts the sidecar on a
24+
localhost port, waits until it answers, then points the WebView at it. No
25+
browser, no port, no "server" ever surfaces to the user.
26+
27+
## Why this shape
28+
29+
- OpenKB's capability lives in a heavy Python stack (litellm, pageindex,
30+
markitdown, pymupdf). Rust can't replace that, so the Python runs as a frozen
31+
sidecar. The Rust shell is glue: window, WebView, process lifecycle, updater.
32+
- Tauri (vs Electron) ships no Chromium — smaller download, less memory — at the
33+
cost of using each platform's system WebView.
34+
35+
## Layout
36+
37+
```
38+
desktop/
39+
packaging/ # freeze the Python API into a slim sidecar binary
40+
build_sidecar.sh # PyInstaller recipe (slim: no magika/onnxruntime)
41+
sidecar_entry.py # frozen entry -> openkb.api:main
42+
pyi_rthook_magika.py# runtime hook: stub magika (drops onnxruntime)
43+
prune_litellm_proxy.py # post-build: delete the unused LiteLLM proxy server
44+
src-tauri/ # Tauri (Rust) shell — spawns the sidecar, opens the window
45+
```
46+
47+
## Build pipeline
48+
49+
```bash
50+
# 1. Python env with API + PyInstaller
51+
python -m venv .venv && . .venv/bin/activate
52+
pip install -e ".[api]" pyinstaller
53+
54+
# 2. Build the web UI (its output is what the sidecar serves)
55+
cd frontend && npm install && npm run build && cd ..
56+
57+
# 3. Freeze the slim API sidecar -> desktop/packaging/dist/openkb-api-sidecar/
58+
PYTHON=.venv/bin/python desktop/packaging/build_sidecar.sh
59+
60+
# 4. Build the Tauri app (bundles the sidecar + opens the WebView)
61+
cd desktop/src-tauri && cargo tauri build
62+
```
63+
64+
## Slimming (measured, x86_64 Linux)
65+
66+
The sidecar reuses the packaging work from the CLI slimming (see PR #186 for the
67+
lazy-markitdown source change that makes it possible):
68+
69+
| stage | compressed |
70+
|---|---|
71+
| full PyInstaller freeze | 147 MB |
72+
| − magika / onnxruntime (stub hook) | 140 MB |
73+
| − LiteLLM proxy server | 133 MB |
74+
75+
The frozen **API sidecar** (adds FastAPI + uvicorn over the CLI baseline)
76+
measures **≈134 MB compressed / 338 MB on disk**, verified booting uvicorn and
77+
serving `GET /api/v1/kbs` → 200.
78+
79+
`pymupdf` (PDF engine) and `pandas`/`numpy` (Excel support) are kept
80+
deliberately — they are load-bearing for OpenKB's document formats.
81+
82+
## Toolchain / status
83+
84+
Full builds need, per platform: Rust + Cargo, Node + npm, a Python 3.10+ env,
85+
and on **Linux `webkit2gtk`** (`libwebkit2gtk-4.1-dev`) for the WebView.
86+
87+
**Status:** the API sidecar freeze (`packaging/`) is implemented and verified.
88+
The `src-tauri/` shell is a reference skeleton — it is *not* compiled in the
89+
CI/dev container used so far because `webkit2gtk` is absent there; build it on a
90+
machine (or CI runner) that has the WebView libraries installed.

desktop/packaging/build_sidecar.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Build the OpenKB API sidecar: a self-contained, slimmed PyInstaller binary
4+
# that the Tauri desktop shell spawns and talks to over localhost HTTP.
5+
#
6+
# Slimming (see ../README.md for measured sizes):
7+
# - exclude magika + onnxruntime; a runtime hook stubs magika so markitdown
8+
# still converts Office/HTML by file extension.
9+
# - post-build prune of the unused LiteLLM Proxy Server.
10+
#
11+
# Prerequisites: a Python env with `pip install -e ".[api]"` and `pyinstaller`.
12+
# Set PYTHON to that env's interpreter (default: python3).
13+
#
14+
# Usage: PYTHON=/path/to/venv/bin/python desktop/packaging/build_sidecar.sh
15+
# Output: desktop/packaging/dist/openkb-api-sidecar/
16+
set -euo pipefail
17+
18+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19+
PYTHON="${PYTHON:-python3}"
20+
cd "$HERE"
21+
22+
rm -rf build dist openkb-api-sidecar.spec
23+
24+
COLLECT=(litellm markitdown pageindex tiktoken agents openai tiktoken_ext uvicorn fastapi)
25+
EXCLUDE=(magika onnxruntime)
26+
27+
ARGS=(--onedir --name openkb-api-sidecar --noconfirm --clean --log-level=WARN)
28+
ARGS+=(--runtime-hook "$HERE/pyi_rthook_magika.py")
29+
for pkg in "${COLLECT[@]}"; do ARGS+=(--collect-all "$pkg"); done
30+
for pkg in "${EXCLUDE[@]}"; do ARGS+=(--exclude-module "$pkg"); done
31+
32+
echo ">>> freezing openkb-api sidecar (slim: no magika/onnxruntime)"
33+
"$PYTHON" -m PyInstaller "${ARGS[@]}" sidecar_entry.py
34+
35+
echo ">>> pruning unused litellm proxy server"
36+
"$PYTHON" prune_litellm_proxy.py "dist/openkb-api-sidecar"
37+
38+
echo ">>> done: dist/openkb-api-sidecar/openkb-api-sidecar"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Prune the unused LiteLLM Proxy Server from a frozen build.
2+
3+
`--collect-all litellm` pulls litellm's entire *Proxy Server* (~44 MB: a
4+
FastAPI gateway with an admin UI, swagger assets and OpenAPI snapshots), but
5+
OpenKB only uses litellm as a client. `import litellm` loads a handful of small
6+
proxy submodules; none of the static/data assets below are reached by any
7+
client code path (verified: not in sys.modules after `import litellm` plus a
8+
completion call). Deleting them from the frozen tree is safe; the sidecar smoke
9+
test (`GET /api/v1/kbs` returns 200) confirms the client path stays intact.
10+
11+
Usage: python prune_litellm_proxy.py <path-to-frozen-app-dir>
12+
The frozen app dir is PyInstaller's onedir output (contains `_internal/`).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import sys
18+
from pathlib import Path
19+
20+
# Static/data dead weight — never imported by a litellm *client*.
21+
_PRUNE_DIRS = ("_experimental", "swagger")
22+
_PRUNE_GLOBS = ("*.jpg", "*.json", "*.yaml", "*.txt", "README.md")
23+
24+
25+
def _dir_size_mb(path: Path) -> int:
26+
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) // (1024 * 1024)
27+
28+
29+
def prune(app_dir: Path) -> None:
30+
proxy = app_dir / "_internal" / "litellm" / "proxy"
31+
if not proxy.is_dir():
32+
print(f"prune_litellm_proxy: no proxy dir at {proxy}; nothing to do")
33+
return
34+
before = _dir_size_mb(proxy)
35+
import shutil
36+
37+
for name in _PRUNE_DIRS:
38+
target = proxy / name
39+
if target.exists():
40+
shutil.rmtree(target)
41+
for pattern in _PRUNE_GLOBS:
42+
for f in proxy.glob(pattern):
43+
f.unlink()
44+
after = _dir_size_mb(proxy)
45+
print(f"prune_litellm_proxy: {before}M -> {after}M")
46+
47+
48+
if __name__ == "__main__":
49+
if len(sys.argv) != 2:
50+
raise SystemExit("usage: python prune_litellm_proxy.py <frozen-app-dir>")
51+
prune(Path(sys.argv[1]))
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""PyInstaller runtime hook: stub `magika` so markitdown runs without onnxruntime.
2+
3+
markitdown hard-depends on magika, which loads an ONNX model via onnxruntime
4+
(tens of MB) purely to sniff a file's type from its content. OpenKB only ever
5+
converts real files with correct extensions, so markitdown's extension/mimetype
6+
based stream-info guessing is sufficient and magika's content sniffing is
7+
redundant. This hook installs a lightweight in-memory `magika` module before
8+
markitdown imports it; `identify_stream` returns a non-"ok" status, which makes
9+
markitdown fall back to the extension guess. Paired with
10+
`--exclude-module magika --exclude-module onnxruntime` at build time, this drops
11+
onnxruntime (and magika's model) from the packaged sidecar entirely.
12+
"""
13+
14+
import sys
15+
import types
16+
17+
18+
class _Result:
19+
# Any status other than "ok" makes markitdown use its extension-based guess.
20+
status = "stub"
21+
22+
23+
class Magika:
24+
def __init__(self, *args, **kwargs):
25+
pass
26+
27+
def identify_stream(self, *args, **kwargs):
28+
return _Result()
29+
30+
def identify_bytes(self, *args, **kwargs):
31+
return _Result()
32+
33+
def identify_path(self, *args, **kwargs):
34+
return _Result()
35+
36+
37+
_stub = types.ModuleType("magika")
38+
_stub.Magika = Magika
39+
sys.modules.setdefault("magika", _stub)

desktop/packaging/sidecar_entry.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Frozen entry point for the OpenKB API sidecar.
2+
3+
Mirrors the `openkb-api = openkb.api:main` console script. The Tauri shell
4+
spawns the frozen binary as `openkb-api-sidecar --host 127.0.0.1 --port <PORT>`;
5+
argparse in `openkb.api.main` reads those args from sys.argv unchanged.
6+
"""
7+
8+
from openkb.api import main
9+
10+
if __name__ == "__main__":
11+
main()

desktop/src-tauri/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "openkb-desktop"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "OpenKB desktop shell (Tauri) — wraps the frozen openkb-api sidecar"
6+
license = "Apache-2.0"
7+
8+
[build-dependencies]
9+
tauri-build = { version = "2", features = [] }
10+
11+
[dependencies]
12+
tauri = { version = "2", features = [] }
13+
# ureq is a tiny blocking HTTP client used only to poll the sidecar's health
14+
# endpoint during startup. Swap for anything you prefer.
15+
ureq = "2"

desktop/src-tauri/build.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fn main() {
2+
tauri_build::build()
3+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>OpenKB</title>
7+
<style>
8+
html, body {
9+
margin: 0;
10+
height: 100%;
11+
display: grid;
12+
place-items: center;
13+
font-family: system-ui, -apple-system, sans-serif;
14+
background: #0b0d10;
15+
color: #e6e8eb;
16+
}
17+
.box { text-align: center; }
18+
.dot {
19+
width: 10px; height: 10px; border-radius: 50%;
20+
background: #4c8bf5; display: inline-block;
21+
animation: pulse 1s ease-in-out infinite;
22+
}
23+
@keyframes pulse { 0%,100% { opacity: .3 } 50% { opacity: 1 } }
24+
</style>
25+
</head>
26+
<body>
27+
<div class="box">
28+
<h1>OpenKB</h1>
29+
<p>Starting your knowledge base <span class="dot"></span></p>
30+
</div>
31+
</body>
32+
</html>

desktop/src-tauri/src/main.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// OpenKB desktop shell.
2+
//
3+
// Reference skeleton (Tauri v2). On launch it: (1) spawns the frozen
4+
// openkb-api sidecar on a localhost port, (2) shows a splash window while
5+
// polling the sidecar's health, (3) navigates the WebView to the sidecar once
6+
// it answers, and (4) kills the sidecar on exit.
7+
//
8+
// NOTE: not compiled in the container used so far (no webkit2gtk). Build on a
9+
// machine with the platform WebView libraries. Treat exact Tauri-v2 API details
10+
// (navigate signature, resource path) as things to confirm against your Tauri
11+
// version.
12+
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
13+
14+
use std::process::{Child, Command};
15+
use std::sync::Mutex;
16+
use std::time::{Duration, Instant};
17+
18+
use tauri::{Manager, RunEvent, Url};
19+
20+
/// Held in Tauri state so the sidecar can be killed when the app exits.
21+
struct Sidecar(Mutex<Option<Child>>);
22+
23+
const HOST: &str = "127.0.0.1";
24+
const PORT: u16 = 8765;
25+
26+
/// Spawn the bundled PyInstaller onedir sidecar.
27+
///
28+
/// The sidecar is bundled as a resource *directory* named `sidecar` (see
29+
/// `tauri.conf.json` `bundle.resources`), because PyInstaller onedir output is
30+
/// a folder, not a single executable — so this can't use Tauri's `externalBin`
31+
/// mechanism.
32+
fn spawn_sidecar(app: &tauri::App) -> std::io::Result<Child> {
33+
let resource_dir = app
34+
.path()
35+
.resource_dir()
36+
.expect("resource dir should resolve");
37+
let exe_name = if cfg!(windows) {
38+
"openkb-api-sidecar.exe"
39+
} else {
40+
"openkb-api-sidecar"
41+
};
42+
let exe = resource_dir
43+
.join("sidecar")
44+
.join("openkb-api-sidecar")
45+
.join(exe_name);
46+
Command::new(exe)
47+
.args(["--host", HOST, "--port", &PORT.to_string()])
48+
.spawn()
49+
}
50+
51+
/// Poll `url` until it answers or `timeout` elapses.
52+
fn wait_until_ready(url: &str, timeout: Duration) -> bool {
53+
let deadline = Instant::now() + timeout;
54+
while Instant::now() < deadline {
55+
if ureq::get(url)
56+
.timeout(Duration::from_millis(500))
57+
.call()
58+
.is_ok()
59+
{
60+
return true;
61+
}
62+
std::thread::sleep(Duration::from_millis(200));
63+
}
64+
false
65+
}
66+
67+
fn main() {
68+
tauri::Builder::default()
69+
.manage(Sidecar(Mutex::new(None)))
70+
.setup(|app| {
71+
let child = spawn_sidecar(app)?;
72+
app.state::<Sidecar>().0.lock().unwrap().replace(child);
73+
74+
let url = format!("http://{HOST}:{PORT}/");
75+
let handle = app.handle().clone();
76+
// Poll off the main thread so the splash window can paint.
77+
std::thread::spawn(move || {
78+
if wait_until_ready(&url, Duration::from_secs(30)) {
79+
if let (Some(win), Ok(parsed)) =
80+
(handle.get_webview_window("main"), Url::parse(&url))
81+
{
82+
let _ = win.navigate(parsed);
83+
}
84+
}
85+
});
86+
Ok(())
87+
})
88+
.build(tauri::generate_context!())
89+
.expect("error while building tauri app")
90+
.run(|app_handle, event| {
91+
if let RunEvent::Exit = event {
92+
if let Some(mut child) = app_handle.state::<Sidecar>().0.lock().unwrap().take() {
93+
let _ = child.kill();
94+
}
95+
}
96+
});
97+
}

0 commit comments

Comments
 (0)