auto-git:

[add] docs/
 [add] src-tauri/
 [change] src/desktop/desktopApi.js
This commit is contained in:
2026-05-06 04:56:04 +02:00
parent 08a7c78af6
commit 81daab141c
7 changed files with 343 additions and 10 deletions

52
docs/tauri-migration.md Normal file
View File

@@ -0,0 +1,52 @@
# Tauri Migration Spike
This spike adds an isolated Tauri v2 scaffold while keeping the Electron app as the supported desktop shell.
References checked during the spike:
- Tauri Vite setup: https://v2.tauri.app/start/frontend/vite/
- Tauri JavaScript API: https://v2.tauri.app/reference/javascript/api/
- Tauri CLI: https://v2.tauri.app/reference/cli/
## Current Electron Responsibilities
| Area | Current Electron implementation | Tauri target |
| --- | --- | --- |
| Main app window | `BrowserWindow` in `electron/main.cjs`, loading Vite in dev and `dist/index.html` in production | `src-tauri/tauri.conf.json` window loading the same Vite dev URL or `../dist` |
| Settings window | Separate modal `BrowserWindow` routed to `#/settings` | Later phase: either a second Tauri webview window or in-app settings route |
| Settings persistence | JSON file under Electron `app.getPath('userData')`, with migrations and normalization | Later phase: Tauri command backed by app config/data dir with the same setting keys and migrations |
| Renderer bridge | `electron/preload.cjs` exposes `window.electronAPI`; renderer uses `src/desktop/desktopApi.js` | `desktopApi` can call Tauri commands through `window.__TAURI__.core.invoke` when running in Tauri |
| File picker | `dialog.showOpenDialog` via `pick-paths` IPC | Later phase: official Tauri dialog plugin with scoped permissions |
| Open file/path | `shell.openPath` via `open-path` IPC | Later phase: official Tauri opener plugin or a scoped Rust command |
| External links | `shell.openExternal` and `setWindowOpenHandler` | Later phase: official Tauri opener plugin and window navigation policy |
| Window focus events | Electron `focus` event forwarded to renderer | Later phase: Tauri window event listener or emitted command event |
| App menu | Electron `Menu.buildFromTemplate` | Later phase: Tauri menu API |
| UI scale | Electron `webContents.setZoomFactor` | Later phase: Tauri/webview equivalent or renderer CSS scale strategy |
| DevTools preference | Electron opens/closes DevTools in dev | Later phase: Tauri devtools behavior and persisted preference |
| Updates/changelog | Git-based update check, pull, restart, and local changelog | Later phase: replace with Tauri updater or another signed update flow |
| Backend lifecycle | External Node scripts start FastAPI in dev | Not part of this spike; later sidecar/package design |
| Packaging | Electron starts from `main` and existing npm scripts | Later phase: Tauri bundle config, icons, signing, backend sidecar |
## Scaffold Added
- `src-tauri/tauri.conf.json` points Tauri at the existing Vite renderer on `http://127.0.0.1:5173` and the existing `dist` build output.
- `src-tauri/src/main.rs` registers placeholder Tauri commands that match the current `desktopApi` surface.
- `src-tauri/capabilities/default.json` grants the main window the default core capability for this spike.
- `src/desktop/desktopApi.js` still prefers Electron. When Electron is absent and Tauri is present, it invokes matching Tauri commands. Without either desktop bridge, it falls back to safe defaults so the renderer can mount.
## Intentional Non-Goals
- No Electron files, scripts, or IPC behavior were removed.
- No Python backend sidecar, packaging, or lifecycle work was added.
- No Tauri updater, opener, dialog, menu, or settings persistence parity was implemented.
- The Tauri settings command is in-memory only and exists to let the renderer hydrate during the spike.
## Remaining Parity Gaps
- Settings: persistent storage, migrations, normalization, UI scale side effects, DevTools preference side effects.
- File picker: native picker and path filters.
- Open path and external links: scoped native opener behavior.
- Window focus events: renderer notification equivalent.
- Update flow: startup/manual checks, changelog, restart, and signed release strategy.
- Backend lifecycle: FastAPI startup, health gating, ffmpeg/ffprobe environment, shutdown, and sidecar packaging.
- Packaging: bundle metadata, icons, signing/notarization, auto-update channel, and platform install behavior.

