Migrate to bread-utils: Hyprland IPC, single-instance lock, popup scaffold

Replaces three hand-rolled pieces of code with the new shared bread-utils
crate (path dependency for now, see the TODO in breadbox/Cargo.toml):

- get_active_workspace's raw socket1 client -> bread_utils::hypr
- toggle_or_continue's TOCTOU-prone PID-file dance -> bread_utils::singleton
  (flock-based, no read-then-write race)
- the layer-shell window setup, Up/Down visible-row navigation, and
  click-outside-close gesture -> bread_utils::gtk_popup

Builds and tests clean across the whole breadbox workspace.
This commit is contained in:
Breadway 2026-07-17 09:20:06 +08:00
parent a6bba2c362
commit 50e5a63528
3 changed files with 31 additions and 113 deletions

12
Cargo.lock generated
View file

@ -37,11 +37,23 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "bread-utils"
version = "0.2.3"
dependencies = [
"dirs",
"gtk4",
"gtk4-layer-shell",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "breadbox" name = "breadbox"
version = "0.2.7" version = "0.2.7"
dependencies = [ dependencies = [
"bread-theme", "bread-theme",
"bread-utils",
"breadbox-shared", "breadbox-shared",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",

View file

@ -10,6 +10,8 @@ path = "src/main.rs"
[dependencies] [dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
bread-utils = { path = "../../bread-ecosystem-fix-worktree/bread-utils", features = ["gtk"] }
breadbox-shared = { path = "../breadbox-shared" } breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"

View file

@ -4,8 +4,6 @@ use std::{
collections::HashMap, collections::HashMap,
env, env,
fs, fs,
io::{Read, Write},
os::unix::net::UnixStream,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
rc::Rc, rc::Rc,
@ -18,28 +16,9 @@ use gtk4::{
glib, glib,
pango::EllipsizeMode, pango::EllipsizeMode,
prelude::*, prelude::*,
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, Application, Box as GBox, CssProvider, EventControllerKey, Label,
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
}; };
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
// ---- Hyprland IPC -----------------------------------------------------------
fn get_active_workspace() -> Option<String> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
let rt = env::var("XDG_RUNTIME_DIR").ok()?;
let socket_path = format!("{}/hypr/{}/.socket.sock", rt, sig);
let mut stream = UnixStream::connect(&socket_path).ok()?;
stream.write_all(b"j/activeworkspace").ok()?;
stream.shutdown(std::net::Shutdown::Write).ok()?;
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
let v: serde_json::Value = serde_json::from_str(&response).ok()?;
v["name"].as_str().map(|s| s.to_string())
}
// ---- Manifest --------------------------------------------------------------- // ---- Manifest ---------------------------------------------------------------
@ -288,40 +267,6 @@ fn match_tier(query: &str, entry: &DesktopEntry) -> Option<u32> {
None None
} }
// ---- PID file toggle --------------------------------------------------------
fn pid_file() -> PathBuf {
env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("breadbox.pid")
}
fn is_breadbox_pid(pid: u32) -> bool {
fs::read_to_string(format!("/proc/{}/comm", pid))
.map(|s| s.trim() == "breadbox")
.unwrap_or(false)
}
// Returns false if an existing instance was killed (caller should exit).
fn toggle_or_continue() -> bool {
let pf = pid_file();
if let Ok(content) = fs::read_to_string(&pf) {
if let Ok(pid) = content.trim().parse::<u32>() {
if is_breadbox_pid(pid) {
let _ = Command::new("kill").arg(pid.to_string()).status();
return false;
}
}
}
let _ = fs::write(&pf, std::process::id().to_string());
true
}
fn cleanup_pid() {
let _ = fs::remove_file(pid_file());
}
// ---- UI --------------------------------------------------------------------- // ---- UI ---------------------------------------------------------------------
fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> { fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
@ -354,20 +299,11 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
} }
// Full-screen transparent window; clicks outside the launcher panel close it. // Full-screen transparent window; clicks outside the launcher panel close it.
let window = ApplicationWindow::builder().application(app).build(); let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
window.init_layer_shell();
window.set_namespace(Some("breadbox"));
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.set_exclusive_zone(0);
let close_all: Rc<dyn Fn()> = Rc::new({ let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone(); let w = window.clone();
move || { move || {
cleanup_pid();
w.close(); w.close();
} }
}); });
@ -513,36 +449,11 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Down => { Key::Down => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); bread_utils::gtk_popup::select_next_visible(&list_k);
let mut i = cur + 1;
loop {
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Up => { Key::Up => {
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); bread_utils::gtk_popup::select_prev_visible(&list_k);
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match list_k.row_at_index(i) {
Some(r) if r.is_visible() => {
list_k.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
glib::Propagation::Stop glib::Propagation::Stop
} }
_ => glib::Propagation::Proceed, _ => glib::Propagation::Proceed,
@ -564,23 +475,8 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
// Click outside launcher panel → close // Click outside launcher panel → close
let close_outside = Rc::clone(&close_all); let close_outside = Rc::clone(&close_all);
let vbox_ref = vbox.clone(); bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
let win_ref = window.clone();
let outside_click = gtk4::GestureClick::new();
outside_click.connect_pressed(move |_, _, x, y| {
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
if x < b.x() as f64
|| x > (b.x() + b.width()) as f64
|| y < b.y() as f64
|| y > (b.y() + b.height()) as f64
{
close_outside();
}
}
});
window.add_controller(outside_click);
window.connect_destroy(|_| cleanup_pid());
window.present(); window.present();
search.grab_focus(); search.grab_focus();
}); });
@ -591,12 +487,20 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
// ---- Main ------------------------------------------------------------------- // ---- Main -------------------------------------------------------------------
fn main() { fn main() {
if !toggle_or_continue() { // Kept alive for the rest of `main` — dropping it releases the
return; // single-instance lock and removes the pid file, which happens
// naturally once `run_ui` returns (after the window closes).
let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadbox") {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => {
eprintln!("breadbox: single-instance lock unavailable ({e}); continuing without it");
None
} }
};
let config = Config::load(); let config = Config::load();
let workspace = get_active_workspace().unwrap_or_default(); let workspace = bread_utils::hypr::active_workspace_name().unwrap_or_default();
let priority = config let priority = config
.context_for(&workspace) .context_for(&workspace)
.map(|c| c.priority.clone()) .map(|c| c.priority.clone())