From c61b79e84ba42c4ae91d9e513f936464cedd1c58 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 16 May 2026 23:52:52 +0200 Subject: [PATCH] tauri-app: allow editing record-daemon settings --- tauri-app/src-tauri/Cargo.lock | 1 + tauri-app/src-tauri/Cargo.toml | 1 + tauri-app/src-tauri/src/daemon_ipc.rs | 377 +++++++ tauri-app/src-tauri/src/lib.rs | 14 +- tauri-app/src/App.vue | 89 +- tauri-app/src/components/GameHistory.vue | 4 - tauri-app/src/components/Settings.vue | 1239 ++++++++++++++++++++++ tauri-app/src/types/daemon.ts | 148 +++ 8 files changed, 1865 insertions(+), 8 deletions(-) create mode 100644 tauri-app/src-tauri/src/daemon_ipc.rs create mode 100644 tauri-app/src/components/Settings.vue create mode 100644 tauri-app/src/types/daemon.ts diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index 065ee5b..046648c 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -3809,6 +3809,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tokio", "uuid", ] diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index 6081a55..dbd7c7c 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -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"] } diff --git a/tauri-app/src-tauri/src/daemon_ipc.rs b/tauri-app/src-tauri/src/daemon_ipc.rs new file mode 100644 index 0000000..96527f7 --- /dev/null +++ b/tauri-app/src-tauri/src/daemon_ipc.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +/// 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(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// --------------------------------------------------------------------------- +// 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, + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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())) + } +} diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 11ed0a7..7813992 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -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"); diff --git a/tauri-app/src/App.vue b/tauri-app/src/App.vue index 98710ba..f77ef6e 100644 --- a/tauri-app/src/App.vue +++ b/tauri-app/src/App.vue @@ -2,10 +2,11 @@ import { ref } from "vue"; import GameHistory from "./components/GameHistory.vue"; import GameReview from "./components/GameReview.vue"; +import Settings from "./components/Settings.vue"; import type { GameHistoryItem } from "./types/timeline"; // Current view state -const currentView = ref<"history" | "review">("history"); +const currentView = ref<"history" | "review" | "settings">("history"); const selectedGame = ref(null); // Navigate to review view @@ -19,12 +20,50 @@ function closeReview() { currentView.value = "history"; selectedGame.value = null; } + +// Navigate to settings +function openSettings() { + currentView.value = "settings"; +} + +// Navigate back from settings +function closeSettings() { + currentView.value = "history"; +} @@ -72,4 +115,44 @@ body { min-height: 100vh; background: #0a0a13; } + +/* Navigation bar */ +.app-nav { + display: flex; + align-items: center; + padding: 0 24px; + height: 48px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.nav-left { + display: flex; + gap: 4px; +} + +.nav-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: none; + border: none; + border-radius: 6px; + color: #94a3b8; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.nav-tab:hover { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.06); +} + +.nav-tab.active { + color: #f1f5f9; + background: rgba(255, 255, 255, 0.08); +} diff --git a/tauri-app/src/components/GameHistory.vue b/tauri-app/src/components/GameHistory.vue index 50d3e7f..cfee0c8 100644 --- a/tauri-app/src/components/GameHistory.vue +++ b/tauri-app/src/components/GameHistory.vue @@ -104,10 +104,6 @@ async function loadGameHistory() { } } -function selectGame(game: GameHistoryItem) { - selectedGame.value = game; -} - function closeDetail() { selectedGame.value = null; } diff --git a/tauri-app/src/components/Settings.vue b/tauri-app/src/components/Settings.vue new file mode 100644 index 0000000..7c00127 --- /dev/null +++ b/tauri-app/src/components/Settings.vue @@ -0,0 +1,1239 @@ + + + + + diff --git a/tauri-app/src/types/daemon.ts b/tauri-app/src/types/daemon.ts new file mode 100644 index 0000000..eaae8de --- /dev/null +++ b/tauri-app/src/types/daemon.ts @@ -0,0 +1,148 @@ +/** + * TypeScript types for daemon settings and status. + * Mirrors the Rust types from record-daemon/src/config/ and record-daemon/src/ipc/protocol.rs. + */ + +// --------------------------------------------------------------------------- +// Encoder presets +// --------------------------------------------------------------------------- + +export interface NvencPreset { + type: "nvenc"; + bitrate: number; + cq_level: number; + two_pass: boolean; +} + +export interface AmfPreset { + type: "amf"; + bitrate: number; + quality: "speed" | "balanced" | "quality"; +} + +export interface X264Preset { + type: "x264"; + preset: string; + bitrate: number; + crf: number | null; +} + +export type EncoderPreset = NvencPreset | AmfPreset | X264Preset; + +// --------------------------------------------------------------------------- +// Quality level +// --------------------------------------------------------------------------- + +export type QualityLevel = "low" | "medium" | "high" | "ultra"; + +// --------------------------------------------------------------------------- +// Settings structures +// --------------------------------------------------------------------------- + +export interface VideoSettings { + encoder_preset: EncoderPreset; + quality: QualityLevel; + frame_rate: number; + hardware_acceleration: boolean; +} + +export interface OutputSettings { + path: string; + naming_pattern: string; + container: string; + split_size_mb: number; + split_time_minutes: number; +} + +export interface AudioSettings { + enabled: boolean; + bitrate: number; + sample_rate: number; + channels: number; + capture_game: boolean; + capture_mic: boolean; + mic_device: string | null; +} + +export interface DaemonSettings { + auto_record: boolean; + monitor_client: boolean; + poll_interval_ms: number; + socket_path: string | null; + log_level: string; + save_timeline: boolean; +} + +export interface Settings { + video: VideoSettings; + output: OutputSettings; + audio: AudioSettings; + daemon: DaemonSettings; +} + +/** Response wrapper from daemon_get_settings (daemon wraps in { settings: {...} }). */ +export interface GetSettingsResponse { + settings: Settings; +} + +// --------------------------------------------------------------------------- +// Daemon status +// --------------------------------------------------------------------------- + +export type DaemonStatus = "idle" | "monitoring" | "recording" | "error" | "shutting_down"; + +export interface DaemonStatusResponse { + status: DaemonStatus; + is_recording: boolean; + current_game_id: number | null; + client_connected: boolean; +} + +// --------------------------------------------------------------------------- +// Encoder info +// --------------------------------------------------------------------------- + +export interface EncoderInfo { + id: string; + name: string; + is_hardware: boolean; + available: boolean; +} + +export interface EncodersResponse { + available: EncoderInfo[]; + current: string; +} + +// --------------------------------------------------------------------------- +// Quality level metadata +// --------------------------------------------------------------------------- + +export const QUALITY_LEVELS: { value: QualityLevel; label: string; description: string }[] = [ + { value: "low", label: "Low", description: "720p @ 30fps" }, + { value: "medium", label: "Medium", description: "1080p @ 30fps" }, + { value: "high", label: "High", description: "1080p @ 60fps" }, + { value: "ultra", label: "Ultra", description: "1440p @ 60fps" }, +]; + +export const CONTAINER_FORMATS = ["mp4", "mkv", "mov", "flv"] as const; + +export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error"] as const; + +export const X264_PRESETS = [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", +] as const; + +export const AMF_QUALITIES: { value: "speed" | "balanced" | "quality"; label: string }[] = [ + { value: "speed", label: "Speed" }, + { value: "balanced", label: "Balanced" }, + { value: "quality", label: "Quality" }, +];