#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; use tauri::{AppHandle, Manager, State}; use tokio::fs; #[derive(Serialize, Deserialize, Clone, Debug)] struct AppDefinition { id: String, #[serde(default)] name: Option, #[serde(default)] payload: Value, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] struct SecureStore { data: HashMap, } impl SecureStore { async fn load(path: &PathBuf) -> Result { if !path.exists() { return Ok(Self::default()); } let content = fs::read_to_string(path) .await .map_err(|e| e.to_string())?; serde_json::from_str(&content).map_err(|e| e.to_string()) } async fn save(&self, path: &PathBuf) -> Result<(), String> { let content = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; fs::write(path, content) .await .map_err(|e| e.to_string()) } fn get(&self, key: &str) -> Option<&String> { self.data.get(key) } fn insert(&mut self, key: String, value: String) { self.data.insert(key, value); } } #[derive(Clone)] struct BackendState { client: Client, backend_url: String, } impl BackendState { fn new() -> Self { let backend_url = std::env::var("BACKEND_BASE_URL") .unwrap_or_else(|_| "https://api.example.com".to_string()); Self { client: Client::new(), backend_url, } } } fn config_store_path(app: &AppHandle) -> Result { let dir = app .path() .app_config_dir() .map_err(|e| e.to_string())? .join("secrets"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir.join("secure.json")) } async fn read_api_key(app: &AppHandle) -> Result { let store_path = config_store_path(app)?; let store = SecureStore::load(&store_path).await?; match store.get("opencode_api_key") { Some(v) if !v.is_empty() => Ok(v.clone()), _ => Err("API key not set".to_string()), } } fn apps_dir(app: &AppHandle) -> Result { let dir = app .path() .app_local_data_dir() .map_err(|e| e.to_string())? .join("apps"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir) } #[tauri::command] async fn save_api_key(token: String, app: AppHandle) -> Result<(), String> { if token.trim().is_empty() { return Err("token is required".into()); } let store_path = config_store_path(&app)?; let mut store = SecureStore::load(&store_path).await?; store.insert("opencode_api_key".to_string(), token); store.save(&store_path).await } #[tauri::command] async fn persist_app(app: AppDefinition, app_handle: AppHandle) -> Result<(), String> { if app.id.trim().is_empty() { return Err("app.id is required".into()); } let dir = apps_dir(&app_handle)?; let path = dir.join(format!("{}.json", app.id)); let payload = serde_json::to_vec_pretty(&app).map_err(|e| e.to_string())?; fs::write(path, payload) .await .map_err(|e| format!("failed to persist app: {e}")) } #[tauri::command] async fn list_apps(app_handle: AppHandle) -> Result, String> { let dir = apps_dir(&app_handle)?; let mut apps = Vec::new(); let mut entries = fs::read_dir(dir) .await .map_err(|e| format!("failed to read apps dir: {e}"))?; while let Some(entry) = entries .next_entry() .await .map_err(|e| format!("failed to read entry: {e}"))? { let path = entry.path(); if path.extension().and_then(|v| v.to_str()) != Some("json") { continue; } let data = fs::read(&path) .await .map_err(|e| format!("failed to read app: {e}"))?; if let Ok(app) = serde_json::from_slice::(&data) { apps.push(app); } } Ok(apps) } #[tauri::command] async fn sync_app(app_id: String, app_handle: AppHandle, state: State<'_, BackendState>) -> Result<(), String> { if app_id.trim().is_empty() { return Err("appId is required".into()); } let dir = apps_dir(&app_handle)?; let path = dir.join(format!("{}.json", app_id)); let data = fs::read(&path) .await .map_err(|e| format!("failed to read app for sync: {e}"))?; let app: AppDefinition = serde_json::from_slice(&data).map_err(|e| e.to_string())?; let url = format!("{}/desktop/apps/sync", state.backend_url); let resp = state .client .post(url) .json(&app) .send() .await .map_err(|e| format!("sync request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(format!("sync failed: {status} {text}")); } Ok(()) } fn opencode_binary_path(app: &AppHandle) -> Result { let base = app .path() .app_local_data_dir() .map_err(|e| e.to_string())? .join("opencode"); std::fs::create_dir_all(&base).map_err(|e| e.to_string())?; let name = if cfg!(target_os = "windows") { "opencode.exe" } else { "opencode" }; Ok(base.join(name)) } #[tauri::command] async fn run_opencode_task( app_id: String, task_name: String, args: Vec, app_handle: AppHandle, ) -> Result { if app_id.trim().is_empty() { return Err("appId is required".into()); } if task_name.trim().is_empty() { return Err("taskName is required".into()); } let binary = opencode_binary_path(&app_handle)?; if !binary.exists() { return Err("OpenCode binary not found on device".into()); } let api_key = read_api_key(&app_handle).await?; let mut command = Command::new(&binary); command.arg(task_name); for arg in args { command.arg(arg); } command.env("OPENCODE_API_KEY", api_key); command.env("APP_ID", app_id); let output = command .output() .map_err(|e| format!("failed to run OpenCode: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("OpenCode exited with error: {stderr}")); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout.to_string()) } fn main() { tauri::Builder::default() .manage(BackendState::new()) .invoke_handler(tauri::generate_handler![ save_api_key, persist_app, list_apps, sync_app, run_opencode_task ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }