420 lines
15 KiB
Rust
420 lines
15 KiB
Rust
//! Main GTK4 application for the launcher
|
|
|
|
use gtk4::glib;
|
|
use gtk4::prelude::*;
|
|
use std::cell::RefCell;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
use tokio::runtime::Runtime;
|
|
use tokio::sync::mpsc;
|
|
use tracing::{debug, error, info};
|
|
|
|
use crate::client::{ClientCommand, CommandClient, DaemonClient};
|
|
use crate::grid::LauncherGrid;
|
|
use crate::state::{LauncherState, SharedState};
|
|
|
|
/// CSS styling for the launcher
|
|
const LAUNCHER_CSS: &str = r#"
|
|
window {
|
|
background-color: #1a1a2e;
|
|
}
|
|
|
|
.launcher-grid {
|
|
padding: 48px;
|
|
}
|
|
|
|
.launcher-tile {
|
|
background-color: #16213e;
|
|
border-radius: 16px;
|
|
padding: 16px;
|
|
min-width: 140px;
|
|
min-height: 140px;
|
|
border: 2px solid transparent;
|
|
transition: all 200ms ease;
|
|
}
|
|
|
|
.launcher-tile:hover {
|
|
background-color: #1f3460;
|
|
border-color: #4a90d9;
|
|
}
|
|
|
|
.launcher-tile:active {
|
|
background-color: #0f3460;
|
|
}
|
|
|
|
.launcher-tile:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.tile-label {
|
|
color: #ffffff;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-label {
|
|
color: #888888;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.error-label {
|
|
color: #ff6b6b;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.launching-spinner {
|
|
min-width: 64px;
|
|
min-height: 64px;
|
|
}
|
|
|
|
.session-active-box {
|
|
padding: 48px;
|
|
}
|
|
|
|
.session-label {
|
|
color: #ffffff;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
}
|
|
"#;
|
|
|
|
pub struct LauncherApp {
|
|
socket_path: PathBuf,
|
|
}
|
|
|
|
impl LauncherApp {
|
|
pub fn new(socket_path: PathBuf) -> Self {
|
|
Self { socket_path }
|
|
}
|
|
|
|
pub fn run(&self) -> i32 {
|
|
let app = gtk4::Application::builder()
|
|
.application_id("org.shepherd.launcher")
|
|
.build();
|
|
|
|
let socket_path = self.socket_path.clone();
|
|
|
|
app.connect_activate(move |app| {
|
|
Self::build_ui(app, socket_path.clone());
|
|
});
|
|
|
|
app.run().into()
|
|
}
|
|
|
|
fn build_ui(app: >k4::Application, socket_path: PathBuf) {
|
|
// Load CSS
|
|
let provider = gtk4::CssProvider::new();
|
|
provider.load_from_data(LAUNCHER_CSS);
|
|
gtk4::style_context_add_provider_for_display(
|
|
>k4::gdk::Display::default().expect("Could not get default display"),
|
|
&provider,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
);
|
|
|
|
// Create main window
|
|
let window = gtk4::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("Shepherd Launcher")
|
|
.default_width(1280)
|
|
.default_height(720)
|
|
.build();
|
|
|
|
// Make fullscreen
|
|
window.fullscreen();
|
|
|
|
// Create main stack for different views
|
|
let stack = gtk4::Stack::new();
|
|
stack.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
|
stack.set_transition_duration(300);
|
|
|
|
// Create views
|
|
let grid = LauncherGrid::new();
|
|
let loading_view = Self::create_loading_view();
|
|
let error_view = Self::create_error_view();
|
|
let session_view = Self::create_session_view();
|
|
let disconnected_view = Self::create_disconnected_view();
|
|
|
|
stack.add_named(&grid, Some("grid"));
|
|
stack.add_named(&loading_view, Some("loading"));
|
|
stack.add_named(&error_view.0, Some("error"));
|
|
stack.add_named(&session_view.0, Some("session"));
|
|
stack.add_named(&disconnected_view.0, Some("disconnected"));
|
|
|
|
window.set_child(Some(&stack));
|
|
|
|
// Create shared state
|
|
let state = SharedState::new();
|
|
let state_receiver = state.subscribe();
|
|
|
|
// Create tokio runtime for async operations
|
|
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
|
|
|
|
// Create command channel
|
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
|
|
|
// Create command client for sending commands
|
|
let command_client = Arc::new(CommandClient::new(&socket_path));
|
|
|
|
// Connect grid launch callback
|
|
let cmd_client = command_client.clone();
|
|
let state_clone = state.clone();
|
|
let rt = runtime.clone();
|
|
grid.connect_launch(move |entry_id| {
|
|
info!(entry_id = %entry_id, "Launch requested");
|
|
state_clone.set(LauncherState::Launching {
|
|
entry_id: entry_id.to_string(),
|
|
});
|
|
|
|
let client = cmd_client.clone();
|
|
let state = state_clone.clone();
|
|
let entry_id = entry_id.clone();
|
|
rt.spawn(async move {
|
|
match client.launch(&entry_id).await {
|
|
Ok(response) => {
|
|
debug!(response = ?response, "Launch response");
|
|
// Handle error responses from daemon
|
|
match response.result {
|
|
shepherd_api::ResponseResult::Ok(payload) => {
|
|
// Check what kind of success response we got
|
|
match payload {
|
|
shepherd_api::ResponsePayload::LaunchApproved { session_id, deadline } => {
|
|
info!(session_id = %session_id, "Launch approved, setting SessionActive");
|
|
let now = chrono::Local::now();
|
|
let time_remaining = if deadline > now {
|
|
(deadline - now).to_std().ok()
|
|
} else {
|
|
Some(std::time::Duration::ZERO)
|
|
};
|
|
state.set(LauncherState::SessionActive {
|
|
session_id,
|
|
entry_label: entry_id.to_string(),
|
|
time_remaining,
|
|
});
|
|
}
|
|
shepherd_api::ResponsePayload::LaunchDenied { reasons } => {
|
|
let message = reasons
|
|
.iter()
|
|
.map(|r| format!("{:?}", r))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
error!(message = %message, "Launch denied");
|
|
state.set(LauncherState::Error { message });
|
|
}
|
|
_ => {
|
|
// Other OK responses - events will update state
|
|
}
|
|
}
|
|
}
|
|
shepherd_api::ResponseResult::Err(err) => {
|
|
// Launch failed on server side - refresh state to recover
|
|
error!(error = %err.message, "Launch failed on server");
|
|
// Request fresh state from daemon to get back to correct state
|
|
match client.get_state().await {
|
|
Ok(state_resp) => {
|
|
if let shepherd_api::ResponseResult::Ok(
|
|
shepherd_api::ResponsePayload::State(snapshot)
|
|
) = state_resp.result {
|
|
if snapshot.current_session.is_some() {
|
|
// Session is still active somehow
|
|
debug!("Session still active after spawn failure");
|
|
} else {
|
|
// No session - return to idle with entries
|
|
state.set(LauncherState::Idle {
|
|
entries: snapshot.entries,
|
|
});
|
|
}
|
|
} else {
|
|
// Unexpected response, show error
|
|
state.set(LauncherState::Error {
|
|
message: format!("Launch failed: {}", err.message),
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// Can't get state, show error
|
|
error!(error = %e, "Failed to get state after launch failure");
|
|
state.set(LauncherState::Error {
|
|
message: format!("Launch failed: {}", err.message),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!(error = %e, "Launch failed");
|
|
state.set(LauncherState::Error {
|
|
message: format!("Launch failed: {}", e),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Connect retry button
|
|
let cmd_client = command_client.clone();
|
|
let state_clone = state.clone();
|
|
let rt = runtime.clone();
|
|
disconnected_view.1.connect_clicked(move |_| {
|
|
info!("Retry connection requested");
|
|
state_clone.set(LauncherState::Connecting);
|
|
|
|
let client = cmd_client.clone();
|
|
let state = state_clone.clone();
|
|
rt.spawn(async move {
|
|
match client.get_state().await {
|
|
Ok(_) => {
|
|
// Will trigger state update
|
|
}
|
|
Err(e) => {
|
|
error!(error = %e, "Reconnect failed");
|
|
state.set(LauncherState::Disconnected);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Start daemon client in background thread (separate from GTK main loop)
|
|
// This ensures the tokio runtime is properly driven for event reception
|
|
let state_for_client = state.clone();
|
|
let socket_for_client = socket_path.clone();
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for event loop");
|
|
rt.block_on(async move {
|
|
let client = DaemonClient::new(socket_for_client, state_for_client, command_rx);
|
|
client.run().await;
|
|
});
|
|
});
|
|
|
|
// Set up state change handler
|
|
let stack_weak = stack.downgrade();
|
|
let grid_weak = grid.downgrade();
|
|
let error_label = error_view.1.clone();
|
|
let session_label = session_view.1.clone();
|
|
|
|
glib::spawn_future_local(async move {
|
|
let mut receiver = state_receiver;
|
|
|
|
loop {
|
|
receiver.changed().await.ok();
|
|
|
|
let state = receiver.borrow().clone();
|
|
|
|
let Some(stack) = stack_weak.upgrade() else {
|
|
break;
|
|
};
|
|
|
|
let grid = grid_weak.upgrade();
|
|
|
|
match state {
|
|
LauncherState::Disconnected => {
|
|
stack.set_visible_child_name("disconnected");
|
|
}
|
|
LauncherState::Connecting => {
|
|
stack.set_visible_child_name("loading");
|
|
}
|
|
LauncherState::Idle { entries } => {
|
|
if let Some(grid) = grid {
|
|
grid.set_entries(entries);
|
|
grid.set_tiles_sensitive(true);
|
|
}
|
|
stack.set_visible_child_name("grid");
|
|
}
|
|
LauncherState::Launching { entry_id } => {
|
|
if let Some(grid) = grid {
|
|
grid.set_tiles_sensitive(false);
|
|
}
|
|
stack.set_visible_child_name("loading");
|
|
}
|
|
LauncherState::SessionActive {
|
|
session_id: _,
|
|
entry_label,
|
|
time_remaining: _,
|
|
} => {
|
|
session_label.set_text(&format!("Running: {}", entry_label));
|
|
stack.set_visible_child_name("session");
|
|
}
|
|
LauncherState::Error { message } => {
|
|
error_label.set_text(&message);
|
|
stack.set_visible_child_name("error");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
window.present();
|
|
}
|
|
|
|
fn create_loading_view() -> gtk4::Box {
|
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
|
container.set_halign(gtk4::Align::Center);
|
|
container.set_valign(gtk4::Align::Center);
|
|
|
|
let spinner = gtk4::Spinner::new();
|
|
spinner.set_spinning(true);
|
|
spinner.add_css_class("launching-spinner");
|
|
container.append(&spinner);
|
|
|
|
let label = gtk4::Label::new(Some("Loading..."));
|
|
label.add_css_class("status-label");
|
|
container.append(&label);
|
|
|
|
container
|
|
}
|
|
|
|
fn create_error_view() -> (gtk4::Box, gtk4::Label) {
|
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
|
container.set_halign(gtk4::Align::Center);
|
|
container.set_valign(gtk4::Align::Center);
|
|
|
|
let icon = gtk4::Image::from_icon_name("dialog-error");
|
|
icon.set_pixel_size(64);
|
|
container.append(&icon);
|
|
|
|
let label = gtk4::Label::new(Some("An error occurred"));
|
|
label.add_css_class("error-label");
|
|
label.set_wrap(true);
|
|
label.set_max_width_chars(40);
|
|
container.append(&label);
|
|
|
|
(container, label)
|
|
}
|
|
|
|
fn create_session_view() -> (gtk4::Box, gtk4::Label) {
|
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
|
container.set_halign(gtk4::Align::Center);
|
|
container.set_valign(gtk4::Align::Center);
|
|
container.add_css_class("session-active-box");
|
|
|
|
let label = gtk4::Label::new(Some("Session Active"));
|
|
label.add_css_class("session-label");
|
|
container.append(&label);
|
|
|
|
let hint = gtk4::Label::new(Some("Use the HUD to view time remaining"));
|
|
hint.add_css_class("status-label");
|
|
container.append(&hint);
|
|
|
|
(container, label)
|
|
}
|
|
|
|
fn create_disconnected_view() -> (gtk4::Box, gtk4::Button) {
|
|
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
|
container.set_halign(gtk4::Align::Center);
|
|
container.set_valign(gtk4::Align::Center);
|
|
|
|
let icon = gtk4::Image::from_icon_name("network-offline");
|
|
icon.set_pixel_size(64);
|
|
container.append(&icon);
|
|
|
|
let label = gtk4::Label::new(Some("System not ready"));
|
|
label.add_css_class("status-label");
|
|
container.append(&label);
|
|
|
|
let retry_button = gtk4::Button::with_label("Retry");
|
|
retry_button.add_css_class("launcher-tile");
|
|
container.append(&retry_button);
|
|
|
|
(container, retry_button)
|
|
}
|
|
}
|