Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ itertools = "0.14"
alive_lock_file = "0.2"
regex = "1"
open = "5"
notify = "7.0"

[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic"
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ The goal is to make a simple yet fast clipboard history, with a focus on UX, rap

There is a quick settings popup when you right click the icon.

## Usage

### Keyboard Shortcut (Recommended)

You can set up a global keyboard shortcut to toggle the clipboard manager from anywhere:

1. Open **COSMIC Settings** → **Keyboard** → **Custom Shortcuts**
2. Click **Add Custom Shortcut**
3. Fill in the details:
- **Name**: Clipboard Manager
- **Command**: `cosmic-ext-applet-clipboard-manager --toggle`
- **Shortcut**: Press **Super+V** (or your preferred key combination)

Once set up, press your configured shortcut to open the clipboard manager, use arrow keys to navigate, and press Enter to select an item.

### Command Line

The applet supports the following command-line options:

```sh
cosmic-ext-applet-clipboard-manager --toggle # Toggle the clipboard popup
cosmic-ext-applet-clipboard-manager --help # Show help message
cosmic-ext-applet-clipboard-manager --version # Show version information
```

## Install

Use the flatpak version in the cosmic store.
Expand Down
73 changes: 44 additions & 29 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::message::{AppMsg, ConfigMsg, ContextMenuMsg};
use crate::navigation::EventMsg;
use crate::utils::task_message;
use crate::view::SCROLLABLE_ID;
use crate::{clipboard, clipboard_watcher, config, navigation};
use crate::{clipboard, clipboard_watcher, config, ipc, navigation};

use cosmic::{cosmic_config, iced_runtime};
use std::sync::atomic::{self};
Expand All @@ -53,6 +53,7 @@ pub struct AppState<Db: DbTrait> {
pub qr_code: Option<Result<qr_code::Data, ()>>,
last_quit: Option<(i64, PopupKind)>,
pub preferred_mime_types_regex: Vec<Regex>,
last_signal_content: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -181,9 +182,11 @@ impl<Db: DbTrait> AppState<Db> {

self.last_quit = Some((Utc::now().timestamp_millis(), popup.kind));

if self.config.horizontal {
// Popup now always uses layer surface for reliable keyboard focus
if popup.kind == PopupKind::Popup {
destroy_layer_surface(popup.id)
} else {
// QuickSettings still uses popup
destroy_popup(popup.id)
}
} else {
Expand All @@ -209,34 +212,28 @@ impl<Db: DbTrait> AppState<Db> {

match kind {
PopupKind::Popup => {
if self.config.horizontal {
get_layer_surface(SctkLayerSurfaceSettings {
id: new_id,
keyboard_interactivity: KeyboardInteractivity::OnDemand,
anchor: layer_surface::Anchor::BOTTOM
// Always use layer surface for external toggles to ensure keyboard focus
// Popups don't reliably receive keyboard focus when opened programmatically
get_layer_surface(SctkLayerSurfaceSettings {
id: new_id,
keyboard_interactivity: KeyboardInteractivity::Exclusive,
anchor: if self.config.horizontal {
layer_surface::Anchor::BOTTOM
| layer_surface::Anchor::LEFT
| layer_surface::Anchor::RIGHT,
namespace: "clipboard manager".into(),
size: Some((None, Some(350))),
size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
..Default::default()
})
} else {
let mut popup_settings = self.core.applet.get_popup_settings(
self.core.main_window_id().unwrap(),
new_id,
None,
None,
None,
);

popup_settings.positioner.size_limits = Limits::NONE
.min_width(300.0)
.max_width(400.0)
.min_height(200.0)
.max_height(500.0);
get_popup(popup_settings)
}
| layer_surface::Anchor::RIGHT
} else {
// Position at top-right for vertical layout
layer_surface::Anchor::TOP | layer_surface::Anchor::RIGHT
},
namespace: "clipboard manager".into(),
size: if self.config.horizontal {
Some((None, Some(350)))
} else {
Some((Some(400), Some(530)))
},
size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
..Default::default()
})
}
PopupKind::QuickSettings => {
let mut popup_settings = self.core.applet.get_popup_settings(
Expand Down Expand Up @@ -301,6 +298,7 @@ impl<Db: DbTrait + 'static> cosmic::Application for AppState<Db> {
})
.collect(),
config,
last_signal_content: None,
};

#[cfg(debug_assertions)]
Expand Down Expand Up @@ -336,6 +334,22 @@ impl<Db: DbTrait + 'static> cosmic::Application for AppState<Db> {
}

match message {
AppMsg::CheckSignalFile => {
// Only check signal file if XDG_RUNTIME_DIR is set
if let Some(signal_file) = ipc::get_signal_file_path() {
if let Ok(content) = std::fs::read_to_string(&signal_file) {
if self.last_signal_content.as_ref() != Some(&content) {
self.last_signal_content = Some(content);

// Clear last_quit for external toggles to ensure it works
self.last_quit = None;

// Toggle the popup using the existing toggle_popup function
return self.toggle_popup(PopupKind::Popup);
}
}
}
}
AppMsg::ChangeConfig(config) => {
if config.private_mode != self.config.private_mode {
PRIVATE_MODE.store(config.private_mode, atomic::Ordering::Relaxed);
Expand Down Expand Up @@ -570,6 +584,7 @@ impl<Db: DbTrait + 'static> cosmic::Application for AppState<Db> {
config::sub(),
navigation::sub().map(AppMsg::Navigation),
db_sub().map(AppMsg::Db),
ipc::signal_file_watcher(),
];

if !self.clipboard_state.is_error() {
Expand Down
84 changes: 84 additions & 0 deletions src/ipc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! IPC mechanism for external toggle functionality via file-based signaling.
//!
//! This module provides a simple IPC mechanism using a signal file in XDG_RUNTIME_DIR.
//! When the `--toggle` command is invoked, it writes a timestamp to the signal file.
//! The timestamp ensures the file content changes on each toggle, which triggers
//! the file watcher to notify the app to toggle the popup.

use std::path::PathBuf;
use std::fs;
use std::time::SystemTime;

use cosmic::iced_futures::Subscription;
use crate::message::AppMsg;

/// Get the signal file path for IPC toggle functionality.
/// Returns None if XDG_RUNTIME_DIR is not set.
pub fn get_signal_file_path() -> Option<PathBuf> {
std::env::var("XDG_RUNTIME_DIR").ok().map(|runtime_dir| {
PathBuf::from(runtime_dir).join("cosmic-clipboard-manager-toggle")
})
}

/// Send a toggle signal by writing a timestamp to the signal file.
/// The timestamp ensures the file content changes, triggering the file watcher.
pub fn send_toggle_signal() -> std::io::Result<()> {
let signal_file = get_signal_file_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"XDG_RUNTIME_DIR not set - cannot send toggle signal"
)
})?;

// Write current timestamp to signal file.
// The timestamp value itself isn't used, but ensures the file content changes
// on each toggle, which triggers the file watcher to detect the change.
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
.as_millis()
.to_string();

fs::write(&signal_file, timestamp)?;
Ok(())
}

/// Create a file watcher subscription that monitors the signal file for changes.
/// When the file is modified, it sends a CheckSignalFile message to toggle the popup.
pub fn signal_file_watcher() -> Subscription<AppMsg> {
use notify::{Watcher, RecursiveMode, Event};
use futures::stream;

Subscription::run_with_id(
"signal_file_watcher",
stream::unfold((), |_| async {
// Only set up watcher if XDG_RUNTIME_DIR is set
let signal_file = match get_signal_file_path() {
Some(path) => path,
None => {
// If XDG_RUNTIME_DIR is not set, just wait forever
futures::future::pending::<()>().await;
return Some((AppMsg::CheckSignalFile, ()));
}
};

let (tx, mut rx) = tokio::sync::mpsc::channel(1);

let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
if res.is_ok() {
let _ = tx.blocking_send(());
}
}).ok()?;

// Watch the signal file's parent directory since the file might not exist yet
if let Some(parent) = signal_file.parent() {
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);

// Wait for file change notification
rx.recv().await;
}

Some((AppMsg::CheckSignalFile, ()))
})
)
}
Loading