Skip to content

Commit ffe127d

Browse files
authored
Merge pull request locainin#10 from locainin/dev
Harden popup reload coalescing, visibility, and startup wiring
2 parents 0bb202f + 5005400 commit ffe127d

15 files changed

Lines changed: 895 additions & 387 deletions

File tree

crates/noticenterctl/src/preset/import/exec_review.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ mod tests {
256256
use std::io;
257257
use std::path::PathBuf;
258258
use std::process::Command;
259+
use std::sync::Mutex;
260+
261+
// Pager tests mutate one process-global env var, so they need one tiny lock
262+
static PAGER_ENV_LOCK: Mutex<()> = Mutex::new(());
259263

260264
#[test]
261265
fn exec_review_renders_commands_and_files() {
@@ -285,6 +289,7 @@ mod tests {
285289

286290
#[test]
287291
fn pager_command_adds_raw_control_for_less() {
292+
let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env");
288293
let original = env::var_os("PAGER");
289294
unsafe {
290295
env::set_var("PAGER", "less -F");
@@ -322,6 +327,7 @@ mod tests {
322327

323328
#[test]
324329
fn pager_command_respects_quoted_arguments() {
330+
let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env");
325331
let original = env::var_os("PAGER");
326332
unsafe {
327333
env::set_var("PAGER", "less --prompt='unixnotis review'");
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//! Popup startup glue kept outside the crate entrypoint
2+
//!
3+
//! This module keeps the binary `main.rs` small while leaving reload
4+
//! coalescing and GTK runtime wiring in focused files
5+
6+
use std::cell::Cell;
7+
use std::path::PathBuf;
8+
use std::rc::Rc;
9+
use std::sync::{Arc, Mutex};
10+
11+
use anyhow::{anyhow, Context, Result};
12+
use clap::Parser;
13+
use glib::MainContext;
14+
use gtk::prelude::*;
15+
use tracing::info;
16+
use unixnotis_core::Config;
17+
use unixnotis_ui::css::{self, CssKind};
18+
19+
use crate::{dbus, ui};
20+
21+
mod reload;
22+
mod runtime;
23+
mod startup;
24+
#[cfg(test)]
25+
mod tests;
26+
27+
use self::reload::{start_reload_timer, ReloadGate};
28+
use self::runtime::handle_ui_event;
29+
use self::startup::{init_tracing, is_wayland_session, load_config};
30+
31+
const UI_EVENT_QUEUE_CAPACITY: usize = 512;
32+
33+
#[derive(Parser, Debug)]
34+
#[command(author, version, about)]
35+
pub(crate) struct Args {
36+
/// Path to config.toml
37+
#[arg(long)]
38+
config: Option<PathBuf>,
39+
}
40+
41+
pub(crate) fn run(args: Args) -> Result<()> {
42+
// Load and validate config before GTK starts so startup failures stay clear
43+
let (config, config_path) = load_config(&args).context("load config")?;
44+
init_tracing(&config);
45+
let config_source = if args.config.is_some() {
46+
"custom"
47+
} else if config_path.exists() {
48+
"default"
49+
} else {
50+
"builtin"
51+
};
52+
info!(config_source, "popup configuration loaded");
53+
if unixnotis_core::util::diagnostic_mode() {
54+
info!(
55+
limit = unixnotis_core::util::log_limit(),
56+
"diagnostic logging enabled (snippets capped; newlines stripped)"
57+
);
58+
}
59+
60+
if !is_wayland_session() {
61+
return Err(anyhow!("Wayland session not detected; UI requires Wayland"));
62+
}
63+
64+
let theme_base = Config::config_dir_for_path(&config_path).context("resolve config dir")?;
65+
let theme_paths = config
66+
.resolve_theme_paths_from(&theme_base)
67+
.context("resolve theme paths")?;
68+
config
69+
.ensure_theme_files(&theme_paths)
70+
.context("ensure theme files")?;
71+
72+
let app = gtk::Application::new(Some("com.unixnotis.Popups"), Default::default());
73+
// Activation can happen more than once in one process, so runtime setup
74+
// needs one gate that makes repeated activation a no-op
75+
let activation_started = Rc::new(Cell::new(false));
76+
77+
app.connect_activate(move |app| {
78+
// Repeated activation should not start a second D-Bus runtime or watcher set
79+
if activation_started.replace(true) {
80+
info!("popup activation ignored because runtime is already initialized");
81+
return;
82+
}
83+
84+
// Bound the queue so a stalled UI cannot grow memory forever
85+
let (event_tx, event_rx) = async_channel::bounded(UI_EVENT_QUEUE_CAPACITY);
86+
let command_tx = dbus::start_dbus_runtime(event_tx.clone());
87+
let reload_gate = Arc::new(ReloadGate::new());
88+
// Timer state keeps only one flush source alive at a time
89+
let reload_timer = Arc::new(Mutex::new(None::<glib::SourceId>));
90+
91+
let css_manager = css::CssManager::new_popup(theme_paths.clone(), config.theme.clone());
92+
css_manager.apply_to_display();
93+
css_manager.reload(css::DEFAULT_CSS);
94+
95+
let ui = Rc::new(std::cell::RefCell::new(ui::UiState::new(
96+
app,
97+
config.clone(),
98+
config_path.clone(),
99+
command_tx,
100+
css_manager,
101+
)));
102+
103+
let ui_clone = ui.clone();
104+
let reload_gate_loop = Arc::clone(&reload_gate);
105+
let event_tx_loop = event_tx.clone();
106+
let reload_timer_loop = Arc::clone(&reload_timer);
107+
MainContext::default().spawn_local(async move {
108+
while let Ok(event) = event_rx.recv().await {
109+
handle_ui_event(
110+
&ui_clone,
111+
&reload_gate_loop,
112+
&event_tx_loop,
113+
&reload_timer_loop,
114+
event,
115+
);
116+
}
117+
});
118+
119+
css::start_css_watcher(&theme_paths, CssKind::Popup, {
120+
let event_tx = event_tx.clone();
121+
let reload_gate = Arc::clone(&reload_gate);
122+
let reload_timer = Arc::clone(&reload_timer);
123+
move || {
124+
// Only start the retry timer when queue pressure actually blocked the send
125+
if reload_gate.request_css(&event_tx) {
126+
let reload_gate = Arc::clone(&reload_gate);
127+
let event_tx = event_tx.clone();
128+
let reload_timer = Arc::clone(&reload_timer);
129+
MainContext::default().invoke(move || {
130+
start_reload_timer(&reload_gate, &event_tx, &reload_timer);
131+
});
132+
}
133+
}
134+
});
135+
css::start_config_watcher(config_path.clone(), {
136+
let event_tx = event_tx.clone();
137+
let reload_gate = Arc::clone(&reload_gate);
138+
let reload_timer = Arc::clone(&reload_timer);
139+
move || {
140+
// Config reloads use the same bounded retry path as popup CSS reloads
141+
if reload_gate.request_config(&event_tx) {
142+
let reload_gate = Arc::clone(&reload_gate);
143+
let event_tx = event_tx.clone();
144+
let reload_timer = Arc::clone(&reload_timer);
145+
MainContext::default().invoke(move || {
146+
start_reload_timer(&reload_gate, &event_tx, &reload_timer);
147+
});
148+
}
149+
}
150+
});
151+
info!("unixnotis-popups running");
152+
});
153+
154+
app.run();
155+
Ok(())
156+
}

0 commit comments

Comments
 (0)