markdown · 22372 bytes Raw Blame History

garclip Implementation Plan

Overview

A bespoke X11 clipboard manager for the gardesk suite. Implements the X11 selection protocol directly using x11rb without any third-party clipboard crates. Supports clipboard history, PRIMARY/CLIPBOARD selections, and integrates with the gardesk ecosystem.


X11 Clipboard Protocol (The Bespoke Implementation)

How X11 Clipboard Works

X11 uses a selection ownership model - the clipboard doesn't store data. Instead:

  1. When an app copies text, it becomes the selection owner
  2. When another app wants to paste, it sends a SelectionRequest to the owner
  3. The owner responds with SelectionNotify containing the data
  4. Problem: When the owner app closes, clipboard data is lost

Clipboard Manager's Role

A clipboard manager solves this by:

  1. Monitoring selection ownership changes
  2. Grabbing clipboard contents before the owner releases
  3. Becoming the new owner to persist the data
  4. Responding to selection requests from other apps

Key X11 Atoms

CLIPBOARD          - Standard copy/paste selection
PRIMARY            - Middle-click paste (highlighted text)
TARGETS            - Query supported data formats
UTF8_STRING        - UTF-8 encoded text
STRING             - Latin-1 text (fallback)
TEXT               - Generic text
ATOM               - List of atoms
TIMESTAMP          - Selection timestamp
MULTIPLE           - Multiple target request
INCR               - Incremental transfer (large data)
CLIPBOARD_MANAGER  - Clipboard manager registration
SAVE_TARGETS       - Request to save clipboard

X11 Event Flow

┌─────────────────────────────────────────────────────────────────┐
│                    CLIPBOARD MONITORING                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. App copies text → SetSelectionOwner(CLIPBOARD)              │
│                       ↓                                         │
│  2. garclip receives SelectionClear (lost ownership)            │
│     OR detects new owner via polling                            │
│                       ↓                                         │
│  3. garclip requests content: ConvertSelection(CLIPBOARD)       │
│                       ↓                                         │
│  4. Owner sends SelectionNotify with data                       │
│                       ↓                                         │
│  5. garclip stores in history                                   │
│                       ↓                                         │
│  6. When owner closes → garclip becomes new owner               │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    SERVING PASTE REQUESTS                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. App requests paste: ConvertSelection(CLIPBOARD, target)     │
│                       ↓                                         │
│  2. garclip receives SelectionRequest event                     │
│                       ↓                                         │
│  3. Check requested target (TARGETS, UTF8_STRING, etc.)         │
│                       ↓                                         │
│  4. Set property on requestor window with data                  │
│                       ↓                                         │
│  5. Send SelectionNotify to requestor                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Architecture

Project Structure

garclip/
├── Cargo.toml                    # Workspace definition
├── PLAN.md                       # This file
├── garclip/                      # Main daemon crate
│   ├── Cargo.toml
│   └── src/
│       ├── main.rs               # Entry point, CLI parsing
│       ├── lib.rs                # Library exports
│       ├── error.rs              # Error types (thiserror)
│       │
│       ├── x11/                  # X11 clipboard implementation
│       │   ├── mod.rs
│       │   ├── atoms.rs          # Atom interning and caching
│       │   ├── selection.rs      # Selection ownership management
│       │   └── transfer.rs       # Data transfer (request/response)
│       │
│       ├── clipboard/            # Clipboard logic
│       │   ├── mod.rs
│       │   ├── manager.rs        # Main clipboard manager
│       │   ├── entry.rs          # Clipboard entry type
│       │   └── history.rs        # History storage
│       │
│       ├── daemon/               # Daemon infrastructure
│       │   ├── mod.rs
│       │   └── state.rs          # Daemon state machine
│       │
│       ├── ipc/                  # IPC layer
│       │   ├── mod.rs
│       │   ├── protocol.rs       # Command/Response/Event types
│       │   ├── server.rs         # Unix socket server
│       │   └── client.rs         # Client helpers
│       │
│       └── config/               # Configuration
│           ├── mod.rs
│           └── types.rs          # Config structs
│
└── garclipctl/                   # CLI control tool
    ├── Cargo.toml
    └── src/
        └── main.rs               # CLI commands

Core Types

// clipboard/entry.rs
pub struct ClipboardEntry {
    pub id: u64,                          // Unique ID
    pub content: ClipboardContent,        // The actual data
    pub source: Option<String>,           // Source application (if known)
    pub timestamp: SystemTime,            // When captured
    pub pinned: bool,                      // Pinned entries persist
}

pub enum ClipboardContent {
    Text(String),                         // UTF-8 text
    // Future: Image, Html, Files, etc.
}

// clipboard/history.rs
pub struct ClipboardHistory {
    entries: VecDeque<ClipboardEntry>,
    max_entries: usize,
    next_id: u64,
}

impl ClipboardHistory {
    pub fn push(&mut self, content: ClipboardContent, source: Option<String>);
    pub fn get(&self, id: u64) -> Option<&ClipboardEntry>;
    pub fn current(&self) -> Option<&ClipboardEntry>;
    pub fn list(&self, limit: usize) -> Vec<&ClipboardEntry>;
    pub fn remove(&mut self, id: u64) -> bool;
    pub fn clear(&mut self);
    pub fn pin(&mut self, id: u64) -> bool;
    pub fn unpin(&mut self, id: u64) -> bool;
}

