Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | <https://rustup.rs/> |
| **Node.js ≥ 20** | <https://nodejs.org/> |
| **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
Expand All @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -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"] }
179 changes: 166 additions & 13 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<T> = Result<T, String>;

#[derive(Clone)]
struct DbState {
db_path: PathBuf,
}

#[derive(Clone)]
struct JobsState {
active: Arc<Mutex<Vec<Uuid>>>,
}

#[derive(Debug, Serialize, Clone)]
struct JobProgressEvent {
job_id: String,
stage: String,
percent: u8,
message: String,
}

fn get_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, AppError> {
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<serde_json::Value> {
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<DbState>) -> AppResult<serde_json::Value> {
Ok(serde_json::json!({
"dbPath": db.db_path.to_string_lossy(),
"ok": true
}))
}

#[derive(Debug, Deserialize)]
struct StartDemoJobInput {
stage_count: Option<u8>,
}

#[tauri::command]
async fn start_demo_job(
app: tauri::AppHandle,
jobs: State<'_, JobsState>,
input: StartDemoJobInput,
) -> AppResult<String> {
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::<JobsState>() {
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");
}

20 changes: 13 additions & 7 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
}
Expand All @@ -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"
}
}
}
}

29 changes: 29 additions & 0 deletions src/tauri/backend.ts
Original file line number Diff line number Diff line change
@@ -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<string>("start_demo_job", { input: { stage_count: stageCount } });
}

export function onJobProgress(cb: (e: JobProgressEvent) => void) {
return listen<JobProgressEvent>("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));
}