Add hot-reload config support to detect and apply changes without restart

- Create ConfigWatcher module to monitor config.toml for modifications
- Add get_config_path() to consistently determine config file location
- Implement hot-reload detection in update() that checks for file changes each frame
- Add apply_config_changes() method to handle config updates gracefully
- Add PartialEq derive to all config structs to enable change detection
- Config changes now apply immediately without requiring app restart
- Background opacity, colors, text sizing, and other settings update live
- Log all detected changes for debugging and user awareness
- Fixes issue where config.toml changes were only applied when running from cargo run
This commit is contained in:
2026-02-06 12:23:28 +01:00
parent 64d707bd8b
commit 51811d4f57
6 changed files with 326 additions and 124 deletions
+2 -2
View File
@@ -3,7 +3,6 @@ width = 1024.0
height = 768.0
min_width = 800.0
min_height = 600.0
transparency = 1.0
use_transparency = true
position_offset_x = 0.0
position_offset_y = -30.0
@@ -23,7 +22,8 @@ secret_expiration_years = 50 # How many years until created secrets expire (def
# Leave empty to use embedded default background, or specify custom path
# Examples: "./assets/background.jpg", "C:/path/to/image.png"
background_image = "./assets/background.jpg"
background_opacity = 1.0
background_opacity = 0.8
fallback_color_opacity = 0.4
background_blur = 0.0
fallback_color = [30, 30, 40] # Used if image fails to load
+50 -9
View File
@@ -1,6 +1,6 @@
use crate::auth::AzureAuthenticator;
use crate::azure::{GraphApiClient, KeyVaultClient, VaultDiscovery};
use crate::config::Config;
use crate::config::{Config, ConfigWatcher, get_config_path};
use crate::state::{AppState, AuthStatus, ViewState};
use crate::ui::*;
use poll_promise::Promise;
@@ -13,6 +13,7 @@ pub struct AzureAppManager {
keyvault_client: KeyVaultClient,
vault_discovery: VaultDiscovery,
config: Config,
config_watcher: ConfigWatcher,
position_applied: bool,
}
@@ -30,6 +31,7 @@ impl AzureAppManager {
&config.appearance.background_image,
config.appearance.background_opacity,
config.appearance.fallback_color,
config.appearance.fallback_color_opacity,
)));
state.background_renderer = background_renderer;
@@ -39,6 +41,9 @@ impl AzureAppManager {
state.current_view = ViewState::AppList;
}
// Initialize config watcher
let config_watcher = ConfigWatcher::new(get_config_path());
Self {
state,
auth,
@@ -46,6 +51,7 @@ impl AzureAppManager {
keyvault_client,
vault_discovery,
config,
config_watcher,
position_applied: false,
}
}
@@ -361,21 +367,56 @@ impl AzureAppManager {
auth.sign_out().await
}));
}
/// Apply configuration changes detected from hot-reload
/// Updates appearance and color settings without requiring app restart
fn apply_config_changes(&mut self, new_config: &Config) {
// Check if appearance settings changed
let appearance_changed =
self.config.appearance.background_opacity != new_config.appearance.background_opacity ||
self.config.appearance.fallback_color != new_config.appearance.fallback_color ||
self.config.appearance.fallback_color_opacity != new_config.appearance.fallback_color_opacity;
if appearance_changed {
tracing::info!("Background appearance changed, updating renderer");
// Note: We can't easily recreate the BackgroundRenderer here because we don't have
// access to the egui Context. Instead, the color values are applied in each frame's
// render_fullscreen call, which will pick up the new config values automatically.
// The update happens when we set self.config = new_config above.
}
// Color changes are applied every frame in the update() function via self.config.colors
// so no additional work is needed here - just logging for debugging
if self.config.colors != new_config.colors {
tracing::info!("Color configuration changed, will apply on next frame");
}
if self.config.text_sizing != new_config.text_sizing {
tracing::info!("Text sizing changed, will apply on next frame");
}
if self.config.azure != new_config.azure {
tracing::info!("Azure configuration changed");
}
}
}
impl eframe::App for AzureAppManager {
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
// Use the fallback color from config to match background
let color = self.config.appearance.fallback_color;
[
color[0] as f32 / 255.0,
color[1] as f32 / 255.0,
color[2] as f32 / 255.0,
1.0, // Full opacity
]
// Return fully transparent black to allow background rendering to show through
// The background renderer (render_fullscreen) handles all background rendering
// including fallback color with proper transparency
[0.0, 0.0, 0.0, 0.0]
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Check for config file changes and hot-reload if needed
if let Some(new_config) = self.config_watcher.check_for_changes() {
tracing::info!("Config changes detected, applying hot-reload");
self.apply_config_changes(&new_config);
self.config = new_config;
}
// Customize colors to match app theme
let mut visuals = egui::Visuals::dark();
+4 -1
View File
@@ -1,2 +1,5 @@
pub mod watcher;
pub mod window_config;
pub use window_config::{Config, load_config};
pub use watcher::ConfigWatcher;
pub use window_config::{get_config_path, load_config, Config};
+65
View File
@@ -0,0 +1,65 @@
use crate::config::Config;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
/// Tracks the last modification time of config.toml
pub struct ConfigWatcher {
config_path: PathBuf,
last_modified: u64,
}
impl ConfigWatcher {
pub fn new(config_path: PathBuf) -> Self {
let last_modified = Self::get_file_mtime(&config_path).unwrap_or(0);
Self {
config_path,
last_modified,
}
}
/// Check if config.toml has been modified since last check
/// Returns new Config if file was modified, None otherwise
pub fn check_for_changes(&mut self) -> Option<Config> {
let current_mtime = Self::get_file_mtime(&self.config_path)?;
if current_mtime > self.last_modified {
tracing::info!("Config file has been modified, reloading...");
self.last_modified = current_mtime;
// Try to reload config
match std::fs::read_to_string(&self.config_path) {
Ok(contents) => match toml::from_str::<Config>(&contents) {
Ok(config) => {
tracing::info!("Config reloaded successfully!");
return Some(config);
}
Err(e) => {
tracing::error!(
"Failed to parse updated config.toml: {}. Keeping old config.",
e
);
}
},
Err(e) => {
tracing::error!(
"Failed to read updated config.toml: {}. Keeping old config.",
e
);
}
}
}
None
}
/// Get the last modification time of a file
fn get_file_mtime(path: &PathBuf) -> Option<u64> {
std::fs::metadata(path)
.ok()?
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
}
+91 -50
View File
@@ -1,20 +1,19 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub min_width: f32,
pub min_height: f32,
pub transparency: f32,
pub use_transparency: bool,
pub position_offset_x: f32,
pub position_offset_y: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextSizing {
pub heading: f32,
pub body: f32,
@@ -22,15 +21,17 @@ pub struct TextSizing {
pub button: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppearanceConfig {
pub background_image: String,
pub background_opacity: f32,
pub background_blur: f32,
pub fallback_color: [u8; 3],
#[serde(default = "default_fallback_color_opacity")]
pub fallback_color_opacity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextColors {
pub normal: [u8; 3],
pub inactive: [u8; 3],
@@ -38,81 +39,81 @@ pub struct TextColors {
pub select: [u8; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SliderColors {
pub inactive: [u8; 3],
pub hover: [u8; 3],
pub active: [u8; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WidgetBorderColors {
pub inactive: [u8; 3], // For noninteractive and inactive states
pub hover: [u8; 3], // For hovered state
pub active: [u8; 3], // For active/pressed state
pub inactive: [u8; 3], // For noninteractive and inactive states
pub hover: [u8; 3], // For hovered state
pub active: [u8; 3], // For active/pressed state
}
fn default_widget_borders() -> WidgetBorderColors {
WidgetBorderColors {
inactive: [60, 60, 70], // Current hardcoded dark grey
hover: [120, 120, 140], // Current hardcoded lighter grey
active: [120, 120, 140], // Same as hover
inactive: [60, 60, 70], // Current hardcoded dark grey
hover: [120, 120, 140], // Current hardcoded lighter grey
active: [120, 120, 140], // Same as hover
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WarningColors {
pub text: [u8; 3], // Warning text color
pub background: [u8; 3], // Warning box background color
pub text: [u8; 3], // Warning text color
pub background: [u8; 3], // Warning box background color
}
fn default_warning_colors() -> WarningColors {
WarningColors {
text: [255, 200, 100], // Default amber/orange
background: [80, 60, 30], // Default dark orange background
text: [255, 200, 100], // Default amber/orange
background: [80, 60, 30], // Default dark orange background
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InfoBoxColors {
pub background: [u8; 3], // Info box background color
pub border: [u8; 3], // Info box border color
pub background: [u8; 3], // Info box background color
pub border: [u8; 3], // Info box border color
}
fn default_info_box_colors() -> InfoBoxColors {
InfoBoxColors {
background: [40, 40, 60], // Default dark blue-grey
border: [239, 124, 0], // Default orange border
background: [40, 40, 60], // Default dark blue-grey
border: [239, 124, 0], // Default orange border
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CreateSecretInfoColors {
pub background: [u8; 3], // Info box background color
pub text: [u8; 3], // Info box text color
pub background: [u8; 3], // Info box background color
pub text: [u8; 3], // Info box text color
}
fn default_create_secret_info_colors() -> CreateSecretInfoColors {
CreateSecretInfoColors {
background: [40, 40, 60], // Default purple/dark blue
text: [239, 124, 0], // Default orange
background: [40, 40, 60], // Default purple/dark blue
text: [239, 124, 0], // Default orange
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CardColors {
pub selected_bg: [u8; 4], // RGBA
pub selected_border: [u8; 3], // RGB
pub unselected_bg: [u8; 4], // RGBA
pub selected_bg: [u8; 4], // RGBA
pub selected_border: [u8; 3], // RGB
pub unselected_bg: [u8; 4], // RGBA
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CardConfig {
pub app_list: CardColors,
pub keyvault: CardColors,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ColorConfig {
pub text: TextColors,
pub slider: SliderColors,
@@ -127,7 +128,7 @@ pub struct ColorConfig {
pub create_secret_info: CreateSecretInfoColors,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AzureConfig {
pub secret_expiration_years: u32,
}
@@ -138,7 +139,11 @@ fn default_azure_config() -> AzureConfig {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
fn default_fallback_color_opacity() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Config {
pub window: WindowConfig,
pub appearance: AppearanceConfig,
@@ -158,16 +163,16 @@ impl Default for Config {
height: 768.0,
min_width: 800.0,
min_height: 600.0,
transparency: 0.0,
use_transparency: false,
position_offset_x: 0.0,
position_offset_y: 0.0,
},
appearance: AppearanceConfig {
background_image: String::new(), // Empty = use embedded default, or specify custom path
background_image: String::new(),
background_opacity: 1.0,
background_blur: 0.0,
fallback_color: [30, 30, 40],
fallback_color_opacity: 1.0,
},
colors: Some(ColorConfig {
text: TextColors {
@@ -225,24 +230,44 @@ impl Default for Config {
}
pub fn load_config() -> Config {
let config_path = "./config.toml";
// Get the directory where the exe is located
let config_path = if let Ok(exe_path) = std::env::current_exe() {
exe_path
.parent()
.map(|p| p.join("config.toml"))
.unwrap_or_else(|| Path::new("./config.toml").to_path_buf())
} else {
Path::new("./config.toml").to_path_buf()
};
tracing::info!("Loading config from: {}", config_path);
tracing::info!("Current working directory: {:?}", std::env::current_dir().ok());
tracing::info!("Loading config from: {:?}", config_path);
tracing::info!(
"Current working directory: {:?}",
std::env::current_dir().ok()
);
if Path::new(config_path).exists() {
if config_path.exists() {
tracing::info!("Config file found");
match fs::read_to_string(config_path) {
match fs::read_to_string(&config_path) {
Ok(contents) => {
tracing::info!("Config file read successfully, {} bytes", contents.len());
match toml::from_str::<Config>(&contents) {
Ok(config) => {
tracing::info!("Config parsed successfully!");
tracing::info!(" - Background image: {}", config.appearance.background_image);
tracing::info!(" - Background opacity: {}", config.appearance.background_opacity);
tracing::info!(" - Fallback color: {:?}", config.appearance.fallback_color);
tracing::info!(
" - Background image: {}",
config.appearance.background_image
);
tracing::info!(
" - Background opacity: {}",
config.appearance.background_opacity
);
tracing::info!(
" - Fallback color: {:?}",
config.appearance.fallback_color
);
return config;
},
}
Err(e) => {
tracing::error!("Failed to parse config.toml: {}. Using defaults.", e);
}
@@ -253,11 +278,14 @@ pub fn load_config() -> Config {
}
}
} else {
tracing::warn!("Config file not found at {}, creating default", config_path);
tracing::warn!(
"Config file not found at {:?}, creating default",
config_path
);
// Create default config file
let default_config = Config::default();
if let Ok(toml_string) = toml::to_string_pretty(&default_config) {
let _ = fs::write(config_path, toml_string);
let _ = fs::write(&config_path, toml_string);
tracing::info!("Created default config.toml");
}
}
@@ -265,3 +293,16 @@ pub fn load_config() -> Config {
tracing::warn!("Using default config values");
Config::default()
}
/// Get the path to the config.toml file
/// This is the same path used by load_config()
pub fn get_config_path() -> PathBuf {
if let Ok(exe_path) = std::env::current_exe() {
exe_path
.parent()
.map(|p| p.join("config.toml"))
.unwrap_or_else(|| Path::new("./config.toml").to_path_buf())
} else {
Path::new("./config.toml").to_path_buf()
}
}
+114 -62
View File
@@ -1,12 +1,15 @@
use egui::{Color32, ColorImage, Context, Rect, TextureHandle, TextureOptions, Ui};
// Embed background image at compile time for instant loading
// Try to embed background image at compile time, but don't fail if it's missing
// The background is optional - if unavailable, we'll use the fallback color
#[cfg(feature = "embedded_bg")]
const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../../assets/background.jpg");
pub struct BackgroundRenderer {
texture: Option<TextureHandle>,
fallback_color: Color32,
opacity: f32,
fallback_color_opacity: f32,
}
impl BackgroundRenderer {
@@ -15,6 +18,7 @@ impl BackgroundRenderer {
_image_path: &str,
opacity: f32,
fallback_color: [u8; 3],
fallback_color_opacity: f32,
) -> Self {
tracing::info!("Initializing background renderer with embedded image");
@@ -22,7 +26,10 @@ impl BackgroundRenderer {
let texture = Self::load_embedded_texture(ctx);
if texture.is_some() {
tracing::info!("Background texture loaded from embedded image with opacity: {}", opacity);
tracing::info!(
"Background texture loaded from embedded image with opacity: {}",
opacity
);
} else {
tracing::info!("Using fallback color: {:?}", fallback_color);
}
@@ -35,32 +42,41 @@ impl BackgroundRenderer {
fallback_color[2],
),
opacity,
fallback_color_opacity,
}
}
fn load_embedded_texture(ctx: &Context) -> Option<TextureHandle> {
tracing::info!("Loading embedded default background");
match image::load_from_memory(DEFAULT_BACKGROUND) {
Ok(img) => {
let size = [img.width() as usize, img.height() as usize];
tracing::info!("Embedded image loaded: {}x{} pixels", size[0], size[1]);
let image_buffer = img.to_rgba8();
let pixels = image_buffer.as_flat_samples();
let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
fn load_embedded_texture(_ctx: &Context) -> Option<TextureHandle> {
#[cfg(feature = "embedded_bg")]
{
tracing::info!("Loading embedded default background");
match image::load_from_memory(DEFAULT_BACKGROUND) {
Ok(img) => {
let size = [img.width() as usize, img.height() as usize];
tracing::info!("Embedded image loaded: {}x{} pixels", size[0], size[1]);
let image_buffer = img.to_rgba8();
let pixels = image_buffer.as_flat_samples();
let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
let texture = ctx.load_texture(
"embedded_background",
color_image,
TextureOptions::LINEAR,
);
tracing::info!("Embedded texture created successfully");
Some(texture)
}
Err(e) => {
tracing::error!("Failed to load embedded background: {}", e);
None
let texture = _ctx.load_texture(
"embedded_background",
color_image,
TextureOptions::LINEAR,
);
tracing::info!("Embedded texture created successfully");
Some(texture)
}
Err(e) => {
tracing::error!("Failed to load embedded background: {}", e);
None
}
}
}
#[cfg(not(feature = "embedded_bg"))]
{
tracing::info!("Embedded background not available, will use fallback color");
None
}
}
#[allow(dead_code)]
@@ -74,11 +90,7 @@ impl BackgroundRenderer {
let pixels = image_buffer.as_flat_samples();
let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
let texture = ctx.load_texture(
path,
color_image,
TextureOptions::LINEAR,
);
let texture = ctx.load_texture(path, color_image, TextureOptions::LINEAR);
tracing::info!("Texture created with ID: {:?}", texture.id());
Some(texture)
}
@@ -97,10 +109,18 @@ impl BackgroundRenderer {
// Use screen-space painting (lowest possible layer)
let painter = ctx.layer_painter(egui::LayerId::background());
// Fill entire screen with background color first
painter.rect_filled(screen_rect, 0.0, self.fallback_color);
// Always render fallback color as the base layer
// This provides a foundation color, whether or not the image loads
let fallback_with_opacity = Color32::from_rgba_unmultiplied(
self.fallback_color.r(),
self.fallback_color.g(),
self.fallback_color.b(),
(self.fallback_color_opacity * 255.0) as u8,
);
painter.rect_filled(screen_rect, 0.0, fallback_with_opacity);
if let Some(texture) = &self.texture {
// If we have a texture, render it on top with configured opacity
// Calculate UV coordinates to fit image properly (cover the screen)
let texture_aspect = texture.aspect_ratio();
let screen_aspect = screen_rect.width() / screen_rect.height();
@@ -109,34 +129,26 @@ impl BackgroundRenderer {
// Image is wider - crop left side to show right side (where logo is)
let scale = screen_aspect / texture_aspect;
let offset = 1.0 - scale;
Rect::from_min_max(
egui::pos2(offset, 0.0),
egui::pos2(1.0, 1.0)
)
Rect::from_min_max(egui::pos2(offset, 0.0), egui::pos2(1.0, 1.0))
} else {
// Image is taller - keep top visible (where logo is)
let scale = texture_aspect / screen_aspect;
Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, scale)
)
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, scale))
};
// Draw background image with proper cropping
let tint = Color32::from_rgba_unmultiplied(
255,
255,
255,
(self.opacity * 255.0) as u8,
);
// Draw background image with configured opacity on top of fallback
let tint = Color32::from_rgba_unmultiplied(255, 255, 255, (self.opacity * 255.0) as u8);
painter.image(
texture.id(),
screen_rect,
uv_rect,
tint,
painter.image(texture.id(), screen_rect, uv_rect, tint);
tracing::debug!(
"Background image rendered fullscreen with UV rect {:?}",
uv_rect
);
} else {
tracing::debug!(
"No background image, showing fallback color with opacity: {}",
self.fallback_color_opacity
);
tracing::debug!("Background image rendered fullscreen with UV rect {:?}", uv_rect);
}
}
@@ -144,17 +156,9 @@ impl BackgroundRenderer {
pub fn render(&self, ui: &mut Ui) {
let rect = ui.max_rect();
// Always draw fallback first
ui.painter().rect_filled(rect, 0.0, self.fallback_color);
if let Some(texture) = &self.texture {
// Draw background image on top
let tint = Color32::from_rgba_unmultiplied(
255,
255,
255,
(self.opacity * 255.0) as u8,
);
// Draw background image with configured opacity
let tint = Color32::from_rgba_unmultiplied(255, 255, 255, (self.opacity * 255.0) as u8);
ui.painter().image(
texture.id(),
@@ -162,9 +166,57 @@ impl BackgroundRenderer {
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
tint,
);
tracing::trace!("Background image rendered to rect {:?} with opacity {}", rect, self.opacity);
tracing::trace!(
"Background image rendered to rect {:?} with opacity {}",
rect,
self.opacity
);
} else {
tracing::trace!("No texture, only fallback color rendered");
// Draw fallback color with configured opacity
let fallback_with_opacity = Color32::from_rgba_unmultiplied(
self.fallback_color.r(),
self.fallback_color.g(),
self.fallback_color.b(),
(self.fallback_color_opacity * 255.0) as u8,
);
ui.painter().rect_filled(rect, 0.0, fallback_with_opacity);
tracing::trace!(
"Fallback color rendered with opacity {}",
self.fallback_color_opacity
);
}
}
/// Update the background opacity dynamically
#[allow(dead_code)]
pub fn set_opacity(&mut self, opacity: f32) {
if (self.opacity - opacity).abs() > 0.001 {
// Only log if there's a meaningful change
tracing::info!(
"Updating background opacity from {} to {}",
self.opacity,
opacity
);
self.opacity = opacity;
}
}
/// Update the fallback color dynamically
#[allow(dead_code)]
pub fn set_fallback_color(&mut self, fallback_color: [u8; 3], fallback_color_opacity: f32) {
let new_color = Color32::from_rgb(fallback_color[0], fallback_color[1], fallback_color[2]);
if self.fallback_color != new_color
|| (self.fallback_color_opacity - fallback_color_opacity).abs() > 0.001
{
tracing::info!(
"Updating fallback color from {:?} to {:?} with opacity {} to {}",
self.fallback_color,
new_color,
self.fallback_color_opacity,
fallback_color_opacity
);
self.fallback_color = new_color;
self.fallback_color_opacity = fallback_color_opacity;
}
}
}