X11 Selection Manager

// x11/selection.rs
pub struct SelectionManager {
    conn: Arc<RustConnection>,
    window: Window,                       // Our window for selection ownership
    atoms: Atoms,                         // Cached atoms
    owned_selections: HashSet<Atom>,      // Currently owned selections
}

impl SelectionManager {
    pub fn new(conn: Arc<RustConnection>) -> Result<Self>;

    // Ownership
    pub fn claim_ownership(&mut self, selection: Atom) -> Result<()>;
    pub fn release_ownership(&mut self, selection: Atom) -> Result<()>;
    pub fn is_owner(&self, selection: Atom) -> Result<bool>;

    // Requesting data from current owner
    pub fn request_targets(&self, selection: Atom) -> Result<Vec<Atom>>;
    pub fn request_text(&self, selection: Atom) -> Result<Option<String>>;

    // Handling incoming requests (when we're the owner)
    pub fn handle_selection_request(&self, event: SelectionRequestEvent, data: &[u8]) -> Result<()>;
    pub fn handle_selection_clear(&mut self, event: SelectionClearEvent);
}

// x11/atoms.rs
pub struct Atoms {
    pub clipboard: Atom,
    pub primary: Atom,
    pub targets: Atom,
    pub utf8_string: Atom,
    pub string: Atom,
    pub text: Atom,
    pub atom: Atom,
    pub timestamp: Atom,
    pub multiple: Atom,
    pub incr: Atom,
    pub clipboard_manager: Atom,
    pub save_targets: Atom,
    // Property atoms
    pub garclip_data: Atom,               // Our property for storing data
}

impl Atoms {
    pub fn intern(conn: &RustConnection) -> Result<Self>;
}

IPC Protocol

// ipc/protocol.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum Command {
    // Clipboard operations
    Copy { text: String },                 // Programmatic copy
    Paste,                                 // Get current clipboard

    // History operations
    History {
        limit: Option<usize>,              // Max entries to return (default: 50)
        #[serde(default)]
        include_pinned: bool,              // Include pinned entries
    },
    Select { id: u64 },                    // Select entry from history (makes it current)
    Delete { id: u64 },                    // Delete entry from history
    Clear,                                 // Clear clipboard
    ClearHistory { keep_pinned: bool },    // Clear all history

    // Pin management
    Pin { id: u64 },                       // Pin an entry
    Unpin { id: u64 },                     // Unpin an entry
    ListPinned,                            // List pinned entries

    // Search
    Search {
        query: String,
        limit: Option<usize>,
    },

    // Daemon control
    Status,                                // Get daemon status
    Reload,                                // Reload configuration
    Quit,                                  // Shutdown daemon

    // Subscriptions
    Subscribe { events: Vec<String> },     // Subscribe to events
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
    ClipboardChanged {
        id: u64,
        preview: String,                   // First ~100 chars
        source: Option<String>,
    },
    HistoryCleared,
    EntryPinned { id: u64 },
    EntryUnpinned { id: u64 },
    EntryDeleted { id: u64 },
}

Configuration

# ~/.config/garclip/config.toml

[history]
max_entries = 1000                # Maximum history entries
persist = true                    # Persist history across restarts
persist_path = ""                 # Custom path (default: ~/.local/share/garclip/history.json)

[behavior]
watch_primary = true              # Also track PRIMARY selection (middle-click)
watch_clipboard = true            # Track CLIPBOARD selection (Ctrl+C)
deduplicate = true                # Don't add duplicate entries
ignore_empty = true               # Ignore empty clipboard
min_length = 1                    # Minimum text length to record
max_length = 1048576              # Maximum text length (1MB)

[filters]
# Regex patterns to ignore
ignore_patterns = [
    "^\\s*$",                     # Whitespace only
]
# Window classes to ignore
ignore_classes = [
    "keepassxc",                  # Password managers
    "1password",
]

[daemon]
socket_path = ""                  # Custom socket path (default: $XDG_RUNTIME_DIR/garclip.sock)
log_level = "info"                # Logging level

Implementation Phases

Phase 1: Foundation (Core X11 + Basic Daemon)

Goal: Get the daemon running and monitoring clipboard

  1. Project scaffolding

    • Create workspace structure
    • Set up Cargo.toml with dependencies
    • Basic error types
  2. X11 atom management (x11/atoms.rs)

    • Intern all required atoms
    • Atom caching
  3. Selection ownership (x11/selection.rs)

    • Create hidden window for selection
    • Claim/release ownership
    • Check current owner
  4. Basic clipboard read (x11/transfer.rs)

    • Request TARGETS from owner
    • Request UTF8_STRING/STRING data
    • Handle SelectionNotify response
  5. Daemon skeleton (daemon/state.rs)

    • X11 event loop
    • Detect clipboard changes
    • Log clipboard content

Deliverable: Daemon that prints clipboard changes to stdout

Phase 2: Clipboard Manager (Persistence + History)

