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

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.
This commit is contained in:
Breadway 2026-07-17 09:24:38 +08:00
parent 8097d1944c
commit e16c1b461c
6 changed files with 47 additions and 172 deletions

13
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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 {

View file

@ -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"

View file

@ -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::<u32>() {
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<ClipEntry>) {
@ -228,21 +194,13 @@ fn run_ui(entries: Vec<ClipEntry>) {
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<ClipEntry>) {
// 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<ClipEntry>) {
// 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<ClipEntry>) {
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
move || {
cleanup_pid();
w.close();
}
});
@ -421,36 +378,11 @@ fn run_ui(entries: Vec<ClipEntry>) {
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<ClipEntry>) {
// ---- 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<ClipEntry>) {
// ---- 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))

View file

@ -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<WindowInfo> {
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<MonitorInfo> {
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<String> {
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)
}