Strata/strata-daemon/src/main.rs
edu4rdshl 350d647953 Initial commit
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 03:02:34 -05:00

363 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod clipboard;
mod config;
mod db;
mod dbus_service;
use std::sync::Arc;
use anyhow::{Context, Result};
use base64::Engine as _;
use dbus_service::{Limits, StrataManager, SubmitRequest, THUMB_PX};
use tokio::sync::{mpsc, Mutex};
use tracing_subscriber::EnvFilter;
use wl_clipboard_rs::paste::{get_contents, ClipboardType, MimeType, Seat};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
tracing::info!("Strata daemon starting");
let cfg = config::Config::new();
let db = Arc::new(db::Db::open(&cfg.db_path).context("Opening database")?);
let focused_app = Arc::new(Mutex::new(String::new()));
let limits = Limits::with_defaults();
limits
.max_history
.store(cfg.max_history, std::sync::atomic::Ordering::Relaxed);
// -----------------------------------------------------------------------
// Channels
// -----------------------------------------------------------------------
// Wayland-monitor path: daemon reads clipboard bytes itself.
let (clip_tx, mut clip_rx) = mpsc::unbounded_channel::<clipboard::monitor::ClipboardChange>();
// GJS submit path: GJS reads via Meta.Selection, sends bytes over D-Bus.
let (submit_tx, mut submit_rx) = mpsc::unbounded_channel::<SubmitRequest>();
// D-Bus Shutdown method path.
let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>();
// -----------------------------------------------------------------------
// D-Bus service
// -----------------------------------------------------------------------
let manager = StrataManager {
db: db.clone(),
limits: limits.clone(),
focused_app: focused_app.clone(),
submit_tx,
shutdown_tx,
};
let dbus_conn = zbus::connection::Builder::session()?
.serve_at("/org/gnome/Strata", manager)?
.build()
.await
.context("Building D-Bus connection")?;
// Request the well-known name with ReplaceExisting so a new instance always
// wins the race against the outgoing instance during extension reload.
dbus_conn
.request_name_with_flags(
"org.gnome.Strata",
zbus::fdo::RequestNameFlags::ReplaceExisting.into(),
)
.await
.context("Acquiring D-Bus name org.gnome.Strata")?;
tracing::info!("D-Bus service registered as org.gnome.Strata");
// -----------------------------------------------------------------------
// Clipboard monitor (Wayland protocols - optional, soft-fail on GNOME)
// GJS provides clipboard content via SubmitItem when this is unavailable.
// -----------------------------------------------------------------------
match clipboard::monitor::spawn(clip_tx) {
Ok(()) => tracing::info!("Wayland clipboard monitor started"),
Err(_) => tracing::info!(
"Wayland clipboard monitor unavailable, using GJS Meta.Selection path (expected on GNOME)"
),
}
// -----------------------------------------------------------------------
// Clipboard processing tasks
// -----------------------------------------------------------------------
let db_clone = db.clone();
let conn_clone = dbus_conn.clone();
let limits_clone = limits.clone();
// Wayland-monitor path.
tokio::spawn(async move {
while let Some(change) = clip_rx.recv().await {
if let Err(e) =
process_change(&change.mime_types, &db_clone, &conn_clone, &limits_clone).await
{
tracing::warn!("Error processing Wayland clipboard change: {e:#}");
}
}
});
let db_submit = db.clone();
let conn_submit = dbus_conn.clone();
let limits_submit = limits.clone();
// GJS submit path.
tokio::spawn(async move {
while let Some(req) = submit_rx.recv().await {
if let Err(e) = process_bytes(
req.mime_type,
req.bytes,
&db_submit,
&conn_submit,
&limits_submit,
)
.await
{
tracing::warn!("Error processing submitted clipboard item: {e:#}");
}
}
});
// -----------------------------------------------------------------------
// Graceful shutdown on SIGTERM / SIGINT
// -----------------------------------------------------------------------
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
tokio::select! {
_ = sigterm.recv() => { tracing::info!("Received SIGTERM, shutting down"); }
_ = sigint.recv() => { tracing::info!("Received SIGINT, shutting down"); }
_ = shutdown_rx.recv() => { tracing::info!("Shutdown via D-Bus, shutting down"); }
}
Ok(())
}
/// Select the best MIME type to store from a list of offered types.
///
/// Priority: images > plain text > rich text (html/rtf) > file URIs.
///
/// Plain text is intentionally ranked above text/html and text/rtf so that
/// when an application (e.g. a code editor or web browser) advertises both a
/// rich-text variant and a plain-text variant, we capture the plain text. This
/// avoids storing syntax-highlighted HTML markup instead of source code.
/// Rich-text types are still captured when no plain-text alternative is offered.
fn pick_mime(mimes: &[String]) -> Option<&str> {
const PREFERRED: &[&str] = &[
// Raster images (size-capped at MAX_IMAGE_BYTES).
"image/png",
"image/jpeg",
"image/jpg",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/avif",
"image/x-icon",
"image/svg+xml",
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
// Ranked above rich-text so editors that expose both text/plain and
// text/html give us the raw source, not styled markup.
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"STRING",
"TEXT",
// Rich text -- only reached when no plain-text offer is available.
"text/html",
"text/rtf",
"application/rtf",
"text/markdown",
// File-manager copy/cut payloads (URI list, not bytes).
"x-special/gnome-copied-files",
"x-special/nautilus-clipboard",
"application/x-kde-cutselection",
"text/uri-list",
];
// Allowlist only: accepting arbitrary mimes would force us to read
// potentially huge payloads (1 GB binaries, etc.) before we could
// size-check them. Unknown mimes are silently dropped.
PREFERRED
.iter()
.find(|&&want| mimes.iter().any(|m| m == want))
.copied()
}
async fn process_change(
mime_types: &[String],
db: &Arc<db::Db>,
conn: &zbus::Connection,
limits: &Limits,
) -> Result<()> {
// Password managers (KeePassXC, KeePass, some Bitwarden builds) mark
// copied secrets with this mime hint. Skip the change entirely so we
// never store the password.
if mime_types.iter().any(|m| m == "x-kde-passwordManagerHint") {
tracing::debug!("Skipping clipboard change marked as sensitive");
return Ok(());
}
let mime = match pick_mime(mime_types) {
Some(m) => m.to_string(),
None => {
tracing::debug!("No usable MIME type in {:?}", mime_types);
return Ok(());
}
};
tracing::debug!("Processing Wayland clipboard change, MIME: {}", mime);
let mime_clone = mime.clone();
let (raw_bytes, actual_mime) =
tokio::task::spawn_blocking(move || read_clipboard_bytes(&mime_clone))
.await?
.context("Reading clipboard content")?;
if raw_bytes.is_empty() {
return Ok(());
}
process_bytes(actual_mime, raw_bytes, db, conn, limits).await
}
/// Core storage path - shared by the Wayland-monitor and GJS-submit paths.
async fn process_bytes(
mime: String,
raw_bytes: Vec<u8>,
db: &Arc<db::Db>,
conn: &zbus::Connection,
limits: &Limits,
) -> Result<()> {
if raw_bytes.is_empty() {
return Ok(());
}
let is_image = mime.starts_with("image/");
// Enforce size limits
let max = if is_image {
limits.max_image_bytes()
} else {
limits.max_text_bytes()
};
if raw_bytes.len() > max {
tracing::debug!(
"Clipboard item too large ({} bytes, limit {}), skipping",
raw_bytes.len(),
max
);
return Ok(());
}
// Compute content hash for deduplication
let hash = blake3::hash(&raw_bytes).to_hex().to_string();
// For images: generate a thumbnail in spawn_blocking; store original as blob, thumbnail separately.
// For text: store as UTF-8 string.
// The `preview` string emitted with the ItemAdded signal is now ALWAYS a text excerpt;
// image thumbnails are fetched lazily by clients via GetThumbnail(id) to keep the
// signal payload small (and consistent with paginated GetHistory results).
let (content_text, content_blob, thumbnail_blob, preview) = if is_image {
let raw_for_thumb = raw_bytes.clone();
let thumb_result =
tokio::task::spawn_blocking(move || make_thumbnail(&raw_for_thumb, THUMB_PX)).await?;
let thumb_bytes = match thumb_result {
Ok((bytes, _b64)) => bytes,
Err(e) => {
tracing::warn!("Image thumbnail generation failed: {e}");
return Ok(());
}
};
(None, Some(raw_bytes), Some(thumb_bytes), String::new())
} else {
let text = String::from_utf8_lossy(&raw_bytes).into_owned();
let preview: String = text.chars().take(120).collect();
(Some(text), None, None, preview)
};
let db_clone = db.clone();
let mime_clone = mime.clone();
let hash_clone = hash.clone();
let (id, is_new) = tokio::task::spawn_blocking(move || {
db_clone.upsert_item(
&mime_clone,
content_text.as_deref(),
content_blob.as_deref(),
thumbnail_blob.as_deref(),
&hash_clone,
None, // source_app is set via SetFocusedApp
)
})
.await?
.context("Upserting clipboard item")?;
// Prune old items to stay within max_history limit.
// Only needed when a genuinely new item was just inserted.
let pruned_ids: Vec<String> = if is_new {
let db_prune = db.clone();
let max_history = limits.max_history();
tokio::task::spawn_blocking(move || db_prune.prune(max_history))
.await?
.context("Pruning history")?
} else {
Vec::new()
};
if is_new {
let iface = conn
.object_server()
.interface::<_, StrataManager>("/org/gnome/Strata")
.await
.context("Getting StrataManager interface ref")?;
StrataManager::item_added(iface.signal_context(), &id, &mime, &preview)
.await
.context("Emitting ItemAdded signal")?;
for pid in &pruned_ids {
if let Err(e) = StrataManager::item_deleted(iface.signal_context(), pid).await {
tracing::warn!("Emitting ItemDeleted for pruned id={}: {}", pid, e);
}
}
tracing::debug!("Stored new clipboard item id={} mime={}", id, mime);
} else {
tracing::debug!("Promoted duplicate clipboard item id={}", id);
}
Ok(())
}
/// Read clipboard content using wl-clipboard-rs (opens its own Wayland connection).
/// Returns `(bytes, actual_mime_type)`.
fn read_clipboard_bytes(mime: &str) -> Result<(Vec<u8>, String)> {
use std::io::Read;
let (mut reader, actual_mime) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
MimeType::Specific(mime),
)
.map_err(|e| anyhow::anyhow!("wl-clipboard-rs read error: {e:?}"))?;
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
Ok((buf, actual_mime))
}
/// Generate a PNG thumbnail (≤`max_px`×`max_px`) from raw image bytes.
/// Returns `(thumbnail_bytes, base64_preview)`.
fn make_thumbnail(raw: &[u8], max_px: u32) -> Result<(Vec<u8>, String)> {
let img = image::load_from_memory(raw).context("Decoding image")?;
let thumb = img.thumbnail(max_px, max_px);
let mut out = Vec::new();
thumb
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.context("Encoding thumbnail")?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
Ok((out, b64))
}