From e16c1b461c327c3a8a2669c50d69aa5ef504e78d Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 09:24:38 +0800 Subject: [PATCH] Migrate to bread-utils: Hyprland IPC, single-instance lock, popup scaffold, XDG paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors tonight's breadbox migration (same shared crate, same duplicated patterns): - position.rs's raw socket1 client -> bread_utils::hypr (file removed entirely, its logic now lives in the shared crate) - toggle_or_continue's TOCTOU-prone PID-file dance -> bread_utils::singleton - the layer-shell window setup, Up/Down visible-row navigation, and click-outside-close gesture -> bread_utils::gtk_popup - breadclip-core's data_dir(): replaced the buggy `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))` fallback (flagged but never fixed in tonight's earlier audit pass — PathBuf never expands `~`) with bread_utils::xdg::data_dir, which resolves a real $HOME before ever falling back. Builds and tests clean across the whole breadclip workspace. --- Cargo.lock | 13 ++++ breadclip-core/Cargo.toml | 2 + breadclip-core/src/lib.rs | 10 +++- breadclip/Cargo.toml | 2 + breadclip/src/main.rs | 121 ++++++++------------------------------ breadclip/src/position.rs | 71 ---------------------- 6 files changed, 47 insertions(+), 172 deletions(-) delete mode 100644 breadclip/src/position.rs diff --git a/Cargo.lock b/Cargo.lock index b683a8b..e06bdd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,11 +46,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.2.3" +dependencies = [ + "dirs", + "gtk4", + "gtk4-layer-shell", + "serde", + "serde_json", +] + [[package]] name = "breadclip" version = "0.1.1" dependencies = [ "bread-theme", + "bread-utils", "breadclip-core", "gtk4", "gtk4-layer-shell", @@ -61,6 +73,7 @@ dependencies = [ name = "breadclip-core" version = "0.1.0" dependencies = [ + "bread-utils", "dirs", "hex", "rusqlite", diff --git a/breadclip-core/Cargo.toml b/breadclip-core/Cargo.toml index 4ae8786..df1bafd 100644 --- a/breadclip-core/Cargo.toml +++ b/breadclip-core/Cargo.toml @@ -8,3 +8,5 @@ rusqlite = { version = "0.31", features = ["bundled"] } sha2 = "0.10" hex = "0.4" dirs = "5" +# 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" } diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index 75035ff..5afed9f 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -169,9 +169,13 @@ pub fn sha256_hex(data: &[u8]) -> String { } pub fn data_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) - .join("breadclip") + // Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))` + // — PathBuf/std::fs never expand `~`, so on the rare box where `dirs` + // can't resolve a home directory, that fallback silently resolved to a + // directory literally named `~` under the current working directory + // instead of the user's actual home. bread_utils::xdg resolves a real + // $HOME before ever falling back. + bread_utils::xdg::data_dir("breadclip") } fn unix_now() -> i64 { diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 956750c..f023500 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -10,6 +10,8 @@ path = "src/main.rs" [dependencies] breadclip-core = { path = "../breadclip-core" } 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"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" diff --git a/breadclip/src/main.rs b/breadclip/src/main.rs index 2df0dd5..282b4a6 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -1,5 +1,4 @@ mod css; -mod position; use breadclip_core::{ClipEntry, HistoryDb}; use bread_theme::{load_palette}; @@ -7,16 +6,13 @@ use gtk4::{ glib, pango::EllipsizeMode, prelude::*, - Application, ApplicationWindow, Box as GBox, Button, ContentFit, EventControllerKey, Label, + Application, Box as GBox, Button, ContentFit, EventControllerKey, Label, ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, }; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use std::{ cell::Cell, - env, fs, io::Write, - path::PathBuf, process::{Command, Stdio}, rc::Rc, }; @@ -186,36 +182,6 @@ fn do_copy(entry: &ClipEntry) { } } -// ---- PID file toggle (single-instance, matches breadbox pattern) ------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadclip.pid") -} - -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::() { - let alive = fs::read_to_string(format!("/proc/{}/comm", pid)) - .map(|s| s.trim() == "breadclip") - .unwrap_or(false); - if alive { - 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 --------------------------------------------------------------------- fn run_ui(entries: Vec) { @@ -228,21 +194,13 @@ fn run_ui(entries: Vec) { bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette())); // Full-screen transparent overlay; panel widget is positioned inside it. - let window = ApplicationWindow::builder().application(app).build(); - window.init_layer_shell(); - window.set_namespace(Some("breadclip")); - 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 window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip"); // ---- Position panel relative to the active window ---- // If a non-fullscreen window is focused, anchor the panel just below it. // Otherwise, centre the panel on screen. - let active_win = position::get_active_window(); - let monitor = position::get_focused_monitor(); + let active_win = bread_utils::hypr::active_window(); + let monitor = bread_utils::hypr::focused_monitor(); let panel = GBox::new(Orientation::Vertical, 0); panel.add_css_class("clip-panel"); @@ -257,7 +215,7 @@ fn run_ui(entries: Vec) { // Clamp horizontally so the panel never runs off the left/right // edge of the focused monitor. let clamped_left = win - .x + .x() .min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP) .max(mon_x + PANEL_GAP); @@ -265,12 +223,12 @@ fn run_ui(entries: Vec) { // isn't enough room underneath (e.g. a maximized/tiled window // with a text box near the bottom of the screen) — otherwise the // panel gets pushed off-screen and never becomes visible. - let space_below = (mon_y + mon_h) - (win.y + win.height + PANEL_GAP); - let space_above = win.y - mon_y - PANEL_GAP; + let space_below = (mon_y + mon_h) - (win.y() + win.height() + PANEL_GAP); + let space_above = win.y() - mon_y - PANEL_GAP; let top = if space_below >= PANEL_HEIGHT_ESTIMATE || space_below >= space_above { - win.y + win.height + PANEL_GAP + win.y() + win.height() + PANEL_GAP } else { - win.y - PANEL_GAP - PANEL_HEIGHT_ESTIMATE + win.y() - PANEL_GAP - PANEL_HEIGHT_ESTIMATE }; let clamped_top = top .min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP) @@ -337,7 +295,6 @@ fn run_ui(entries: Vec) { let close_all: Rc = Rc::new({ let w = window.clone(); move || { - cleanup_pid(); w.close(); } }); @@ -421,36 +378,11 @@ fn run_ui(entries: Vec) { glib::Propagation::Stop } Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - 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, - } - } + bread_utils::gtk_popup::select_next_visible(&list_k); glib::Propagation::Stop } Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - 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, - } - } + bread_utils::gtk_popup::select_prev_visible(&list_k); glib::Propagation::Stop } _ => glib::Propagation::Proceed, @@ -473,24 +405,9 @@ fn run_ui(entries: Vec) { // ---- Click outside panel → close (same pattern as breadbox) ---- { let close_outside = Rc::clone(&close_all); - let panel_ref = panel.clone(); - let win_ref = window.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - if let Some(b) = panel_ref.compute_bounds(&win_ref) { - let outside = x < b.x() as f64 - || x > (b.x() + b.width()) as f64 - || y < b.y() as f64 - || y > (b.y() + b.height()) as f64; - if outside { - close_outside(); - } - } - }); - window.add_controller(outside_click); + bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside()); } - window.connect_destroy(|_| cleanup_pid()); window.present(); search.grab_focus(); }); @@ -501,9 +418,17 @@ fn run_ui(entries: Vec) { // ---- Main ------------------------------------------------------------------- fn main() { - if !toggle_or_continue() { - return; - } + // Kept alive for the rest of `main` — dropping it releases the + // 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("breadclip") { + Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), + Ok(bread_utils::singleton::Toggle::KilledExisting) => return, + Err(e) => { + eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); + None + } + }; let entries = HistoryDb::open() .and_then(|db| db.list_entries(MAX_ENTRIES)) diff --git a/breadclip/src/position.rs b/breadclip/src/position.rs deleted file mode 100644 index f0a2193..0000000 --- a/breadclip/src/position.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::{env, io::{Read, Write}, os::unix::net::UnixStream}; - -#[allow(dead_code)] -pub struct WindowInfo { - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, -} - -#[allow(dead_code)] -pub struct MonitorInfo { - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, -} - -/// Query Hyprland for the currently active (focused) window via its IPC socket. -/// Returns `None` if the window is fullscreen or no window is focused. -pub fn get_active_window() -> Option { - let json = hyprctl_json("j/activewindow")?; - let v: serde_json::Value = serde_json::from_str(&json).ok()?; - - // Fullscreen windows should cause centred fallback positioning - if v["fullscreen"].as_i64().unwrap_or(0) != 0 { - return None; - } - // "class" is empty when no window is focused - if v["class"].as_str().unwrap_or("").is_empty() { - return None; - } - - let x = v["at"][0].as_i64()? as i32; - let y = v["at"][1].as_i64()? as i32; - let w = v["size"][0].as_i64()? as i32; - let h = v["size"][1].as_i64()? as i32; - Some(WindowInfo { x, y, width: w, height: h }) -} - -/// Query Hyprland for the focused monitor's dimensions. -pub fn get_focused_monitor() -> Option { - let json = hyprctl_json("j/monitors")?; - let v: serde_json::Value = serde_json::from_str(&json).ok()?; - let monitors = v.as_array()?; - let m = monitors - .iter() - .find(|m| m["focused"].as_bool().unwrap_or(false)) - .or_else(|| monitors.first())?; - Some(MonitorInfo { - x: m["x"].as_i64()? as i32, - y: m["y"].as_i64()? as i32, - width: m["width"].as_i64()? as i32, - height: m["height"].as_i64()? as i32, - }) -} - -/// Send a request to Hyprland's IPC socket and return the response string. -fn hyprctl_json(request: &str) -> Option { - let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; - let rt = env::var("XDG_RUNTIME_DIR").ok()?; - let socket = format!("{}/hypr/{}/.socket.sock", rt, sig); - - let mut stream = UnixStream::connect(&socket).ok()?; - stream.write_all(request.as_bytes()).ok()?; - stream.shutdown(std::net::Shutdown::Write).ok()?; - - let mut buf = String::new(); - stream.read_to_string(&mut buf).ok()?; - Some(buf) -}