Skip to content

Commit 51408db

Browse files
authored
feat(ipc): add Unix domain socket for inter-process communication (#10)
* feat(ipc): add Unix domain socket for inter-process communication - Create ipc.rs module with async socket server - Implement commands: open, ping, show - Socket path: ~/.arandu/arandu.sock with 0600 permissions - Update CLI script to use socket with fallback to open - Add tokio features: net, io-util - Graceful degradation if socket setup fails * fix(ipc): handle CLI fallback, JSON escaping, bind-before-state, and cfg guard - CLI script: handle empty args (send "show"), escape paths for JSON, check nc exit status and fall back on failure - ipc.rs: set SocketState only after successful UnixListener::bind - lib.rs: wrap ipc::SocketState manage call with #[cfg(unix)] * fix(ipc): enforce 0700 permissions on ~/.arandu directory
1 parent 05f835a commit 51408db

4 files changed

Lines changed: 239 additions & 3 deletions

File tree

apps/tauri/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ cpal = "0.15"
2828
rubato = "0.16"
2929
whisper-rs = "0.15"
3030
reqwest = { version = "0.12", features = ["stream"] }
31-
tokio = { version = "1", features = ["fs"] }
31+
tokio = { version = "1", features = ["fs", "net", "io-util"] }
3232
futures-util = "0.3"
3333
sha2 = "0.10"

apps/tauri/src-tauri/src/cli_installer.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ use std::path::PathBuf;
55
use std::process::Command;
66

77
const CLI_SCRIPT: &str = r#"#!/bin/bash
8+
SOCKET="$HOME/.arandu/arandu.sock"
9+
10+
# Se socket existe, usar IPC (caminho rápido)
11+
if [ -S "$SOCKET" ]; then
12+
if [ "$#" -eq 0 ]; then
13+
if printf '{"command":"show"}\n' | nc -U "$SOCKET" -w 2 2>/dev/null; then
14+
exit 0
15+
fi
16+
else
17+
FAILED=0
18+
for f in "$@"; do
19+
ABS="$(cd "$(dirname "$f")" 2>/dev/null && echo "$PWD/$(basename "$f")")"
20+
ESCAPED=${ABS//\\/\\\\}
21+
ESCAPED=${ESCAPED//\"/\\\"}
22+
if ! printf '{"command":"open","path":"%s"}\n' "$ESCAPED" | nc -U "$SOCKET" -w 2 2>/dev/null; then
23+
FAILED=1
24+
break
25+
fi
26+
done
27+
[ "$FAILED" -eq 0 ] && exit 0
28+
fi
29+
fi
30+
31+
# Fallback: método tradicional com open (inicia app se necessário)
832
APP=""
933
for p in "/Applications/Arandu.app" "$HOME/Applications/Arandu.app"; do
1034
[ -d "$p" ] && APP="$p" && break

apps/tauri/src-tauri/src/ipc.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::path::{Path, PathBuf};
3+
use std::sync::Mutex;
4+
use tauri::{Emitter, Manager};
5+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
6+
use tokio::net::{UnixListener, UnixStream};
7+
8+
#[derive(Deserialize)]
9+
struct IpcCommand {
10+
command: String,
11+
#[serde(default)]
12+
path: Option<String>,
13+
}
14+
15+
#[derive(Serialize)]
16+
struct IpcResponse {
17+
success: bool,
18+
#[serde(skip_serializing_if = "Option::is_none")]
19+
error: Option<String>,
20+
}
21+
22+
pub struct SocketState(pub Mutex<Option<PathBuf>>);
23+
24+
pub fn setup(app: &tauri::App) -> Result<(), String> {
25+
let sock_path = socket_path()?;
26+
cleanup_stale_socket(&sock_path)?;
27+
28+
let app_handle = app.handle().clone();
29+
tauri::async_runtime::spawn(async move {
30+
match UnixListener::bind(&sock_path) {
31+
Ok(listener) => {
32+
let state = app_handle.state::<SocketState>();
33+
if let Ok(mut guard) = state.0.lock() {
34+
*guard = Some(sock_path.clone());
35+
}
36+
37+
#[cfg(unix)]
38+
{
39+
use std::os::unix::fs::PermissionsExt;
40+
let perms = std::fs::Permissions::from_mode(0o600);
41+
let _ = std::fs::set_permissions(&sock_path, perms);
42+
}
43+
44+
socket_listener_loop(listener, app_handle).await;
45+
}
46+
Err(e) => {
47+
eprintln!("Failed to bind socket: {}", e);
48+
}
49+
}
50+
});
51+
52+
Ok(())
53+
}
54+
55+
pub fn cleanup(state: tauri::State<SocketState>) {
56+
if let Ok(guard) = state.0.lock() {
57+
if let Some(path) = guard.as_ref() {
58+
let _ = std::fs::remove_file(path);
59+
}
60+
}
61+
}
62+
63+
fn socket_path() -> Result<PathBuf, String> {
64+
let home = std::env::var("HOME")
65+
.map_err(|_| "HOME environment variable not set".to_string())?;
66+
let arandu_dir = PathBuf::from(home).join(".arandu");
67+
68+
std::fs::create_dir_all(&arandu_dir)
69+
.map_err(|e| format!("Failed to create ~/.arandu: {}", e))?;
70+
#[cfg(unix)]
71+
{
72+
use std::os::unix::fs::PermissionsExt;
73+
std::fs::set_permissions(&arandu_dir, std::fs::Permissions::from_mode(0o700))
74+
.map_err(|e| format!("Failed to set ~/.arandu permissions: {}", e))?;
75+
}
76+
77+
Ok(arandu_dir.join("arandu.sock"))
78+
}
79+
80+
fn cleanup_stale_socket(path: &Path) -> Result<(), String> {
81+
if !path.exists() {
82+
return Ok(());
83+
}
84+
85+
match std::os::unix::net::UnixStream::connect(path) {
86+
Ok(_) => Err("Socket already in use by another instance".to_string()),
87+
Err(_) => {
88+
std::fs::remove_file(path)
89+
.map_err(|e| format!("Failed to remove stale socket: {}", e))
90+
}
91+
}
92+
}
93+
94+
async fn socket_listener_loop(listener: UnixListener, app: tauri::AppHandle) {
95+
loop {
96+
match listener.accept().await {
97+
Ok((stream, _addr)) => {
98+
let app_clone = app.clone();
99+
tauri::async_runtime::spawn(async move {
100+
if let Err(e) = handle_client(stream, app_clone).await {
101+
eprintln!("Client error: {}", e);
102+
}
103+
});
104+
}
105+
Err(e) => {
106+
eprintln!("Accept error: {}", e);
107+
}
108+
}
109+
}
110+
}
111+
112+
async fn handle_client(stream: UnixStream, app: tauri::AppHandle) -> Result<(), String> {
113+
let (reader, mut writer) = stream.into_split();
114+
let reader = BufReader::new(reader);
115+
let mut lines = reader.lines();
116+
117+
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
118+
let response = match serde_json::from_str::<IpcCommand>(&line) {
119+
Ok(cmd) => process_command(cmd, &app),
120+
Err(e) => IpcResponse {
121+
success: false,
122+
error: Some(format!("Invalid JSON: {}", e)),
123+
},
124+
};
125+
126+
let json = serde_json::to_string(&response).unwrap_or_default();
127+
writer
128+
.write_all(format!("{}\n", json).as_bytes())
129+
.await
130+
.map_err(|e| e.to_string())?;
131+
}
132+
133+
Ok(())
134+
}
135+
136+
fn process_command(cmd: IpcCommand, app: &tauri::AppHandle) -> IpcResponse {
137+
match cmd.command.as_str() {
138+
"open" => {
139+
if let Some(path) = cmd.path {
140+
match std::fs::canonicalize(&path) {
141+
Ok(abs_path) => {
142+
let path_str = abs_path.to_string_lossy().to_string();
143+
144+
if let Some(window) = app.get_webview_window("main") {
145+
let _ = window.unminimize();
146+
let _ = window.show();
147+
let _ = window.set_focus();
148+
}
149+
150+
match app.emit("open-file", &path_str) {
151+
Ok(_) => IpcResponse {
152+
success: true,
153+
error: None,
154+
},
155+
Err(e) => IpcResponse {
156+
success: false,
157+
error: Some(format!("Failed to emit event: {}", e)),
158+
},
159+
}
160+
}
161+
Err(e) => IpcResponse {
162+
success: false,
163+
error: Some(format!("Invalid path: {}", e)),
164+
},
165+
}
166+
} else {
167+
IpcResponse {
168+
success: false,
169+
error: Some("Missing 'path' field".to_string()),
170+
}
171+
}
172+
}
173+
"ping" => IpcResponse {
174+
success: true,
175+
error: None,
176+
},
177+
"show" => {
178+
if let Some(window) = app.get_webview_window("main") {
179+
let _ = window.unminimize();
180+
let _ = window.show();
181+
let _ = window.set_focus();
182+
}
183+
IpcResponse {
184+
success: true,
185+
error: None,
186+
}
187+
}
188+
_ => IpcResponse {
189+
success: false,
190+
error: Some(format!("Unknown command: {}", cmd.command)),
191+
},
192+
}
193+
}

apps/tauri/src-tauri/src/lib.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
1010

1111
#[cfg(target_os = "macos")]
1212
mod cli_installer;
13+
#[cfg(unix)]
14+
mod ipc;
1315
mod tray;
1416
mod whisper;
1517

@@ -334,7 +336,7 @@ fn setup_macos_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
334336

335337
#[cfg_attr(mobile, tauri::mobile_entry_point)]
336338
pub fn run() {
337-
tauri::Builder::default()
339+
let builder = tauri::Builder::default()
338340
.plugin(tauri_plugin_cli::init())
339341
.plugin(tauri_plugin_dialog::init())
340342
.plugin(tauri_plugin_fs::init())
@@ -370,13 +372,25 @@ pub fn run() {
370372
.manage(ExplicitQuit(Arc::new(AtomicBool::new(false))))
371373
.manage(IsRecording(Arc::new(AtomicBool::new(false))))
372374
.manage(whisper::commands::RecorderState(Mutex::new(None)))
373-
.manage(whisper::commands::TranscriberState(Mutex::new(None)))
375+
.manage(whisper::commands::TranscriberState(Mutex::new(None)));
376+
377+
#[cfg(unix)]
378+
let builder = builder.manage(ipc::SocketState(Mutex::new(None)));
379+
380+
builder
374381
.setup(|app| {
375382
#[cfg(target_os = "macos")]
376383
setup_macos_menu(app)?;
377384

378385
tray::setup(app)?;
379386

387+
#[cfg(unix)]
388+
{
389+
if let Err(e) = ipc::setup(app) {
390+
eprintln!("Failed to setup IPC socket: {}", e);
391+
}
392+
}
393+
380394
let shortcut_str = if let Ok(app_data_dir) = app.path().app_data_dir() {
381395
let settings = whisper::model_manager::load_settings(&app_data_dir);
382396
settings.shortcut
@@ -475,6 +489,11 @@ pub fn run() {
475489
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
476490
let quit_flag = app_handle.state::<ExplicitQuit>();
477491
if quit_flag.0.load(Ordering::Relaxed) {
492+
#[cfg(unix)]
493+
{
494+
let socket_state = app_handle.state::<ipc::SocketState>();
495+
ipc::cleanup(socket_state);
496+
}
478497
return;
479498
}
480499
api.prevent_exit();

0 commit comments

Comments
 (0)