Goal: Persist clipboard data and maintain history

  1. Clipboard entry types (clipboard/entry.rs)

    • ClipboardEntry struct
    • ClipboardContent enum
  2. History storage (clipboard/history.rs)

    • In-memory VecDeque
    • Push/get/list/remove operations
    • Deduplication
  3. Selection serving (x11/transfer.rs)

    • Handle SelectionRequest events
    • Respond with stored data
    • TARGETS response
  4. Become clipboard owner

    • Take ownership when original owner releases
    • Serve clipboard content to requestors
  5. Persistence (clipboard/history.rs)

    • Save history to JSON file
    • Load on startup

Deliverable: Daemon that persists clipboard across app closes

Phase 3: IPC Layer

Goal: Control daemon via Unix socket

  1. Protocol types (ipc/protocol.rs)

    • Command, Response, Event enums
    • Serde serialization
  2. IPC server (ipc/server.rs)

    • Unix socket listener
    • Accept connections
    • JSON message handling
  3. Command handlers (daemon/state.rs)

    • Implement all commands
    • Wire to clipboard manager
  4. Event subscriptions

    • Track subscribed clients
    • Broadcast events

Deliverable: Daemon controllable via JSON over socket

Phase 4: CLI Tool (garclipctl)

Goal: User-friendly CLI interface

  1. CLI structure

    • clap argument parsing
    • Subcommands for each operation
  2. Commands

    • garclipctl paste - Print current clipboard
    • garclipctl copy <text> - Copy text
    • garclipctl history - List history
    • garclipctl select <id> - Select from history
    • garclipctl clear - Clear clipboard
    • garclipctl search <query> - Search history
    • garclipctl status - Show daemon status
  3. Output formatting

    • Human-readable default
    • --json flag for scripting

Deliverable: Full CLI control tool

Phase 5: Configuration + Polish

Goal: Configurable and robust

  1. Configuration loading

    • Parse TOML config
    • Defaults for all values
    • Config reload via IPC
  2. Filtering

    • Ignore patterns
    • Ignore window classes
  3. PRIMARY selection

    • Monitor PRIMARY in addition to CLIPBOARD
    • Configurable
  4. Signal handling

    • SIGTERM graceful shutdown
    • SIGHUP config reload
  5. Logging

    • tracing integration
    • Log file support

Deliverable: Production-ready clipboard manager


Dependencies

[dependencies]
# X11
x11rb = { version = "0.13", features = ["allow-unsafe-code"] }

# Async runtime
tokio = { version = "1", features = ["full", "signal"] }

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"

# Error handling
thiserror = "2.0"
anyhow = "1.0"

# CLI
clap = { version = "4.5", features = ["derive"] }

# Utilities
dirs = "6.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
regex = "1.10"      # For ignore patterns

Key Implementation Details

Detecting Clipboard Changes

