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:
parent
8097d1944c
commit
e16c1b461c
6 changed files with 47 additions and 172 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue