| 1 | //! Main Iced Application implementation |
| 2 | |
| 3 | use iced::widget::{button, column, container, row, text, text_input, Space}; |
| 4 | use iced::{Element, Length, Task}; |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | use super::canvas::machine_canvas; |
| 8 | use super::messages::{CanvasEvent, Message}; |
| 9 | use super::state::GuiState; |
| 10 | use super::theme; |
| 11 | use crate::config::Config; |
| 12 | |
| 13 | /// The main HyprKVM GUI application |
| 14 | pub struct HyprKvmGui { |
| 15 | state: GuiState, |
| 16 | } |
| 17 | |
| 18 | impl HyprKvmGui { |
| 19 | /// Create a new GUI from a config path |
| 20 | pub fn new(config_path: PathBuf) -> (Self, Task<Message>) { |
| 21 | // Load config or use default |
| 22 | let config = Config::load(&config_path).unwrap_or_else(|e| { |
| 23 | tracing::warn!("Failed to load config: {}, using defaults", e); |
| 24 | Config::default() |
| 25 | }); |
| 26 | |
| 27 | let state = GuiState::from_config(config_path, config); |
| 28 | |
| 29 | (Self { state }, Task::none()) |
| 30 | } |
| 31 | |
| 32 | /// Update state based on message |
| 33 | pub fn update(&mut self, message: Message) -> Task<Message> { |
| 34 | match message { |
| 35 | Message::CanvasEvent(event) => self.handle_canvas_event(event), |
| 36 | Message::AddMachine => { |
| 37 | self.state.show_add_modal = true; |
| 38 | self.state.new_machine = Default::default(); |
| 39 | Task::none() |
| 40 | } |
| 41 | Message::CancelAddMachine => { |
| 42 | self.state.show_add_modal = false; |
| 43 | Task::none() |
| 44 | } |
| 45 | Message::ConfirmAddMachine => { |
| 46 | self.confirm_add_machine(); |
| 47 | Task::none() |
| 48 | } |
| 49 | Message::RemoveMachine(idx) => { |
| 50 | if self.state.remove_machine(idx) { |
| 51 | self.save_and_reload() |
| 52 | } else { |
| 53 | Task::none() |
| 54 | } |
| 55 | } |
| 56 | Message::UpdateNewMachineName(name) => { |
| 57 | self.state.new_machine.name = name; |
| 58 | Task::none() |
| 59 | } |
| 60 | Message::UpdateNewMachineAddress(address) => { |
| 61 | self.state.new_machine.address = address; |
| 62 | Task::none() |
| 63 | } |
| 64 | Message::SelectNewMachinePosition(pos) => { |
| 65 | // Add machine at the selected position |
| 66 | let name = self.state.new_machine.name.clone(); |
| 67 | let address = self.state.new_machine.address.clone(); |
| 68 | match self.state.add_machine(name, address, pos) { |
| 69 | Ok(()) => { |
| 70 | self.state.show_add_modal = false; |
| 71 | self.save_and_reload() |
| 72 | } |
| 73 | Err(e) => { |
| 74 | self.state.error = Some(e); |
| 75 | Task::none() |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | Message::SaveConfig => self.save_and_reload(), |
| 80 | Message::ReloadDaemon => { |
| 81 | self.reload_daemon(); |
| 82 | Task::none() |
| 83 | } |
| 84 | Message::RestartDaemon => { |
| 85 | self.restart_daemon(); |
| 86 | Task::none() |
| 87 | } |
| 88 | Message::ConfigSaved(result) => { |
| 89 | if let Err(e) = result { |
| 90 | self.state.error = Some(format!("Failed to save config: {}", e)); |
| 91 | } |
| 92 | Task::none() |
| 93 | } |
| 94 | Message::DaemonReloaded(result) => { |
| 95 | if let Err(e) = result { |
| 96 | self.state.error = Some(format!("Failed to reload daemon: {}", e)); |
| 97 | } |
| 98 | Task::none() |
| 99 | } |
| 100 | Message::StatusUpdate(statuses) => { |
| 101 | for (name, status) in statuses { |
| 102 | for machine in &mut self.state.machines { |
| 103 | if machine.name == name { |
| 104 | machine.status = status; |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | Task::none() |
| 109 | } |
| 110 | Message::ClearError => { |
| 111 | self.state.error = None; |
| 112 | Task::none() |
| 113 | } |
| 114 | Message::ClearSuccess => { |
| 115 | self.state.success = None; |
| 116 | Task::none() |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | fn handle_canvas_event(&mut self, event: CanvasEvent) -> Task<Message> { |
| 122 | match event { |
| 123 | CanvasEvent::MachinePressed(idx) => { |
| 124 | // Don't drag self machine |
| 125 | if !self.state.machines[idx].is_self { |
| 126 | self.state.drag.start(idx); |
| 127 | } |
| 128 | Task::none() |
| 129 | } |
| 130 | CanvasEvent::MouseMoved(pos, snap_target) => { |
| 131 | self.state.drag.update(pos); |
| 132 | self.state.drag.snap_target = snap_target; |
| 133 | Task::none() |
| 134 | } |
| 135 | CanvasEvent::MouseReleased => { |
| 136 | if let Some((idx, snap_target)) = self.state.drag.end() { |
| 137 | if let Some(new_pos) = snap_target { |
| 138 | if self.state.move_machine(idx, new_pos) { |
| 139 | return self.save_and_reload(); |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | Task::none() |
| 144 | } |
| 145 | CanvasEvent::MouseEntered | CanvasEvent::MouseExited => Task::none(), |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | fn confirm_add_machine(&mut self) { |
| 150 | // Find first available position |
| 151 | let available = self.state.available_positions(); |
| 152 | if available.is_empty() { |
| 153 | self.state.error = Some("No available positions".to_string()); |
| 154 | return; |
| 155 | } |
| 156 | |
| 157 | let name = self.state.new_machine.name.clone(); |
| 158 | let address = self.state.new_machine.address.clone(); |
| 159 | |
| 160 | if name.is_empty() || address.is_empty() { |
| 161 | self.state.error = Some("Name and address are required".to_string()); |
| 162 | return; |
| 163 | } |
| 164 | |
| 165 | // Use first available position |
| 166 | let pos = available[0]; |
| 167 | |
| 168 | match self.state.add_machine(name, address, pos) { |
| 169 | Ok(()) => { |
| 170 | self.state.show_add_modal = false; |
| 171 | } |
| 172 | Err(e) => { |
| 173 | self.state.error = Some(e); |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | fn save_and_reload(&mut self) -> Task<Message> { |
| 179 | // Save config |
| 180 | if let Err(e) = self.state.save_config() { |
| 181 | self.state.error = Some(format!("Failed to save: {}", e)); |
| 182 | return Task::none(); |
| 183 | } |
| 184 | |
| 185 | // Reload daemon via IPC |
| 186 | self.reload_daemon(); |
| 187 | Task::none() |
| 188 | } |
| 189 | |
| 190 | fn reload_daemon(&mut self) { |
| 191 | use std::io::{BufRead, BufReader, Write}; |
| 192 | use std::os::unix::net::UnixStream; |
| 193 | |
| 194 | let socket_path = crate::ipc::socket_path(); |
| 195 | |
| 196 | match UnixStream::connect(&socket_path) { |
| 197 | Ok(mut stream) => { |
| 198 | // Send reload request (format: {"type": "reload"}) |
| 199 | if writeln!(stream, r#"{{"type":"reload"}}"#).is_err() { |
| 200 | self.state.error = Some("Failed to send reload".to_string()); |
| 201 | return; |
| 202 | } |
| 203 | stream.flush().ok(); |
| 204 | |
| 205 | // Set read timeout |
| 206 | stream |
| 207 | .set_read_timeout(Some(std::time::Duration::from_secs(2))) |
| 208 | .ok(); |
| 209 | |
| 210 | // Read response |
| 211 | let mut reader = BufReader::new(&stream); |
| 212 | let mut response = String::new(); |
| 213 | if reader.read_line(&mut response).is_ok() { |
| 214 | if response.contains("Error") { |
| 215 | self.state.error = Some("Failed to reload daemon".to_string()); |
| 216 | self.state.needs_restart = false; |
| 217 | } else if response.contains("restart required") { |
| 218 | self.state.error = Some("Direction changed - restart required".to_string()); |
| 219 | self.state.needs_restart = true; |
| 220 | } else { |
| 221 | self.state.success = Some("Config saved & applied".to_string()); |
| 222 | self.state.needs_restart = false; |
| 223 | } |
| 224 | } else { |
| 225 | self.state.success = Some("Config saved".to_string()); |
| 226 | self.state.needs_restart = false; |
| 227 | } |
| 228 | } |
| 229 | Err(_) => { |
| 230 | // Daemon not running - just show config saved |
| 231 | self.state.success = Some("Config saved (daemon not running)".to_string()); |
| 232 | self.state.needs_restart = false; |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | fn restart_daemon(&mut self) { |
| 238 | use std::io::{BufRead, BufReader, Write}; |
| 239 | use std::os::unix::net::UnixStream; |
| 240 | use std::process::Command; |
| 241 | |
| 242 | let socket_path = crate::ipc::socket_path(); |
| 243 | |
| 244 | // First, try to shutdown the running daemon |
| 245 | if let Ok(mut stream) = UnixStream::connect(&socket_path) { |
| 246 | // Send shutdown request |
| 247 | if writeln!(stream, r#"{{"type":"shutdown"}}"#).is_ok() { |
| 248 | stream.flush().ok(); |
| 249 | // Wait for response |
| 250 | stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).ok(); |
| 251 | let mut reader = BufReader::new(&stream); |
| 252 | let mut response = String::new(); |
| 253 | let _ = reader.read_line(&mut response); |
| 254 | } |
| 255 | // Give daemon time to shutdown |
| 256 | std::thread::sleep(std::time::Duration::from_millis(1000)); |
| 257 | } |
| 258 | |
| 259 | // Now spawn a new daemon process |
| 260 | // Try current_exe first, fall back to PATH lookup |
| 261 | let exe = std::env::current_exe().unwrap_or_else(|_| "hyprkvm".into()); |
| 262 | tracing::info!("Restarting daemon with exe: {:?}", exe); |
| 263 | |
| 264 | // Use setsid to create a new session so daemon survives GUI exit |
| 265 | // This is the Unix way to properly daemonize |
| 266 | match Command::new("setsid") |
| 267 | .arg("--fork") |
| 268 | .arg(&exe) |
| 269 | .arg("daemon") |
| 270 | .stdin(std::process::Stdio::null()) |
| 271 | .stdout(std::process::Stdio::null()) |
| 272 | .stderr(std::process::Stdio::null()) |
| 273 | .spawn() |
| 274 | { |
| 275 | Ok(_) => { |
| 276 | // Wait a moment for daemon to start |
| 277 | std::thread::sleep(std::time::Duration::from_millis(1000)); |
| 278 | |
| 279 | // Verify daemon actually started by checking socket |
| 280 | if UnixStream::connect(&socket_path).is_ok() { |
| 281 | self.state.error = None; |
| 282 | self.state.needs_restart = false; |
| 283 | self.state.success = Some("Daemon restarted".to_string()); |
| 284 | } else { |
| 285 | self.state.error = Some("Daemon spawn succeeded but socket not available".to_string()); |
| 286 | self.state.needs_restart = true; |
| 287 | } |
| 288 | } |
| 289 | Err(e) => { |
| 290 | // setsid not available, try direct spawn |
| 291 | tracing::warn!("setsid failed: {}, trying direct spawn", e); |
| 292 | match Command::new(&exe) |
| 293 | .arg("daemon") |
| 294 | .stdin(std::process::Stdio::null()) |
| 295 | .stdout(std::process::Stdio::null()) |
| 296 | .stderr(std::process::Stdio::null()) |
| 297 | .spawn() |
| 298 | { |
| 299 | Ok(_) => { |
| 300 | std::thread::sleep(std::time::Duration::from_millis(1000)); |
| 301 | if UnixStream::connect(&socket_path).is_ok() { |
| 302 | self.state.error = None; |
| 303 | self.state.needs_restart = false; |
| 304 | self.state.success = Some("Daemon restarted".to_string()); |
| 305 | } else { |
| 306 | self.state.error = Some("Daemon spawn succeeded but socket not available".to_string()); |
| 307 | self.state.needs_restart = true; |
| 308 | } |
| 309 | } |
| 310 | Err(e2) => { |
| 311 | self.state.error = Some(format!("Failed to start daemon: {}", e2)); |
| 312 | self.state.needs_restart = false; |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | /// Build the view |
| 320 | pub fn view(&self) -> Element<'_, Message> { |
| 321 | // Toolbar |
| 322 | let toolbar = self.toolbar_view(); |
| 323 | |
| 324 | // Main canvas |
| 325 | let canvas = machine_canvas( |
| 326 | self.state.machines.clone(), |
| 327 | self.state.drag.clone(), |
| 328 | self.state.available_positions(), |
| 329 | ); |
| 330 | |
| 331 | // Notification banners |
| 332 | let error_banner = self.error_banner(); |
| 333 | let success_banner = self.success_banner(); |
| 334 | |
| 335 | // Add machine modal (if showing) |
| 336 | let content: Element<Message> = if self.state.show_add_modal { |
| 337 | let modal = self.add_machine_modal(); |
| 338 | // Overlay modal on canvas |
| 339 | container( |
| 340 | column![toolbar, error_banner, success_banner, container(modal).center_x(Length::Fill).center_y(Length::Fill)] |
| 341 | .spacing(0), |
| 342 | ) |
| 343 | .width(Length::Fill) |
| 344 | .height(Length::Fill) |
| 345 | .into() |
| 346 | } else { |
| 347 | column![toolbar, error_banner, success_banner, canvas] |
| 348 | .spacing(0) |
| 349 | .width(Length::Fill) |
| 350 | .height(Length::Fill) |
| 351 | .into() |
| 352 | }; |
| 353 | |
| 354 | container(content) |
| 355 | .width(Length::Fill) |
| 356 | .height(Length::Fill) |
| 357 | .style(|_theme| container::Style { |
| 358 | background: Some(theme::BACKGROUND.into()), |
| 359 | ..Default::default() |
| 360 | }) |
| 361 | .into() |
| 362 | } |
| 363 | |
| 364 | fn toolbar_view(&self) -> Element<'_, Message> { |
| 365 | let add_btn = button(text("+").size(20)) |
| 366 | .padding([5, 15]) |
| 367 | .on_press(Message::AddMachine); |
| 368 | |
| 369 | let title = text("HyprKVM").size(18); |
| 370 | |
| 371 | let save_btn = button(text("Save")) |
| 372 | .padding([5, 10]) |
| 373 | .on_press(Message::SaveConfig); |
| 374 | |
| 375 | container( |
| 376 | row![add_btn, Space::with_width(Length::Fill), title, Space::with_width(Length::Fill), save_btn] |
| 377 | .spacing(10) |
| 378 | .padding(10), |
| 379 | ) |
| 380 | .width(Length::Fill) |
| 381 | .style(|_theme| container::Style { |
| 382 | background: Some(iced::Color::from_rgb(0.95, 0.93, 0.91).into()), |
| 383 | ..Default::default() |
| 384 | }) |
| 385 | .into() |
| 386 | } |
| 387 | |
| 388 | fn error_banner(&self) -> Element<'_, Message> { |
| 389 | if let Some(ref error) = self.state.error { |
| 390 | let mut row_content = row![ |
| 391 | text(error).style(|_theme| text::Style { |
| 392 | color: Some(theme::STATUS_DISCONNECTED) |
| 393 | }), |
| 394 | Space::with_width(Length::Fill) |
| 395 | ]; |
| 396 | |
| 397 | // Add restart button if restart is needed |
| 398 | if self.state.needs_restart { |
| 399 | row_content = row_content.push( |
| 400 | button(text("Restart Daemon")) |
| 401 | .padding([5, 10]) |
| 402 | .on_press(Message::RestartDaemon) |
| 403 | ); |
| 404 | row_content = row_content.push(Space::with_width(10)); |
| 405 | } |
| 406 | |
| 407 | row_content = row_content.push(button(text("x")).on_press(Message::ClearError)); |
| 408 | |
| 409 | container(row_content.padding(10)) |
| 410 | .width(Length::Fill) |
| 411 | .style(|_theme| container::Style { |
| 412 | background: Some(iced::Color::from_rgba(1.0, 0.9, 0.9, 1.0).into()), |
| 413 | ..Default::default() |
| 414 | }) |
| 415 | .into() |
| 416 | } else { |
| 417 | Space::with_height(0).into() |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | fn success_banner(&self) -> Element<'_, Message> { |
| 422 | if let Some(ref msg) = self.state.success { |
| 423 | container( |
| 424 | row![ |
| 425 | text(msg).style(|_theme| text::Style { |
| 426 | color: Some(theme::STATUS_CONNECTED) |
| 427 | }), |
| 428 | Space::with_width(Length::Fill), |
| 429 | button(text("x")).on_press(Message::ClearSuccess) |
| 430 | ] |
| 431 | .padding(10), |
| 432 | ) |
| 433 | .width(Length::Fill) |
| 434 | .style(|_theme| container::Style { |
| 435 | background: Some(iced::Color::from_rgba(0.9, 1.0, 0.9, 1.0).into()), |
| 436 | ..Default::default() |
| 437 | }) |
| 438 | .into() |
| 439 | } else { |
| 440 | Space::with_height(0).into() |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | fn add_machine_modal(&self) -> Element<'_, Message> { |
| 445 | let available = self.state.available_positions(); |
| 446 | |
| 447 | // Position buttons |
| 448 | let position_buttons: Vec<Element<Message>> = available |
| 449 | .iter() |
| 450 | .map(|&pos| { |
| 451 | let label = pos.to_direction().map(|d| format!("{}", d)).unwrap_or_default(); |
| 452 | button(text(label)) |
| 453 | .padding([8, 16]) |
| 454 | .on_press(Message::SelectNewMachinePosition(pos)) |
| 455 | .into() |
| 456 | }) |
| 457 | .collect(); |
| 458 | |
| 459 | let position_row = row(position_buttons).spacing(10); |
| 460 | |
| 461 | container( |
| 462 | column![ |
| 463 | text("Add Machine").size(20), |
| 464 | Space::with_height(20), |
| 465 | text("Name:"), |
| 466 | text_input("Machine name", &self.state.new_machine.name) |
| 467 | .on_input(Message::UpdateNewMachineName) |
| 468 | .padding(8), |
| 469 | Space::with_height(10), |
| 470 | text("Address:"), |
| 471 | text_input("192.168.1.100:24850", &self.state.new_machine.address) |
| 472 | .on_input(Message::UpdateNewMachineAddress) |
| 473 | .padding(8), |
| 474 | Space::with_height(20), |
| 475 | text("Position:"), |
| 476 | position_row, |
| 477 | Space::with_height(20), |
| 478 | row![ |
| 479 | button(text("Cancel")).on_press(Message::CancelAddMachine), |
| 480 | Space::with_width(10), |
| 481 | button(text("Add")).on_press(Message::ConfirmAddMachine) |
| 482 | ] |
| 483 | ] |
| 484 | .spacing(5) |
| 485 | .padding(30), |
| 486 | ) |
| 487 | .style(|_theme| container::Style { |
| 488 | background: Some(iced::Color::WHITE.into()), |
| 489 | border: iced::Border { |
| 490 | color: iced::Color::from_rgb(0.8, 0.8, 0.8), |
| 491 | width: 1.0, |
| 492 | radius: 8.0.into(), |
| 493 | }, |
| 494 | shadow: iced::Shadow { |
| 495 | color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.2), |
| 496 | offset: iced::Vector::new(0.0, 2.0), |
| 497 | blur_radius: 10.0, |
| 498 | }, |
| 499 | ..Default::default() |
| 500 | }) |
| 501 | .width(Length::Fixed(400.0)) |
| 502 | .into() |
| 503 | } |
| 504 | |
| 505 | /// Window title |
| 506 | pub fn title(&self) -> String { |
| 507 | format!("HyprKVM - {}", self.state.config.machines.self_name) |
| 508 | } |
| 509 | |
| 510 | /// Theme |
| 511 | pub fn theme(&self) -> iced::Theme { |
| 512 | iced::Theme::Light |
| 513 | } |
| 514 | } |
| 515 |