This commit is contained in:
nora 2025-09-07 01:06:14 +02:00
commit 1164ed1e3e
8 changed files with 4696 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

4339
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "clippyboard"
version = "0.1.0"
edition = "2024"
[dependencies]
ciborium = "0.2.2"
dirs = "6.0.0"
eframe = "0.32.2"
egui_extras = { version = "0.32.2", features = ["image"] }
eyre = "0.6.12"
serde = "1.0.219"
wl-clipboard-rs = "0.9.2"

22
shell.nix Normal file
View file

@ -0,0 +1,22 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell rec {
buildInputs = with pkgs; [
expat
fontconfig
freetype
freetype.dev
libGL
pkg-config
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
wayland
libxkbcommon
];
LD_LIBRARY_PATH =
builtins.foldl' (a: b: "${a}:${b}/lib") "${pkgs.vulkan-loader}/lib" buildInputs;
}

131
src/daemon.rs Normal file
View file

@ -0,0 +1,131 @@
use super::Entry;
use super::MAX_ENTRY_SIZE;
use eyre::Context;
use std::io::BufWriter;
use std::io::Read;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicU64;
use std::time::SystemTime;
use wl_clipboard_rs::paste::ClipboardType;
use wl_clipboard_rs::paste::MimeType;
use wl_clipboard_rs::paste::Seat;
pub(crate) fn handle_peer(
mut peer: UnixStream,
next_id: Arc<AtomicU64>,
items: Arc<Mutex<Vec<Entry>>>,
) -> eyre::Result<()> {
let mut request = [0; 1];
peer.read_exact(&mut request)
.wrap_err("failed to read message type")?;
match request[0] {
super::MESSAGE_STORE => {
let mime_types =
wl_clipboard_rs::paste::get_mime_types(ClipboardType::Regular, Seat::Unspecified)
.wrap_err("getting mime types")?;
let time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let Some(mime) = ["text/plain", "image/png"]
.iter()
.find(|mime| mime_types.contains(**mime))
else {
eprintln!("WARN: No supported mime type found. Found mime types: {mime_types:?}");
return Ok(());
};
let (data_readear, _) = wl_clipboard_rs::paste::get_contents(
ClipboardType::Regular,
Seat::Unspecified,
MimeType::Specific(mime),
)
.wrap_err("getting contents")?;
let mut data_reader = data_readear.take(MAX_ENTRY_SIZE);
let mut data = Vec::new();
data_reader
.read_to_end(&mut data)
.wrap_err("reading content data")?;
items.lock().unwrap().push(Entry {
id: next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
mime: mime.to_string(),
data,
created_time: u64::try_from(time.as_millis()).unwrap(),
});
println!("INFO Successfully stored clipboard value of mime type {mime}");
}
super::MESSAGE_READ => {
let items = items.lock().unwrap();
ciborium::into_writer(items.as_slice(), BufWriter::new(peer))
.wrap_err("writing items to socket")?;
}
super::MESSAGE_COPY => {
let mut id = [0; 8];
peer.read_exact(&mut id).wrap_err("failed to read id")?;
let id = u64::from_le_bytes(id);
let items = items.lock().unwrap();
let Some(idx) = items.iter().position(|item| item.id == id) else {
return Ok(());
};
let entry = items[idx].clone();
// select
let mut opts = wl_clipboard_rs::copy::Options::new();
opts.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
let result = wl_clipboard_rs::copy::copy(
opts,
wl_clipboard_rs::copy::Source::Bytes(entry.data.into_boxed_slice()),
wl_clipboard_rs::copy::MimeType::Specific(entry.mime),
);
if let Err(err) = result {
println!("WARNING: Copy failed: {err:?}");
}
}
_ => {}
};
Ok(())
}
pub fn main(socket_path: &PathBuf) -> eyre::Result<()> {
let _ = std::fs::remove_file(&socket_path); // lol
let socket = UnixListener::bind(&socket_path)
.wrap_err_with(|| format!("binding path {}", socket_path.display()))?;
let next_id = Arc::new(AtomicU64::new(0));
let items = Arc::new(Mutex::new(Vec::<Entry>::new()));
println!("INFO: Listening on {}", socket_path.display());
for peer in socket.incoming() {
match peer {
Ok(peer) => {
let next_id = next_id.clone();
let items = items.clone();
std::thread::spawn(move || {
let result = handle_peer(peer, next_id, items);
if let Err(err) = result {
eprintln!("ERROR: Error handling peer: {err:?}");
}
});
}
Err(err) => {
eprintln!("ERROR: Error accepting peer: {err}");
}
}
}
Ok(())
}

139
src/display.rs Normal file
View file

