record-daemon: initial commit

This commit is contained in:
2026-03-19 17:48:07 +01:00
commit d6c0334369
30 changed files with 9486 additions and 0 deletions

View File

@@ -0,0 +1,305 @@
//! IPC command handlers.
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::RwLock;
use tracing::{debug, info};
use super::protocol::{IpcCommand, IpcMessage, IpcResponse};
use crate::config::Settings;
use crate::error::Result;
use crate::recording::encoder::{detect_hardware_encoders, EncoderCapability};
use crate::recording::RecordingEngine;
use crate::state::DaemonStatus;
use crate::timeline::TimelineStore;
/// Command handler trait.
#[async_trait]
pub trait CommandHandler: Send + Sync {
/// Handle the command and return a response.
async fn handle(&self, payload: Option<serde_json::Value>) -> Result<serde_json::Value>;
}
/// Collection of IPC command handlers.
pub struct IpcHandlers {
/// Settings reference.
settings: Arc<RwLock<Settings>>,
/// Recording engine reference.
recording: Arc<RwLock<Option<RecordingEngine>>>,
/// Timeline store reference.
timeline: Arc<RwLock<TimelineStore>>,
/// Daemon status.
status: Arc<RwLock<DaemonStatus>>,
/// Client connection status.
client_connected: Arc<RwLock<bool>>,
}
impl IpcHandlers {
/// Create a new handlers collection.
pub fn new(
settings: Arc<RwLock<Settings>>,
recording: Arc<RwLock<Option<RecordingEngine>>>,
timeline: Arc<RwLock<TimelineStore>>,
status: Arc<RwLock<DaemonStatus>>,
client_connected: Arc<RwLock<bool>>,
) -> Self {
Self {
settings,
recording,
timeline,
status,
client_connected,
}
}
/// Handle an incoming message.
pub async fn handle(&self, message: IpcMessage) -> IpcResponse {
let command = match message.command {
Some(cmd) => cmd,
None => return IpcResponse::error(message.id, "No command specified"),
};
debug!("Handling command: {:?}", command);
let result = match command {
IpcCommand::GetSettings => self.handle_get_settings().await,
IpcCommand::UpdateSettings => self.handle_update_settings(message.payload).await,
IpcCommand::ResetSettings => self.handle_reset_settings().await,
IpcCommand::GetStatus => self.handle_get_status().await,
IpcCommand::GetEncoders => self.handle_get_encoders().await,
IpcCommand::StartRecording => self.handle_start_recording().await,
IpcCommand::StopRecording => self.handle_stop_recording().await,
IpcCommand::GetRecordings => self.handle_get_recordings().await,
IpcCommand::GetRecording { id } => self.handle_get_recording(id).await,
IpcCommand::DeleteRecording { id } => self.handle_delete_recording(id).await,
IpcCommand::GetTimeline { recording_id } => {
self.handle_get_timeline(recording_id).await
}
IpcCommand::ExportTimeline {
recording_id,
format,
} => self.handle_export_timeline(recording_id, format).await,
IpcCommand::Shutdown => self.handle_shutdown().await,
};
match result {
Ok(data) => IpcResponse::success(message.id, data),
Err(e) => {
info!("Command failed: {}", e);
IpcResponse::error(message.id, e.to_string())
}
}
}
async fn handle_get_settings(&self) -> Result<serde_json::Value> {
let settings = self.settings.read().clone();
Ok(serde_json::to_value(settings)?)
}
async fn handle_update_settings(
&self,
payload: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let payload = payload.ok_or_else(|| {
crate::error::IpcError::InvalidCommand("Missing settings payload".to_string())
})?;
let new_settings: Settings = serde_json::from_value(payload)?;
// Validate settings
if new_settings.video.frame_rate == 0 || new_settings.video.frame_rate > 240 {
return Err(
crate::error::ConfigError::InvalidConfig("Invalid frame rate".to_string()).into(),
);
}
// Update settings
*self.settings.write() = new_settings.clone();
// Persist settings
crate::config::save_config(&new_settings)?;
info!("Settings updated");
Ok(serde_json::json!({ "success": true }))
}
async fn handle_reset_settings(&self) -> Result<serde_json::Value> {
let default_settings = Settings::default();
*self.settings.write() = default_settings.clone();
crate::config::save_config(&default_settings)?;
info!("Settings reset to defaults");
Ok(serde_json::to_value(default_settings)?)
}
async fn handle_get_status(&self) -> Result<serde_json::Value> {
let status = *self.status.read();
let recording = self.recording.read();
let is_recording = recording
.as_ref()
.map(|r| r.is_recording())
.unwrap_or(false);
let client_connected = *self.client_connected.read();
Ok(serde_json::json!({
"status": status,
"isRecording": is_recording,
"clientConnected": client_connected,
}))
}
async fn handle_get_encoders(&self) -> Result<serde_json::Value> {
let capabilities = detect_hardware_encoders();
let current = self
.settings
.read()
.video
.encoder_preset
.encoder_name()
.to_string();
let encoders: Vec<_> = capabilities
.iter()
.map(|cap| match cap {
EncoderCapability::Nvenc => serde_json::json!({
"id": "jim_nvenc",
"name": "NVIDIA NVENC",
"isHardware": true,
"available": true,
}),
EncoderCapability::Amf => serde_json::json!({
"id": "amd_amf_h264",
"name": "AMD AMF",
"isHardware": true,
"available": true,
}),
EncoderCapability::QuickSync => serde_json::json!({
"id": "qsv",
"name": "Intel QuickSync",
"isHardware": true,
"available": true,
}),
EncoderCapability::Software => serde_json::json!({
"id": "x264",
"name": "x264 (Software)",
"isHardware": false,
"available": true,
}),
})
.collect();
Ok(serde_json::json!({
"available": encoders,
"current": current,
}))
}
async fn handle_start_recording(&self) -> Result<serde_json::Value> {
let mut recording_guard = self.recording.write();
if let Some(ref mut engine) = *recording_guard {
if engine.is_recording() {
return Err(crate::error::RecordingError::AlreadyRecording.into());
}
engine.start_recording(None, None)?;
Ok(serde_json::json!({ "success": true }))
} else {
Err(crate::error::RecordingError::ObsInitError(
"Recording engine not initialized".to_string(),
)
.into())
}
}
async fn handle_stop_recording(&self) -> Result<serde_json::Value> {
let mut recording_guard = self.recording.write();
if let Some(ref mut engine) = *recording_guard {
let result = engine.stop_recording()?;
// Save to timeline
self.timeline.write().add_recording(result.clone())?;
Ok(serde_json::to_value(result)?)
} else {
Err(crate::error::RecordingError::NotRecording.into())
}
}
async fn handle_get_recordings(&self) -> Result<serde_json::Value> {
let recordings = self.timeline.read().get_all_recordings()?;
Ok(serde_json::to_value(recordings)?)
}
async fn handle_get_recording(&self, id: uuid::Uuid) -> Result<serde_json::Value> {
let recording = self.timeline.read().get_recording(id)?;
Ok(serde_json::to_value(recording)?)
}
async fn handle_delete_recording(&self, id: uuid::Uuid) -> Result<serde_json::Value> {
self.timeline.write().delete_recording(id)?;
Ok(serde_json::json!({ "success": true }))
}
async fn handle_get_timeline(&self, recording_id: uuid::Uuid) -> Result<serde_json::Value> {
let timeline = self.timeline.read().get_timeline(recording_id)?;
Ok(serde_json::to_value(timeline)?)
}
async fn handle_export_timeline(
&self,
recording_id: uuid::Uuid,
format: super::protocol::ExportFormat,
) -> Result<serde_json::Value> {
let timeline = self.timeline.read().get_timeline(recording_id)?;
let exported = match format {
super::protocol::ExportFormat::Json => serde_json::to_string_pretty(&timeline)?,
super::protocol::ExportFormat::Csv => {
// Convert to CSV format
let mut csv = String::from("timestamp,event_type,description\n");
for event in &timeline.events {
csv.push_str(&format!(
"{},{},{}\n",
event.timestamp.to_rfc3339(),
event.event_type,
event.description,
));
}
csv
}
};
Ok(serde_json::json!({
"data": exported,
"format": format,
}))
}
async fn handle_shutdown(&self) -> Result<serde_json::Value> {
info!("Shutdown requested via IPC");
// Signal shutdown through status
*self.status.write() = DaemonStatus::ShuttingDown;
Ok(serde_json::json!({ "success": true }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_handlers_creation() {
let settings = Arc::new(RwLock::new(Settings::default()));
let recording = Arc::new(RwLock::new(None));
let timeline = Arc::new(RwLock::new(TimelineStore::new()));
let status = Arc::new(RwLock::new(DaemonStatus::Idle));
let client_connected = Arc::new(RwLock::new(false));
let _handlers = IpcHandlers::new(settings, recording, timeline, status, client_connected);
}
}

View File

@@ -0,0 +1,29 @@
//! IPC module for communication with the Tauri app.
//!
//! This module provides a Unix socket-based IPC server that allows
//! the Tauri app to configure and control the daemon.
mod handlers;
mod protocol;
mod server;
pub use handlers::{CommandHandler, IpcHandlers};
pub use protocol::{IpcCommand, IpcMessage, IpcNotification, IpcResponse};
pub use server::{IpcServer, IpcServerConfig};
use std::path::PathBuf;
/// 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")
}
/// Default socket path for the IPC server.
#[cfg(target_os = "windows")]
pub fn default_socket_path() -> PathBuf {
PathBuf::from(r"\\.\pipe\record-daemon")
}

View File

@@ -0,0 +1,277 @@
//! IPC protocol definitions.
//!
//! Defines the message format and commands for IPC communication.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::Settings;
use crate::lqp::GameEvent;
use crate::recording::RecordingResult;
use crate::state::DaemonStatus;
use crate::timeline::RecordingMetadata;
/// IPC message wrapper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcMessage {
/// Message type.
#[serde(rename = "type")]
pub message_type: MessageType,
/// Unique message ID.
pub id: Uuid,
/// Command name (for requests).
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<IpcCommand>,
/// Payload data.
#[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 message (expects response).
Request,
/// Response message.
Response,
/// Notification message (no response expected).
Notification,
}
/// IPC commands that can be sent to the daemon.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum IpcCommand {
// Configuration commands
/// Get current settings.
GetSettings,
/// Update settings.
UpdateSettings,
/// Reset settings to defaults.
ResetSettings,
// Status commands
/// Get daemon status.
GetStatus,
/// Get available encoders.
GetEncoders,
// Recording commands
/// Start recording manually.
StartRecording,
/// Stop recording.
StopRecording,
/// Get list of recordings.
GetRecordings,
/// Get specific recording details.
GetRecording { id: Uuid },
/// Delete a recording.
DeleteRecording { id: Uuid },
// Timeline commands
/// Get timeline for a recording.
GetTimeline { recording_id: Uuid },
/// Export timeline to file.
ExportTimeline {
recording_id: Uuid,
format: ExportFormat,
},
// Daemon control
/// Shutdown the daemon.
Shutdown,
}
/// Export format for timeline.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
/// JSON format.
Json,
/// CSV format.
Csv,
}
/// IPC response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
/// Original request ID.
pub request_id: Uuid,
/// Whether the request was successful.
pub success: bool,
/// Response data (if successful).
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
/// Error message (if failed).
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl IpcResponse {
/// Create a successful response.
pub fn success(request_id: Uuid, data: impl Serialize) -> Self {
Self {
request_id,
success: true,
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
error: None,
}
}
/// Create an error response.
pub fn error(request_id: Uuid, error: impl Into<String>) -> Self {
Self {
request_id,
success: false,
data: None,
error: Some(error.into()),
}
}
/// Serialize response to JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
/// IPC notification (server-initiated message).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "notification", rename_all = "camelCase")]
pub enum IpcNotification {
/// Recording started.
RecordingStarted {
recording_id: Uuid,
game_id: Option<u64>,
champion: Option<String>,
},
/// Recording stopped.
RecordingStopped {
recording_id: Uuid,
result: RecordingResult,
},
/// Game event received.
GameEvent { event: GameEvent },
/// Daemon status changed.
StatusChanged { status: DaemonStatus },
/// League Client connection changed.
ClientConnectionChanged { connected: bool },
/// Settings updated.
SettingsUpdated { settings: Settings },
/// Error occurred.
Error { message: String },
}
/// Response data types for specific commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSettingsResponse {
pub settings: Settings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetStatusResponse {
pub status: DaemonStatus,
pub is_recording: bool,
pub current_game_id: Option<u64>,
pub client_connected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetRecordingsResponse {
pub recordings: Vec<RecordingMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetEncodersResponse {
pub available: Vec<EncoderInfo>,
pub current: String,
}
/// Information about an available encoder.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncoderInfo {
/// Encoder ID.
pub id: String,
/// Display name.
pub name: String,
/// Whether this is a hardware encoder.
pub is_hardware: bool,
/// Whether this encoder is available on this system.
pub available: bool,
}
impl IpcMessage {
/// Create a new request message.
pub fn request(command: IpcCommand, payload: Option<serde_json::Value>) -> Self {
Self {
message_type: MessageType::Request,
id: Uuid::new_v4(),
command: Some(command),
payload,
}
}
/// Create a new notification message.
pub fn notification(notification: IpcNotification) -> Self {
Self {
message_type: MessageType::Notification,
id: Uuid::new_v4(),
command: None,
payload: Some(serde_json::to_value(notification).unwrap_or(serde_json::Value::Null)),
}
}
/// Parse from JSON string.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Serialize to JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_message_creation() {
let msg = IpcMessage::request(IpcCommand::GetSettings, None);
assert_eq!(msg.message_type, MessageType::Request);
assert_eq!(msg.command, Some(IpcCommand::GetSettings));
}
#[test]
fn test_ipc_response_success() {
let id = Uuid::new_v4();
let response = IpcResponse::success(id, "test data");
assert!(response.success);
assert!(response.data.is_some());
assert!(response.error.is_none());
}
#[test]
fn test_ipc_response_error() {
let id = Uuid::new_v4();
let response = IpcResponse::error(id, "Something went wrong");
assert!(!response.success);
assert!(response.data.is_none());
assert!(response.error.is_some());
}
#[test]
fn test_message_serialization() {
let msg = IpcMessage::request(IpcCommand::GetStatus, None);
let json = msg.to_json().unwrap();
let parsed = IpcMessage::from_json(&json).unwrap();
assert_eq!(msg.command, parsed.command);
}
}

View File

@@ -0,0 +1,464 @@
//! IPC server for communication with the Tauri app.
//!
//! Uses Unix domain sockets on Linux and named pipes on Windows.
use std::path::PathBuf;
use std::sync::Arc;
use futures::SinkExt;
use futures::StreamExt;
use tokio::sync::{broadcast, RwLock};
use tokio_util::codec::{Framed, LinesCodec};
use tracing::{debug, error, info, trace, warn};
use super::handlers::IpcHandlers;
use super::protocol::{IpcMessage, IpcNotification, IpcResponse, MessageType};
use crate::error::{IpcError, Result};
/// IPC server configuration.
#[derive(Debug, Clone)]
pub struct IpcServerConfig {
/// Socket path (Linux) or pipe name (Windows).
pub socket_path: PathBuf,
/// Maximum connections.
pub max_connections: usize,
/// Connection timeout in seconds.
pub timeout_secs: u64,
}
impl Default for IpcServerConfig {
fn default() -> Self {
Self {
socket_path: super::default_socket_path(),
max_connections: 10,
timeout_secs: 30,
}
}
}
/// Platform-specific listener type.
#[cfg(target_os = "linux")]
type PlatformListener = tokio::net::UnixListener;
/// Platform-specific stream type.
#[cfg(target_os = "linux")]
type PlatformStream = tokio::net::UnixStream;
/// IPC server for communication with Tauri app.
pub struct IpcServer {
/// Server configuration.
config: IpcServerConfig,
/// Platform-specific listener.
listener: Option<PlatformListener>,
/// Command handlers.
handlers: Arc<IpcHandlers>,
/// Notification broadcaster.
notification_tx: broadcast::Sender<IpcNotification>,
/// Shutdown signal.
shutdown: Arc<RwLock<bool>>,
/// Connected clients count.
client_count: Arc<RwLock<usize>>,
}
impl IpcServer {
/// Create a new IPC server.
pub fn new(config: IpcServerConfig, handlers: IpcHandlers) -> Self {
let (notification_tx, _) = broadcast::channel(64);
Self {
config,
listener: None,
handlers: Arc::new(handlers),
notification_tx,
shutdown: Arc::new(RwLock::new(false)),
client_count: Arc::new(RwLock::new(0)),
}
}
/// Get the socket path.
pub fn socket_path(&self) -> &PathBuf {
&self.config.socket_path
}
/// Get a subscriber for notifications.
pub fn subscribe(&self) -> broadcast::Receiver<IpcNotification> {
self.notification_tx.subscribe()
}
/// Broadcast a notification to all connected clients.
pub fn broadcast(&self, notification: IpcNotification) {
if self.notification_tx.send(notification).is_err() {
trace!("No clients connected to receive notification");
}
}
/// Start the IPC server.
#[cfg(target_os = "linux")]
pub async fn start(&mut self) -> Result<()> {
// Remove existing socket if present
if self.config.socket_path.exists() {
std::fs::remove_file(&self.config.socket_path).map_err(IpcError::BindError)?;
}
// Ensure parent directory exists
if let Some(parent) = self.config.socket_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(IpcError::BindError)?;
}
}
info!("Starting IPC server at {:?}", self.config.socket_path);
let listener = PlatformListener::bind(&self.config.socket_path).map_err(IpcError::BindError)?;
self.listener = Some(listener);
info!("IPC server started successfully");
Ok(())
}
/// Start the IPC server (Windows).
#[cfg(target_os = "windows")]
pub async fn start(&mut self) -> Result<()> {
// On Windows, we don't need to bind in advance for named pipes
// The server will create pipe instances on demand
info!("Starting IPC server at {:?}", self.config.socket_path);
// Mark as "started" - actual pipe creation happens in accept loop
self.listener = None; // We'll create pipes on demand
info!("IPC server started successfully");
Ok(())
}
/// Run the server loop.
#[cfg(target_os = "linux")]
pub async fn run(&self) -> Result<()> {
let listener = self.listener.as_ref().ok_or_else(|| {
IpcError::BindError(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Server not started",
))
})?;
info!("IPC server listening for connections");
loop {
if *self.shutdown.read().await {
info!("IPC server shutting down");
break;
}
// Accept new connection
match listener.accept().await {
Ok((stream, addr)) => {
self.handle_new_connection(stream, format!("{:?}", addr)).await;
}
Err(e) => {
warn!("Failed to accept connection: {}", e);
}
}
}
Ok(())
}
/// Run the server loop (Windows).
#[cfg(target_os = "windows")]
pub async fn run(&self) -> Result<()> {
use tokio::net::windows::named_pipe::{ServerOptions, NamedPipeServer};
use std::os::windows::io::AsRawHandle;
info!("IPC server listening for connections on {:?}", self.config.socket_path);
let pipe_name = self.config.socket_path.to_string_lossy();
loop {
if *self.shutdown.read().await {
info!("IPC server shutting down");
break;
}
// Create a new named pipe instance
let server = ServerOptions::new()
.first_pipe_instance(false)
.reject_remote_clients(true)
.create(&pipe_name)
.map_err(IpcError::BindError)?;
// Wait for a client to connect
match server.connect().await {
Ok(()) => {
debug!("New IPC client connected");
// Spawn handler for this connection
let handlers = self.handlers.clone();
let shutdown = self.shutdown.clone();
let notification_tx = self.notification_tx.clone();
let client_count = self.client_count.clone();
let max_connections = self.config.max_connections;
// Check connection limit
let current_count = *client_count.read().await;
if current_count >= max_connections {
warn!("Connection limit reached, rejecting connection");
continue;
}
tokio::spawn(async move {
*client_count.write().await += 1;
if let Err(e) = Self::handle_connection_windows(
server,
handlers,
shutdown.clone(),
notification_tx,
)
.await
{
error!("Connection error: {}", e);
}
*client_count.write().await -= 1;
debug!("Client disconnected");
});
}
Err(e) => {
warn!("Failed to accept connection: {}", e);
}
}
}
Ok(())
}
/// Handle a new connection (Linux).
#[cfg(target_os = "linux")]
async fn handle_new_connection(&self, stream: PlatformStream, addr: String) {
let client_count = self.client_count.clone();
let max_connections = self.config.max_connections;
// Check connection limit
let current_count = *client_count.read().await;
if current_count >= max_connections {
warn!(
"Connection limit reached, rejecting connection from {}",
addr
);
return;
}
debug!("New IPC client connected from {}", addr);
// Spawn handler for this connection
let handlers = self.handlers.clone();
let shutdown = self.shutdown.clone();
let notification_tx = self.notification_tx.clone();
tokio::spawn(async move {
*client_count.write().await += 1;
if let Err(e) = Self::handle_connection(stream, handlers, shutdown.clone(), notification_tx).await
{
error!("Connection error: {}", e);
}
*client_count.write().await -= 1;
debug!("Client disconnected");
});
}
/// Handle a single client connection (Linux).
#[cfg(target_os = "linux")]
async fn handle_connection(
stream: PlatformStream,
handlers: Arc<IpcHandlers>,
shutdown: Arc<RwLock<bool>>,
_notification_tx: broadcast::Sender<IpcNotification>,
) -> Result<()> {
let framed = Framed::new(stream, LinesCodec::new());
let (mut writer, mut reader) = framed.split();
while !*shutdown.read().await {
// Read message
let line = tokio::time::timeout(std::time::Duration::from_secs(30), reader.next())
.await
.map_err(|_| {
IpcError::ReadError(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Read timeout",
))
})?;
let line = match line {
Some(Ok(line)) => line,
Some(Err(e)) => {
warn!("Error reading from client: {}", e);
break;
}
None => {
debug!("Client disconnected");
break;
}
};
trace!("Received: {}", line);
// Parse message
let message = match IpcMessage::from_json(&line) {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.send(response.to_json()?).await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
continue;
}
};
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
continue;
}
MessageType::Response => {
// We shouldn't receive responses
warn!("Unexpected response message from client");
continue;
}
};
// Send response
let response_json = response.to_json()?;
writer.send(response_json).await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
}
Ok(())
}
/// Handle a single client connection (Windows).
#[cfg(target_os = "windows")]
async fn handle_connection_windows(
server: tokio::net::windows::named_pipe::NamedPipeServer,
handlers: Arc<IpcHandlers>,
shutdown: Arc<RwLock<bool>>,
_notification_tx: broadcast::Sender<IpcNotification>,
) -> Result<()> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (mut reader, mut writer) = tokio::io::split(server);
let mut reader = BufReader::new(reader).lines();
while !*shutdown.read().await {
// Read message with timeout
let line = tokio::time::timeout(std::time::Duration::from_secs(30), reader.next_line())
.await
.map_err(|_| {
IpcError::ReadError(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Read timeout",
))
})?;
let line = match line {
Ok(Some(line)) => line,
Ok(None) => {
debug!("Client disconnected");
break;
}
Err(e) => {
warn!("Error reading from client: {}", e);
break;
}
};
trace!("Received: {}", line);
// Parse message
let message = match IpcMessage::from_json(&line) {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.write_all(response.to_json()?.as_bytes()).await
.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
continue;
}
};
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
continue;
}
MessageType::Response => {
// We shouldn't receive responses
warn!("Unexpected response message from client");
continue;
}
};
// Send response
let response_json = response.to_json()?;
writer.write_all(response_json.as_bytes()).await.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
}
Ok(())
}
/// Stop the IPC server.
pub async fn stop(&mut self) -> Result<()> {
*self.shutdown.write().await = true;
// Remove socket file (Linux only)
#[cfg(target_os = "linux")]
{
if self.config.socket_path.exists() {
std::fs::remove_file(&self.config.socket_path)
.map_err(IpcError::BindError)?;
}
}
info!("IPC server stopped");
Ok(())
}
}
impl Clone for IpcServer {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
listener: None, // Can't clone the listener
handlers: self.handlers.clone(),
notification_tx: self.notification_tx.clone(),
shutdown: self.shutdown.clone(),
client_count: self.client_count.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_server_config_default() {
let config = IpcServerConfig::default();
assert!(config.socket_path.to_string_lossy().contains("record-daemon"));
assert_eq!(config.max_connections, 10);
assert_eq!(config.timeout_secs, 30);
}
}