tauri-app: allow editing record-daemon settings
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m28s

This commit is contained in:
2026-05-16 23:52:52 +02:00
parent 3279948e78
commit c61b79e84b
8 changed files with 1865 additions and 8 deletions

View File

@@ -3809,6 +3809,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tokio",
"uuid",
]

View File

@@ -26,4 +26,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
directories = "6"
ffmpeg-sidecar = "2.4"
tokio = { version = "1", features = ["net", "io-util", "time"] }

View File

@@ -0,0 +1,377 @@
//! IPC client for communicating with the record-daemon.
//!
//! Connects to the daemon via named pipes on Windows or Unix sockets on Linux,
//! using the same JSON-over-newline-delimited protocol.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
// ---------------------------------------------------------------------------
// Protocol types (mirror of record-daemon IPC protocol)
// ---------------------------------------------------------------------------
/// IPC message wrapper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcMessage {
#[serde(rename = "type")]
pub message_type: MessageType,
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
}
/// Message type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
Request,
Response,
Notification,
}
/// IPC response from the daemon.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
pub request_id: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// IPC commands (camelCase to match daemon's serde rename)
// ---------------------------------------------------------------------------
/// IPC command names matching the daemon's `IpcCommand` enum.
pub mod commands {
pub const GET_SETTINGS: &str = "getSettings";
pub const UPDATE_SETTINGS: &str = "updateSettings";
pub const RESET_SETTINGS: &str = "resetSettings";
pub const GET_STATUS: &str = "getStatus";
pub const GET_ENCODERS: &str = "getEncoders";
pub const START_RECORDING: &str = "startRecording";
pub const STOP_RECORDING: &str = "stopRecording";
pub const SHUTDOWN: &str = "shutdown";
}
// ---------------------------------------------------------------------------
// Default socket path (same logic as daemon)
// ---------------------------------------------------------------------------
/// Default socket path for the IPC server.
#[cfg(target_os = "windows")]
pub fn default_socket_path() -> PathBuf {
PathBuf::from(r"\\.\pipe\record-daemon")
}
/// Default socket path for the IPC server.
#[cfg(target_os = "linux")]
pub fn default_socket_path() -> PathBuf {
std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("record-daemon.sock")
}
// ---------------------------------------------------------------------------
// IPC Client
// ---------------------------------------------------------------------------
/// IPC client for communicating with the record-daemon.
pub struct IpcClient {
socket_path: PathBuf,
timeout: Duration,
}
impl IpcClient {
/// Create a new IPC client with the default socket path.
pub fn new() -> Self {
Self {
socket_path: default_socket_path(),
timeout: Duration::from_secs(5),
}
}
/// Create a new IPC client with a custom socket path.
#[allow(dead_code)]
pub fn with_socket_path(socket_path: PathBuf) -> Self {
Self {
socket_path,
timeout: Duration::from_secs(5),
}
}
/// Send a command to the daemon and return the response.
pub async fn send_command(
&self,
command: &str,
payload: Option<serde_json::Value>,
) -> Result<IpcResponse, String> {
let id = uuid::Uuid::new_v4().to_string();
let message = IpcMessage {
message_type: MessageType::Request,
id: id.clone(),
command: Some(command.to_string()),
payload,
};
let message_json = serde_json::to_string(&message)
.map_err(|e| format!("Failed to serialize message: {}", e))?;
let response_str = self.send_raw(&message_json).await?;
let response: IpcResponse = serde_json::from_str(&response_str)
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(response)
}
/// Send a raw JSON string and read the response.
#[cfg(target_os = "windows")]
async fn send_raw(&self, message: &str) -> Result<String, String> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::ClientOptions;
// Connect to the named pipe
let pipe_name = self.socket_path.to_string_lossy().to_string();
let client = tokio::time::timeout(self.timeout, async {
// Try to connect - if the pipe is busy, retry a few times
let mut attempts = 0;
loop {
match ClientOptions::new().open(&pipe_name) {
Ok(client) => return Ok(client),
Err(e) if e.raw_os_error() == Some(231) => {
// ERROR_PIPE_BUSY = 231
attempts += 1;
if attempts > 5 {
return Err(format!("Pipe busy after 5 attempts: {}", e));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => return Err(format!("Failed to connect to daemon: {}", e)),
}
}
})
.await
.map_err(|_| "Connection to daemon timed out".to_string())??;
let (reader, mut writer) = tokio::io::split(client);
// Send message
writer
.write_all(message.as_bytes())
.await
.map_err(|e| format!("Failed to write to pipe: {}", e))?;
writer
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write newline: {}", e))?;
writer
.flush()
.await
.map_err(|e| format!("Failed to flush pipe: {}", e))?;
// Read response
let mut lines = BufReader::new(reader).lines();
let line = tokio::time::timeout(self.timeout, lines.next_line())
.await
.map_err(|_| "Reading from daemon timed out".to_string())?
.map_err(|e| format!("Failed to read from pipe: {}", e))?;
match line {
Some(line) => Ok(line),
None => Err("Daemon closed connection without response".to_string()),
}
}
/// Send a raw JSON string and read the response (Linux).
#[cfg(target_os = "linux")]
async fn send_raw(&self, message: &str) -> Result<String, String> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let stream = tokio::time::timeout(self.timeout, UnixStream::connect(&self.socket_path))
.await
.map_err(|_| "Connection to daemon timed out".to_string())?
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
let (reader, mut writer) = stream.into_split();
// Send message
writer
.write_all(message.as_bytes())
.await
.map_err(|e| format!("Failed to write to socket: {}", e))?;
writer
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write newline: {}", e))?;
writer
.flush()
.await
.map_err(|e| format!("Failed to flush socket: {}", e))?;
// Read response
let mut lines = BufReader::new(reader).lines();
let line = tokio::time::timeout(self.timeout, lines.next_line())
.await
.map_err(|_| "Reading from daemon timed out".to_string())?
.map_err(|e| format!("Failed to read from socket: {}", e))?;
match line {
Some(line) => Ok(line),
None => Err("Daemon closed connection without response".to_string()),
}
}
/// Check if the daemon is reachable.
pub async fn is_daemon_running(&self) -> bool {
self.send_command(commands::GET_STATUS, None).await.is_ok()
}
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Check if the record-daemon is running.
#[tauri::command]
pub async fn daemon_is_running() -> bool {
let client = IpcClient::new();
client.is_daemon_running().await
}
/// Get the daemon status.
#[tauri::command]
pub async fn daemon_get_status() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::GET_STATUS, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Get the current daemon settings.
#[tauri::command]
pub async fn daemon_get_settings() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::GET_SETTINGS, None)
.await
.map_err(|e| e)?;
if response.success {
// The daemon wraps settings in { settings: {...} }
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Update daemon settings.
#[tauri::command]
pub async fn daemon_update_settings(settings: serde_json::Value) -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::UPDATE_SETTINGS, Some(settings))
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Reset daemon settings to defaults.
#[tauri::command]
pub async fn daemon_reset_settings() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::RESET_SETTINGS, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Get available encoders from the daemon.
#[tauri::command]
pub async fn daemon_get_encoders() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::GET_ENCODERS, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Start recording manually via the daemon.
#[tauri::command]
pub async fn daemon_start_recording() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::START_RECORDING, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Stop recording via the daemon.
#[tauri::command]
pub async fn daemon_stop_recording() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::STOP_RECORDING, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}
/// Shutdown the daemon.
#[tauri::command]
pub async fn daemon_shutdown() -> Result<serde_json::Value, String> {
let client = IpcClient::new();
let response = client
.send_command(commands::SHUTDOWN, None)
.await
.map_err(|e| e)?;
if response.success {
Ok(response.data.unwrap_or(serde_json::Value::Null))
} else {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
}
}

View File

@@ -1,3 +1,5 @@
mod daemon_ipc;
use ffmpeg_sidecar::{
command::{ffmpeg_is_installed, FfmpegCommand},
download::auto_download,
@@ -403,7 +405,17 @@ pub fn run() {
export_clip_precise,
check_ffmpeg,
download_ffmpeg,
get_video_metadata
get_video_metadata,
// Daemon IPC commands
daemon_ipc::daemon_is_running,
daemon_ipc::daemon_get_status,
daemon_ipc::daemon_get_settings,
daemon_ipc::daemon_update_settings,
daemon_ipc::daemon_reset_settings,
daemon_ipc::daemon_get_encoders,
daemon_ipc::daemon_start_recording,
daemon_ipc::daemon_stop_recording,
daemon_ipc::daemon_shutdown,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");