Two approaches:

  1. Polling (simpler, works reliably)

    loop {
        let owner = conn.get_selection_owner(atoms.clipboard)?.reply()?.owner;
        if owner != last_owner && owner != our_window {
            // New owner - request content
            request_clipboard_content()?;
            last_owner = owner;
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    
  2. XFixes extension (more efficient, event-driven)

    // Request SelectionNotify events
    xfixes::select_selection_input(
        &conn,
        our_window,
        atoms.clipboard,
        xfixes::SelectionEventMask::SET_SELECTION_OWNER,
    )?;
    

Recommendation: Start with polling (simpler), add XFixes later if needed.

Requesting Clipboard Content

async fn request_text(&self, selection: Atom) -> Result<Option<String>> {
    // 1. Request conversion to our property
    self.conn.convert_selection(
        self.window,
        selection,
        self.atoms.utf8_string,
        self.atoms.garclip_data,
        x11rb::CURRENT_TIME,
    )?;
    self.conn.flush()?;

    // 2. Wait for SelectionNotify event
    let event = self.wait_for_selection_notify(selection).await?;

    if event.property == x11rb::NONE {
        return Ok(None); // Conversion failed
    }

    // 3. Read property data
    let reply = self.conn.get_property(
        true,  // Delete after reading
        self.window,
        self.atoms.garclip_data,
        self.atoms.utf8_string,
        0,
        u32::MAX / 4,
    )?.reply()?;

    Ok(Some(String::from_utf8_lossy(&reply.value).into_owned()))
}

Serving Clipboard Requests

fn handle_selection_request(&self, event: SelectionRequestEvent, data: &str) -> Result<()> {
    let target = event.target;
    let property = if event.property == x11rb::NONE {
        event.target  // Old clients
    } else {
        event.property
    };

    if target == self.atoms.targets {
        // Respond with supported targets
        let targets = [
            self.atoms.targets,
            self.atoms.utf8_string,
            self.atoms.string,
            self.atoms.timestamp,
        ];
        self.conn.change_property32(
            PropMode::REPLACE,
            event.requestor,
            property,
            self.atoms.atom,
            &targets.iter().map(|a| a.resource_id()).collect::<Vec<_>>(),
        )?;
    } else if target == self.atoms.utf8_string || target == self.atoms.string {
        // Respond with text
        self.conn.change_property8(
            PropMode::REPLACE,
            event.requestor,
            property,
            target,
            data.as_bytes(),
        )?;
    } else {
        // Unsupported target - send failure
        property = x11rb::NONE;
    }

    // Send SelectionNotify
    let notify = SelectionNotifyEvent {
        response_type: SELECTION_NOTIFY_EVENT,
        sequence: 0,
        time: event.time,
        requestor: event.requestor,
        selection: event.selection,
        target: event.target,
        property,
    };
    self.conn.send_event(false, event.requestor, EventMask::NO_EVENT, notify)?;
    self.conn.flush()?;

    Ok(())
}

Integration Points

With gar (Window Manager)

  • Could query gar IPC for focused window class (for source tracking)
  • Future: Workspace-aware clipboard history

With garlaunch

  • garlaunch could have a clipboard history picker mode
  • Or garclip could have its own popup (using gartk)

Testing Strategy

  1. Unit tests

    • History operations
    • Config parsing
    • IPC protocol serialization
  2. Integration tests (Xephyr)

    Xephyr -br -ac -noreset -screen 1280x720 :1 &
    DISPLAY=:1 cargo run --bin garclip -- daemon
    DISPLAY=:1 garclipctl status
    
  3. Manual testing

    • Copy in various apps
    • Close source app, verify paste works
    • History persistence across daemon restart

Progress & TODO

Phase 1: Foundation (COMPLETE)

  • Project scaffolding (workspace, Cargo.toml)
  • Error types
  • X11 atom interning
  • Selection ownership management
  • Data transfer (request/response)
  • Clipboard entry types (text + images)
  • History storage with persistence
  • Clipboard manager
  • Configuration (TOML)
  • IPC protocol (JSON over Unix socket)
  • IPC server/client
  • Daemon state machine
  • CLI entry point
  • garclipctl control tool

Phase 2: Daemon Command Handler (COMPLETE)

  • Refactor client handler to use channels for daemon state access
  • Proper async message passing between IPC and daemon loop
  • Handle all commands through the daemon via CommandRequest + oneshot
  • Handle Quit command for graceful shutdown
  • Handle Subscribe for event streaming to clients

Phase 3: Selection Monitoring (COMPLETE)

  • XFixes extension for event-driven clipboard monitoring
  • Claim ownership when original owner releases
  • Reduce polling overhead

Phase 4: Filtering

  • Regex patterns to ignore content
  • Window class filtering (ignore password managers, etc.)
  • Configurable min/max content length enforcement

Phase 5: Signals & Lifecycle

  • SIGHUP for config reload
  • Graceful SIGTERM handling
  • PID file for single-instance enforcement

Phase 6: Integration

  • Query gar IPC for focused window class (source tracking)
  • Optional garlaunch integration for history picker UI
  • Systemd user service file

Open Questions

  1. Encryption - Should sensitive clipboard data be encrypted at rest?

    • Recommendation: Optional, not in initial implementation
  2. Popup UI - Should garclip have its own history picker UI, or rely on garlaunch?

    • Recommendation: Defer UI to later, CLI-first approach
View source
1 # garclip Implementation Plan
2
3 ## Overview
4
5 A bespoke X11 clipboard manager for the gardesk suite. Implements the X11 selection protocol directly using x11rb without any third-party clipboard crates. Supports clipboard history, PRIMARY/CLIPBOARD selections, and integrates with the gardesk ecosystem.
6
7 ---
8
9 ## X11 Clipboard Protocol (The Bespoke Implementation)
10
11 ### How X11 Clipboard Works
12
13 X11 uses a **selection ownership model** - the clipboard doesn't store data. Instead:
14
15 1. When an app copies text, it becomes the **selection owner**
16 2. When another app wants to paste, it sends a **SelectionRequest** to the owner
17 3. The owner responds with **SelectionNotify** containing the data
18 4. Problem: When the owner app closes, clipboard data is lost
19
20 ### Clipboard Manager's Role
21
22 A clipboard manager solves this by:
23 1. **Monitoring** selection ownership changes
24 2. **Grabbing** clipboard contents before the owner releases
25 3. **Becoming** the new owner to persist the data
26 4. **Responding** to selection requests from other apps
27
28 ### Key X11 Atoms
29
30 ```
31 CLIPBOARD - Standard copy/paste selection
32 PRIMARY - Middle-click paste (highlighted text)
33 TARGETS - Query supported data formats
34 UTF8_STRING - UTF-8 encoded text
35 STRING - Latin-1 text (fallback)
36 TEXT - Generic text
37 ATOM - List of atoms
38 TIMESTAMP - Selection timestamp
39 MULTIPLE - Multiple target request
40 INCR - Incremental transfer (large data)
41 CLIPBOARD_MANAGER - Clipboard manager registration
42 SAVE_TARGETS - Request to save clipboard
43 ```
44
45 ### X11 Event Flow
46
47 ```
48 ┌─────────────────────────────────────────────────────────────────┐
49 │ CLIPBOARD MONITORING │
50 ├─────────────────────────────────────────────────────────────────┤
51 │ │
52 │ 1. App copies text → SetSelectionOwner(CLIPBOARD) │
53 │ ↓ │
54 │ 2. garclip receives SelectionClear (lost ownership) │
55 │ OR detects new owner via polling │
56 │ ↓ │
57 │ 3. garclip requests content: ConvertSelection(CLIPBOARD) │
58 │ ↓ │
59 │ 4. Owner sends SelectionNotify with data │
60 │ ↓ │
61 │ 5. garclip stores in history │
62 │ ↓ │
63 │ 6. When owner closes → garclip becomes new owner │
64 │ │
65 └─────────────────────────────────────────────────────────────────┘
66
67 ┌─────────────────────────────────────────────────────────────────┐
68 │ SERVING PASTE REQUESTS │
69 ├─────────────────────────────────────────────────────────────────┤
70 │ │
71 │ 1. App requests paste: ConvertSelection(CLIPBOARD, target) │
72 │ ↓ │
73 │ 2. garclip receives SelectionRequest event │
74 │ ↓ │
75 │ 3. Check requested target (TARGETS, UTF8_STRING, etc.) │
76 │ ↓ │
77 │ 4. Set property on requestor window with data │
78 │ ↓ │
79 │ 5. Send SelectionNotify to requestor │
80 │ │
81 └─────────────────────────────────────────────────────────────────┘
82 ```
83
84 ---
85
86 ## Architecture
87
88 ### Project Structure
89
90 ```
91 garclip/
92 ├── Cargo.toml # Workspace definition
93 ├── PLAN.md # This file
94 ├── garclip/ # Main daemon crate
95 │ ├── Cargo.toml
96 │ └── src/
97 │ ├── main.rs # Entry point, CLI parsing
98 │ ├── lib.rs # Library exports
99 │ ├── error.rs # Error types (thiserror)
100 │ │
101 │ ├── x11/ # X11 clipboard implementation
102 │ │ ├── mod.rs
103 │ │ ├── atoms.rs # Atom interning and caching
104 │ │ ├── selection.rs # Selection ownership management
105 │ │ └── transfer.rs # Data transfer (request/response)
106 │ │
107 │ ├── clipboard/ # Clipboard logic
108 │ │ ├── mod.rs
109 │ │ ├── manager.rs # Main clipboard manager
110 │ │ ├── entry.rs # Clipboard entry type
111 │ │ └── history.rs # History storage
112 │ │
113 │ ├── daemon/ # Daemon infrastructure
114 │ │ ├── mod.rs
115 │ │ └── state.rs # Daemon state machine
116 │ │
117 │ ├── ipc/ # IPC layer
118 │ │ ├── mod.rs
119 │ │ ├── protocol.rs # Command/Response/Event types
120 │ │ ├── server.rs # Unix socket server
121 │ │ └── client.rs # Client helpers
122 │ │
123 │ └── config/ # Configuration
124 │ ├── mod.rs
125 │ └── types.rs # Config structs
126
127 └── garclipctl/ # CLI control tool
128 ├── Cargo.toml
129 └── src/
130 └── main.rs # CLI commands
131 ```
132
133 ### Core Types
134
135 ```rust
136 // clipboard/entry.rs
137 pub struct ClipboardEntry {
138 pub id: u64, // Unique ID
139 pub content: ClipboardContent, // The actual data
140 pub source: Option<String>, // Source application (if known)
141 pub timestamp: SystemTime, // When captured
142 pub pinned: bool, // Pinned entries persist
143 }
144
145 pub enum ClipboardContent {
146 Text(String), // UTF-8 text
147 // Future: Image, Html, Files, etc.
148 }
149
150 // clipboard/history.rs
151 pub struct ClipboardHistory {
152 entries: VecDeque<ClipboardEntry>,
153 max_entries: usize,
154 next_id: u64,
155 }
156
157 impl ClipboardHistory {
158 pub fn push(&mut self, content: ClipboardContent, source: Option<String>);
159 pub fn get(&self, id: u64) -> Option<&ClipboardEntry>;
160 pub fn current(&self) -> Option<&ClipboardEntry>;
161 pub fn list(&self, limit: usize) -> Vec<&ClipboardEntry>;
162 pub fn remove(&mut self, id: u64) -> bool;
163 pub fn clear(&mut self);
164 pub fn pin(&mut self, id: u64) -> bool;
165 pub fn unpin(&mut self, id: u64) -> bool;
166 }
167 ```
168
169 ### X11 Selection Manager
170
171 ```rust
172 // x11/selection.rs
173 pub struct SelectionManager {
174 conn: Arc<RustConnection>,
175 window: Window, // Our window for selection ownership
176 atoms: Atoms, // Cached atoms
177 owned_selections: HashSet<Atom>, // Currently owned selections
178 }
179
180 impl SelectionManager {
181 pub fn new(conn: Arc<RustConnection>) -> Result<Self>;
182
183 // Ownership
184 pub fn claim_ownership(&mut self, selection: Atom) -> Result<()>;
185 pub fn release_ownership(&mut self, selection: Atom) -> Result<()>;
186 pub fn is_owner(&self, selection: Atom) -> Result<bool>;
187
188 // Requesting data from current owner
189 pub fn request_targets(&self, selection: Atom) -> Result<Vec<Atom>>;
190 pub fn request_text(&self, selection: Atom) -> Result<Option<String>>;
191
192 // Handling incoming requests (when we're the owner)
193 pub fn handle_selection_request(&self, event: SelectionRequestEvent, data: &[u8]) -> Result<()>;
194 pub fn handle_selection_clear(&mut self, event: SelectionClearEvent);
195 }
196
197 // x11/atoms.rs
198 pub struct Atoms {
199 pub clipboard: Atom,
200 pub primary: Atom,
201 pub targets: Atom,
202 pub utf8_string: Atom,
203 pub string: Atom,
204 pub text: Atom,
205 pub atom: Atom,
206 pub timestamp: Atom,
207 pub multiple: Atom,
208 pub incr: Atom,
209 pub clipboard_manager: Atom,
210 pub save_targets: Atom,
211 // Property atoms
212 pub garclip_data: Atom, // Our property for storing data
213 }
214
215 impl Atoms {
216 pub fn intern(conn: &RustConnection) -> Result<Self>;
217 }
218 ```
219
220 ### IPC Protocol
221
222 ```rust
223 // ipc/protocol.rs
224
225 #[derive(Debug, Clone, Serialize, Deserialize)]
226 #[serde(tag = "command", rename_all = "snake_case")]
227 pub enum Command {
228 // Clipboard operations
229 Copy { text: String }, // Programmatic copy
230 Paste, // Get current clipboard
231
232 // History operations
233 History {
234 limit: Option<usize>, // Max entries to return (default: 50)
235 #[serde(default)]
236 include_pinned: bool, // Include pinned entries
237 },
238 Select { id: u64 }, // Select entry from history (makes it current)
239 Delete { id: u64 }, // Delete entry from history
240 Clear, // Clear clipboard
241 ClearHistory { keep_pinned: bool }, // Clear all history
242
243 // Pin management
244 Pin { id: u64 }, // Pin an entry
245 Unpin { id: u64 }, // Unpin an entry
246 ListPinned, // List pinned entries
247
248 // Search
249 Search {
250 query: String,
251 limit: Option<usize>,
252 },
253
254 // Daemon control
255 Status, // Get daemon status
256 Reload, // Reload configuration
257 Quit, // Shutdown daemon
258
259 // Subscriptions
260 Subscribe { events: Vec<String> }, // Subscribe to events
261 }
262
263 #[derive(Debug, Clone, Serialize, Deserialize)]
264 pub struct Response {
265 pub success: bool,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub data: Option<serde_json::Value>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub error: Option<String>,
270 }
271
272 #[derive(Debug, Clone, Serialize, Deserialize)]
273 #[serde(tag = "event", rename_all = "snake_case")]
274 pub enum Event {
275 ClipboardChanged {
276 id: u64,
277 preview: String, // First ~100 chars
278 source: Option<String>,
279 },
280 HistoryCleared,
281 EntryPinned { id: u64 },
282 EntryUnpinned { id: u64 },
283 EntryDeleted { id: u64 },
284 }
285 ```
286
287 ### Configuration
288
289 ```toml
290 # ~/.config/garclip/config.toml
291
292 [history]
293 max_entries = 1000 # Maximum history entries
294 persist = true # Persist history across restarts
295 persist_path = "" # Custom path (default: ~/.local/share/garclip/history.json)
296
297 [behavior]
298 watch_primary = true # Also track PRIMARY selection (middle-click)
299 watch_clipboard = true # Track CLIPBOARD selection (Ctrl+C)
300 deduplicate = true # Don't add duplicate entries
301 ignore_empty = true # Ignore empty clipboard
302 min_length = 1 # Minimum text length to record
303 max_length = 1048576 # Maximum text length (1MB)
304
305 [filters]
306 # Regex patterns to ignore
307 ignore_patterns = [
308 "^\\s*$", # Whitespace only
309 ]
310 # Window classes to ignore
311 ignore_classes = [
312 "keepassxc", # Password managers
313 "1password",
314 ]
315
316 [daemon]
317 socket_path = "" # Custom socket path (default: $XDG_RUNTIME_DIR/garclip.sock)
318 log_level = "info" # Logging level
319 ```
320
321 ---
322
323 ## Implementation Phases
324
325 ### Phase 1: Foundation (Core X11 + Basic Daemon)
326
327 **Goal**: Get the daemon running and monitoring clipboard
328
329 1. **Project scaffolding**
330 - Create workspace structure
331 - Set up Cargo.toml with dependencies
332 - Basic error types
333
334 2. **X11 atom management** (`x11/atoms.rs`)
335 - Intern all required atoms
336 - Atom caching
337
338 3. **Selection ownership** (`x11/selection.rs`)
339 - Create hidden window for selection
340 - Claim/release ownership
341 - Check current owner
342
343 4. **Basic clipboard read** (`x11/transfer.rs`)
344 - Request TARGETS from owner
345 - Request UTF8_STRING/STRING data
346 - Handle SelectionNotify response
347
348 5. **Daemon skeleton** (`daemon/state.rs`)
349 - X11 event loop
350 - Detect clipboard changes
351 - Log clipboard content
352
353 **Deliverable**: Daemon that prints clipboard changes to stdout
354
355 ### Phase 2: Clipboard Manager (Persistence + History)
356
357 **Goal**: Persist clipboard data and maintain history
358
359 1. **Clipboard entry types** (`clipboard/entry.rs`)
360 - ClipboardEntry struct
361 - ClipboardContent enum
362
363 2. **History storage** (`clipboard/history.rs`)
364 - In-memory VecDeque
365 - Push/get/list/remove operations
366 - Deduplication
367
368 3. **Selection serving** (`x11/transfer.rs`)
369 - Handle SelectionRequest events
370 - Respond with stored data
371 - TARGETS response
372
373 4. **Become clipboard owner**
374 - Take ownership when original owner releases
375 - Serve clipboard content to requestors
376
377 5. **Persistence** (`clipboard/history.rs`)
378 - Save history to JSON file
379 - Load on startup
380
381 **Deliverable**: Daemon that persists clipboard across app closes
382
383 ### Phase 3: IPC Layer
384
385 **Goal**: Control daemon via Unix socket
386
387 1. **Protocol types** (`ipc/protocol.rs`)
388 - Command, Response, Event enums
389 - Serde serialization
390
391 2. **IPC server** (`ipc/server.rs`)
392 - Unix socket listener
393 - Accept connections
394 - JSON message handling
395
396 3. **Command handlers** (`daemon/state.rs`)
397 - Implement all commands
398 - Wire to clipboard manager
399
400 4. **Event subscriptions**
401 - Track subscribed clients
402 - Broadcast events
403
404 **Deliverable**: Daemon controllable via JSON over socket
405
406 ### Phase 4: CLI Tool (garclipctl)
407
408 **Goal**: User-friendly CLI interface
409
410 1. **CLI structure**
411 - clap argument parsing
412 - Subcommands for each operation
413
414 2. **Commands**
415 - `garclipctl paste` - Print current clipboard
416 - `garclipctl copy <text>` - Copy text
417 - `garclipctl history` - List history
418 - `garclipctl select <id>` - Select from history
419 - `garclipctl clear` - Clear clipboard
420 - `garclipctl search <query>` - Search history
421 - `garclipctl status` - Show daemon status
422
423 3. **Output formatting**
424 - Human-readable default
425 - `--json` flag for scripting
426
427 **Deliverable**: Full CLI control tool
428
429 ### Phase 5: Configuration + Polish
430
431 **Goal**: Configurable and robust
432
433 1. **Configuration loading**
434 - Parse TOML config
435 - Defaults for all values
436 - Config reload via IPC
437
438 2. **Filtering**
439 - Ignore patterns
440 - Ignore window classes
441
442 3. **PRIMARY selection**
443 - Monitor PRIMARY in addition to CLIPBOARD
444 - Configurable
445
446 4. **Signal handling**
447 - SIGTERM graceful shutdown
448 - SIGHUP config reload
449
450 5. **Logging**
451 - tracing integration
452 - Log file support
453
454 **Deliverable**: Production-ready clipboard manager
455
456 ---
457
458 ## Dependencies
459
460 ```toml
461 [dependencies]
462 # X11
463 x11rb = { version = "0.13", features = ["allow-unsafe-code"] }
464
465 # Async runtime
466 tokio = { version = "1", features = ["full", "signal"] }
467
468 # Serialization
469 serde = { version = "1.0", features = ["derive"] }
470 serde_json = "1.0"
471 toml = "0.8"
472
473 # Error handling
474 thiserror = "2.0"
475 anyhow = "1.0"
476
477 # CLI
478 clap = { version = "4.5", features = ["derive"] }
479
480 # Utilities
481 dirs = "6.0"
482 tracing = "0.1"
483 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
484 regex = "1.10" # For ignore patterns
485 ```
486
487 ---
488
489 ## Key Implementation Details
490
491 ### Detecting Clipboard Changes
492
493 Two approaches:
494
495 1. **Polling** (simpler, works reliably)
496 ```rust
497 loop {
498 let owner = conn.get_selection_owner(atoms.clipboard)?.reply()?.owner;
499 if owner != last_owner && owner != our_window {
500 // New owner - request content
501 request_clipboard_content()?;
502 last_owner = owner;
503 }
504 tokio::time::sleep(Duration::from_millis(100)).await;
505 }
506 ```
507
508 2. **XFixes extension** (more efficient, event-driven)
509 ```rust
510 // Request SelectionNotify events
511 xfixes::select_selection_input(
512 &conn,
513 our_window,
514 atoms.clipboard,
515 xfixes::SelectionEventMask::SET_SELECTION_OWNER,
516 )?;
517 ```
518
519 **Recommendation**: Start with polling (simpler), add XFixes later if needed.
520
521 ### Requesting Clipboard Content
522
523 ```rust
524 async fn request_text(&self, selection: Atom) -> Result<Option<String>> {
525 // 1. Request conversion to our property
526 self.conn.convert_selection(
527 self.window,
528 selection,
529 self.atoms.utf8_string,
530 self.atoms.garclip_data,
531 x11rb::CURRENT_TIME,
532 )?;
533 self.conn.flush()?;
534
535 // 2. Wait for SelectionNotify event
536 let event = self.wait_for_selection_notify(selection).await?;
537
538 if event.property == x11rb::NONE {
539 return Ok(None); // Conversion failed
540 }
541
542 // 3. Read property data
543 let reply = self.conn.get_property(
544 true, // Delete after reading
545 self.window,
546 self.atoms.garclip_data,
547 self.atoms.utf8_string,
548 0,
549 u32::MAX / 4,
550 )?.reply()?;
551
552 Ok(Some(String::from_utf8_lossy(&reply.value).into_owned()))
553 }
554 ```
555
556 ### Serving Clipboard Requests
557
558 ```rust
559 fn handle_selection_request(&self, event: SelectionRequestEvent, data: &str) -> Result<()> {
560 let target = event.target;
561 let property = if event.property == x11rb::NONE {
562 event.target // Old clients
563 } else {
564 event.property
565 };
566
567 if target == self.atoms.targets {
568 // Respond with supported targets
569 let targets = [
570 self.atoms.targets,
571 self.atoms.utf8_string,
572 self.atoms.string,
573 self.atoms.timestamp,
574 ];
575 self.conn.change_property32(
576 PropMode::REPLACE,
577 event.requestor,
578 property,
579 self.atoms.atom,
580 &targets.iter().map(|a| a.resource_id()).collect::<Vec<_>>(),
581 )?;
582 } else if target == self.atoms.utf8_string || target == self.atoms.string {
583 // Respond with text
584 self.conn.change_property8(
585 PropMode::REPLACE,
586 event.requestor,
587 property,
588 target,
589 data.as_bytes(),
590 )?;
591 } else {
592 // Unsupported target - send failure
593 property = x11rb::NONE;
594 }
595
596 // Send SelectionNotify
597 let notify = SelectionNotifyEvent {
598 response_type: SELECTION_NOTIFY_EVENT,
599 sequence: 0,
600 time: event.time,
601 requestor: event.requestor,
602 selection: event.selection,
603 target: event.target,
604 property,
605 };
606 self.conn.send_event(false, event.requestor, EventMask::NO_EVENT, notify)?;
607 self.conn.flush()?;
608
609 Ok(())
610 }
611 ```
612
613 ---
614
615 ## Integration Points
616
617 ### With gar (Window Manager)
618
619 - Could query gar IPC for focused window class (for source tracking)
620 - Future: Workspace-aware clipboard history
621
622 ### With garlaunch
623
624 - garlaunch could have a clipboard history picker mode
625 - Or garclip could have its own popup (using gartk)
626
627 ---
628
629 ## Testing Strategy
630
631 1. **Unit tests**
632 - History operations
633 - Config parsing
634 - IPC protocol serialization
635
636 2. **Integration tests (Xephyr)**
637 ```bash
638 Xephyr -br -ac -noreset -screen 1280x720 :1 &
639 DISPLAY=:1 cargo run --bin garclip -- daemon
640 DISPLAY=:1 garclipctl status
641 ```
642
643 3. **Manual testing**
644 - Copy in various apps
645 - Close source app, verify paste works
646 - History persistence across daemon restart
647
648 ---
649
650 ## Progress & TODO
651
652 ### Phase 1: Foundation (COMPLETE)
653 - [x] Project scaffolding (workspace, Cargo.toml)
654 - [x] Error types
655 - [x] X11 atom interning
656 - [x] Selection ownership management
657 - [x] Data transfer (request/response)
658 - [x] Clipboard entry types (text + images)
659 - [x] History storage with persistence
660 - [x] Clipboard manager
661 - [x] Configuration (TOML)
662 - [x] IPC protocol (JSON over Unix socket)
663 - [x] IPC server/client
664 - [x] Daemon state machine
665 - [x] CLI entry point
666 - [x] garclipctl control tool
667
668 ### Phase 2: Daemon Command Handler (COMPLETE)
669 - [x] Refactor client handler to use channels for daemon state access
670 - [x] Proper async message passing between IPC and daemon loop
671 - [x] Handle all commands through the daemon via CommandRequest + oneshot
672 - [x] Handle Quit command for graceful shutdown
673 - [x] Handle Subscribe for event streaming to clients
674
675 ### Phase 3: Selection Monitoring (COMPLETE)
676 - [x] XFixes extension for event-driven clipboard monitoring
677 - [x] Claim ownership when original owner releases
678 - [x] Reduce polling overhead
679
680 ### Phase 4: Filtering
681 - [ ] Regex patterns to ignore content
682 - [ ] Window class filtering (ignore password managers, etc.)
683 - [ ] Configurable min/max content length enforcement
684
685 ### Phase 5: Signals & Lifecycle
686 - [ ] SIGHUP for config reload
687 - [ ] Graceful SIGTERM handling
688 - [ ] PID file for single-instance enforcement
689
690 ### Phase 6: Integration
691 - [ ] Query gar IPC for focused window class (source tracking)
692 - [ ] Optional garlaunch integration for history picker UI
693 - [ ] Systemd user service file
694
695 ---
696
697 ## Open Questions
698
699 1. **Encryption** - Should sensitive clipboard data be encrypted at rest?
700 - Recommendation: Optional, not in initial implementation
701
702 2. **Popup UI** - Should garclip have its own history picker UI, or rely on garlaunch?
703 - Recommendation: Defer UI to later, CLI-first approach