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",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-utils"
|
||||||
|
version = "0.2.3"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"gtk4",
|
||||||
|
"gtk4-layer-shell",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadclip"
|
name = "breadclip"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-theme",
|
"bread-theme",
|
||||||
|
"bread-utils",
|
||||||
"breadclip-core",
|
"breadclip-core",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"gtk4-layer-shell",
|
"gtk4-layer-shell",
|
||||||
|
|
@ -61,6 +73,7 @@ dependencies = [
|
||||||
name = "breadclip-core"
|
name = "breadclip-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bread-utils",
|
||||||
"dirs",
|
"dirs",
|
||||||
"hex",
|
"hex",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,5 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
dirs = "5"
|
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 {
|
pub fn data_dir() -> PathBuf {
|
||||||
dirs::data_local_dir()
|
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
// — PathBuf/std::fs never expand `~`, so on the rare box where `dirs`
|
||||||
.join("breadclip")
|
// 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 {
|
fn unix_now() -> i64 {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
breadclip-core = { path = "../breadclip-core" }
|
breadclip-core = { path = "../breadclip-core" }
|
||||||
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"] }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
mod css;
|
mod css;
|
||||||
mod position;
|
|
||||||
|
|
||||||
use breadclip_core::{ClipEntry, HistoryDb};
|
use breadclip_core::{ClipEntry, HistoryDb};
|
||||||
use bread_theme::{load_palette};
|
use bread_theme::{load_palette};
|
||||||
|
|
@ -7,16 +6,13 @@ use gtk4::{
|
||||||
glib,
|
glib,
|
||||||
pango::EllipsizeMode,
|
pango::EllipsizeMode,
|
||||||
prelude::*,
|
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,
|
ListBox, Orientation, Picture, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
||||||
};
|
};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
use std::{
|
use std::{
|
||||||
cell::Cell,
|
cell::Cell,
|
||||||
env,
|
|
||||||
fs,
|
fs,
|
||||||
io::Write,
|
io::Write,
|
||||||
path::PathBuf,
|
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
rc::Rc,
|
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 ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn run_ui(entries: Vec<ClipEntry>) {
|
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()));
|
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
||||||
|
|
||||||
// Full-screen transparent overlay; panel widget is positioned inside it.
|
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||||
let window = ApplicationWindow::builder().application(app).build();
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadclip");
|
||||||
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);
|
|
||||||
|
|
||||||
// ---- Position panel relative to the active window ----
|
// ---- Position panel relative to the active window ----
|
||||||
// If a non-fullscreen window is focused, anchor the panel just below it.
|
// If a non-fullscreen window is focused, anchor the panel just below it.
|
||||||
// Otherwise, centre the panel on screen.
|
// Otherwise, centre the panel on screen.
|
||||||
let active_win = position::get_active_window();
|
let active_win = bread_utils::hypr::active_window();
|
||||||
let monitor = position::get_focused_monitor();
|
let monitor = bread_utils::hypr::focused_monitor();
|
||||||
|
|
||||||
let panel = GBox::new(Orientation::Vertical, 0);
|
let panel = GBox::new(Orientation::Vertical, 0);
|
||||||
panel.add_css_class("clip-panel");
|
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
|
// Clamp horizontally so the panel never runs off the left/right
|
||||||
// edge of the focused monitor.
|
// edge of the focused monitor.
|
||||||
let clamped_left = win
|
let clamped_left = win
|
||||||
.x
|
.x()
|
||||||
.min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP)
|
.min(mon_x + mon_w - PANEL_WIDTH - PANEL_GAP)
|
||||||
.max(mon_x + 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
|
// isn't enough room underneath (e.g. a maximized/tiled window
|
||||||
// with a text box near the bottom of the screen) — otherwise the
|
// with a text box near the bottom of the screen) — otherwise the
|
||||||
// panel gets pushed off-screen and never becomes visible.
|
// panel gets pushed off-screen and never becomes visible.
|
||||||
let space_below = (mon_y + mon_h) - (win.y + win.height + 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 space_above = win.y() - mon_y - PANEL_GAP;
|
||||||
let top = if space_below >= PANEL_HEIGHT_ESTIMATE || space_below >= space_above {
|
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 {
|
} else {
|
||||||
win.y - PANEL_GAP - PANEL_HEIGHT_ESTIMATE
|
win.y() - PANEL_GAP - PANEL_HEIGHT_ESTIMATE
|
||||||
};
|
};
|
||||||
let clamped_top = top
|
let clamped_top = top
|
||||||
.min(mon_y + mon_h - PANEL_HEIGHT_ESTIMATE - PANEL_GAP)
|
.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 close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
move || {
|
move || {
|
||||||
cleanup_pid();
|
|
||||||
w.close();
|
w.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -421,36 +378,11 @@ fn run_ui(entries: Vec<ClipEntry>) {
|
||||||
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,
|
||||||
|
|
@ -473,24 +405,9 @@ fn run_ui(entries: Vec<ClipEntry>) {
|
||||||
// ---- Click outside panel → close (same pattern as breadbox) ----
|
// ---- Click outside panel → close (same pattern as breadbox) ----
|
||||||
{
|
{
|
||||||
let close_outside = Rc::clone(&close_all);
|
let close_outside = Rc::clone(&close_all);
|
||||||
let panel_ref = panel.clone();
|
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, 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) = 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.connect_destroy(|_| cleanup_pid());
|
|
||||||
window.present();
|
window.present();
|
||||||
search.grab_focus();
|
search.grab_focus();
|
||||||
});
|
});
|
||||||
|
|
@ -501,9 +418,17 @@ fn run_ui(entries: Vec<ClipEntry>) {
|
||||||
// ---- 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("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()
|
let entries = HistoryDb::open()
|
||||||
.and_then(|db| db.list_entries(MAX_ENTRIES))
|
.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