@@ -14,7 +14,7 @@ use crate::media::{scale_image, AnimatedGif, AnimatedPng, AnimatedWebP, Animatio |
| 14 | 14 | #[cfg(feature = "video")] |
| 15 | 15 | use crate::media::{VideoDecoder, is_video_file}; |
| 16 | 16 | use crate::state::{detect_source_type, PlaylistState}; |
| 17 | | -use crate::x11::{AnimationRenderer, Connection}; |
| 17 | +use crate::x11::{AnimationRenderer, Connection, Compositor, Monitor}; |
| 18 | 18 | |
| 19 | 19 | use super::pid; |
| 20 | 20 | |
@@ -172,15 +172,12 @@ impl Daemon { |
| 172 | 172 | /// Create a new daemon |
| 173 | 173 | /// |
| 174 | 174 | /// Establishes X11 connection and initializes state. |
| 175 | | - /// Fails early with helpful error messages if X11 is unavailable. |
| 175 | + /// The daemon should be started by systemd after graphical-session.target |
| 176 | + /// is active, so X11 should already be ready. |
| 176 | 177 | pub fn new(config: Config) -> Result<Self> { |
| 178 | + // Connect to X11 (fail fast - systemd ensures session is ready) |
| 177 | 179 | let conn = Connection::new() |
| 178 | | - .context("Failed to initialize X11 connection for daemon")?; |
| 179 | | - |
| 180 | | - // Verify connection is working |
| 181 | | - if !conn.is_alive() { |
| 182 | | - anyhow::bail!("X11 connection established but not responding"); |
| 183 | | - } |
| 180 | + .context("Failed to connect to X11. Is the graphical session active?")?; |
| 184 | 181 | |
| 185 | 182 | let (width, height) = conn.screen_dimensions(); |
| 186 | 183 | tracing::info!("X11 connection established (screen: {}x{})", width, height); |
@@ -232,6 +229,14 @@ impl Daemon { |
| 232 | 229 | let server = IpcServer::new().await?; |
| 233 | 230 | tracing::info!("Listening on {}", server.path().display()); |
| 234 | 231 | |
| 232 | + // Notify systemd that we're ready (for Type=notify services) |
| 233 | + // This ensures gar-session.sh waits until the socket is actually listening |
| 234 | + if let Err(e) = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) { |
| 235 | + tracing::debug!("sd_notify failed (not running under systemd?): {}", e); |
| 236 | + } else { |
| 237 | + tracing::debug!("Notified systemd: READY=1"); |
| 238 | + } |
| 239 | + |
| 235 | 240 | // Set initial wallpaper from config if specified |
| 236 | 241 | if !self.state.config.default.source.is_empty() { |
| 237 | 242 | if let Err(e) = self.apply_default_wallpaper() { |
@@ -242,6 +247,11 @@ impl Daemon { |
| 242 | 247 | // Try to connect to gar (optional) |
| 243 | 248 | let mut gar_client = self.try_connect_gar().await; |
| 244 | 249 | |
| 250 | + // Reconnection state for gar |
| 251 | + let mut gar_reconnect_backoff = Duration::from_secs(1); |
| 252 | + let mut last_gar_reconnect = std::time::Instant::now(); |
| 253 | + let gar_max_backoff = Duration::from_secs(60); |
| 254 | + |
| 245 | 255 | // Track next slideshow time |
| 246 | 256 | let mut next_slideshow: Option<tokio::time::Instant> = self.state.slideshow_interval |
| 247 | 257 | .map(|d| tokio::time::Instant::now() + d); |
@@ -259,8 +269,24 @@ impl Daemon { |
| 259 | 269 | let mut sigint = signal(SignalKind::interrupt())?; |
| 260 | 270 | let mut sighup = signal(SignalKind::hangup())?; |
| 261 | 271 | |
| 272 | + // Track whether we need to attempt gar reconnection |
| 273 | + let mut gar_needs_reconnect = gar_client.is_none(); |
| 274 | + |
| 262 | 275 | // Main event loop |
| 276 | + // Note: Session lifecycle is handled by systemd (PartOf=graphical-session.target) |
| 263 | 277 | loop { |
| 278 | + // Compute reconnection delay (if needed) before select |
| 279 | + let reconnect_delay = if gar_needs_reconnect { |
| 280 | + let elapsed = last_gar_reconnect.elapsed(); |
| 281 | + if elapsed < gar_reconnect_backoff { |
| 282 | + Some(gar_reconnect_backoff - elapsed) |
| 283 | + } else { |
| 284 | + Some(Duration::ZERO) |
| 285 | + } |
| 286 | + } else { |
| 287 | + None |
| 288 | + }; |
| 289 | + |
| 264 | 290 | tokio::select! { |
| 265 | 291 | // SIGTERM - graceful shutdown |
| 266 | 292 | _ = sigterm.recv() => { |
@@ -347,7 +373,7 @@ impl Daemon { |
| 347 | 373 | .map(|d| tokio::time::Instant::now() + d); |
| 348 | 374 | } |
| 349 | 375 | |
| 350 | | - // gar workspace events (only if connected) |
| 376 | + // gar workspace/monitor events (only if connected) |
| 351 | 377 | event = async { |
| 352 | 378 | if let Some(ref mut client) = gar_client { |
| 353 | 379 | client.read_event().await |
@@ -357,6 +383,8 @@ impl Daemon { |
| 357 | 383 | } => { |
| 358 | 384 | match event { |
| 359 | 385 | Ok(event) => { |
| 386 | + // Reset backoff on successful event |
| 387 | + gar_reconnect_backoff = Duration::from_secs(1); |
| 360 | 388 | if let Err(e) = self.handle_gar_event(event) { |
| 361 | 389 | tracing::warn!("gar event handling failed: {}", e); |
| 362 | 390 | } |
@@ -364,6 +392,34 @@ impl Daemon { |
| 364 | 392 | Err(e) => { |
| 365 | 393 | tracing::debug!("gar connection lost: {}", e); |
| 366 | 394 | gar_client = None; |
| 395 | + gar_needs_reconnect = true; |
| 396 | + last_gar_reconnect = std::time::Instant::now(); |
| 397 | + } |
| 398 | + } |
| 399 | + } |
| 400 | + |
| 401 | + // gar reconnection timer (only when disconnected) |
| 402 | + _ = async { |
| 403 | + if let Some(delay) = reconnect_delay { |
| 404 | + tokio::time::sleep(delay).await; |
| 405 | + } else { |
| 406 | + std::future::pending::<()>().await; |
| 407 | + } |
| 408 | + } => { |
| 409 | + tracing::debug!("Attempting to reconnect to gar..."); |
| 410 | + last_gar_reconnect = std::time::Instant::now(); |
| 411 | + |
| 412 | + match self.try_connect_gar().await { |
| 413 | + Some(client) => { |
| 414 | + gar_client = Some(client); |
| 415 | + gar_needs_reconnect = false; |
| 416 | + gar_reconnect_backoff = Duration::from_secs(1); |
| 417 | + tracing::info!("Reconnected to gar"); |
| 418 | + } |
| 419 | + None => { |
| 420 | + // Exponential backoff, max 60 seconds |
| 421 | + gar_reconnect_backoff = (gar_reconnect_backoff * 2).min(gar_max_backoff); |
| 422 | + tracing::debug!("gar reconnection failed, next attempt in {:?}", gar_reconnect_backoff); |
| 367 | 423 | } |
| 368 | 424 | } |
| 369 | 425 | } |
@@ -518,7 +574,7 @@ impl Daemon { |
| 518 | 574 | Command::Toggle => { |
| 519 | 575 | self.state.paused = !self.state.paused; |
| 520 | 576 | tracing::info!("Slideshow {}", if self.state.paused { "paused" } else { "resumed" }); |
| 521 | | - Response::ok() |
| 577 | + Response::ok_with_data(serde_json::json!({ "paused": self.state.paused })) |
| 522 | 578 | } |
| 523 | 579 | Command::Reload => { |
| 524 | 580 | match self.reload_config() { |
@@ -549,6 +605,21 @@ impl Daemon { |
| 549 | 605 | // Subscriptions not yet implemented |
| 550 | 606 | Response::error("Subscriptions not yet implemented") |
| 551 | 607 | } |
| 608 | + Command::QueryMonitors => { |
| 609 | + let monitors = self.get_monitors(); |
| 610 | + Response::ok_with_data(serde_json::json!({ "monitors": monitors })) |
| 611 | + } |
| 612 | + Command::QueryCurrent => { |
| 613 | + let current = self.get_current_wallpaper_info(); |
| 614 | + Response::ok_with_data(current) |
| 615 | + } |
| 616 | + Command::SetMonitor { monitor, source, mode } => { |
| 617 | + let scale_mode = mode.unwrap_or(self.state.config.general.mode); |
| 618 | + match self.set_monitor_wallpaper(&monitor, &source, scale_mode) { |
| 619 | + Ok(_) => Response::ok(), |
| 620 | + Err(e) => Response::error(e.to_string()), |
| 621 | + } |
| 622 | + } |
| 552 | 623 | } |
| 553 | 624 | } |
| 554 | 625 | |
@@ -560,8 +631,8 @@ impl Daemon { |
| 560 | 631 | self.on_workspace_change(current)?; |
| 561 | 632 | } |
| 562 | 633 | GarEvent::Monitor { name, action } => { |
| 563 | | - tracing::debug!("Monitor {}: {}", action, name); |
| 564 | | - // TODO: Handle monitor changes |
| 634 | + tracing::info!("Monitor event: {} {}", name, action); |
| 635 | + self.on_monitor_change(&name, &action)?; |
| 565 | 636 | } |
| 566 | 637 | GarEvent::Focus { .. } => { |
| 567 | 638 | // Ignore focus events |
@@ -573,12 +644,70 @@ impl Daemon { |
| 573 | 644 | Ok(()) |
| 574 | 645 | } |
| 575 | 646 | |
| 647 | + /// Handle monitor hotplug event from gar |
| 648 | + fn on_monitor_change(&mut self, name: &str, action: &str) -> Result<()> { |
| 649 | + match action { |
| 650 | + "added" => { |
| 651 | + tracing::info!("Monitor added: {} - re-applying wallpapers", name); |
| 652 | + // Re-apply wallpapers to all monitors when one is added |
| 653 | + // This ensures the new monitor gets a wallpaper |
| 654 | + self.refresh_wallpapers()?; |
| 655 | + } |
| 656 | + "removed" => { |
| 657 | + tracing::info!("Monitor removed: {}", name); |
| 658 | + // Remove monitor from our state if we're tracking per-monitor wallpapers |
| 659 | + self.state.monitors.remove(name); |
| 660 | + } |
| 661 | + "changed" => { |
| 662 | + tracing::info!("Monitor changed: {} - re-applying wallpapers", name); |
| 663 | + // Resolution or position changed, re-apply wallpapers |
| 664 | + self.refresh_wallpapers()?; |
| 665 | + } |
| 666 | + _ => { |
| 667 | + tracing::debug!("Unknown monitor action: {} for {}", action, name); |
| 668 | + } |
| 669 | + } |
| 670 | + Ok(()) |
| 671 | + } |
| 672 | + |
| 673 | + /// Refresh wallpapers on all monitors |
| 674 | + fn refresh_wallpapers(&mut self) -> Result<()> { |
| 675 | + // If we have an active animation, restart it (picks up new screen size) |
| 676 | + if self.animation.is_some() { |
| 677 | + tracing::debug!("Active animation detected, will restart on next frame"); |
| 678 | + // Animation will pick up new screen dimensions on next render |
| 679 | + } |
| 680 | + |
| 681 | + // Check if we have per-monitor wallpapers |
| 682 | + let monitors = Monitor::get_all(&self.conn).unwrap_or_default(); |
| 683 | + |
| 684 | + if monitors.len() > 1 && !self.state.monitors.is_empty() { |
| 685 | + // Multiple monitors with per-monitor wallpapers: use compositor |
| 686 | + self.composite_all_wallpapers(&monitors)?; |
| 687 | + tracing::debug!("Wallpapers refreshed (composited {} monitors)", monitors.len()); |
| 688 | + } else if let Some(ref playlist) = self.state.playlist { |
| 689 | + // Single monitor or no per-monitor state: use global wallpaper |
| 690 | + if let Some(current) = playlist.current() { |
| 691 | + let mode = playlist.mode; |
| 692 | + let current = current.to_string(); |
| 693 | + self.set_wallpaper(¤t, mode)?; |
| 694 | + tracing::debug!("Wallpapers refreshed"); |
| 695 | + } |
| 696 | + } else if !self.state.config.default.source.is_empty() { |
| 697 | + // Fall back to default wallpaper |
| 698 | + self.apply_default_wallpaper()?; |
| 699 | + } |
| 700 | + |
| 701 | + Ok(()) |
| 702 | + } |
| 703 | + |
| 576 | 704 | /// Try to connect to gar IPC |
| 577 | 705 | async fn try_connect_gar(&self) -> Option<GarIpcClient> { |
| 578 | 706 | match GarIpcClient::connect().await { |
| 579 | 707 | Ok(mut client) => { |
| 580 | | - if client.subscribe(&["workspace"]).await.is_ok() { |
| 581 | | - tracing::info!("Connected to gar IPC"); |
| 708 | + // Subscribe to workspace and monitor events |
| 709 | + if client.subscribe(&["workspace", "monitor"]).await.is_ok() { |
| 710 | + tracing::info!("Connected to gar IPC (subscribed to workspace, monitor events)"); |
| 582 | 711 | Some(client) |
| 583 | 712 | } else { |
| 584 | 713 | tracing::debug!("Failed to subscribe to gar events"); |
@@ -1088,6 +1217,199 @@ impl Daemon { |
| 1088 | 1217 | images.sort(); |
| 1089 | 1218 | Ok(images) |
| 1090 | 1219 | } |
| 1220 | + |
| 1221 | + /// Get connected monitors info via RandR |
| 1222 | + fn get_monitors(&self) -> Vec<serde_json::Value> { |
| 1223 | + match Monitor::get_all(&self.conn) { |
| 1224 | + Ok(monitors) if !monitors.is_empty() => { |
| 1225 | + monitors.iter().map(|m| { |
| 1226 | + serde_json::json!({ |
| 1227 | + "name": m.name, |
| 1228 | + "width": m.width, |
| 1229 | + "height": m.height, |
| 1230 | + "x": m.x, |
| 1231 | + "y": m.y, |
| 1232 | + "primary": m.primary, |
| 1233 | + }) |
| 1234 | + }).collect() |
| 1235 | + } |
| 1236 | + Ok(_) => { |
| 1237 | + // No monitors detected, fall back to screen dimensions |
| 1238 | + tracing::debug!("No monitors detected via RandR, using screen dimensions"); |
| 1239 | + let (width, height) = self.conn.screen_dimensions(); |
| 1240 | + vec![serde_json::json!({ |
| 1241 | + "name": "default", |
| 1242 | + "width": width, |
| 1243 | + "height": height, |
| 1244 | + "x": 0, |
| 1245 | + "y": 0, |
| 1246 | + "primary": true, |
| 1247 | + })] |
| 1248 | + } |
| 1249 | + Err(e) => { |
| 1250 | + // RandR failed, fall back to screen dimensions |
| 1251 | + tracing::warn!("RandR detection failed: {}, using screen dimensions", e); |
| 1252 | + let (width, height) = self.conn.screen_dimensions(); |
| 1253 | + vec![serde_json::json!({ |
| 1254 | + "name": "default", |
| 1255 | + "width": width, |
| 1256 | + "height": height, |
| 1257 | + "x": 0, |
| 1258 | + "y": 0, |
| 1259 | + "primary": true, |
| 1260 | + })] |
| 1261 | + } |
| 1262 | + } |
| 1263 | + } |
| 1264 | + |
| 1265 | + /// Get current wallpaper info |
| 1266 | + fn get_current_wallpaper_info(&self) -> serde_json::Value { |
| 1267 | + let current_image = self.state.playlist.as_ref() |
| 1268 | + .and_then(|p| p.current().map(|s| s.to_string())); |
| 1269 | + |
| 1270 | + let mode = self.state.playlist.as_ref() |
| 1271 | + .map(|p| format!("{}", p.mode)) |
| 1272 | + .unwrap_or_else(|| format!("{}", self.state.config.general.mode)); |
| 1273 | + |
| 1274 | + let animation_active = self.animation.is_some(); |
| 1275 | + |
| 1276 | + serde_json::json!({ |
| 1277 | + "source": current_image, |
| 1278 | + "mode": mode, |
| 1279 | + "paused": self.state.paused, |
| 1280 | + "animation_active": animation_active, |
| 1281 | + "workspace": self.state.current_workspace, |
| 1282 | + }) |
| 1283 | + } |
| 1284 | + |
| 1285 | + /// Set wallpaper for a specific monitor |
| 1286 | + fn set_monitor_wallpaper(&mut self, monitor_name: &str, source: &str, mode: ScaleMode) -> Result<()> { |
| 1287 | + // Get detected monitors |
| 1288 | + let monitors = Monitor::get_all(&self.conn)?; |
| 1289 | + |
| 1290 | + if monitors.is_empty() { |
| 1291 | + // Fall back to setting global wallpaper if no monitors detected |
| 1292 | + tracing::warn!("No monitors detected, setting wallpaper globally"); |
| 1293 | + return self.set_wallpaper(source, mode); |
| 1294 | + } |
| 1295 | + |
| 1296 | + // Find the target monitor |
| 1297 | + let target_monitor = monitors.iter() |
| 1298 | + .find(|m| m.name == monitor_name) |
| 1299 | + .ok_or_else(|| anyhow::anyhow!( |
| 1300 | + "Monitor '{}' not found. Available: {:?}", |
| 1301 | + monitor_name, |
| 1302 | + monitors.iter().map(|m| &m.name).collect::<Vec<_>>() |
| 1303 | + ))?; |
| 1304 | + |
| 1305 | + // Load and scale the image for this monitor |
| 1306 | + let expanded = shellexpand::tilde(source); |
| 1307 | + let image = ImageLoader::load_file(expanded.as_ref())?; |
| 1308 | + |
| 1309 | + // Store wallpaper state for this monitor |
| 1310 | + self.state.monitors.insert(monitor_name.to_string(), MonitorWallpaper { |
| 1311 | + name: monitor_name.to_string(), |
| 1312 | + source: source.to_string(), |
| 1313 | + mode, |
| 1314 | + }); |
| 1315 | + |
| 1316 | + tracing::info!( |
| 1317 | + "Set wallpaper for monitor {}: {} (mode: {})", |
| 1318 | + monitor_name, |
| 1319 | + source, |
| 1320 | + mode |
| 1321 | + ); |
| 1322 | + |
| 1323 | + // If only one monitor, set wallpaper directly |
| 1324 | + if monitors.len() == 1 { |
| 1325 | + let scaled = scale_image( |
| 1326 | + &image, |
| 1327 | + target_monitor.width as u32, |
| 1328 | + target_monitor.height as u32, |
| 1329 | + mode, |
| 1330 | + ); |
| 1331 | + return self.conn.set_wallpaper(&scaled); |
| 1332 | + } |
| 1333 | + |
| 1334 | + // Multiple monitors: composite all wallpapers |
| 1335 | + self.composite_all_wallpapers(&monitors) |
| 1336 | + } |
| 1337 | + |
| 1338 | + /// Composite wallpapers for all monitors and set the result |
| 1339 | + fn composite_all_wallpapers(&mut self, monitors: &[Monitor]) -> Result<()> { |
| 1340 | + if monitors.is_empty() { |
| 1341 | + return Ok(()); |
| 1342 | + } |
| 1343 | + |
| 1344 | + let compositor = Compositor::new(monitors); |
| 1345 | + let mut wallpapers = Vec::new(); |
| 1346 | + |
| 1347 | + // Get global default wallpaper for monitors without specific wallpaper |
| 1348 | + let default_image = if !self.state.config.default.source.is_empty() { |
| 1349 | + let expanded = shellexpand::tilde(&self.state.config.default.source); |
| 1350 | + ImageLoader::load_file(expanded.as_ref()).ok() |
| 1351 | + } else if let Some(ref playlist) = self.state.playlist { |
| 1352 | + playlist.current() |
| 1353 | + .and_then(|path| { |
| 1354 | + let expanded = shellexpand::tilde(path); |
| 1355 | + ImageLoader::load_file(expanded.as_ref()).ok() |
| 1356 | + }) |
| 1357 | + } else { |
| 1358 | + None |
| 1359 | + }; |
| 1360 | + |
| 1361 | + for monitor in monitors { |
| 1362 | + let image = if let Some(wp_state) = self.state.monitors.get(&monitor.name) { |
| 1363 | + // Use the per-monitor wallpaper |
| 1364 | + let expanded = shellexpand::tilde(&wp_state.source); |
| 1365 | + match ImageLoader::load_file(expanded.as_ref()) { |
| 1366 | + Ok(img) => img, |
| 1367 | + Err(e) => { |
| 1368 | + tracing::warn!("Failed to load wallpaper for {}: {}", monitor.name, e); |
| 1369 | + if let Some(ref def) = default_image { |
| 1370 | + def.clone() |
| 1371 | + } else { |
| 1372 | + // Create a black image as fallback |
| 1373 | + image::RgbaImage::from_pixel( |
| 1374 | + monitor.width as u32, |
| 1375 | + monitor.height as u32, |
| 1376 | + image::Rgba([0, 0, 0, 255]) |
| 1377 | + ) |
| 1378 | + } |
| 1379 | + } |
| 1380 | + } |
| 1381 | + } else if let Some(ref def) = default_image { |
| 1382 | + // Use the default wallpaper |
| 1383 | + def.clone() |
| 1384 | + } else { |
| 1385 | + // No wallpaper, use black |
| 1386 | + image::RgbaImage::from_pixel( |
| 1387 | + monitor.width as u32, |
| 1388 | + monitor.height as u32, |
| 1389 | + image::Rgba([0, 0, 0, 255]) |
| 1390 | + ) |
| 1391 | + }; |
| 1392 | + |
| 1393 | + let mode = self.state.monitors.get(&monitor.name) |
| 1394 | + .map(|wp| wp.mode) |
| 1395 | + .unwrap_or(self.state.config.general.mode); |
| 1396 | + |
| 1397 | + wallpapers.push(Compositor::create_monitor_wallpaper(monitor, &image, mode)); |
| 1398 | + } |
| 1399 | + |
| 1400 | + // Composite and set |
| 1401 | + let composited = compositor.composite(&wallpapers); |
| 1402 | + self.conn.set_wallpaper(&composited)?; |
| 1403 | + |
| 1404 | + tracing::debug!( |
| 1405 | + "Composited {} monitors ({}x{})", |
| 1406 | + monitors.len(), |
| 1407 | + compositor.total_width, |
| 1408 | + compositor.total_height |
| 1409 | + ); |
| 1410 | + |
| 1411 | + Ok(()) |
| 1412 | + } |
| 1091 | 1413 | } |
| 1092 | 1414 | |
| 1093 | 1415 | /// RAII guard for PID file cleanup |