Skip to content

Commit

Permalink
Add --one-shot mode for copy command
Browse files Browse the repository at this point in the history
The default mode to copy data requires to assemble bytes in order to
follow the simple protocol, which is not easy for the simple command
line use case. "--one-shot" is added to make richclip work like
traditional clipboard utilities.
  • Loading branch information
beeender committed Jan 13, 2025
1 parent 0da0981 commit 9913ef8
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 16 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
- name: Run tests
run: cargo test --verbose
- name: Run bats test
run: bats test/bats/x
run: |
bats test/bats/x
bats test/bats/loopback
shell: bash
env:
BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }}
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGES

Next Release

- Add `--one-shot` mode to allow copying data as it is from stdin.

v0.2.1

- The first public release.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,23 @@ Options:
❯ richclip copy --help
Receive and copy data to the clipboard
Usage: richclip copy [OPTIONS]
Options:
-p, --primary Use the 'primary' clipboard
--foreground Run in foreground
-h, --help Print help
-p, --primary Use the 'primary' clipboard
--foreground Run in foreground
--one-shot Enable one-shot mode, anything received from stdin will be copied as it is
-t, --type [<mime-type>] Specify mime-type(s) to copy and implicitly enable one-shot copy mode
-h, --help Print help
```

#### Bulk mode copy

By default, `richclip` receives data in bulk mode. In this mode, multiple formats
of data can be copied to the clipboard with multiple mime-types. This allows
the paste side to choose the favorite format of data to use.

The data to be copied to the clipboard needs to follow a simple protocol which
is described as below. A simpler transfer mode will be supported in the future
for copying single type content like other clipboard utilities.
is described as below.

| Item | Bytes | Content |
|------------------| :------- | :------------------ |
Expand All @@ -84,3 +92,18 @@ for copying single type content like other clipboard utilities.
- Every section starts with the section type, `M` (mime-type) or `C` (content).
- Before `C` section, there must be one or more `M` section to indicate the data type.
- Section length will be parsed as big-endian uint32 number.

#### One-shot mode copy

This is the traditional way to copy data like other clipboard utilities. The
mime-types can be specified through the command line, and all data received
through `stdin` will be copied as it is.

```bash
# Copy "TestData" to the clipboard, with default mime-type
echo TestData | richclip copy --one-shot


