diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index a9589a9..ad58862 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -28,6 +28,6 @@ cpal = "0.15" rubato = "0.16" whisper-rs = "0.15" reqwest = { version = "0.12", features = ["stream"] } -tokio = { version = "1", features = ["fs"] } +tokio = { version = "1", features = ["fs", "net", "io-util"] } futures-util = "0.3" sha2 = "0.10" diff --git a/apps/tauri/src-tauri/src/cli_installer.rs b/apps/tauri/src-tauri/src/cli_installer.rs index bfa8231..658f5d0 100644 --- a/apps/tauri/src-tauri/src/cli_installer.rs +++ b/apps/tauri/src-tauri/src/cli_installer.rs @@ -5,6 +5,30 @@ use std::path::PathBuf; use std::process::Command; const CLI_SCRIPT: &str = r#"#!/bin/bash +SOCKET="$HOME/.arandu/arandu.sock" + +# Se socket existe, usar IPC (caminho rápido) +if [ -S "$SOCKET" ]; then + if [ "$#" -eq 0 ]; then + if printf '{"command":"show"}\n' | nc -U "$SOCKET" -w 2 2>/dev/null; then + exit 0 + fi + else + FAILED=0 + for f in "$@"; do + ABS="$(cd "$(dirname "$f")" 2>/dev/null && echo "$PWD/$(basename "$f")")" + ESCAPED=${ABS//\\/\\\\} + ESCAPED=${ESCAPED//\"/\\\"} + if ! printf '{"command":"open","path":"%s"}\n' "$ESCAPED" | nc -U "$SOCKET" -w 2 2>/dev/null; then + FAILED=1 + break + fi + done + [ "$FAILED" -eq 0 ] && exit 0 + fi +fi + +# Fallback: método tradicional com open (inicia app se necessário) APP="" for p in "/Applications/Arandu.app" "$HOME/Applications/Arandu.app"; do [ -d "$p" ] && APP="$p" && break diff --git a/apps/tauri/src-tauri/src/ipc.rs b/apps/tauri/src-tauri/src/ipc.rs new file mode 100644 index 0000000..1415522 --- /dev/null +++ b/apps/tauri/src-tauri/src/ipc.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{Emitter, Manager}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; + +#[derive(Deserialize)] +struct IpcCommand { + command: String, + #[serde(default)] + path: Option, +} + +#[derive(Serialize)] +struct IpcResponse { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +pub struct SocketState(pub Mutex>); + +pub fn setup(app: &tauri::App) -> Result<(), String> { + let sock_path = socket_path()?; + cleanup_stale_socket(&sock_path)?; + + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + match UnixListener::bind(&sock_path) { + Ok(listener) => { + let state = app_handle.state::(); + if let Ok(mut guard) = state.0.lock() { + *guard = Some(sock_path.clone()); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&sock_path, perms); + } + + socket_listener_loop(listener, app_handle).await; + } + Err(e) => { + eprintln!("Failed to bind socket: {}", e); + } + } + }); + + Ok(()) +} + +pub fn cleanup(state: tauri::State) { + if let Ok(guard) = state.0.lock() { + if let Some(path) = guard.as_ref() { + let _ = std::fs::remove_file(path); + } + } +} + +fn socket_path() -> Result { + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + let arandu_dir = PathBuf::from(home).join(".arandu"); + + std::fs::create_dir_all(&arandu_dir) + .map_err(|e| format!("Failed to create ~/.arandu: {}", e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&arandu_dir, std::fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("Failed to set ~/.arandu permissions: {}", e))?; + } + + Ok(arandu_dir.join("arandu.sock")) +} + +fn cleanup_stale_socket(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => Err("Socket already in use by another instance".to_string()), + Err(_) => { + std::fs::remove_file(path) + .map_err(|e| format!("Failed to remove stale socket: {}", e)) + } + } +} + +async fn socket_listener_loop(listener: UnixListener, app: tauri::AppHandle) { + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = handle_client(stream, app_clone).await { + eprintln!("Client error: {}", e); + } + }); + } + Err(e) => { + eprintln!("Accept error: {}", e); + } + } + } +} + +async fn handle_client(stream: UnixStream, app: tauri::AppHandle) -> Result<(), String> { + let (reader, mut writer) = stream.into_split(); + let reader = BufReader::new(reader); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? { + let response = match serde_json::from_str::(&line) { + Ok(cmd) => process_command(cmd, &app), + Err(e) => IpcResponse { + success: false, + error: Some(format!("Invalid JSON: {}", e)), + }, + }; + + let json = serde_json::to_string(&response).unwrap_or_default(); + writer + .write_all(format!("{}\n", json).as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +fn process_command(cmd: IpcCommand, app: &tauri::AppHandle) -> IpcResponse { + match cmd.command.as_str() { + "open" => { + if let Some(path) = cmd.path { + match std::fs::canonicalize(&path) { + Ok(abs_path) => { + let path_str = abs_path.to_string_lossy().to_string(); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + + match app.emit("open-file", &path_str) { + Ok(_) => IpcResponse { + success: true, + error: None, + }, + Err(e) => IpcResponse { + success: false, + error: Some(format!("Failed to emit event: {}", e)), + }, + } + } + Err(e) => IpcResponse { + success: false, + error: Some(format!("Invalid path: {}", e)), + }, + } + } else { + IpcResponse { + success: false, + error: Some("Missing 'path' field".to_string()), + } + } + } + "ping" => IpcResponse { + success: true, + error: None, + }, + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + IpcResponse { + success: true, + error: None, + } + } + _ => IpcResponse { + success: false, + error: Some(format!("Unknown command: {}", cmd.command)), + }, + } +} diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 29bdb40..34dcfb8 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; #[cfg(target_os = "macos")] mod cli_installer; +#[cfg(unix)] +mod ipc; mod tray; mod whisper; @@ -334,7 +336,7 @@ fn setup_macos_menu(app: &tauri::App) -> Result<(), Box> #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_cli::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -370,13 +372,25 @@ pub fn run() { .manage(ExplicitQuit(Arc::new(AtomicBool::new(false)))) .manage(IsRecording(Arc::new(AtomicBool::new(false)))) .manage(whisper::commands::RecorderState(Mutex::new(None))) - .manage(whisper::commands::TranscriberState(Mutex::new(None))) + .manage(whisper::commands::TranscriberState(Mutex::new(None))); + + #[cfg(unix)] + let builder = builder.manage(ipc::SocketState(Mutex::new(None))); + + builder .setup(|app| { #[cfg(target_os = "macos")] setup_macos_menu(app)?; tray::setup(app)?; + #[cfg(unix)] + { + if let Err(e) = ipc::setup(app) { + eprintln!("Failed to setup IPC socket: {}", e); + } + } + let shortcut_str = if let Ok(app_data_dir) = app.path().app_data_dir() { let settings = whisper::model_manager::load_settings(&app_data_dir); settings.shortcut @@ -475,6 +489,11 @@ pub fn run() { if let tauri::RunEvent::ExitRequested { api, .. } = &event { let quit_flag = app_handle.state::(); if quit_flag.0.load(Ordering::Relaxed) { + #[cfg(unix)] + { + let socket_state = app_handle.state::(); + ipc::cleanup(socket_state); + } return; } api.prevent_exit();