14
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "heimgeist-tauri"
version = "0.1.0"
description = "Tauri migration spike for Heimgeist"
authors = ["Heimgeist"]
edition = "2021"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1"
tauri = { version = "2", features = [] }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,6 @@
{
"identifier": "default",
"description": "Default capability for the Heimgeist Tauri migration spike.",
"windows": ["main"],
"permissions": ["core:default"]
}

124
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,124 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{collections::HashMap, sync::Mutex};
use serde_json::{json, Value};
use tauri::State;
type SettingsStore = Mutex<HashMap<String, Value>>;
fn default_settings() -> HashMap<String, Value> {
HashMap::from([
("backendApiUrl".into(), json!("http://127.0.0.1:8000")),
("ollamaApiUrl".into(), json!("http://127.0.0.1:11434")),
("chatModel".into(), json!("llama3")),
("visionModel".into(), json!("")),
("embedModel".into(), json!("nomic-embed-text:latest")),
("rerankModel".into(), json!("nomic-embed-text:latest")),
("transcriptionModel".into(), json!("base")),
("colorScheme".into(), json!("Default")),
("uiScale".into(), json!(1)),
("openDevToolsOnStartup".into(), json!(false)),
("audioInputEnabled".into(), json!(true)),
("audioInputDeviceId".into(), json!("")),
("audioInputLanguage".into(), json!("")),
])
}
fn unavailable_update_status(message: &str) -> Value {
json!({
"state": "unavailable",
"message": message,
"checkedAt": null,
"localCommit": null,
"remoteCommit": null,
"branch": null,
"restartScheduled": false
})
}
#[tauri::command]
fn get_settings(settings: State<'_, SettingsStore>) -> Result<Value, String> {
let settings = settings
.lock()
.map_err(|_| "Settings store is unavailable.".to_string())?;
Ok(json!(settings.clone()))
}
#[tauri::command]
fn set_setting(
key: String,
value: Value,
settings: State<'_, SettingsStore>,
) -> Result<bool, String> {
let mut settings = settings
.lock()
.map_err(|_| "Settings store is unavailable.".to_string())?;
settings.insert(key, value);
Ok(true)
}
#[tauri::command]
fn update_settings(
settings: HashMap<String, Value>,
store: State<'_, SettingsStore>,
) -> Result<bool, String> {
let mut store = store
.lock()
.map_err(|_| "Settings store is unavailable.".to_string())?;
store.extend(settings);
Ok(true)
}
#[tauri::command]
fn get_update_status() -> Value {
unavailable_update_status("Tauri update checks are not implemented in this migration spike.")
}
#[tauri::command]
fn check_for_updates() -> Value {
unavailable_update_status("Tauri update checks are not implemented in this migration spike.")
}
#[tauri::command]
fn get_changelog_page(page: Option<u64>) -> Value {
json!({
"page": page.unwrap_or(1),
"pageSize": 50,
"hasMore": false,
"entries": []
})
}
#[tauri::command]
fn pick_paths(_options: Option<Value>) -> Vec<String> {
Vec::new()
}
#[tauri::command]
fn open_path(_file_path: String) -> bool {
false
}
#[tauri::command]
fn open_external_link(_url: String) -> bool {
false
}
fn main() {
tauri::Builder::default()
.manage(Mutex::new(default_settings()))
.invoke_handler(tauri::generate_handler![
get_settings,
set_setting,
update_settings,
get_update_status,
check_for_updates,
get_changelog_page,
pick_paths,
open_path,
open_external_link
])
.run(tauri::generate_context!())
.expect("failed to run Heimgeist Tauri migration spike");
}

32
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Heimgeist",
"version": "0.1.0",
"identifier": "com.giers.heimgeist",
"build": {
"beforeDevCommand": "npm run dev:renderer",
"beforeBuildCommand": "npm run build",
"devUrl": "http://127.0.0.1:5173",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Heimgeist",
"width": 1000,
"height": 720,
"minWidth": 680,
"minHeight": 300
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all"
}
}

View File