@ -0,0 +1,139 @@
use eframe::egui;
use eyre::Context;
use crate::MESSAGE_READ;
use super::MESSAGE_COPY;
use std::{io::{BufReader, Write}, os::unix::net::UnixStream, path::Path, time::Instant};
use super::Entry;
pub(crate) struct App {
pub(crate) items: Vec<Entry>,
pub(crate) selected_idx: usize,
pub(crate) socket: UnixStream,
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.input(|i| {
if i.key_pressed(egui::Key::J) || i.key_pressed(egui::Key::ArrowDown) {
if self.selected_idx + 1 != self.items.len() {
self.selected_idx += 1;
}
}
if i.key_pressed(egui::Key::K) || i.key_pressed(egui::Key::ArrowUp) {
self.selected_idx = self.selected_idx.saturating_sub(1);
}
if i.key_pressed(egui::Key::Enter)
&& let Some(item) = self.items.get(self.selected_idx)
{
let _ = self.socket.write_all(&[MESSAGE_COPY]);
let _ = self.socket.write_all(&item.id.to_le_bytes());
std::process::exit(0);
}
});
ui.heading("clippyboard");
egui::SidePanel::left("selection_panel")
.default_width(400.0)
.show_inside(ui, |ui| {
ui.heading("History");
for (idx, item) in self.items.iter().enumerate() {
let mut frame = egui::Frame::new();
if self.selected_idx == idx {
frame = frame.stroke(egui::Stroke::new(1.0, egui::Color32::PURPLE));
}
frame.show(ui, |ui| match item.mime.as_str() {
"text/plain" => {
ui.label(str::from_utf8(&item.data).unwrap_or("<invalid UTF-8>"));
}
"image/png" => {
ui.label("<image>");
}
_ => {
ui.label("<unsupported mime type>");
}
});
}
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.heading("Detail");
let Some(item) = &self.items.get(self.selected_idx) else {
return;
};
match item.mime.as_str() {
"text/plain" => {
ui.label(str::from_utf8(&item.data).unwrap_or("<invalid UTF-8>"));
}
"image/png" => {
ui.image(egui::ImageSource::Bytes {
uri: format!("bytes://{}", item.id).into(),
bytes: item.data.clone().into(),
});
}
_ => {
ui.label("<unsupported mime type>");
}
}
});
});
}
}
pub fn main(socket_path: &Path) -> eyre::Result<()> {
let mut socket = UnixStream::connect(&socket_path).wrap_err_with(|| {
format!(
"connecting to socket at {}. is the daemon running?",
socket_path.display()
)
})?;
socket
.write_all(&[MESSAGE_READ])
.wrap_err("writing request type")?;
println!("INFO: Reading clipboard history from socket");
let start = Instant::now();
let items =
ciborium::from_reader(BufReader::new(socket)).wrap_err("reading items from socket")?;
println!(
"INFO: Read clipboard history from socket in {:?}",
start.elapsed()
);
// heh. good design.
let socket = UnixStream::connect(&socket_path).wrap_err_with(|| {
format!(
"connecting to socket at {}. is the daemon running?",
socket_path.display()
)
})?;
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([500.0, 500.0]),
..Default::default()
};
eframe::run_native(
"clippyboard",
options,
Box::new(|cc| {
egui_extras::install_image_loaders(&cc.egui_ctx);
Ok(Box::new(App {
items,
selected_idx: 0,
socket,
}))
}),
)
.map_err(|err| eyre::eyre!(err.to_string()))
.wrap_err("running GUI")?;
Ok(())
}

50
src/main.rs Normal file
View file

@ -0,0 +1,50 @@
mod display;
mod daemon;
use std::{io::Write, os::unix::net::UnixStream};
use eyre::{Context, OptionExt, bail};
const MAX_ENTRY_SIZE: u64 = 100_000_000;
#[derive(Clone, serde::Deserialize, serde::Serialize)]
struct Entry {
id: u64,
mime: String,
data: Vec<u8>,
created_time: u64,
}
const MESSAGE_STORE: u8 = 0;
const MESSAGE_READ: u8 = 1;
/// Argument: One u64-bit LE value, the ID
const MESSAGE_COPY: u8 = 2;
fn main() -> eyre::Result<()> {
let Some(mode) = std::env::args().nth(1) else {
bail!("missing mode");
};
let socket_path = dirs::runtime_dir()
.ok_or_eyre("missing XDG_RUNTIME_DIR")?
.join("clippyboard.sock");
match mode.as_str() {
"daemon" => daemon::main(&socket_path)?,
"store" => {
let mut socket = UnixStream::connect(&socket_path).wrap_err_with(|| {
format!(
"connecting to socket at {}. is the daemon running?",
socket_path.display()
)
})?;
socket
.write_all(&[MESSAGE_STORE])
.wrap_err("writing request type")?;
}
"display" => display::main(&socket_path)?,
_ => panic!("invalid mode"),
}
Ok(())
}