Add Tauri desktop app scaffolding for offline KJV Study

Introduces a complete Tauri v2 project structure for building a native
macOS desktop application. The app runs FastAPI as a sidecar process
on port 31102 (number of KJV verses) and renders the existing web UI
in a native WebKit webview.

Key additions:
- src-tauri/: Complete Tauri configuration and Rust source
- Desktop entry point (kjvstudy_org/desktop.py)
- PyInstaller bundling script for Python sidecar
- Desktop-specific pyproject.toml without WeasyPrint
- App icons for macOS (.icns) and Windows (.ico)
- Makefile.desktop with build targets
- Comprehensive DESKTOP.md documentation

WeasyPrint is intentionally excluded from the desktop build to avoid
native library bundling complexity. PDF buttons are automatically
hidden when WeasyPrint is unavailable (existing graceful degradation).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 13:35:35 -05:00
parent fd9e89f565
commit 79dfd14400
25 changed files with 577 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# KJV Study Desktop App
A fully offline desktop application for studying the King James Bible, built with [Tauri](https://tauri.app/).
## Features
Everything from kjvstudy.org, completely offline:
- **Complete KJV Bible** - All 31,102 verses from the 1769 Cambridge edition
- **Verse Commentary** - In-depth theological analysis for 12,321+ verses
- **Interlinear Bible** - Hebrew (OT) and Greek (NT) word-by-word analysis
- **Strong's Concordance** - 14,298 Hebrew and Greek word definitions
- **Cross-References** - Treasury of Scripture Knowledge
- **Study Resources** - 36 study guides, 38 topics, 12 reading plans
- **Family Trees** - 429+ biblical figures with genealogies
- **Full-Text Search** - Fast SQLite FTS5 search across all verses
- **Dark Mode** - System-aware theme switching
- **Accessibility** - Full keyboard navigation, screen reader support
**Note:** PDF export is not available in the desktop version (WeasyPrint system dependencies are not bundled).
## Requirements
### For Development
- macOS 10.15+ (Catalina or later)
- Rust (install via [rustup](https://rustup.rs/))
- Python 3.11+
- Node.js 18+ (for Tauri CLI)
### For Users
- macOS 10.15+ (Catalina or later)
- ~200MB disk space
## Quick Start (Development)
```bash
# 1. Install Tauri CLI
cargo install tauri-cli
# 2. Start the Python server (in one terminal)
make -f Makefile.desktop dev
# 3. Run Tauri in dev mode (in another terminal)
make -f Makefile.desktop dev-tauri
```
## Building for Distribution
```bash
# Full build (bundle Python + build Tauri app)
make -f Makefile.desktop build
# Or step by step:
make -f Makefile.desktop bundle-python # Create Python executable
make -f Makefile.desktop build-tauri # Build macOS app
```
The app bundle will be in `src-tauri/target/release/bundle/`.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Tauri Shell │
│ ┌───────────────────────────────────────────┐ │
│ │ Native WebKit WebView │ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ http://127.0.0.1:31102 │ │ │
│ │ │ │ │ │
│ │ │ KJV Study Web Interface │ │ │
│ │ │ (Jinja2 templates + Tufte CSS) │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │ │
│ └────────────────────│───────────────────────┘ │
│ │ │
│ ┌────────────────────▼───────────────────────┐ │
│ │ FastAPI Server (Sidecar) │ │
│ │ │ │
│ │ • Bible data (JSON) • Search (SQLite)│ │
│ │ • Commentary • Cross-refs │ │
│ │ • Interlinear • Strong's │ │
│ │ • Study guides • Reading plans │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
**How it works:**
1. Tauri app launches and spawns the Python/FastAPI server as a sidecar process
2. Server runs on `127.0.0.1:31102` (port = number of KJV verses)
3. WebView navigates to the local server
4. All data is served from bundled JSON/SQLite files
5. On quit, Tauri terminates the sidecar process
## Port Selection
The app uses port **31102** - the exact number of verses in the King James Bible. This avoids conflicts with common development ports (3000, 5000, 8000, 8080).
## Directory Structure
```
kjvstudy.org/
├── src-tauri/ # Tauri application
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ ├── src/
│ │ └── main.rs # Sidecar launcher
│ └── icons/ # App icons
├── sidecar/ # Bundled Python executable (after build)
├── kjvstudy_org/ # Python application
│ ├── server.py # FastAPI app
│ ├── desktop.py # Desktop entry point
│ ├── templates/ # Jinja2 templates
│ └── static/ # CSS, JS, fonts
├── data/ # Bible data (JSON)
├── Makefile.desktop # Build commands
├── pyproject-desktop.toml # Desktop dependencies (no WeasyPrint)
└── DESKTOP.md # This file
```
## Troubleshooting
### Server won't start
Check if port 31102 is already in use:
```bash
lsof -i :31102
```
### App shows blank screen
The server may not be ready. Check Console.app for logs, or run in dev mode to see output.
### Icons not showing
Regenerate icons:
```bash
make -f Makefile.desktop icons
```
## License
MIT License - See LICENSE file for details.
+80
View File
@@ -0,0 +1,80 @@
# Makefile for KJV Study Desktop App
# Usage: make -f Makefile.desktop [target]
.PHONY: all clean bundle-python build-tauri build dev install-deps icons
# Default target: build everything
all: install-deps bundle-python build-tauri
# Install required dependencies
install-deps:
@echo "=== Installing dependencies ==="
pip install pyinstaller
cd src-tauri && cargo fetch
@echo "Dependencies installed!"
# Bundle Python application with PyInstaller
bundle-python:
@echo "=== Bundling Python server ==="
./scripts/bundle-desktop.sh
@echo "Python bundle complete!"
# Build Tauri application
build-tauri:
@echo "=== Building Tauri app ==="
cd src-tauri && cargo tauri build
@echo "Tauri build complete!"
# Full production build
build: all
@echo ""
@echo "=== Build Complete! ==="
@echo "App bundle: src-tauri/target/release/bundle/"
# Development mode (runs server + opens browser)
dev:
@echo "=== Starting development server ==="
uv run uvicorn kjvstudy_org.server:app --host 127.0.0.1 --port 31102 --reload
# Development with Tauri
dev-tauri:
@echo "=== Starting Tauri dev mode ==="
@echo "Make sure the Python server is running (make -f Makefile.desktop dev)"
cd src-tauri && cargo tauri dev
# Generate app icons from source PNG (requires ImageMagick)
icons:
@echo "=== Generating icons ==="
@if [ -f "kjvstudy_org/static/images/icon-source.png" ]; then \
convert kjvstudy_org/static/images/icon-source.png -resize 32x32 src-tauri/icons/32x32.png; \
convert kjvstudy_org/static/images/icon-source.png -resize 128x128 src-tauri/icons/128x128.png; \
convert kjvstudy_org/static/images/icon-source.png -resize 256x256 src-tauri/icons/128x128@2x.png; \
convert kjvstudy_org/static/images/icon-source.png -resize 512x512 src-tauri/icons/icon.png; \
echo "Icons generated!"; \
else \
echo "No icon-source.png found. Using placeholders."; \
fi
# Clean build artifacts
clean:
@echo "=== Cleaning build artifacts ==="
rm -rf build/ dist/ *.spec
rm -rf sidecar/
rm -rf src-tauri/target/
@echo "Clean complete!"
# Help
help:
@echo "KJV Study Desktop Build System"
@echo ""
@echo "Targets:"
@echo " all - Build everything (default)"
@echo " install-deps - Install Python and Rust dependencies"
@echo " bundle-python- Bundle Python server with PyInstaller"
@echo " build-tauri - Build Tauri application"
@echo " build - Full production build"
@echo " dev - Run development server on port 31102"
@echo " dev-tauri - Run Tauri in development mode"
@echo " icons - Generate icons from source PNG"
@echo " clean - Remove build artifacts"
@echo " help - Show this help"
+44
View File
@@ -0,0 +1,44 @@
"""Desktop application entry point for KJV Study.
This module provides a standalone entry point for the desktop application,
configured to run on a non-standard port (31102 - the number of verses in the KJV).
"""
import os
import sys
def main():
"""Start the KJV Study server for desktop use."""
import uvicorn
# Get port from environment or use default (31102 = number of KJV verses)
port = int(os.environ.get("KJVSTUDY_PORT", "31102"))
host = os.environ.get("KJVSTUDY_HOST", "127.0.0.1")
# Determine the base directory for resources
if getattr(sys, "frozen", False):
# Running as bundled executable (PyInstaller)
base_dir = os.path.dirname(sys.executable)
else:
# Running as script
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Change to base directory so relative paths work
os.chdir(base_dir)
print(f"Starting KJV Study server on {host}:{port}")
print(f"Base directory: {base_dir}")
uvicorn.run(
"kjvstudy_org.server:app",
host=host,
port=port,
log_level="warning",
# Disable reload in production
reload=False,
)
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
# Desktop-specific pyproject.toml (without WeasyPrint for easier bundling)
# Use this for building the Tauri desktop application
[project]
name = "kjvstudy-desktop"
version = "1.0.0"
description = "KJV Study - Offline Bible Study Desktop Application"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi[standard]>=0.115.12",
"ged4py>=0.5.2",
"mistune>=3.0.2",
"parse>=1.20.2",
"python-gedcom>=1.0.0",
# Note: weasyprint intentionally excluded - PDF buttons will be hidden
# Note: requests excluded - not actually used in the codebase
]
[project.scripts]
kjvstudy-server = "kjvstudy_org.desktop:main"
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["kjvstudy_org*"]
[tool.setuptools.package-data]
kjvstudy_org = ["static/**/*", "templates/**/*"]
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Bundle the KJV Study Python application for desktop distribution
# This script creates a standalone executable using PyInstaller
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="$PROJECT_DIR/sidecar"
echo "=== KJV Study Desktop Bundler ==="
echo "Project directory: $PROJECT_DIR"
echo "Output directory: $OUTPUT_DIR"
cd "$PROJECT_DIR"
# Create output directory
mkdir -p "$OUTPUT_DIR"
# Install desktop dependencies (without weasyprint)
echo ""
echo "=== Installing desktop dependencies ==="
pip install pyinstaller
pip install "fastapi[standard]>=0.115.12" "ged4py>=0.5.2" "mistune>=3.0.2" "parse>=1.20.2" "python-gedcom>=1.0.0"
# Create PyInstaller spec file
echo ""
echo "=== Creating PyInstaller spec ==="
cat > "$PROJECT_DIR/kjvstudy-server.spec" << 'EOF'
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
block_cipher = None
# Collect all kjvstudy_org submodules
hiddenimports = collect_submodules('kjvstudy_org')
hiddenimports += collect_submodules('fastapi')
hiddenimports += collect_submodules('starlette')
hiddenimports += collect_submodules('uvicorn')
hiddenimports += ['kjvstudy_org.server', 'kjvstudy_org.desktop']
# Collect data files
datas = [
('kjvstudy_org/templates', 'kjvstudy_org/templates'),
('kjvstudy_org/static', 'kjvstudy_org/static'),
('data', 'data'),
]
a = Analysis(
['kjvstudy_org/desktop.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['weasyprint', 'cairo', 'gi'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='kjvstudy-server',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
EOF
# Run PyInstaller
echo ""
echo "=== Running PyInstaller ==="
pyinstaller --clean --noconfirm kjvstudy-server.spec
# Move output to sidecar directory
echo ""
echo "=== Moving output to sidecar directory ==="
cp -f "dist/kjvstudy-server" "$OUTPUT_DIR/"
echo ""
echo "=== Bundle complete! ==="
echo "Executable: $OUTPUT_DIR/kjvstudy-server"
echo ""
echo "Next steps:"
echo " 1. cd src-tauri"
echo " 2. cargo tauri build"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "kjvstudy"
version = "1.0.0"
description = "KJV Study - Offline Bible Study App"
authors = ["Kenneth Reitz"]
license = "MIT"
repository = "https://github.com/kennethreitz/kjvstudy.org"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["shell-open"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "time"] }
reqwest = { version = "0.12", features = ["blocking"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+146
View File
@@ -0,0 +1,146 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::{Child, Command};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager, RunEvent};
struct ServerProcess(Mutex<Option<Child>>);
fn find_python_executable(app: &AppHandle) -> Option<String> {
// In development, use system Python
if cfg!(debug_assertions) {
return Some("python3".to_string());
}
// In production, look for bundled Python sidecar
let resource_path = app.path().resource_dir().ok()?;
#[cfg(target_os = "macos")]
{
let sidecar_path = resource_path.join("sidecar").join("kjvstudy-server");
if sidecar_path.exists() {
return Some(sidecar_path.to_string_lossy().to_string());
}
}
#[cfg(target_os = "windows")]
{
let sidecar_path = resource_path.join("sidecar").join("kjvstudy-server.exe");
if sidecar_path.exists() {
return Some(sidecar_path.to_string_lossy().to_string());
}
}
// Fallback to system Python
Some("python3".to_string())
}
fn start_server(app: &AppHandle) -> Option<Child> {
let python = find_python_executable(app)?;
// Get the resource directory for data files
let resource_dir = app.path().resource_dir().ok()?;
// For development, use the project directory
let working_dir = if cfg!(debug_assertions) {
std::env::current_dir().ok()?
} else {
resource_dir.clone()
};
println!("Starting KJV Study server...");
println!("Python executable: {}", python);
println!("Working directory: {:?}", working_dir);
let child = if python.ends_with("kjvstudy-server") || python.ends_with("kjvstudy-server.exe") {
// Running bundled executable
Command::new(&python)
.current_dir(&working_dir)
.env("KJVSTUDY_PORT", "31102")
.spawn()
.ok()?
} else {
// Running with Python interpreter (development)
Command::new(&python)
.args([
"-m", "uvicorn",
"kjvstudy_org.server:app",
"--host", "127.0.0.1",
"--port", "31102",
"--log-level", "warning"
])
.current_dir(&working_dir)
.spawn()
.ok()?
};
Some(child)
}
fn wait_for_server(max_attempts: u32) -> bool {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(1))
.build()
.unwrap();
for attempt in 1..=max_attempts {
println!("Waiting for server... (attempt {}/{})", attempt, max_attempts);
match client.get("http://127.0.0.1:31102/api/health").send() {
Ok(response) if response.status().is_success() => {
println!("Server is ready!");
return true;
}
_ => {
std::thread::sleep(Duration::from_millis(500));
}
}
}
println!("Server failed to start after {} attempts", max_attempts);
false
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(ServerProcess(Mutex::new(None)))
.setup(|app| {
// Start the Python server
let handle = app.handle().clone();
if let Some(child) = start_server(&handle) {
let state = app.state::<ServerProcess>();
*state.0.lock().unwrap() = Some(child);
// Wait for server to be ready
if !wait_for_server(30) {
eprintln!("Warning: Server may not be fully ready");
}
} else {
eprintln!("Failed to start server process");
}
// Navigate to the local server
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval("window.location.href = 'http://127.0.0.1:31102'");
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let RunEvent::Exit = event {
// Clean up: kill the server process
let state = app_handle.state::<ServerProcess>();
if let Some(mut child) = state.0.lock().unwrap().take() {
println!("Shutting down server...");
let _ = child.kill();
let _ = child.wait();
}
}
});
}