diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..6b12893 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,51 @@ +name: Build Windows MSI + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Set up Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install npm dependencies + run: npm ci + + - name: Build Tauri app (Windows MSI) + run: npm run tauri:build + env: + TAURI_SIGNING_PRIVATE_KEY: "" + + - name: Upload MSI artifact + uses: actions/upload-artifact@v4 + with: + name: windows-msi + path: src-tauri/target/release/bundle/msi/*.msi + if-no-files-found: error diff --git a/README.md b/README.md index 989eefc..27ce242 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,47 @@ Don't write blindly. The app analyzes trends before you type a single word. --- -## 💾 Installation & Setup +## 🖥️ Desktop App (Tauri v2 — Windows) + +The app ships as a native Windows desktop application built with [Tauri v2](https://tauri.app/). The Rust backend provides a bundled SQLite database (no external DB required) and exposes background job progress events to the frontend. + +### Windows Prerequisites + +| Tool | Install | +|------|---------| +| **Rust** (stable) | | +| **Node.js ≥ 20** | | +| **WebView2 Runtime** | Usually pre-installed on Windows 10/11; if the app fails to open, download the **Evergreen Bootstrapper** from [Microsoft](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) and run it | +| **Visual Studio Build Tools** | Install the "Desktop development with C++" workload from [VS Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) | + +### Run locally in dev mode + +```powershell +npm install +npm run tauri:dev +``` + +This starts the Vite dev server and the Tauri desktop window simultaneously. + +### Build a release MSI locally + +```powershell +npm run tauri:build +# Installer: src-tauri\target\release\bundle\msi\*.msi +``` + +### Download a CI-built MSI (no local build required) + +1. Go to the **Actions** tab of this repository. +2. Click the latest **"Build Windows MSI"** workflow run. +3. Scroll to **Artifacts** → download **`windows-msi`**. +4. Extract the zip and run the `.msi` installer. + +The workflow runs automatically on every push to `main` and can also be triggered manually via **Actions → Build Windows MSI → Run workflow**. + +--- + +## 💾 Web / Development Setup 1. **Clone the Repo** ```bash @@ -104,9 +144,9 @@ Don't write blindly. The app analyzes trends before you type a single word. API_KEY=your_google_gemini_api_key_here ``` -4. **Run the App** +4. **Run the App (web only)** ```bash - npm start + npm run dev ``` ### 🤖 Setting up the Automation Bot (Optional) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23b83e3..ddcf9f3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,15 +1,11 @@ [package] -name = "app" +name = "binary-books-e-crafter" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] -license = "" -repository = "" +description = "Binary Books E-Crafter" +authors = ["Binary Books"] edition = "2021" rust-version = "1.77.2" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -23,3 +19,7 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +rusqlite = { version = "0.31", features = ["bundled"] } +thiserror = "1" +uuid = { version = "1", features = ["v4", "serde"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c3118c..6b864c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,16 +1,169 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; +use tauri::{Emitter, Manager, State}; +use thiserror::Error; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug, Error)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("SQLite error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("Tauri error: {0}")] + Tauri(String), +} + +type AppResult = Result; + +#[derive(Clone)] +struct DbState { + db_path: PathBuf, +} + +#[derive(Clone)] +struct JobsState { + active: Arc>>, +} + +#[derive(Debug, Serialize, Clone)] +struct JobProgressEvent { + job_id: String, + stage: String, + percent: u8, + message: String, +} + +fn get_data_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|e| AppError::Tauri(e.to_string())) +} + +fn ensure_sqlite_schema(db_path: &PathBuf) -> Result<(), AppError> { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(db_path)?; + conn.execute_batch( + r#" + PRAGMA journal_mode=WAL; + + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS project_notes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + note_type TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(id) + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS project_text_fts + USING fts5(project_id, source, content); + "#, + )?; + Ok(()) +} + +#[tauri::command] +fn get_app_paths(app: tauri::AppHandle) -> AppResult { + let dir = get_data_dir(&app).map_err(|e| e.to_string())?; + Ok(serde_json::json!({ "appDataDir": dir.to_string_lossy() })) +} + +#[tauri::command] +fn sqlite_status(db: State) -> AppResult { + Ok(serde_json::json!({ + "dbPath": db.db_path.to_string_lossy(), + "ok": true + })) +} + +#[derive(Debug, Deserialize)] +struct StartDemoJobInput { + stage_count: Option, +} + +#[tauri::command] +async fn start_demo_job( + app: tauri::AppHandle, + jobs: State<'_, JobsState>, + input: StartDemoJobInput, +) -> AppResult { + let job_id = Uuid::new_v4(); + { + let mut active = jobs.active.lock().await; + active.push(job_id); + } + + let stages = input.stage_count.unwrap_or(5).max(1); + + tauri::async_runtime::spawn(async move { + for i in 0..stages { + let percent = (((i + 1) as f32 / stages as f32) * 100.0) as u8; + let event = JobProgressEvent { + job_id: job_id.to_string(), + stage: format!("stage_{}", i + 1), + percent, + message: format!("Completed stage {}/{}", i + 1, stages), + }; + let _ = app.emit("job_progress", event); + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + } + let _ = app.emit( + "job_done", + serde_json::json!({ "job_id": job_id.to_string() }), + ); + // Remove the completed job from the active list + if let Ok(state) = app.try_state::() { + let mut active = state.active.lock().await; + active.retain(|id| *id != job_id); + } + }); + + Ok(job_id.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + tauri::Builder::default() + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + + let data_dir = get_data_dir(app.handle()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + let db_path = data_dir.join("db").join("app.sqlite3"); + + ensure_sqlite_schema(&db_path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + + app.manage(DbState { db_path }); + app.manage(JobsState { + active: Arc::new(Mutex::new(vec![])), + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + get_app_paths, + sqlite_status, + start_demo_job + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1947af4..d265cfb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "KDP E-Book Generator", + "productName": "Binary Books E-Crafter", "version": "0.1.0", - "identifier": "com.tauri.dev", + "identifier": "com.binarybooks.ecrafter", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", @@ -12,9 +12,9 @@ "app": { "windows": [ { - "title": "KDP E-Book Generator", - "width": 800, - "height": 600, + "title": "Binary Books E-Crafter", + "width": 1280, + "height": 800, "resizable": true, "fullscreen": false } @@ -25,13 +25,19 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["msi"], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "wix": { + "language": "en-US" + } + } } } + diff --git a/src/tauri/backend.ts b/src/tauri/backend.ts new file mode 100644 index 0000000..7d655ad --- /dev/null +++ b/src/tauri/backend.ts @@ -0,0 +1,29 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +export type JobProgressEvent = { + job_id: string; + stage: string; + percent: number; + message: string; +}; + +export async function getAppPaths() { + return invoke<{ appDataDir: string }>("get_app_paths"); +} + +export async function sqliteStatus() { + return invoke<{ dbPath: string; ok: boolean }>("sqlite_status"); +} + +export async function startDemoJob(stageCount = 5) { + return invoke("start_demo_job", { input: { stage_count: stageCount } }); +} + +export function onJobProgress(cb: (e: JobProgressEvent) => void) { + return listen("job_progress", (event) => cb(event.payload)); +} + +export function onJobDone(cb: (payload: { job_id: string }) => void) { + return listen<{ job_id: string }>("job_done", (event) => cb(event.payload)); +}