Rust · 18744 bytes Raw Blame History
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