# Copy "<body>Haha</body>" to the clipboard, as "text/html" and "HTML"
echo "<body>Haha</body>" | richclip copy --type "text/html" --type "HTML"
```
28 changes: 26 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ mod clipboard;
mod protocol;

use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use clap::{ArgAction, Args, Parser, Subcommand};
use daemonize::Daemonize;
use std::env;
use std::fs::File;
Expand Down Expand Up @@ -48,6 +48,13 @@ struct CopyArgs {
/// Run in foreground
#[arg(long = "foreground", num_args = 0)]
foreground: bool,
/// Enable one-shot mode, anything received from stdin will be copied as it is
#[arg(long = "one-shot", num_args = 0)]
oneshot: bool,
/// Specify mime-type(s) to copy and implicitly enable one-shot copy mode
#[arg(long = "type", short = 't', num_args = 0..=1,
value_name = "mime-type", default_missing_value = "TEXT", action = ArgAction::Append )]
mime_types: Option<Vec<String>>,
/// For testing X INCR mode
#[arg(
long = "chunk-size",
Expand Down Expand Up @@ -141,8 +148,25 @@ fn main() -> Result<()> {
}

fn do_copy(copy_args: &CopyArgs) -> Result<()> {
const TEXT_TYPES: [&str; 5] = [
"text/plain",
"text/plain;charset=utf-8",
"TEXT",
"STRING",
"UTF8_STRING",
];
let stdin = stdin();
let source_data = protocol::receive_data(&stdin).context("Failed to read data from stdin")?;
let oneshot = copy_args.oneshot || copy_args.mime_types.is_some();

let source_data = if oneshot {
let mime_types = match &copy_args.mime_types {
Some(types) => types.to_vec(),
_ => TEXT_TYPES.iter().map(|s| s.to_string()).collect(),
};
protocol::receive_data_oneshot(&stdin, &mime_types)?
} else {
protocol::receive_data_bulk(&stdin)?
};

// Move to background. We fork our process and leave the child running in the background, while
// exiting in the parent. We also replace stdin/stdout with /dev/null so the stdout file
Expand Down
3 changes: 2 additions & 1 deletion src/protocol/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
mod recv;
mod source_data;

pub use recv::receive_data;
pub use recv::receive_data_bulk;
pub use recv::receive_data_oneshot;
#[allow(unused_imports)]
pub use recv::PROTOCAL_VER;
pub use source_data::SourceData;
62 changes: 57 additions & 5 deletions src/protocol/recv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use super::source_data::SourceDataItem;
/// - Every section starts with the section type, `M` (mime-type) or `C` (content).
/// - Before `C` section, there must be one or more `M` section to indicate the data type.
/// - Section length will be parsed as big-endian uint32 number.
pub fn receive_data(mut reader: impl Read) -> Result<Vec<SourceDataItem>> {
pub fn receive_data_bulk(mut reader: impl Read) -> Result<Vec<SourceDataItem>> {
// Check magic header
let mut magic = [0u8; 4];
reader
Expand Down Expand Up @@ -86,6 +86,32 @@ pub fn receive_data(mut reader: impl Read) -> Result<Vec<SourceDataItem>> {
Ok(ret)
}

pub fn receive_data_oneshot(
mut reader: impl Read,
mime_types: &[String],
) -> Result<Vec<SourceDataItem>> {
let mut content = Vec::<u8>::new();
let n = reader
.read_to_end(&mut content)
.context("Failed to read content")?;
log::debug!("Read {n} bytes for oneshot mode");

let filtered: Vec<String> = mime_types
.iter()
.filter(|t| !t.is_empty())
.map(String::clone)
.collect();
if filtered.is_empty() {
bail!("All given mime_types are empty");
}

let ret = vec![SourceDataItem {
mime_type: filtered,
content: content.into(),
}];
Ok(ret)
}

fn read_mime_types(reader: &mut impl Read) -> Result<String> {
let mut size_buf = [0u8; 4];
reader
Expand Down Expand Up @@ -173,15 +199,15 @@ mod tests {
}

#[test]
fn test_receive_data() {
fn test_receive_data_bulk() {
// Wrong magic
let buf = [0x02, 0x09, 0x02, 0x14, PROTOCAL_VER, b'M'];
let r = receive_data(&mut &buf[..]);
let r = receive_data_bulk(&mut &buf[..]);
assert!(r.is_err());

// Wrong protoal version
let buf = [0x20, 0x09, 0x02, 0x14, 99, b'M'];
let r = receive_data(&mut &buf[..]);
let r = receive_data_bulk(&mut &buf[..]);
assert!(r.is_err());

// correct
Expand All @@ -194,7 +220,7 @@ mod tests {
b'M', 0, 0, 0, 9, b't', b'e', b'x', b't', b'/', b'h', b't', b'm', b'l',
b'C', 0, 0, 0, 3, b'B', b'A', b'D',
];
let r = receive_data(&mut &buf[..]).unwrap();
let r = receive_data_bulk(&mut &buf[..]).unwrap();
assert_eq!(r.len(), 2);

let data1 = &r[0];
Expand All @@ -207,4 +233,30 @@ mod tests {
assert_eq!(data2.mime_type[0], "text/html");
assert_eq!(data2.content.as_slice(), b"BAD");
}

#[test]
fn test_receive_data_oneshot() {
let buf = [b'G', b'O', b'O', b'D'];

// With one mime-type
let r = receive_data_oneshot(&mut &buf[..], &["text".to_string()]).unwrap();
assert_eq!(r.len(), 1);
assert_eq!(r[0].mime_type.len(), 1);
assert_eq!(r[0].mime_type[0], "text");

// With two mime-types
let r = receive_data_oneshot(
&mut &buf[..],
&["text".to_string(), "text/plain".to_string()],
)
.unwrap();
assert_eq!(r.len(), 1);
assert_eq!(r[0].mime_type.len(), 2);
assert_eq!(r[0].mime_type[0], "text");
assert_eq!(r[0].mime_type[1], "text/plain");

// With zero mime-type
let r = receive_data_oneshot(&mut &buf[..], &["".to_string()]);
assert!(r.is_err())
}
}
4 changes: 2 additions & 2 deletions src/protocol/source_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ impl SourceData for Vec<SourceDataItem> {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::receive_data;
use crate::protocol::receive_data_bulk;
use crate::protocol::PROTOCAL_VER;

#[test]
Expand All @@ -70,7 +70,7 @@ mod tests {
b'M', 0, 0, 0, 9, b't', b'e', b'x', b't', b'/', b'h', b't', b'm', b'l',
b'C', 0, 0, 0, 3, b'B', b'A', b'D',
];
let r = receive_data(&mut &buf[..]).unwrap();
let r = receive_data_bulk(&mut &buf[..]).unwrap();

let (result, content) = r.content_by_mime_type("text/plain");
assert!(result);
Expand Down
68 changes: 68 additions & 0 deletions test/bats/loopback/loopback.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bats

# Tests with own copy & paste. Ideally this would run on all platforms

bats_require_minimum_version 1.5.0

ROOT_DIR=$(realpath "$BATS_TEST_DIRNAME/../../..")
RICHCLIP="$ROOT_DIR/target/debug/richclip"

XVFB_PID=""

setup_file() {
unset WAYLAND_DISPLAY
export DISPLAY=":42"
# Start a headless X server for testing
Xvfb $DISPLAY 3>&- &
XVFB_PID=$!
sleep 1
run -0 cargo build
}

teardown_file() {
if [ -n "$XVFB_PID" ]; then
kill "$XVFB_PID"
fi
}

teardown() {
killall -w richclip > /dev/null || echo ""
}

@test "one-shot mode: no '--type'" {
# one-shot no type
echo "TestDaTA" | $RICHCLIP copy --one-shot

run -0 "$RICHCLIP" paste -l
[ "${lines[0]}" = "TARGETS" ]
[ "${lines[1]}" = "text/plain" ]
[ "${lines[2]}" = "text/plain;charset=utf-8" ]
[ "${lines[3]}" = "TEXT" ]
[ "${lines[4]}" = "STRING" ]
[ "${lines[5]}" = "UTF8_STRING" ]

run -0 "$RICHCLIP" paste
[ "$output" = "TestDaTA" ]
}

@test "one-shot mode: with '--type'" {
# one-shot, one type
echo "TestDaTA" | $RICHCLIP copy --type TypE

run -0 "$RICHCLIP" paste -l
[ "${lines[0]}" = "TARGETS" ]
[ "${lines[1]}" = "TypE" ]

run -0 "$RICHCLIP" paste -t TypE
[ "$output" = "TestDaTA" ]

# one-shot, multi types
echo "TestDaTA" | $RICHCLIP copy --type TypE --type Faker
run -0 "$RICHCLIP" paste -l
[ "${lines[0]}" = "TARGETS" ]
[ "${lines[1]}" = "TypE" ]
[ "${lines[2]}" = "Faker" ]

run -0 "$RICHCLIP" paste -t Faker
[ "$output" = "TestDaTA" ]
}

0 comments on commit 9913ef8

Please sign in to comment.