@@ -1,4 +1,32 @@
const getElectronApi = () => window.electronAPI
const DEFAULT_SETTINGS = Object.freeze({
backendApiUrl: 'http://127.0.0.1:8000',
ollamaApiUrl: 'http://127.0.0.1:11434',
chatModel: 'llama3',
visionModel: '',
embedModel: 'nomic-embed-text:latest',
rerankModel: 'nomic-embed-text:latest',
transcriptionModel: 'base',
colorScheme: 'Default',
uiScale: 1,
openDevToolsOnStartup: false,
audioInputEnabled: true,
audioInputDeviceId: '',
audioInputLanguage: '',
})
const DEFAULT_UPDATE_STATUS = Object.freeze({
state: 'unavailable',
message: 'Desktop update checks are not available in this runtime.',
checkedAt: null,
localCommit: null,
remoteCommit: null,
branch: null,
restartScheduled: false,
})
const hasWindow = () => typeof window !== 'undefined'
const getElectronApi = () => (hasWindow() ? window.electronAPI : undefined)
const getTauriCore = () => (hasWindow() ? window.__TAURI__?.core : undefined)
function callElectronApi(methodName, ...args) {
const method = getElectronApi()?.[methodName]
@@ -8,16 +36,90 @@ function callElectronApi(methodName, ...args) {
return method(...args)
}
function callTauriCommand(commandName, args = {}) {
const invoke = getTauriCore()?.invoke
if (typeof invoke !== 'function') {
return undefined
}
return invoke(commandName, args)
}
function fallbackPromise(value) {
return Promise.resolve(typeof value === 'function' ? value() : value)
}
function defaultSettings() {
return { ...DEFAULT_SETTINGS }
}
function defaultUpdateStatus(message = DEFAULT_UPDATE_STATUS.message) {
return { ...DEFAULT_UPDATE_STATUS, message }
}
function defaultChangelogPage(page = 1) {
const normalizedPage = Number.isFinite(Number(page)) && Number(page) > 0 ? Number(page) : 1
return {
page: normalizedPage,
pageSize: 50,
hasMore: false,
entries: [],
}
}
function resultOrFallback(result, fallback) {
return result === undefined ? fallbackPromise(fallback) : result
}
function getExternalUrl(eventOrUrl) {
if (typeof eventOrUrl === 'string') {
return eventOrUrl
}
return eventOrUrl?.currentTarget?.href || eventOrUrl?.target?.href || ''
}
const desktopApi = {
getSettings: () => callElectronApi('getSettings'),
getUpdateStatus: () => callElectronApi('getUpdateStatus'),
checkForUpdates: () => callElectronApi('checkForUpdates'),
getChangelogPage: (page) => callElectronApi('getChangelogPage', page),
setSetting: (key, value) => callElectronApi('setSetting', key, value),
updateSettings: (settings) => callElectronApi('updateSettings', settings),
pickPaths: (options) => callElectronApi('pickPaths', options),
openPath: (filePath) => callElectronApi('openPath', filePath),
openExternalLink: (event) => callElectronApi('openExternalLink', event),
getSettings: () => resultOrFallback(
callElectronApi('getSettings') ?? callTauriCommand('get_settings'),
defaultSettings,
),
getUpdateStatus: () => resultOrFallback(
callElectronApi('getUpdateStatus') ?? callTauriCommand('get_update_status'),
defaultUpdateStatus,
),
checkForUpdates: () => resultOrFallback(
callElectronApi('checkForUpdates') ?? callTauriCommand('check_for_updates'),
() => defaultUpdateStatus('Desktop update checks are not implemented in this runtime.'),
),
getChangelogPage: (page) => resultOrFallback(
callElectronApi('getChangelogPage', page) ?? callTauriCommand('get_changelog_page', { page }),
() => defaultChangelogPage(page),
),
setSetting: (key, value) => resultOrFallback(
callElectronApi('setSetting', key, value) ?? callTauriCommand('set_setting', { key, value }),
false,
),
updateSettings: (settings) => resultOrFallback(
callElectronApi('updateSettings', settings) ?? callTauriCommand('update_settings', { settings }),
false,
),
pickPaths: (options) => resultOrFallback(
callElectronApi('pickPaths', options) ?? callTauriCommand('pick_paths', { options }),
[],
),
openPath: (filePath) => resultOrFallback(
callElectronApi('openPath', filePath) ?? callTauriCommand('open_path', { filePath }),
false,
),
openExternalLink: (eventOrUrl) => {
const electronResult = callElectronApi('openExternalLink', eventOrUrl)
if (electronResult !== undefined) {
return electronResult
}
eventOrUrl?.preventDefault?.()
const url = getExternalUrl(eventOrUrl)
return resultOrFallback(callTauriCommand('open_external_link', { url }), false)
},
onWindowFocus: (callback) => callElectronApi('onWindowFocus', callback),
offWindowFocus: (callback) => callElectronApi('offWindowFocus', callback),
}