Compare commits
10 Commits
v0.1.0
...
45aac067f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 45aac067f8 | |||
| ba4ed63f4c | |||
| 384ccda515 | |||
| 1c1b9c4d1a | |||
| 642ad3e13a | |||
| 3592b86a94 | |||
| b49ed22665 | |||
| abb35b8fac | |||
| 598c5a2391 | |||
| 7f54e959b8 |
Generated
+21
@@ -2418,6 +2418,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"winapi 0.3.9",
|
||||
@@ -3074,6 +3075,12 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -3255,6 +3262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
@@ -3520,6 +3528,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
|
||||
@@ -34,6 +34,7 @@ anyhow = "1"
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# UUID for recording IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct LqpClient {
|
||||
shutdown: Arc<RwLock<bool>>,
|
||||
/// Last emitted game ID for deduplication of GameStart events.
|
||||
last_emitted_game_id: Arc<RwLock<Option<u64>>>,
|
||||
/// WebSocket connection state (true if WebSocket is connected).
|
||||
ws_connected: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl LqpClient {
|
||||
@@ -50,6 +52,7 @@ impl LqpClient {
|
||||
http_client,
|
||||
shutdown: Arc::new(RwLock::new(false)),
|
||||
last_emitted_game_id: Arc::new(RwLock::new(None)),
|
||||
ws_connected: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +66,26 @@ impl LqpClient {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Check if connected to League Client.
|
||||
/// Check if connected to League Client (has valid credentials).
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.credentials.read().await.is_some()
|
||||
}
|
||||
|
||||
/// Check if WebSocket is connected.
|
||||
pub async fn is_ws_connected(&self) -> bool {
|
||||
*self.ws_connected.read().await
|
||||
}
|
||||
|
||||
/// Connect to the League Client with the given credentials.
|
||||
///
|
||||
/// This only stores credentials and verifies basic connectivity.
|
||||
/// The actual WebSocket connection is established in start_event_listener().
|
||||
pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> {
|
||||
info!("Connecting to League Client at port {}", creds.port);
|
||||
|
||||
// Reset shutdown flag for new connection
|
||||
*self.shutdown.write().await = false;
|
||||
|
||||
// Store credentials
|
||||
*self.credentials.write().await = Some(creds.clone());
|
||||
|
||||
@@ -82,12 +96,13 @@ impl LqpClient {
|
||||
info!("Connected to League Client, current phase: {:?}", phase);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to verify connection: {}", e);
|
||||
// Still consider connected, WebSocket might work
|
||||
warn!("Failed to verify connection via REST API: {}", e);
|
||||
// REST API might not be ready yet, but WebSocket could work
|
||||
// Don't fail here - let start_event_listener() try the WebSocket
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch local player's puuid for champion extraction
|
||||
// Fetch local player's puuid for champion extraction (best effort)
|
||||
if let Ok(summoner) = self.get_summoner().await {
|
||||
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
|
||||
self.state.write().await.local_puuid = Some(puuid.to_string());
|
||||
@@ -101,6 +116,7 @@ impl LqpClient {
|
||||
/// Disconnect from the League Client.
|
||||
pub async fn disconnect(&self) {
|
||||
*self.shutdown.write().await = true;
|
||||
*self.ws_connected.write().await = false;
|
||||
*self.credentials.write().await = None;
|
||||
*self.state.write().await = ClientState::default();
|
||||
*self.last_emitted_game_id.write().await = None;
|
||||
@@ -164,12 +180,16 @@ impl LqpClient {
|
||||
}
|
||||
info!("All subscriptions sent");
|
||||
|
||||
// Mark WebSocket as connected
|
||||
*self.ws_connected.write().await = true;
|
||||
|
||||
// Clone references for the async block
|
||||
let event_sender = self.event_sender.clone();
|
||||
let state = self.state.clone();
|
||||
let shutdown = self.shutdown.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let last_emitted_game_id = self.last_emitted_game_id.clone();
|
||||
let ws_connected = self.ws_connected.clone();
|
||||
|
||||
// Spawn the message handler
|
||||
tokio::spawn(async move {
|
||||
@@ -293,9 +313,10 @@ impl LqpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear credentials on disconnect
|
||||
// Clear connection state on disconnect
|
||||
*ws_connected.write().await = false;
|
||||
*credentials.write().await = None;
|
||||
debug!("WebSocket listener ended");
|
||||
info!("WebSocket listener ended");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -500,12 +521,16 @@ impl LqpClient {
|
||||
info!("Starting live client event poller");
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Small delay to ensure connection is stable
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut last_event_id: Option<u64> = None;
|
||||
let mut poll_count = 0u32;
|
||||
|
||||
loop {
|
||||
if *shutdown.read().await {
|
||||
info!("Live client event poller shutting down");
|
||||
let is_shutdown = *shutdown.read().await;
|
||||
if is_shutdown {
|
||||
info!("Live client event poller shutting down (shutdown flag is true)");
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
+95
-15
@@ -42,6 +42,10 @@ struct Args {
|
||||
/// Socket path for IPC.
|
||||
#[arg(short, long)]
|
||||
socket: Option<std::path::PathBuf>,
|
||||
|
||||
/// Path to log file. If not specified, logs to stdout/stderr.
|
||||
#[arg(short, long, value_name = "PATH")]
|
||||
log_file: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Main daemon structure.
|
||||
@@ -202,16 +206,31 @@ impl Daemon {
|
||||
|
||||
match watcher.check()? {
|
||||
Some(true) => {
|
||||
// Client started
|
||||
// Client started - try to connect
|
||||
info!("League Client detected");
|
||||
self.state_machine
|
||||
.transition(StateTransition::ClientStarted);
|
||||
|
||||
if let Some(creds) = watcher.credentials() {
|
||||
self.lqp_client.connect(creds.clone()).await?;
|
||||
self.lqp_client.start_event_listener().await?;
|
||||
// Start polling for live client events (kills, deaths, objectives)
|
||||
self.lqp_client.start_live_client_event_poller().await;
|
||||
match self.lqp_client.connect(creds.clone()).await {
|
||||
Ok(()) => {
|
||||
// Only transition to Monitoring after successful connection
|
||||
self.state_machine
|
||||
.transition(StateTransition::ClientStarted);
|
||||
|
||||
if let Err(e) = self.lqp_client.start_event_listener().await {
|
||||
warn!("Failed to start event listener: {}", e);
|
||||
// Still stay in Monitoring, will retry on next check
|
||||
} else {
|
||||
// Start polling for live client events (kills, deaths, objectives)
|
||||
self.lqp_client.start_live_client_event_poller().await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to connect to League Client: {}", e);
|
||||
// Don't transition to Monitoring - will retry on next check
|
||||
// The lockfile watcher will return None on next check since
|
||||
// credentials are already set, so we need to handle reconnection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
@@ -221,7 +240,34 @@ impl Daemon {
|
||||
.transition(StateTransition::ClientStopped);
|
||||
self.lqp_client.disconnect().await;
|
||||
}
|
||||
None => {}
|
||||
None => {
|
||||
// No change in lockfile status
|
||||
// Check if we need to reconnect (lockfile exists but WebSocket not connected)
|
||||
if watcher.credentials().is_some() && !self.lqp_client.is_ws_connected().await {
|
||||
info!("Lockfile exists but WebSocket not connected, attempting reconnect...");
|
||||
if let Some(creds) = watcher.credentials() {
|
||||
match self.lqp_client.connect(creds.clone()).await {
|
||||
Ok(()) => {
|
||||
// Transition to Monitoring if not already
|
||||
if !self.state_machine.is_monitoring() {
|
||||
self.state_machine
|
||||
.transition(StateTransition::ClientStarted);
|
||||
}
|
||||
|
||||
if let Err(e) = self.lqp_client.start_event_listener().await {
|
||||
warn!("Failed to start event listener on reconnect: {}", e);
|
||||
} else {
|
||||
self.lqp_client.start_live_client_event_poller().await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Reconnect attempt failed: {}", e);
|
||||
// Will retry on next check
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
@@ -579,15 +625,49 @@ impl Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging.
|
||||
fn init_logging(level: &str) {
|
||||
/// Initialize logging with optional file output.
|
||||
fn init_logging(level: &str, log_file: Option<&std::path::Path>) {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
if let Some(log_path) = log_file {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// Use file appender for logging
|
||||
let prefix = log_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("daemon");
|
||||
|
||||
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||
.rotation(tracing_appender::rolling::Rotation::DAILY)
|
||||
.filename_prefix(prefix)
|
||||
.filename_suffix("log")
|
||||
.build(log_path.parent().unwrap_or(std::path::Path::new(".")))
|
||||
.expect("Failed to create log file appender");
|
||||
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
// Store the guard to prevent it from being dropped
|
||||
// We leak it intentionally to keep logging working for the daemon's lifetime
|
||||
std::mem::forget(_guard);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
|
||||
.init();
|
||||
|
||||
eprintln!("Logging to file: {}", log_path.display());
|
||||
} else {
|
||||
// Log to stdout/stderr
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
// Set up panic hook to log panics
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
@@ -614,7 +694,7 @@ async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize logging
|
||||
init_logging(&args.log_level);
|
||||
init_logging(&args.log_level, args.log_file.as_deref());
|
||||
|
||||
info!("Record Daemon v{} starting", record_daemon::VERSION);
|
||||
|
||||
|
||||
Generated
+74
-8
@@ -216,6 +216,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"thiserror 1.0.69",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -740,7 +751,16 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -749,7 +769,18 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -760,7 +791,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -872,7 +903,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1971,6 +2002,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@@ -2990,6 +3022,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3770,7 +3813,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3821,7 +3864,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -3893,6 +3936,20 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-autostart"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
@@ -4345,7 +4402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -5297,6 +5354,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
@@ -5411,7 +5477,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
@@ -314,6 +314,20 @@ fn find_daemon_binary() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the log file path for the daemon.
|
||||
///
|
||||
/// Returns a path in the app's data directory.
|
||||
fn get_log_file_path() -> Result<PathBuf, String> {
|
||||
let data_dir = directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
|
||||
.ok_or_else(|| "Failed to get app data directory".to_string())?;
|
||||
|
||||
let log_dir = data_dir.data_dir().join("logs");
|
||||
std::fs::create_dir_all(&log_dir)
|
||||
.map_err(|e| format!("Failed to create log directory: {}", e))?;
|
||||
|
||||
Ok(log_dir.join("daemon.log"))
|
||||
}
|
||||
|
||||
/// Spawn the record-daemon as a detached background process.
|
||||
///
|
||||
/// Returns `Ok(())` if the process was successfully spawned.
|
||||
@@ -321,10 +335,13 @@ fn spawn_daemon() -> Result<(), String> {
|
||||
let binary = find_daemon_binary()
|
||||
.ok_or_else(|| "record-daemon binary not found".to_string())?;
|
||||
|
||||
let log_file = get_log_file_path()?;
|
||||
eprintln!("[daemon] Spawning record-daemon: {}", binary.display());
|
||||
eprintln!("[daemon] Log file: {}", log_file.display());
|
||||
|
||||
let mut cmd = std::process::Command::new(&binary);
|
||||
cmd.arg("--foreground");
|
||||
cmd.arg("--log-file").arg(&log_file);
|
||||
|
||||
// Set the working directory to the daemon binary's directory so it can
|
||||
// find its bundled resources (OBS plugins, etc.) relative to itself.
|
||||
@@ -536,3 +553,25 @@ pub async fn daemon_shutdown() -> Result<serde_json::Value, String> {
|
||||
Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the daemon log file path.
|
||||
#[tauri::command]
|
||||
pub fn daemon_get_log_path() -> Result<String, String> {
|
||||
get_log_file_path()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Read the daemon log file.
|
||||
///
|
||||
/// Returns the last `lines` lines of the log file (default 100, max 1000).
|
||||
#[tauri::command]
|
||||
pub fn daemon_read_logs(lines: Option<usize>) -> Result<String, String> {
|
||||
let log_path = get_log_file_path()?;
|
||||
let max_lines = lines.unwrap_or(100).min(1000);
|
||||
|
||||
let content = std::fs::read_to_string(&log_path)
|
||||
.map_err(|e| format!("Failed to read log file: {}", e))?;
|
||||
|
||||
let lines: Vec<&str> = content.lines().rev().take(max_lines).collect();
|
||||
Ok(lines.into_iter().rev().collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
@@ -395,6 +395,10 @@ fn get_video_metadata(video_path: String) -> Result<Value, String> {
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--hidden"]),
|
||||
))
|
||||
.setup(|app| {
|
||||
// Auto-start the record-daemon on app launch.
|
||||
// We spawn a background task so the UI isn't blocked while the
|
||||
@@ -439,6 +443,8 @@ pub fn run() {
|
||||
daemon_ipc::daemon_start_recording,
|
||||
daemon_ipc::daemon_stop_recording,
|
||||
daemon_ipc::daemon_shutdown,
|
||||
daemon_ipc::daemon_get_log_path,
|
||||
daemon_ipc::daemon_read_logs,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "leaguerecorder",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "v.leaguerecorder",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -42,6 +42,7 @@ const typeToggles = ref<Record<HighlightType, boolean>>({
|
||||
death: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("death"),
|
||||
assist: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("assist"),
|
||||
multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"),
|
||||
trade_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("trade_kill"),
|
||||
});
|
||||
|
||||
// Watch type toggles and update settings
|
||||
@@ -82,6 +83,7 @@ const activeClipId = computed(() => {
|
||||
const killCount = computed(() => props.highlights.filter(h => h.highlight_type === "kill").length);
|
||||
const deathCount = computed(() => props.highlights.filter(h => h.highlight_type === "death").length);
|
||||
const multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length);
|
||||
const tradeKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "trade_kill").length);
|
||||
const assistCount = computed(() => props.highlights.filter(h => h.highlight_type === "assist").length);
|
||||
const totalDuration = computed(() => {
|
||||
const total = props.highlights.reduce((sum, h) => sum + (h.end_time - h.start_time), 0);
|
||||
@@ -173,6 +175,7 @@ function formatClipDuration(clip: HighlightClip): string {
|
||||
{ type: 'death' as HighlightType, label: 'Deaths', icon: '💀' },
|
||||
{ type: 'assist' as HighlightType, label: 'Assists', icon: '🤝' },
|
||||
{ type: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' },
|
||||
{ type: 'trade_kill' as HighlightType, label: 'Trade Kills', icon: '🔄' },
|
||||
]"
|
||||
:key="typeInfo.type"
|
||||
class="type-toggle"
|
||||
@@ -230,6 +233,9 @@ function formatClipDuration(clip: HighlightClip): string {
|
||||
<div class="stat-pill" v-if="multiKillCount > 0">
|
||||
<span>🔥</span> {{ multiKillCount }}
|
||||
</div>
|
||||
<div class="stat-pill" v-if="tradeKillCount > 0">
|
||||
<span>🔄</span> {{ tradeKillCount }}
|
||||
</div>
|
||||
<div class="stat-pill total">
|
||||
<span>⏱️</span> {{ formatDuration(totalDuration) }}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,8 @@ const daemonStatus = ref<DaemonStatusResponse | null>(null);
|
||||
const encoders = ref<EncodersResponse | null>(null);
|
||||
const daemonRunning = ref(false);
|
||||
const statusPolling = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const autostartEnabled = ref(false);
|
||||
const autostartLoading = ref(false);
|
||||
|
||||
// Active settings tab
|
||||
type SettingsTab = "video" | "output" | "audio" | "daemon";
|
||||
@@ -97,6 +99,35 @@ async function refreshStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autostart management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkAutostart() {
|
||||
try {
|
||||
autostartEnabled.value = await invoke<boolean>("plugin:autostart|is_enabled");
|
||||
} catch {
|
||||
autostartEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutostart() {
|
||||
autostartLoading.value = true;
|
||||
try {
|
||||
if (autostartEnabled.value) {
|
||||
await invoke("plugin:autostart|disable");
|
||||
autostartEnabled.value = false;
|
||||
} else {
|
||||
await invoke("plugin:autostart|enable");
|
||||
autostartEnabled.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `Failed to ${autostartEnabled.value ? 'disable' : 'enable'} autostart: ${e}`;
|
||||
} finally {
|
||||
autostartLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save settings
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -240,6 +271,7 @@ const statusColor = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
await checkAutostart();
|
||||
// Poll daemon status every 3 seconds
|
||||
statusPolling.value = setInterval(refreshStatus, 3000);
|
||||
});
|
||||
@@ -651,6 +683,23 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Daemon tab -->
|
||||
<div v-if="activeTab === 'daemon'" class="tab-content">
|
||||
<section class="settings-section">
|
||||
<h3>Startup</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Launch at Startup</label>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autostartEnabled"
|
||||
:disabled="autostartLoading"
|
||||
@change="toggleAutostart"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Automatically launch League Recorder when you log in to Windows</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h3>Daemon Behavior</h3>
|
||||
<div class="form-group">
|
||||
|
||||
+141
-28
@@ -728,7 +728,7 @@ export type EventCategory =
|
||||
/**
|
||||
* The type of highlight clip.
|
||||
*/
|
||||
export type HighlightType = "kill" | "death" | "assist" | "multi_kill";
|
||||
export type HighlightType = "kill" | "death" | "assist" | "multi_kill" | "trade_kill";
|
||||
|
||||
/**
|
||||
* Configuration for highlight generation.
|
||||
@@ -755,7 +755,7 @@ export const DEFAULT_HIGHLIGHT_SETTINGS: HighlightSettings = {
|
||||
buffer_before: 10,
|
||||
buffer_after: 3,
|
||||
min_duration: 5,
|
||||
included_types: ["kill", "death", "multi_kill"],
|
||||
included_types: ["kill", "death", "multi_kill", "trade_kill", "assist"],
|
||||
merge_overlapping: true,
|
||||
merge_gap_secs: 5,
|
||||
};
|
||||
@@ -808,23 +808,46 @@ export function getKillHighlightType(
|
||||
return null; // Not involved — skip
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of multi-kill detection, including whether it's a trade kill.
|
||||
*/
|
||||
export interface MultiKillGroup {
|
||||
/** Kill events in the group. */
|
||||
kills: TimestampedEvent[];
|
||||
/** Death event if the player died during the multi-kill window. */
|
||||
death: TimestampedEvent | null;
|
||||
/** Whether this is a trade kill (player died during the sequence). */
|
||||
isTradeKill: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect multi-kills from a sequence of kill events.
|
||||
* Returns groups of kills that happened within `windowSecs` seconds.
|
||||
* Also checks if the player died during the multi-kill window (trade kill).
|
||||
*/
|
||||
export function detectMultiKills(
|
||||
killEvents: TimestampedEvent[],
|
||||
deathEvents: TimestampedEvent[] = [],
|
||||
windowSecs: number = 12,
|
||||
): TimestampedEvent[][] {
|
||||
if (killEvents.length === 0) return [];
|
||||
localPlayerName?: string,
|
||||
): MultiKillGroup[] {
|
||||
// Filter to only include kills where the player is the killer
|
||||
const playerKills = localPlayerName
|
||||
? killEvents.filter((event) => {
|
||||
const rawData = event.raw_data as { KillerName?: string } | null;
|
||||
return rawData?.KillerName === localPlayerName;
|
||||
})
|
||||
: killEvents;
|
||||
|
||||
const sorted = [...killEvents].sort((a, b) => {
|
||||
if (playerKills.length === 0) return [];
|
||||
|
||||
const sorted = [...playerKills].sort((a, b) => {
|
||||
const aTime = a.video_timestamp[0] + a.video_timestamp[1] / 1e9;
|
||||
const bTime = b.video_timestamp[0] + b.video_timestamp[1] / 1e9;
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
const groups: TimestampedEvent[][] = [];
|
||||
const groups: MultiKillGroup[] = [];
|
||||
let currentGroup: TimestampedEvent[] = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
@@ -837,19 +860,57 @@ export function detectMultiKills(
|
||||
currentGroup.push(sorted[i]);
|
||||
} else {
|
||||
if (currentGroup.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
// Check for death in this window
|
||||
const firstTime = currentGroup[0].video_timestamp[0] + currentGroup[0].video_timestamp[1] / 1e9;
|
||||
const lastTime = currentGroup[currentGroup.length - 1].video_timestamp[0] + currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9;
|
||||
const death = findDeathInWindow(deathEvents, firstTime, lastTime, windowSecs);
|
||||
|
||||
groups.push({
|
||||
kills: currentGroup,
|
||||
death,
|
||||
isTradeKill: death !== null,
|
||||
});
|
||||
}
|
||||
currentGroup = [sorted[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last group
|
||||
if (currentGroup.length >= 2) {
|
||||
groups.push(currentGroup);
|
||||
const firstTime = currentGroup[0].video_timestamp[0] + currentGroup[0].video_timestamp[1] / 1e9;
|
||||
const lastTime = currentGroup[currentGroup.length - 1].video_timestamp[0] + currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9;
|
||||
const death = findDeathInWindow(deathEvents, firstTime, lastTime, windowSecs);
|
||||
|
||||
groups.push({
|
||||
kills: currentGroup,
|
||||
death,
|
||||
isTradeKill: death !== null,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a death event within the multi-kill window.
|
||||
* Checks from the first kill to the last kill, plus the window buffer.
|
||||
*/
|
||||
function findDeathInWindow(
|
||||
deathEvents: TimestampedEvent[],
|
||||
firstKillTime: number,
|
||||
lastKillTime: number,
|
||||
windowSecs: number,
|
||||
): TimestampedEvent | null {
|
||||
for (const death of deathEvents) {
|
||||
const deathTime = death.video_timestamp[0] + death.video_timestamp[1] / 1e9;
|
||||
// Death must occur within the window starting from first kill
|
||||
if (deathTime >= firstKillTime && deathTime <= lastKillTime + windowSecs) {
|
||||
return death;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the multi-kill label based on number of kills.
|
||||
*/
|
||||
@@ -872,6 +933,7 @@ export function getHighlightTypeColor(type: HighlightType): string {
|
||||
case "death": return "#f87171"; // red
|
||||
case "assist": return "#a78bfa"; // purple
|
||||
case "multi_kill": return "#f97316"; // orange
|
||||
case "trade_kill": return "#fbbf24"; // amber/yellow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,6 +946,7 @@ export function getHighlightTypeIcon(type: HighlightType): string {
|
||||
case "death": return "💀";
|
||||
case "assist": return "🤝";
|
||||
case "multi_kill": return "🔥";
|
||||
case "trade_kill": return "🔄";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,24 +1038,49 @@ export function computeHighlights(
|
||||
});
|
||||
}
|
||||
|
||||
// Detect multi-kills and create clips for them (only if enabled)
|
||||
if (settings.included_types.includes("multi_kill")) {
|
||||
const multiKillGroups = detectMultiKills(playerKills);
|
||||
// Detect multi-kills and trade kills (only if enabled)
|
||||
if (settings.included_types.includes("multi_kill") || settings.included_types.includes("trade_kill")) {
|
||||
const multiKillGroups = detectMultiKills(playerKills, playerDeaths);
|
||||
for (const group of multiKillGroups) {
|
||||
const firstTime = group[0].video_timestamp[0] + group[0].video_timestamp[1] / 1e9;
|
||||
// Skip if neither multi_kill nor trade_kill is enabled
|
||||
if (!settings.included_types.includes("multi_kill") && !settings.included_types.includes("trade_kill")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a trade kill but trade_kill is not enabled, skip
|
||||
if (group.isTradeKill && !settings.included_types.includes("trade_kill")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a multi kill but multi_kill is not enabled, skip
|
||||
if (!group.isTradeKill && !settings.included_types.includes("multi_kill")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstTime = group.kills[0].video_timestamp[0] + group.kills[0].video_timestamp[1] / 1e9;
|
||||
const lastTime =
|
||||
group[group.length - 1].video_timestamp[0] +
|
||||
group[group.length - 1].video_timestamp[1] / 1e9;
|
||||
const label = getMultiKillLabel(group.length);
|
||||
group.kills[group.kills.length - 1].video_timestamp[0] +
|
||||
group.kills[group.kills.length - 1].video_timestamp[1] / 1e9;
|
||||
const label = getMultiKillLabel(group.kills.length);
|
||||
|
||||
// Include death event if present
|
||||
const allEvents = group.death ? [...group.kills, group.death] : group.kills;
|
||||
|
||||
// Extend end time if death occurred after last kill
|
||||
const endTime = group.death
|
||||
? Math.max(lastTime, group.death.video_timestamp[0] + group.death.video_timestamp[1] / 1e9)
|
||||
: lastTime;
|
||||
|
||||
clips.push({
|
||||
id: clipId++,
|
||||
start_time: Math.max(0, firstTime - settings.buffer_before),
|
||||
end_time: Math.min(duration, lastTime + settings.buffer_after),
|
||||
highlight_type: "multi_kill",
|
||||
events: group,
|
||||
title: label || `${group.length}x Kill`,
|
||||
subtitle: `${group.length} kills in quick succession`,
|
||||
end_time: Math.min(duration, endTime + settings.buffer_after),
|
||||
highlight_type: group.isTradeKill ? "trade_kill" : "multi_kill",
|
||||
events: allEvents,
|
||||
title: group.isTradeKill ? `Trade Kill` : (label || `${group.kills.length}x Kill`),
|
||||
subtitle: group.isTradeKill
|
||||
? `${group.kills.length} kills but died in the trade`
|
||||
: `${group.kills.length} kills in quick succession`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1013,7 +1101,7 @@ export function computeHighlights(
|
||||
current.end_time = Math.max(current.end_time, next.end_time);
|
||||
current.events = [...current.events, ...next.events];
|
||||
// Keep the "more important" highlight type
|
||||
const typePriority: HighlightType[] = ["multi_kill", "kill", "death", "assist"];
|
||||
const typePriority: HighlightType[] = ["multi_kill", "trade_kill", "kill", "death", "assist"];
|
||||
const currentIdx = typePriority.indexOf(current.highlight_type);
|
||||
const nextIdx = typePriority.indexOf(next.highlight_type);
|
||||
if (nextIdx < currentIdx) {
|
||||
@@ -1021,14 +1109,39 @@ export function computeHighlights(
|
||||
current.title = next.title;
|
||||
current.subtitle = next.subtitle;
|
||||
}
|
||||
// If both are kills, check if combined they form a multi-kill
|
||||
const killEvents = current.events.filter(e => e.event_type === "kill");
|
||||
if (killEvents.length >= 2) {
|
||||
const label = getMultiKillLabel(killEvents.length);
|
||||
// If both are kills, check if combined they form a multi-kill or trade kill
|
||||
// Only count kills where the player is the killer, not all kill events
|
||||
// Deduplicate by video_timestamp since same event can appear in multiple merged clips
|
||||
const seenTimestamps = new Set<string>();
|
||||
const playerKillEvents = current.events.filter(e => {
|
||||
if (e.event_type !== "kill") return false;
|
||||
const rawData = e.raw_data as { KillerName?: string } | null;
|
||||
if (rawData?.KillerName !== localPlayerName) return false;
|
||||
// Deduplicate by timestamp
|
||||
const key = `${e.video_timestamp[0]}-${e.video_timestamp[1]}`;
|
||||
if (seenTimestamps.has(key)) return false;
|
||||
seenTimestamps.add(key);
|
||||
return true;
|
||||
});
|
||||
const playerDeathEvents = current.events.filter(e => {
|
||||
if (e.event_type !== "kill") return false;
|
||||
const rawData = e.raw_data as { VictimName?: string } | null;
|
||||
if (rawData?.VictimName !== localPlayerName) return false;
|
||||
// Deduplicate by timestamp
|
||||
const key = `${e.video_timestamp[0]}-${e.video_timestamp[1]}`;
|
||||
if (seenTimestamps.has(key)) return false;
|
||||
seenTimestamps.add(key);
|
||||
return true;
|
||||
});
|
||||
if (playerKillEvents.length >= 2) {
|
||||
const label = getMultiKillLabel(playerKillEvents.length);
|
||||
if (label) {
|
||||
current.highlight_type = "multi_kill";
|
||||
current.title = label;
|
||||
current.subtitle = `${killEvents.length} kills`;
|
||||
const isTradeKill = playerDeathEvents.length > 0;
|
||||
current.highlight_type = isTradeKill ? "trade_kill" : "multi_kill";
|
||||
current.title = isTradeKill ? `Trade Kill` : label;
|
||||
current.subtitle = isTradeKill
|
||||
? `${playerKillEvents.length} kills but died in the trade`
|
||||
: `${playerKillEvents.length} kills`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user