Squashed 'suckless/clipmenu/' content from commit e0ec3bc6

git-subtree-dir: suckless/clipmenu
git-subtree-split: e0ec3bc6608ecc70ed17e8da76e27bf90b1e8e90
This commit is contained in:
Alejandro Guerrero 2026-09-27 17:52:02 +02:00
commit cb051ead76
32 changed files with 5760 additions and 0 deletions

11
.clang-format Normal file
View file

@ -0,0 +1,11 @@
BinPackArguments: true
BreakStringLiterals: false
ColumnLimit: 80
IndentCaseLabels: true
IndentPPDirectives: BeforeHash
IndentWidth: 4
InsertBraces: true
PenaltyBreakComment: 0
SortIncludes: true
SpaceBeforeParens: ControlStatementsExceptForEachMacros
UseTab: Never

28
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,28 @@
jobs:
build_and_test:
name: CI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
- run: sudo apt-get update
- uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: gcc-10 clang clang-format clang-tidy cppcheck xvfb xsel libxfixes-dev libx11-dev
version: 1.0
- run: gcc --version
- run: make clean analyse
- run: make tests
- run: make integration_tests
env:
NO_PID_NAMESPACE: 1
on:
push:
pull_request:
workflow_dispatch:

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
src/clipmenud
src/clipctl
src/clipdel
src/clipdelmenu
src/clipmenu
src/clipserve
tests/test
tests/test_store
*.o

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2014-present Christopher Down
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

135
Makefile Normal file
View file

@ -0,0 +1,135 @@
CFLAGS := -std=gnu11 -O2 -Wall -Wextra -Wshadow -Wpointer-arith \
-Wcast-align -Wmissing-prototypes -Wstrict-overflow -Wformat=2 \
-Wwrite-strings -Warray-bounds -Wstrict-prototypes \
-Wno-maybe-uninitialized \
-Werror $(CFLAGS)
CPPFLAGS += -I/usr/X11R6/include -L/usr/X11R6/lib
LDLIBS += -lX11 -lXfixes
PREFIX ?= /usr/local
bindir := $(PREFIX)/bin
datarootdir := $(PREFIX)/share
mandir := $(datarootdir)/man
systemd_user_dir = $(DESTDIR)$(PREFIX)/lib/systemd/user
debug_cflags := -D_FORTIFY_SOURCE=2 -fsanitize=leak -fsanitize=address \
-fsanitize=undefined -Og -ggdb -fno-omit-frame-pointer \
-fstack-protector-strong
c_files := $(wildcard src/*.c)
h_files := $(wildcard src/*.h)
libs := $(filter $(c_files:.c=.o), $(h_files:.h=.o))
man1_files = clipctl.1 clipdel.1 clipdelmenu.1 clipmenu.1 clipmenud.1 clipserve.1
man5_files = clipmenu.conf.5
bins := clipctl clipmenud clipdel clipdelmenu clipserve clipmenu
all: $(addprefix src/,$(bins))
src/%: src/%.c $(libs)
$(CC) $(CFLAGS) $(CPPFLAGS) $^ $(LDFLAGS) $(LDLIBS) -o $@
src/%.o: src/%.c src/%.h
$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
debug: all
debug: CFLAGS+=$(debug_cflags)
install: all
@for f in $(man1_files); do \
install -Dp -m 644 man/$$f $(DESTDIR)$(mandir)/man1/$$f; \
done
@for f in $(man5_files); do \
install -Dp -m 644 man/$$f $(DESTDIR)$(mandir)/man5/$$f; \
done
mkdir -p $(DESTDIR)$(bindir)/
install -pt $(DESTDIR)$(bindir)/ $(addprefix src/,$(bins))
mkdir -p $(systemd_user_dir)
sed 's|@bindir@|$(bindir)|g' init/clipmenud.service.in > $(systemd_user_dir)/clipmenud.service
uninstall:
rm -f $(addprefix $(DESTDIR)$(PREFIX)/bin/,$(bins))
rm -f "$(DESTDIR)${PREFIX}/lib/systemd/user/clipmenud.service"
rm -f $(addprefix $(DESTDIR)$(mandir)/man1/,$(man1_files))
rm -f $(addprefix $(DESTDIR)$(mandir)/man5/,$(man5_files))
clean:
rm -f src/*.o src/*~ $(addprefix src/,$(bins)) tests/test_store
clang_supports_unsafe_buffer_usage := $(shell clang -x c -c /dev/null -o /dev/null -Werror -Wunsafe-buffer-usage > /dev/null 2>&1; echo $$?)
ifeq ($(clang_supports_unsafe_buffer_usage),0)
extra_clang_flags := -Wno-unsafe-buffer-usage -Wno-missing-include-dirs \
-Wno-unknown-warning-option \
-Wno-unused-command-line-argument \
-Wno-error
else
extra_clang_flags := -Wno-missing-include-dirs \
-Wno-unknown-warning-option \
-Wno-unused-command-line-argument \
-Wno-error
endif
c_analyse_targets := $(c_files:%=%-analyse)
h_analyse_targets := $(h_files:%=%-analyse)
analyse: CFLAGS+=$(debug_cflags)
analyse: cppcheck $(c_analyse_targets) $(h_analyse_targets)
$(c_analyse_targets): %-analyse: %
# -W options here are not clang compatible, so out of generic CFLAGS
gcc $< -o /dev/null -c \
-std=gnu99 -Ofast -fwhole-program -Wall -Wextra \
-Wlogical-op -Wduplicated-cond \
-fanalyzer $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(LDLIBS)
clang $< -o /dev/null -c -std=gnu99 -Ofast \
$(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(LDLIBS) \
$(extra_clang_flags)
$(MAKE) $*-shared-analyse
$(h_analyse_targets): %-analyse:
$(MAKE) $*-shared-analyse
%-shared-analyse: %
# clang-analyzer-unix.Malloc does not understand _drop_()
clang-tidy $< --quiet -checks=-clang-analyzer-unix.Malloc -- -std=gnu99
clang-format --dry-run --Werror $<
# --suppress=missingIncludeSystem:
#
# Without this there's a bunch of noise from cppcheck from the system headers
# themselves.
#
# --suppress=unusedFunction:
#
# cppcheck does not understand _drop_ and marks those as unused. This is
# already well checked by Clang/GCC, just leave it to them.
#
# --suppress=unmatchedSuppression:
#
# We run both locally and on CI, so there may be some suppressions that
# depending on version do not match in one version but do on another.
#
# --suppress=unusedStructMember
#
# Structs are used across translation units and cppcheck gets this wrong.
cppcheck: $(c_files) $(h_files)
cppcheck $(c_files) $(h_files) --std=c99 --quiet --inline-suppr --force \
--enable=all \
--suppress=missingIncludeSystem \
--suppress=unusedFunction \
--suppress=unmatchedSuppression \
--suppress=checkersReport \
--suppress=unusedStructMember \
--check-level=exhaustive \
--max-ctu-depth=10 --error-exitcode=1
tests: tests/test_store
tests/test_store
integration_tests:
tests/x_integration_tests
tests/test_store: tests/test_store.c src/store.o src/util.o
$(CC) $(CFLAGS) $(CPPFLAGS) -I./src -o $@ $^ $(LDLIBS)
.PHONY: all debug install uninstall clean analyse tests integration_tests \
cppcheck

97
README.md Normal file
View file

@ -0,0 +1,97 @@
clipmenu is a simple clipboard manager using [dmenu][], [rofi][] or similar.
# Demo
![Demo](https://cloud.githubusercontent.com/assets/660663/24079784/6f76da94-0c88-11e7-8251-40b1f02ebf3c.gif)
# Usage
## clipmenud
Start `clipmenud`, then run `clipmenu` to select something to put on the
clipboard. For systemd users, a user service called `clipmenud` is packaged as
part of the project.
If you start X via `startx`/`xinit` and use the systemd user service, make sure
`$DISPLAY` is set so clipmenud knows which X server to use. Most distributions
include scripts in `/etc/X11/xinit/xinitrc.d/` for this, so if they are present
you can usually just source them in your `~/.xinitrc`. Alternatively, before
launching the clipmenud service, run:
systemctl --user import-environment DISPLAY
## clipmenu
You may wish to bind a shortcut in your window manager to launch `clipmenu`.
All args passed to clipmenu are transparently dispatched to dmenu. That is, if
you usually call dmenu with args to set colours and other properties, you can
invoke clipmenu in exactly the same way to get the same effect, like so:
clipmenu -i -fn Terminus:size=8 -nb '#002b36' -nf '#839496' -sb '#073642' -sf '#93a1a1'
By default, clipmenu also adds dmenu-compatible defaults such as `-p` and `-l`.
If that gets in the way for a custom launcher, set `launcher_pass_dmenu_args` to
0 in `clipmenu.conf`.
For a full list of configuration options (and their environment variable
equivalents), please see `man clipmenu.conf`.
There is also `clipdel` to delete clips, `clipdelmenu` for interactive deletion,
and `clipctl` to enable or disable clipboard monitoring.
# Features
The behaviour of `clipmenud` can be customised through a config file. As some
examples of things you can change:
* Customising the maximum number of clips stored (default 1000)
* Disabling clip collection temporarily with `clipctl disable`, reenabling with
`clipctl enable`
* Deduplicating repeated clips so only the newest remains
* Not storing clipboard changes from certain applications, like password
managers
* Taking direct ownership of the clipboard
* ...and much more.
See `man clipmenu.conf` to view all possible configuration variables and what
they do.
# Supported launchers
Any dmenu-compliant application will work, but here are `CM_LAUNCHER`
configurations that are known to work:
- `dmenu` (the default)
- `fzf`
- `rofi`
# Installation
Several distributions, including Arch and Nix, provide clipmenu as an official
package called `clipmenu`.
## Manual installation
If your distribution doesn't provide a package, you can manually install using
`make install` (or better yet, create a package for your distribution!).
# How does it work?
## clipmenud
1. clipmenud passively monitors X11 clipboard selections (PRIMARY, CLIPBOARD,
and SECONDARY) for changes using XFixes (no polling).
2. If `clipmenud` detects changes to the clipboard contents, it writes them out
to storage and indexes using a hash as the filename.
## clipmenu
1. `clipmenu` reads the index to find all available clips.
2. `dmenu` (or another configured launcher) is executed to allow the user to
select a clip.
3. After selection, the clip is put onto the PRIMARY and CLIPBOARD X
selections.
[dmenu]: http://tools.suckless.org/dmenu/
[rofi]: https://github.com/DaveDavenport/Rofi

21
init/clipmenud.service.in Normal file
View file

@ -0,0 +1,21 @@
[Unit]
Description=Clipmenu daemon
[Service]
ExecStart=@bindir@/clipmenud
Restart=always
RestartSec=500ms
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
ProtectControlGroups=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=
RestrictRealtime=yes
# We don't need to do any clean up, so if something hangs (borked X server,
# etc), it's going to stay that way. Just forcefully kill and get it over with.
TimeoutStopSec=2
[Install]
WantedBy=default.target

56
man/clipctl.1 Normal file
View file

@ -0,0 +1,56 @@
.TH CLIPCTL 1
.SH NAME
clipctl \- control the clipmenud daemon
.SH SYNOPSIS
.B clipctl
ACTION
.SH DESCRIPTION
.B clipctl
communicates with the clipmenud daemon to control clipboard collection. It
sends a signal to clipmenud so that clipboard monitoring is enabled, disabled,
or toggled, or it simply displays the current status.
.SH OPTIONS
.TP
.B enable
Instruct clipmenud to enable clipboard collection.
.TP
.B disable
Instruct clipmenud to disable clipboard collection.
.TP
.B toggle
Invert the current state of clipboard collection.
.TP
.B status
Print the current state of clipboard collection.
.TP
.B version
Print the major clipmenu version.
.TP
.B cache-dir
Print the cache directory used by clipmenud.
.TP
clipctl also accepts one dash option:
.B \-h, \--help
Display the help message (invokes the manual page).
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipctl requires a running instance of the
.BR clipmenud
daemon.
.SH SEE ALSO
.BR clipdel (1),
.BR clipmenu (1),
.BR clipmenud (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

53
man/clipdel.1 Normal file
View file

@ -0,0 +1,53 @@
.TH CLIPDEL 1
.SH NAME
clipdel \- delete entries from the clip store
.SH SYNOPSIS
.B clipdel
[OPTION...] PATTERN
.SH DESCRIPTION
.B clipdel
removes clipboard entries from the clip store managed by clipmenu. By default,
it performs a dry-run and prints any matching entries. With the -d flag,
matching entries are permanently removed.
.SH OPTIONS
.TP
.B \-d
Real deletion mode. Matching clipboard entries will be removed.
.TP
.B \-F
Perform a literal (fixed-string) match instead of interpreting the pattern as a regular expression.
.TP
.B \-N
Interpret PATTERN as the amount of entries to delete starting from the oldest.
E.g
.I "\-N 1"
deletes the oldest entry.
.TP
.B \-n
Same as
.B \-N
but starting from newest.
.TP
.B \-v
Invert the matching condition; entries that do not match the given pattern are selected for deletion.
.TP
.B \-h, \--help
Display the help message (invokes the manual page).
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipdel requires access to the clip store directory as defined in the configuration.
.SH SEE ALSO
.BR clipctl (1),
.BR clipmenu (1),
.BR clipmenud (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

43
man/clipdelmenu.1 Normal file
View file

@ -0,0 +1,43 @@
.TH CLIPDELMENU 1
.SH NAME
clipdelmenu \- interactive launcher for deleting clipboard entries
.SH SYNOPSIS
.B clipdelmenu
[OPTION...]
.SH DESCRIPTION
.B clipdelmenu
provides an interactive interface for browsing and deleting clipboard entries
stored by clipmenud. It launches a menu using a configured launcher
(e.g., dmenu, rofi, or another custom command) and displays a numbered list of
stored clips. Once a selection is made, the chosen clip(s) are permanently
deleted from the clip store. Multiple clips can be selected at once using the
launcher's multi-select feature (e.g., Ctrl-Enter in dmenu).
.SH OPTIONS
.TP
.B \-h, \--help
Display the help message (invokes the manual page).
Other than that, command-line arguments are passed directly to the underlying
launcher.
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipdelmenu requires a launcher capable of a dmenu-like interface (e.g., rofi or a
custom command). Population of the clipstore requires
.BR clipmenud
to be running.
.SH SEE ALSO
.BR clipctl (1),
.BR clipdel (1),
.BR clipmenu (1),
.BR clipmenud (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

42
man/clipmenu.1 Normal file
View file

@ -0,0 +1,42 @@
.TH CLIPMENU 1
.SH NAME
clipmenu \- interactive launcher for clipboard entry selection
.SH SYNOPSIS
.B clipmenu
[OPTION...]
.SH DESCRIPTION
.B clipmenu
provides an interactive interface for browsing and selecting clipboard entries
stored by clipmenud. It launches a menu using a configured launcher
(e.g., dmenu, rofi, or another custom command) and displays a numbered list of
stored clips. Once a selection is made, clipmenu calls
.BR clipserve
to set the chosen clip as the current X11 selections (PRIMARY and CLIPBOARD).
.SH OPTIONS
.TP
.B \-h, \--help
Display the help message (invokes the manual page).
Other than that, command-line arguments are passed directly to the underlying
launcher.
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipmenu requires a launcher capable of a dmenu-like interface (e.g., rofi or a
custom command). Population of the clipstore requires
.BR clipmenud
to be running.
.SH SEE ALSO
.BR clipctl (1),
.BR clipdel (1),
.BR clipmenud (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

124
man/clipmenu.conf.5 Normal file
View file

@ -0,0 +1,124 @@
.TH CLIPMENU.CONF 5
.SH NAME
clipmenu.conf \- configuration file for clipmenu applications
.SH DESCRIPTION
clipmenu.conf defines the runtime settings for clipmenu applications (including
clipmenu itself, clipmenud, clipctl, clipdel, clipdelmenu, and clipserve).
.SH FORMAT
Each non-comment line in clipmenu.conf consists of a key followed by one or
more whitespace-separated values. Blank lines and lines starting with '#' are
ignored.
.PP
For example:
.EX
max_clips 1000
selections clipboard primary
.EE
.SH CONFIGURATION OPTIONS
.TP
.B max_clips
Specifies the maximum number of clipboard entries to retain in the clip store.
Default: 1000.
.TP
.B max_clips_batch
Provides a buffer above max_clips; when the number of entries exceeds
(max_clips + max_clips_batch), the clip store is trimmed back to max_clips
entries. Default: 100.
.TP
.B oneshot
If set to 1, clipmenud processes clipboard selections only once before exiting.
Default: 0.
.TP
.B deduplicate
If set to 1, clipmenud keeps only the newest copy of duplicate clips instead of
storing every duplicate entry. Default: 0.
.TP
.B own_clipboard
Determines whether clipmenud should claim ownership of the X11 clipboard. Works
together with own_selections. Default: 0.
.TP
.B own_selections
Specifies which X11 selections (e.g., "clipboard" or "primary") clipmenud
should actively own when
.B own_clipboard
is enabled. When a clip from one of these selections is stored, clipmenud uses
clipserve to own those selections. This does not affect which selections are
monitored; use
.B selections
for that. Default: "clipboard".
If this is set to an empty value, it is treated as if
.B own_clipboard
was disabled.
.TP
.B selections
Lists the X11 selections to monitor for changes. Valid values include
"clipboard", "primary", and "secondary". Default: "clipboard primary".
.TP
.B ignore_window
Defines a regular expression matching window titles to exclude from clipboard
monitoring. Unset by default.
.TP
.B launcher
Specifies the launcher command to use with clipmenu. Alternative choices
include rofi's dmenu mode or a custom command. Default: "dmenu".
.TP
.B launcher_pass_dmenu_args
When enabled, clipmenu and clipdelmenu add dmenu-compatible defaults (such as
-p and -l) before any user-provided arguments. Default: 1.
.TP
.B touch_on_select
When an entry is selected via
.BR clipmenu (1)
it will also be moved up to the newest slot.
Default: 0.
.TP
.B partial_merge_secs
Specifies the time window in seconds during which consecutive clipboard updates
where the previous entry was a substring of the new one are merged together
instead of being stored separately. This is useful for applications that
incrementally update the clipboard (e.g. progressive text selection in
browsers). Set to 0 to disable partial merging entirely. Default: 2.
.TP
.B cm_dir
Overrides the default base directory for clipmenu runtime data. The clip store
is created at a subdirectory named clipmenu.<version>.<uid> inside this
directory. By default, it uses XDG_RUNTIME_DIR, TMPDIR, or /tmp if neither are
set. The path must be absolute. A leading ~ expands to $HOME.
.SH FILE LOCATION
Typically, clipmenu.conf is located at
.BR ~/.config/clipmenu/clipmenu.conf
.
The file location can be overridden with $CM_CONFIG, which must be an absolute
path. A leading ~ expands to $HOME. If $CM_CONFIG is unset, $XDG_CONFIG_HOME is
used (falling back to ~/.config).
.SH ENVIRONMENT
.TP
.B CM_CONFIG
Absolute path (or a path beginning with ~) to the configuration file. This
takes precedence over $XDG_CONFIG_HOME.
.TP
.B XDG_CONFIG_HOME
Base directory for configuration files. If unset, ~/.config is used.
.TP
.B CM_DEBUG
If set to 1, enable debug logging.
.TP
.B Config environment variables
CM_MAX_CLIPS, CM_MAX_CLIPS_BATCH, CM_ONESHOT, CM_DEDUPLICATE, CM_OWN_CLIPBOARD,
CM_SELECTIONS, CM_OWN_SELECTIONS, CM_IGNORE_WINDOW, CM_LAUNCHER,
CM_LAUNCHER_PASS_DMENU_ARGS, and CM_DIR are environment variable equivalents for
the configuration keys of the same name. These override values from the config
file and are provided for compatibility with versions prior to 7.
.SH SEE ALSO
.BR clipctl (1),
.BR clipdel (1),
.BR clipmenu (1),
.BR clipmenud (1),
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

32
man/clipmenud.1 Normal file
View file

@ -0,0 +1,32 @@
.TH CLIPMENUD 1
.SH NAME
clipmenud \- daemon for clipboard monitoring and storage
.SH DESCRIPTION
.B clipmenud
runs in the background, monitoring X11 clipboard selections (including PRIMARY,
CLIPBOARD, and SECONDARY). It stores new clipboard entries into a persistent
clip store. clipmenud responds to signals sent by
.BR clipctl
to enable or disable clipboard collection.
.SH OPTIONS
.TP
.B \-h, \--help
Display the help message (invokes the manual page).
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipmenud requires an X11 environment with the XFixes extension and access to the clip store directory as defined in the configuration.
.SH SEE ALSO
.BR clipctl (1),
.BR clipdel (1),
.BR clipmenu (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

44
man/clipserve.1 Normal file
View file

@ -0,0 +1,44 @@
.TH CLIPSERVE 1
.SH NAME
clipserve \- serve a selected clipboard entry to X11 selections
.SH SYNOPSIS
.B clipserve
[\-s selection]... <hash>
.SH DESCRIPTION
.B clipserve
serves the clipboard content identified by a hash from the clip store on the
configured X11 selections. By default, it serves both PRIMARY and CLIPBOARD.
This program is not usually invoked directly, but is instead called from inside
other clipmenu applications.
.SH OPTIONS
.TP
.B \-h, \--help
Display the help message (invokes the manual page).
.TP
.B \-s, \--selection
Specify a selection to serve. Valid values are "primary", "clipboard", and
"secondary". This option may be repeated. If not provided, PRIMARY and
CLIPBOARD are used.
.TP
clipserve requires a hexadecimal hash of the clipboard entry to be served.
.SH CONFIGURATION
See
.BR clipmenu.conf (5).
.SH DEPENDENCIES
clipserve needs a valid clip store and an operational X11 environment to
function correctly.
.SH SEE ALSO
.BR clipctl (1),
.BR clipdel (1),
.BR clipmenu (1),
.BR clipmenud (1),
.BR clipmenu.conf (5)
.SH AUTHOR
Chris Down
.MT chris@chrisdown.name
.ME
.SH REPORTING BUGS
Please send bug reports to
.UR https://github.com/cdown/clipmenu/issues
.UE .

152
src/clipctl.c Normal file
View file

@ -0,0 +1,152 @@
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/file.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#include "config.h"
#include "util.h"
/**
* Check if the clipmenud service is enabled.
*/
static bool is_enabled(struct config *cfg) {
_drop_(fclose) FILE *file = fopen(get_enabled_path(cfg), "r");
die_on(!file, "Failed to open enabled file: %s\n", strerror(errno));
return fgetc(file) == '1';
}
/**
* Retrieve the process ID of the clipmenud daemon.
*
* -EEXIST is returned if multiple instances are detected. -ENOENT is returned
* if no instances are found.
*/
static pid_t get_clipmenud_pid(struct config *cfg) {
int fd = open(get_session_lock_path(cfg), O_RDWR | O_CLOEXEC);
if (fd < 0) {
return -ENOENT;
}
if (flock(fd, LOCK_EX | LOCK_NB) == 0) {
close(fd);
return -ENOENT;
}
bool would_block;
#if EWOULDBLOCK == EAGAIN
would_block = errno == EWOULDBLOCK;
#else
would_block = errno == EWOULDBLOCK || errno == EAGAIN;
#endif
if (!would_block) {
close(fd);
return -ENOENT;
}
char buf[32];
ssize_t read_sz = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (read_sz <= 0) {
return -ENOENT;
}
buf[read_sz] = '\0';
char *newline = strchr(buf, '\n');
if (newline) {
*newline = '\0';
}
uint64_t pid;
if (str_to_uint64(buf, &pid) < 0) {
return -ENOENT;
}
if (kill((pid_t)pid, 0) < 0 && errno == ESRCH) {
return -ENOENT;
}
return (pid_t)pid;
}
/**
* Determine if clipmenud should be enabled based on the given mode string and
* clipmenu state.
*/
static bool _nonnull_ should_enable(struct config *cfg, const char *mode_str) {
if (streq(mode_str, "enable")) {
return true;
} else if (streq(mode_str, "disable")) {
return false;
} else if (streq(mode_str, "toggle")) {
return !is_enabled(cfg);
}
die("Unknown command: %s\n", mode_str);
}
int main(int argc, char *argv[]) {
_drop_(config_free) struct config cfg = setup("clipctl");
exec_man_on_help(argc, argv);
die_on(argc != 2,
"Usage: clipctl <enable|disable|toggle|status|version|cache-dir>\n");
const char *cmd = argv[1];
if (streq(cmd, "cache-dir")) {
printf("%s\n", get_cache_dir(&cfg));
return 0;
}
if (streq(cmd, "version")) {
printf("%d\n", CLIPMENU_VERSION);
return 0;
}
pid_t pid = get_clipmenud_pid(&cfg);
die_on(pid == -ENOENT, "clipmenud is not running\n");
expect(pid > 0);
if (streq(cmd, "status")) {
printf("%s\n", is_enabled(&cfg) ? "enabled" : "disabled");
return 0;
}
bool want_enable = should_enable(&cfg, cmd);
expect(kill(pid, want_enable ? SIGUSR2 : SIGUSR1) == 0);
dbg("Sent signal to pid %d\n", pid);
unsigned int delay_ms = 1;
unsigned int total_wait_ms = 0;
const unsigned int max_wait_ms = 1000;
while (total_wait_ms < max_wait_ms) {
if (is_enabled(&cfg) == want_enable) {
return 0;
}
struct timespec req = {.tv_sec = delay_ms / 1000,
.tv_nsec = (delay_ms % 1000) * 1000000L};
struct timespec rem;
while (nanosleep(&req, &rem) != 0) {
expect(errno == EINTR);
req = rem;
}
total_wait_ms += delay_ms;
delay_ms *= 2;
}
if (is_enabled(&cfg) == want_enable) {
return 0;
}
die("Failed to %s clipmenud within %u ms\n",
want_enable ? "enable" : "disable", max_wait_ms);
}

162
src/clipdel.c Normal file
View file

@ -0,0 +1,162 @@
#include <fcntl.h>
#include <regex.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "config.h"
#include "store.h"
#include "util.h"
/**
* The deletion mode for clipdel operations.
*/
enum delete_mode {
DELETE_DRY_RUN,
DELETE_REAL,
};
/**
* The match type for clipdel operations.
*/
enum match_type {
MATCH_REGEX,
MATCH_LITERAL,
MATCH_COUNT_NEWEST,
MATCH_COUNT_OLDEST,
};
/**
* Holds the application state for a clipdel operation in preparation for
* passing it as private data to the cs_remove callback.
*/
struct clipdel_state {
enum delete_mode mode;
enum match_type type;
bool invert_match;
union {
regex_t rgx;
const char *needle;
struct {
uint64_t current;
uint64_t num_delete;
} count;
};
};
/**
* Callback for cs_remove. In order for the delete to actually happen, we must
* be running DELETE_REAL.
*/
static enum cs_remove_action _nonnull_ remove_if_match(uint64_t hash _unused_,
const char *line,
void *private) {
struct clipdel_state *state = private;
int ret;
bool matches;
switch (state->type) {
case MATCH_LITERAL:
matches = strstr(line, state->needle) != NULL;
break;
case MATCH_REGEX:
ret = regexec(&state->rgx, line, 0, NULL, 0);
expect(ret == 0 || ret == REG_NOMATCH);
matches = ret == 0;
break;
case MATCH_COUNT_NEWEST:
case MATCH_COUNT_OLDEST:
if (state->count.current < state->count.num_delete) {
matches = true;
++state->count.current;
} else {
matches = false;
}
break;
default:
die("unreachable\n");
}
bool wants_del = state->invert_match ? !matches : matches;
if (wants_del) {
puts(line);
}
return state->mode == DELETE_REAL && wants_del ? CS_ACTION_REMOVE
: CS_ACTION_KEEP;
}
int main(int argc, char *argv[]) {
const char usage[] = "Usage: clipdel [-d] [-F] [-N] [-n] [-v] pattern";
_drop_(config_free) struct config cfg = setup("clipdel");
struct clipdel_state state = {0};
int opt;
while ((opt = getopt(argc, argv, "dFNnvh")) != -1) {
switch (opt) {
case 'd':
state.mode = DELETE_REAL;
break;
case 'F':
state.type = MATCH_LITERAL;
break;
case 'N':
state.type = MATCH_COUNT_OLDEST;
break;
case 'n':
state.type = MATCH_COUNT_NEWEST;
break;
case 'v':
state.invert_match = true;
break;
case 'h':
exec_man();
break;
default:
die("%s\n", usage);
}
}
die_on(optind >= argc, "%s\n", usage);
_drop_(close) int content_dir_fd = open(get_cache_dir(&cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(&cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
_drop_(cs_destroy) struct clip_store cs;
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
enum cs_iter_direction direction = CS_ITER_OLDEST_FIRST;
switch (state.type) {
case MATCH_REGEX:
die_on(regcomp(&state.rgx, argv[optind], REG_EXTENDED | REG_NOSUB),
"Could not compile regex\n");
break;
case MATCH_LITERAL:
state.needle = argv[optind];
break;
case MATCH_COUNT_NEWEST:
case MATCH_COUNT_OLDEST:
die_on(str_to_uint64(argv[optind], &state.count.num_delete) < 0,
"Bad argument, expected integer: %s\n", argv[optind]);
state.count.current = 0;
if (state.type == MATCH_COUNT_NEWEST) {
direction = CS_ITER_NEWEST_FIRST;
}
break;
default:
die("unreachable\n");
}
expect(cs_remove(&cs, direction, remove_if_match, &state) == 0);
if (state.type == MATCH_REGEX) {
regfree(&state.rgx);
}
return 0;
}

45
src/clipdelmenu.c Normal file
View file

@ -0,0 +1,45 @@
#include <fcntl.h>
#include <stdint.h>
#include <unistd.h>
#include "config.h"
#include "menu_util.h"
#include "store.h"
#include "util.h"
struct delete_state {
uint64_t hash_to_delete;
};
// cppcheck-suppress-begin constParameterCallback
static enum cs_remove_action _nonnull_
remove_if_hash_match(uint64_t hash, const char *line _unused_, void *private) {
const struct delete_state *state = private;
return hash == state->hash_to_delete ? CS_ACTION_REMOVE : CS_ACTION_KEEP;
}
// cppcheck-suppress-end constParameterCallback
static int _nonnull_ clipdelmenu_action(struct config *cfg, uint64_t hash) {
_drop_(close) int content_dir_fd = open(get_cache_dir(cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
_drop_(cs_destroy) struct clip_store cs;
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
struct delete_state del_state = {.hash_to_delete = hash};
expect(cs_remove(&cs, CS_ITER_NEWEST_FIRST, remove_if_hash_match,
&del_state) == 0);
return 0;
}
int main(int argc, char *argv[]) {
menu_set_user_args(argc, argv);
_drop_(config_free) struct config cfg = setup("clipdelmenu");
exec_man_on_help(argc, argv);
return menu_prompt_and_act(&cfg, "clipdelmenu", clipdelmenu_action);
}

42
src/clipmenu.c Normal file
View file

@ -0,0 +1,42 @@
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "config.h"
#include "menu_util.h"
#include "store.h"
#include "util.h"
#include "x.h"
static int _nonnull_ clipmenu_action(struct config *cfg, uint64_t hash) {
if (cfg->touch_on_select) {
_drop_(close) int content_dir_fd = open(get_cache_dir(cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
_drop_(cs_destroy) struct clip_store cs;
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
int ret = cs_make_newest(&cs, hash);
if (ret == -ENOENT) {
fprintf(stderr, "Selected clip no longer exists\n");
return EXIT_FAILURE;
}
expect(ret == 0);
}
run_clipserve(hash, NULL);
return 0;
}
int main(int argc, char *argv[]) {
menu_set_user_args(argc, argv);
_drop_(config_free) struct config cfg = setup("clipmenu");
exec_man_on_help(argc, argv);
return menu_prompt_and_act(&cfg, "clipmenu", clipmenu_action);
}

765
src/clipmenud.c Normal file
View file

@ -0,0 +1,765 @@
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <regex.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <sys/file.h>
#include <sys/select.h>
#include <time.h>
#include <unistd.h>
#include "config.h"
#include "store.h"
#include "util.h"
#include "x.h"
static Display *dpy;
static struct clip_store cs;
static struct config cfg;
static Window win;
static int enabled = 1;
static int sig_pipe[2];
static Atom timestamp_atom;
static Atom incr_atom;
static struct incr_transfer *it_list;
static struct cm_selections sels[CM_SEL_MAX];
static Time last_disable_time = 0;
static Time last_enable_time = 0;
static bool has_owned_selections(void) {
for (size_t i = 0; i < CM_SEL_MAX; i++) {
if (cfg.owned_selections[i].active) {
return true;
}
}
return false;
}
enum clip_text_source {
CLIP_TEXT_SOURCE_X,
CLIP_TEXT_SOURCE_MALLOC,
CLIP_TEXT_SOURCE_INVALID
};
struct clip_text {
char *data;
enum clip_text_source source;
};
static struct clip_text last_text[CM_SEL_MAX] = {
{NULL, CLIP_TEXT_SOURCE_MALLOC},
{NULL, CLIP_TEXT_SOURCE_MALLOC},
{NULL, CLIP_TEXT_SOURCE_MALLOC},
};
static struct timespec last_text_time[CM_SEL_MAX];
static void free_clip_text(struct clip_text *ct) {
expect(ct->source != CLIP_TEXT_SOURCE_INVALID);
if (ct->data) {
if (ct->source == CLIP_TEXT_SOURCE_X) {
XFree(ct->data);
} else {
free(ct->data);
}
ct->data = NULL;
}
ct->source = CLIP_TEXT_SOURCE_INVALID;
}
static struct timespec get_monotonic_time(void) {
struct timespec ts;
expect(clock_gettime(CLOCK_MONOTONIC, &ts) == 0);
return ts;
}
static bool within_partial_merge_window(struct timespec current_time,
struct timespec last_time) {
time_t elapsed_secs = current_time.tv_sec - last_time.tv_sec;
if (elapsed_secs < cfg.partial_merge_secs) {
return true;
}
if (elapsed_secs > cfg.partial_merge_secs) {
return false;
}
return current_time.tv_nsec <= last_time.tv_nsec;
}
// cppcheck-suppress [constParameterCallback,unmatchedSuppression]
static Bool is_timestamp_event(Display *display _unused_, XEvent *event,
XPointer arg) {
const void *argp = arg;
return event->type == PropertyNotify &&
event->xproperty.atom == *(const Atom *)argp;
}
/**
* Get the current X server time by triggering a PropertyNotify.
*/
static Time get_current_server_time(void) {
XEvent ev;
XChangeProperty(dpy, win, timestamp_atom, XA_INTEGER, 32, PropModeReplace,
NULL, 0);
XIfEvent(dpy, &ev, is_timestamp_event, (XPointer)&timestamp_atom);
return ev.xproperty.time;
}
/**
* Check if a text s1 is a possible partial of s2.
*
* Chromium and some other badly behaved applications spam PRIMARY during
* selection, so if you're selecting the text "abc", you get three clips: "a",
* "ab", and "abc" (or "c", "bc", "abc" if selecting right to left). Attempt to
* detect these. It's possible we were not fast enough to get all of them, so
* unfortunately we can't check for strlen(s1)+1 either. It's also possible the
* user first expands, and then retracts the selection, so we need to handle
* that too.
*/
static bool is_possible_partial(const char *s1, const char *s2) {
size_t len1 = strlen(s1), len2 = strlen(s2);
// Is one a prefix of the other?
if (strncmp(s1, s2, len1 < len2 ? len1 : len2) == 0) {
return true;
}
// Is one a suffix of the other?
if (len1 < len2) {
return strcmp(s1, s2 + len2 - len1) == 0;
} else {
return strcmp(s2, s1 + len1 - len2) == 0;
}
}
/**
* Retrieve the converted text put into our clip atom. In order for this to
* happen a conversion must have been performed in an earlier iteration with
* XConvertSelection.
*/
static struct clip_text get_clipboard_text(Atom clip_atom) {
struct clip_text ct = {NULL, CLIP_TEXT_SOURCE_X};
unsigned char *cur_text;
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
int res = XGetWindowProperty(dpy, win, clip_atom, 0L, (~0L), False,
AnyPropertyType, &actual_type, &actual_format,
&nitems, &bytes_after, &cur_text);
if (res != Success) {
return ct;
}
if (actual_type == incr_atom) {
dbg("Unexpected INCR transfer detected\n");
XFree(cur_text);
return ct;
}
ct.data = (char *)cur_text;
return ct;
}
/**
* Return true if the given string contains any non-whitespace characters.
*/
static bool is_salient_text(const char *str) {
if (!str) {
return false;
}
for (; *str; str++) {
if (!isspace((unsigned char)*str)) {
return true;
}
}
return false;
}
/**
* Write the current enabled status to a designated status file.
*/
static void write_status(void) {
_drop_(close) int fd =
open(get_enabled_path(&cfg), O_WRONLY | O_CREAT, 0600);
die_on(fd < 0, "Failed to update status: %s\n", strerror(errno));
dprintf(fd, "%d", (int)enabled);
}
/**
* Return true if the given window title matches the title of the clipserve
* window.
*/
static bool is_clipserve(const char *win_title) {
return win_title && streq(win_title, "clipserve");
}
/**
* Determine if a window with the given title should be ignored based on user
* configuration.
*/
static bool is_ignored_window(char *win_title) {
if (!win_title || !cfg.ignore_window.set) {
return 0;
}
int ret = regexec(&cfg.ignore_window.rgx, win_title, 0, NULL, 0);
expect(ret == 0 || ret == REG_NOMATCH);
return !ret;
}
/**
* Disable or enable clip collection based on received signals.
*/
static void handle_signal_event(int signo) {
dbg("Got signal %d\n", signo);
switch (signo) {
case SIGUSR1:
// If we're already disabled, we need to keep the original
// timestamp to properly filter all events that were queued during
// any part of the disabled period.
if (enabled) {
last_disable_time = get_current_server_time();
}
enabled = 0;
dbg("Clipboard collection disabled by signal at time %lu\n",
(unsigned long)last_disable_time);
break;
case SIGUSR2:
// If we're already enabled, we need to keep the original timestamp
// so we don't mistakenly filter out valid messages.
if (!enabled) {
last_enable_time = get_current_server_time();
}
enabled = 1;
dbg("Clipboard collection enabled by signal at time %lu\n",
(unsigned long)last_enable_time);
break;
}
write_status();
}
static void handle_signal_pipe(void) {
unsigned char sigs[32];
ssize_t read_sz;
while ((read_sz = read(sig_pipe[0], sigs, sizeof(sigs))) > 0) {
for (ssize_t i = 0; i < read_sz; i++) {
handle_signal_event((int)sigs[i]);
}
}
if (read_sz < 0) {
expect(errno == EAGAIN || errno == EINTR);
}
}
static void signal_handler(int signo) {
int saved_errno = errno;
unsigned char sig = (unsigned char)signo;
ssize_t written = write(sig_pipe[1], &sig, sizeof(sig));
(void)written;
errno = saved_errno;
}
static void set_fd_flags(int fd) {
int flags = fcntl(fd, F_GETFL);
expect(flags >= 0);
expect(fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0);
int fd_flags = fcntl(fd, F_GETFD);
expect(fd_flags >= 0);
expect(fcntl(fd, F_SETFD, fd_flags | FD_CLOEXEC) == 0);
}
/**
* Something changed about the watched selection, consider converting it to our
* desired property type.
*/
static void handle_xfixes_selection_notify(XFixesSelectionNotifyEvent *se) {
if (last_disable_time > 0 && se->timestamp >= last_disable_time &&
se->timestamp < last_enable_time) {
dbg("Ignoring selection event from disabled period (event time: %lu, disabled: %lu, enabled: %lu)\n",
(unsigned long)se->timestamp, (unsigned long)last_disable_time,
(unsigned long)last_enable_time);
return;
}
enum selection_type sel =
selection_atom_to_selection_type(se->selection, sels);
if (sel == CM_SEL_INVALID) {
dbg("Received XFixesSelectionNotify for unknown sel\n");
return;
}
_drop_(XFree) char *win_title = get_window_title(dpy, se->owner);
if (is_clipserve(win_title) || is_ignored_window(win_title)) {
dbg("Ignoring clip from window titled '%s'\n", win_title);
return;
}
dbg("Notified about selection update. Selection: %s, Owner: '%s' (0x%lx)\n",
cfg.selections[sel].name, strnull(win_title), (unsigned long)se->owner);
struct incr_transfer *it = it_list;
while (it) {
struct incr_transfer *next = it->next;
if (it->property == sels[sel].storage) {
it_dbg(it, "Cleaning up stale INCR transfer\n");
free(it->data);
it_remove(&it_list, it);
free(it);
}
it = next;
}
XConvertSelection(dpy, se->selection,
XInternAtom(dpy, "UTF8_STRING", False), sels[sel].storage,
win, CurrentTime);
return;
}
/**
* Something changed about the watched selection, but we don't explicitly
* listen for SelectionNotify, so in reality this only happens in response to
* an explicit request to tell us that there is no owner. In that case, return
* -ENOENT.
*/
static int handle_selection_notify(const XSelectionEvent *se) {
if (se->property == None) {
enum selection_type sel =
selection_atom_to_selection_type(se->selection, sels);
if (sel == CM_SEL_INVALID) {
dbg("Received no owner notification for unknown sel\n");
return 0;
}
dbg("X reports that %s has no current owner\n",
cfg.selections[sel].name);
return -ENOENT;
}
return 0;
}
/**
* Trims the clip store if the number of clips exceeds the configured batch
* size.
*/
static void maybe_trim(void) {
size_t cur_clips;
expect(cs_len(&cs, &cur_clips) == 0);
if (cur_clips > (size_t)cfg.max_clips + (size_t)cfg.max_clips_batch) {
expect(cs_trim(&cs, CS_ITER_NEWEST_FIRST, (size_t)cfg.max_clips) == 0);
}
}
/**
* Store the clipboard text for a selection. If the text is a possible partial
* of the last clip on the same selection and it was received shortly
* afterwards, replace instead of adding.
*/
static uint64_t store_clip(enum selection_type sel, struct clip_text *ct) {
dbg("Clipboard text is considered salient, storing\n");
struct timespec current_time = get_monotonic_time();
uint64_t hash;
if (cfg.partial_merge_secs > 0 && last_text[sel].data &&
within_partial_merge_window(current_time, last_text_time[sel]) &&
is_possible_partial(last_text[sel].data, ct->data)) {
dbg("Possible partial of last clip on %s, replacing\n",
cfg.selections[sel].name);
expect(cs_replace(&cs, CS_ITER_NEWEST_FIRST, 0, ct->data, &hash) == 0);
} else {
expect(cs_add(&cs, ct->data, &hash,
cfg.deduplicate ? CS_DUPE_KEEP_LAST : CS_DUPE_KEEP_ALL) ==
0);
}
free_clip_text(&last_text[sel]);
last_text[sel] = *ct;
last_text_time[sel] = current_time;
// The caller no longer owns this data.
ct->data = NULL;
ct->source = CLIP_TEXT_SOURCE_INVALID;
return hash;
}
/**
* Process the final data collected during an INCR transfer.
*/
static void incr_receive_finish(struct incr_transfer *it) {
enum selection_type sel =
storage_atom_to_selection_type(it->property, sels);
if (sel == CM_SEL_INVALID) {
it_dbg(it, "Received INCR finish for unknown sel\n");
return;
}
it_dbg(it, "Finished (bytes buffered: %zu)\n", it->data_size);
char *text = malloc(it->data_size + 1);
expect(text);
memcpy(text, it->data, it->data_size);
text[it->data_size] = '\0';
struct clip_text ct = {text, CLIP_TEXT_SOURCE_MALLOC};
char line[CS_SNIP_LINE_SIZE];
first_line(ct.data, line);
it_dbg(it, "First line: %s\n", line);
if (is_salient_text(ct.data)) {
uint64_t hash = store_clip(sel, &ct);
maybe_trim();
if (cfg.own_clipboard && has_owned_selections()) {
run_clipserve(hash, cfg.owned_selections);
}
} else {
it_dbg(it, "Clipboard text is whitespace only, ignoring\n");
free_clip_text(&ct);
}
free(it->data);
it_remove(&it_list, it);
free(it);
}
#define INCR_DATA_START_BYTES 1024 * 1024
/**
* Acknowledge and start an INCR transfer.
*/
static void incr_receive_start(const XPropertyEvent *pe) {
struct incr_transfer *it = malloc(sizeof(struct incr_transfer));
expect(it);
*it = (struct incr_transfer){
.property = pe->atom,
.requestor = pe->window,
.data_size = 0,
.data_capacity = INCR_DATA_START_BYTES,
.data = malloc(INCR_DATA_START_BYTES),
};
expect(it->data);
it_dbg(it, "Starting transfer\n");
it_add(&it_list, it);
// Signal readiness for chunks
XDeleteProperty(dpy, win, pe->atom);
}
/**
* Continue receiving data during an INCR transfer.
*/
static void incr_receive_data(const XPropertyEvent *pe,
struct incr_transfer *it) {
if (pe->state != PropertyNewValue) {
return;
}
it_dbg(it, "Receiving chunk (bytes buffered: %zu)\n", it->data_size);
_drop_(XFree) unsigned char *chunk = NULL;
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
XGetWindowProperty(dpy, win, pe->atom, 0, LONG_MAX, False, AnyPropertyType,
&actual_type, &actual_format, &nitems, &bytes_after,
&chunk);
size_t chunk_size = nitems * (actual_format / 8);
if (chunk_size == 0) {
it_dbg(it, "Transfer complete\n");
incr_receive_finish(it);
return;
}
if (it->data_size + chunk_size > it->data_capacity) {
it->data_capacity = (it->data_size + chunk_size) * 2;
it->data = realloc(it->data, it->data_capacity);
expect(it->data);
it_dbg(it, "Expanded data buffer to %zu bytes\n", it->data_capacity);
}
memcpy(it->data + it->data_size, chunk, chunk_size);
it->data_size += chunk_size;
// Signal readiness for next chunk
XDeleteProperty(dpy, win, pe->atom);
}
/**
* Something changed in our clip storage atoms. Work out whether we want to
* store the new content as a clipboard entry.
*/
static int handle_property_notify(const XPropertyEvent *pe) {
if (pe->state != PropertyNewValue && pe->state != PropertyDelete) {
return -EINVAL;
}
enum selection_type sel = storage_atom_to_selection_type(pe->atom, sels);
if (sel == CM_SEL_INVALID) {
dbg("Received PropertyNotify for unknown sel\n");
return -EINVAL;
}
// Check if this property corresponds to an INCR transfer in progress
struct incr_transfer *it = it_list;
while (it) {
if (it->property == pe->atom && it->requestor == pe->window) {
break;
}
it = it->next;
}
if (it) {
incr_receive_data(pe, it);
return 0;
}
// Not an INCR transfer in progress. Check if this is an INCR transfer
// starting
Atom actual_type;
int actual_format;
unsigned long nitems, bytes_after;
_drop_(XFree) unsigned char *prop = NULL;
XGetWindowProperty(dpy, win, pe->atom, 0, 0, False, AnyPropertyType,
&actual_type, &actual_format, &nitems, &bytes_after,
&prop);
if (actual_type == incr_atom) {
incr_receive_start(pe);
} else {
dbg("Received non-INCR PropertyNotify\n");
// store_clip will take care of freeing this later when it's gone from
// last_text.
struct clip_text ct = get_clipboard_text(pe->atom);
if (!ct.data) {
dbg("Failed to get clipboard text\n");
return -EINVAL;
}
char line[CS_SNIP_LINE_SIZE];
first_line(ct.data, line);
dbg("First line: %s\n", line);
if (is_salient_text(ct.data)) {
uint64_t hash = store_clip(sel, &ct);
maybe_trim();
/* We only own CLIPBOARD because otherwise the behaviour is wonky:
*
* 1. When you select in a browser and press ^V, it repastes what
* you have selected instead of the previous content
* 2. urxvt and some other terminal emulators will unhilight on
* PRIMARY ownership being taken away from them
*/
if (cfg.own_clipboard && has_owned_selections()) {
run_clipserve(hash, cfg.owned_selections);
}
} else {
dbg("Clipboard text is whitespace only, ignoring\n");
free_clip_text(&ct);
}
}
return 0;
}
/**
* Process X11 events, returning when we have either processed one clip, or
* have received an indication that the selection is not owned.
*
* The usual sequence is:
*
* 1. Get an XFixesSelectionNotify that we have a new selection.
* 2. Call XConvertSelection() on it to get a string in our prop.
* 3. Wait for a PropertyNotify that says that's ready.
* 4. When it's ready, store it, and return from the function.
*
* Another possible outcome, especially when trying to get the initial state at
* startup, is that we get a SelectionNotify even with owner == None, which
* means the selection is unowned. At that point we also return, since it's
* clear that an explicit request has been nacked.
*/
static int handle_x11_event(int evt_base) {
while (XPending(dpy)) {
XEvent evt;
XNextEvent(dpy, &evt);
if (!enabled) {
dbg("Got X event, but ignoring as collection is disabled\n");
continue;
}
int ret;
if (evt.type == evt_base + XFixesSelectionNotify) {
handle_xfixes_selection_notify((XFixesSelectionNotifyEvent *)&evt);
} else if (evt.type == PropertyNotify) {
ret = handle_property_notify((XPropertyEvent *)&evt);
if (ret == 0) {
return ret;
}
} else if (evt.type == SelectionNotify) {
ret = handle_selection_notify((XSelectionEvent *)&evt);
if (ret < 0) {
return ret;
}
}
}
return -EINPROGRESS;
}
/**
* Continuously wait for and process X11 or signal events until we fully
* process success or failure for a clip.
*
* The usual sequence is:
*
* 1. Get an XFixesSelectionNotify that we have a new selection.
* 2. Call XConvertSelection() on it to get a string in our prop.
* 3. Wait for a PropertyNotify that says that's ready.
* 4. When it's ready, store it, and return from the function.
*
* Another possible outcome, especially when trying to get the initial state at
* startup, is that we get a SelectionNotify even with owner == None, which
* means the selection is unowned. At that point we also return, since it's
* clear that an explicit request has been nacked.
*/
static int get_one_clip(int evt_base) {
while (1) {
// It's possible that we have more X events to process, but because of
// the way the protocol works, we won't get told about them until we
// next get an event if we wait for select(). Check for them first.
if (XPending(dpy)) {
return handle_x11_event(evt_base);
}
fd_set fds;
int x_fd = ConnectionNumber(dpy);
FD_ZERO(&fds);
FD_SET(sig_pipe[0], &fds);
FD_SET(x_fd, &fds);
int max_fd = sig_pipe[0] > x_fd ? sig_pipe[0] : x_fd;
int ret = select(max_fd + 1, &fds, NULL, NULL, NULL);
if (ret < 0) {
if (errno == EINTR) {
return 0;
}
expect(ret > 0);
}
if (FD_ISSET(sig_pipe[0], &fds)) {
handle_signal_pipe();
}
if (FD_ISSET(x_fd, &fds)) {
return handle_x11_event(evt_base);
}
}
}
static int setup_watches(int evt_base) {
XSelectInput(dpy, win, PropertyChangeMask);
for (size_t i = 0; i < CM_SEL_MAX; i++) {
struct selection sel = cfg.selections[i];
if (!sel.active) {
continue;
}
Atom sel_atom = sels[i].selection;
XFixesSelectSelectionInput(dpy, win, sel_atom,
XFixesSetSelectionOwnerNotifyMask);
dbg("Getting initial value for selection %s\n", sel.name);
XConvertSelection(dpy, sel_atom, XInternAtom(dpy, "UTF8_STRING", False),
sels[i].storage, win, CurrentTime);
get_one_clip(evt_base);
}
return 0;
}
static int _noreturn_ run(int evt_base) {
while (1) {
get_one_clip(evt_base);
}
}
#ifndef UNIT_TEST
int main(int argc, char *argv[]) {
int evt_base;
cfg = setup("clipmenud");
exec_man_on_help(argc, argv);
die_on(argc != 1, "clipmenud doesn't accept any arguments\n");
_drop_(close) int session_fd =
open(get_session_lock_path(&cfg), O_RDWR | O_CREAT | O_CLOEXEC, 0600);
die_on(session_fd < 0, "Failed to open session file: %s\n",
strerror(errno));
die_on(flock(session_fd, LOCK_EX | LOCK_NB) < 0,
"Failed to lock session file -- is another clipmenud running?\n");
expect(ftruncate(session_fd, 0) == 0);
expect(lseek(session_fd, 0, SEEK_SET) == 0);
dprintf(session_fd, "%ld\n", (long)getpid());
write_status();
_drop_(close) int content_dir_fd = open(get_cache_dir(&cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(&cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
die_on(!(dpy = XOpenDisplay(NULL)), "Cannot open display\n");
win = DefaultRootWindow(dpy);
setup_selections(dpy, sels);
incr_atom = XInternAtom(dpy, "INCR", False);
timestamp_atom = XInternAtom(dpy, "CLIPMENUD_TIMESTAMP", False);
expect(pipe(sig_pipe) == 0);
set_fd_flags(sig_pipe[0]);
set_fd_flags(sig_pipe[1]);
struct sigaction sa = {0};
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
expect(sigaction(SIGUSR1, &sa, NULL) == 0);
expect(sigaction(SIGUSR2, &sa, NULL) == 0);
expect(signal(SIGCHLD, SIG_IGN) != SIG_ERR);
int unused;
die_on(!XFixesQueryExtension(dpy, &evt_base, &unused), "XFixes missing\n");
setup_watches(evt_base);
if (!cfg.oneshot) {
run(evt_base);
}
expect(cs_destroy(&cs) == 0);
config_free(&cfg);
XCloseDisplay(dpy);
return 0;
}
#endif

286
src/clipserve.c Normal file
View file

@ -0,0 +1,286 @@
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include "config.h"
#include "store.h"
#include "util.h"
#include "x.h"
static struct incr_transfer *it_list = NULL;
static Display *dpy;
static Atom incr_atom;
static size_t incr_threshold;
static size_t chunk_size;
static enum selection_type selection_name_to_type(const char *name) {
if (streq(name, "clipboard")) {
return CM_SEL_CLIPBOARD;
}
if (streq(name, "primary")) {
return CM_SEL_PRIMARY;
}
if (streq(name, "secondary")) {
return CM_SEL_SECONDARY;
}
return CM_SEL_INVALID;
}
/**
* Start an INCR transfer.
*/
static void incr_send_start(XSelectionRequestEvent *req,
struct cs_content *content) {
long incr_size = content->size;
XChangeProperty(dpy, req->requestor, req->property, incr_atom, 32,
PropModeReplace, (unsigned char *)&incr_size, 1);
struct incr_transfer *it = malloc(sizeof(struct incr_transfer));
expect(it);
*it = (struct incr_transfer){
.requestor = req->requestor,
.property = req->property,
.target = req->target,
.format = 8,
.data = (char *)content->data,
.data_size = content->size,
.offset = 0,
};
it_dbg(it, "Starting transfer\n");
it_add(&it_list, it);
// Listen for PropertyNotify events on the requestor window
XSelectInput(dpy, it->requestor, PropertyChangeMask);
}
/**
* Finish an INCR transfer.
*/
static void incr_send_finish(struct incr_transfer *it) {
XChangeProperty(dpy, it->requestor, it->property, it->target, it->format,
PropModeReplace, NULL, 0);
it_dbg(it, "Transfer complete\n");
it_remove(&it_list, it);
free(it);
}
/**
* Continue sending data during an INCR transfer.
*/
static void incr_send_chunk(const XPropertyEvent *pe) {
if (pe->state != PropertyDelete) {
return;
}
struct incr_transfer *it = it_list;
while (it) {
if (it->requestor == pe->window && it->property == pe->atom) {
size_t remaining = it->data_size - it->offset;
size_t this_chunk_size =
(remaining > chunk_size) ? chunk_size : remaining;
it_dbg(it,
"Sending chunk (bytes sent: %zu, bytes remaining: %zu)\n",
it->offset, remaining);
if (this_chunk_size > 0) {
XChangeProperty(dpy, it->requestor, it->property, it->target,
it->format, PropModeReplace,
(unsigned char *)(it->data + it->offset),
this_chunk_size);
it->offset += this_chunk_size;
} else {
incr_send_finish(it);
}
break;
}
it = it->next;
}
}
/**
* Serve clipboard content for all X11 selection requests until all selections
* have been claimed by another application.
*/
static void _nonnull_ serve_clipboard(uint64_t hash, struct cs_content *content,
const bool selection_active[CM_SEL_MAX]) {
bool running = true;
XEvent evt;
Atom targets, utf8_string;
Atom selection_atoms[CM_SEL_MAX];
Window win;
int remaining_selections;
size_t selection_atoms_len = 0;
dpy = XOpenDisplay(NULL);
expect(dpy);
// Threshold and payload size are intentionally different: the server's
// max request size is a reasonable INCR cutoff, but too aggressive as an
// outbound chunk size for some requestors.
incr_threshold = get_incr_threshold(dpy);
chunk_size = get_chunk_size(dpy);
win = XCreateSimpleWindow(dpy, DefaultRootWindow(dpy), 0, 0, 1, 1, 0, 0, 0);
XStoreName(dpy, win, "clipserve");
targets = XInternAtom(dpy, "TARGETS", False);
utf8_string = XInternAtom(dpy, "UTF8_STRING", False);
incr_atom = XInternAtom(dpy, "INCR", False);
if (selection_active[CM_SEL_PRIMARY]) {
selection_atoms[selection_atoms_len++] = XA_PRIMARY;
}
if (selection_active[CM_SEL_CLIPBOARD]) {
selection_atoms[selection_atoms_len++] =
XInternAtom(dpy, "CLIPBOARD", False);
}
if (selection_active[CM_SEL_SECONDARY]) {
selection_atoms[selection_atoms_len++] = XA_SECONDARY;
}
die_on(selection_atoms_len == 0, "No selections configured\n");
for (size_t i = 0; i < selection_atoms_len; i++) {
bool success = false;
for (int attempts = 0; attempts < 5; attempts++) {
XSetSelectionOwner(dpy, selection_atoms[i], win, CurrentTime);
// According to ICCCM 2.1, a client acquiring a selection should
// confirm success by verifying with GetSelectionOwner.
if (XGetSelectionOwner(dpy, selection_atoms[i]) == win) {
success = true;
break;
}
}
if (!success) {
die("Failed to set selection for %s\n",
XGetAtomName(dpy, selection_atoms[i]));
}
}
remaining_selections = (int)selection_atoms_len;
while (running) {
XNextEvent(dpy, &evt);
switch (evt.type) {
case SelectionRequest: {
XSelectionRequestEvent *req = &evt.xselectionrequest;
XSelectionEvent sev = {.type = SelectionNotify,
.display = req->display,
.requestor = req->requestor,
.selection = req->selection,
.time = req->time,
.target = req->target,
.property = req->property};
// cppcheck-suppress constVariablePointer
// _drop_(XFree) passes this to XFree, so keep it non-const.
_drop_(XFree) char *window_title =
get_window_title(dpy, req->requestor);
dbg("Servicing request to window '%s' (0x%lX) for clip " PRI_HASH
"\n",
strnull(window_title), (unsigned long)req->requestor, hash);
if (req->target == targets) {
Atom available_targets[] = {utf8_string, XA_STRING};
XChangeProperty(dpy, req->requestor, req->property, XA_ATOM,
32, PropModeReplace,
(unsigned char *)&available_targets,
arrlen(available_targets));
} else if (req->target == utf8_string ||
req->target == XA_STRING) {
if (content->size < (off_t)incr_threshold) {
// Data size is small enough, send directly
XChangeProperty(dpy, req->requestor, req->property,
req->target, 8, PropModeReplace,
(unsigned char *)content->data,
(int)content->size);
} else {
// Initiate INCR transfer
incr_send_start(req, content);
}
} else {
sev.property = None;
}
XSendEvent(dpy, req->requestor, False, 0, (XEvent *)&sev);
break;
}
case SelectionClear: {
if (--remaining_selections == 0) {
dbg("Finished serving clip " PRI_HASH "\n", hash);
running = false;
} else {
dbg("%d selections remaining to serve for clip " PRI_HASH
"\n",
remaining_selections, hash);
}
break;
}
case PropertyNotify: {
incr_send_chunk(&evt.xproperty);
break;
}
}
}
XCloseDisplay(dpy);
}
int main(int argc, char *argv[]) {
_drop_(config_free) struct config cfg = setup("clipserve");
exec_man_on_help(argc, argv);
bool selection_active[CM_SEL_MAX] = {0};
bool selection_set = false;
int argi = 1;
for (; argi < argc; argi++) {
if (streq(argv[argi], "--")) {
argi++;
break;
}
if (streq(argv[argi], "-s") || streq(argv[argi], "--selection")) {
die_on(argi + 1 >= argc,
"Usage: clipserve [-s selection]... <hash>\n");
enum selection_type sel = selection_name_to_type(argv[++argi]);
die_on(sel == CM_SEL_INVALID, "Unknown selection: %s\n",
argv[argi]);
selection_active[sel] = true;
selection_set = true;
continue;
}
break;
}
die_on(argi != argc - 1, "Usage: clipserve [-s selection]... <hash>\n");
if (!selection_set) {
selection_active[CM_SEL_PRIMARY] = true;
selection_active[CM_SEL_CLIPBOARD] = true;
}
uint64_t hash;
expect(str_to_hex64(argv[argi], &hash) == 0);
_drop_(close) int content_dir_fd = open(get_cache_dir(&cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(&cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
_drop_(cs_destroy) struct clip_store cs;
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
_drop_(cs_content_unmap) struct cs_content content;
die_on(cs_content_get(&cs, hash, &content) < 0,
"Hash " PRI_HASH " inaccessible\n", hash);
serve_clipboard(hash, &content, selection_active);
return 0;
}

500
src/config.c Normal file
View file

@ -0,0 +1,500 @@
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <ctype.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "config.h"
#include "x.h"
/**
* Determines the runtime directory for storing application data. This is _not_
* the clip store, but the place to create the directory that will become it
* (unless CM_DIR is set, in which case we use that directly).
*/
static const char *get_runtime_directory(void) {
static const char *runtime_dir = NULL;
const char *env_vars[] = {"XDG_RUNTIME_DIR", "TMPDIR"};
if (runtime_dir) {
return runtime_dir;
}
for (size_t i = 0; i < arrlen(env_vars); i++) {
const char *dir = getenv(env_vars[i]);
if (dir) {
return (runtime_dir = dir);
}
}
return (runtime_dir = "/tmp");
}
/**
* Constructs the path to the clip store directory for clipmenu, creating the
* directory if it does not exist.
*/
char *get_cache_dir(struct config *cfg) {
expect(cfg->ready);
static char cache_dir[PATH_MAX];
// In case config changed, do the write anyway
snprintf_safe(cache_dir, PATH_MAX, "%s/clipmenu.%d.%ld", cfg->runtime_dir,
CLIPMENU_VERSION, (long)getuid());
die_on(mkdir(cache_dir, S_IRWXU) != 0 && errno != EEXIST,
"Failed to create directory: %s\n", cache_dir);
return cache_dir;
}
static bool is_absolute_path(const char *path) {
return path && path[0] == '/';
}
static int resolve_path(const char *input, void *output) {
char **out_path = output;
if (!input) {
return -EINVAL;
}
if (input[0] == '~') {
if (input[1] != '\0' && input[1] != '/') {
return -EINVAL;
}
const char *home = getenv("HOME");
if (!home) {
return -EINVAL;
}
size_t len = strlen(home) + strlen(input);
char *expanded = malloc(len);
expect(expanded);
snprintf_safe(expanded, len, "%s%s", home, input + 1);
*out_path = expanded;
} else {
char *dup = strdup(input);
expect(dup);
*out_path = dup;
}
if (!is_absolute_path(*out_path)) {
free(*out_path);
*out_path = NULL;
return -EINVAL;
}
return 0;
}
/**
* This whole section consists of conversion functions to go from a string in
* the config file to the type we expect for `struct Config`.
*/
static int convert_bool(const char *str, void *output) {
const char *const truthy[] = {"1", "y", "yes", "true", "on"};
const char *const falsy[] = {"0", "n", "no", "false", "off"};
for (size_t i = 0; i < arrlen(truthy); i++) {
if (strceq(str, truthy[i])) {
*(bool *)output = true;
return 0;
}
}
for (size_t i = 0; i < arrlen(falsy); i++) {
if (strceq(str, falsy[i])) {
*(bool *)output = false;
return 0;
}
}
return -EINVAL;
}
static int convert_positive_int(const char *str, void *output) {
char *end;
long val = strtol(str, &end, 10);
if (*end != '\0' || end == str || val < 0 || val > INT_MAX) {
return -EINVAL;
}
*(int *)output = (int)val;
return 0;
}
static int convert_ignore_window(const char *str, void *output) {
struct ignore_window *iw = output;
iw->set = (bool)str;
if (!iw->set) {
return 0;
}
if (regcomp(&iw->rgx, str, REG_EXTENDED | REG_NOSUB)) {
return -EINVAL;
}
return 0;
}
static int convert_cm_dir(const char *str, void *output) {
if (!str) {
str = get_runtime_directory();
}
return resolve_path(str, output);
}
static int _nonnull_ convert_launcher(const char *str, void *output) {
struct launcher *lnch = output;
lnch->custom = strdup(str);
expect(lnch->custom);
if (streq(str, "rofi")) {
lnch->ltype = LAUNCHER_ROFI;
} else {
lnch->ltype = LAUNCHER_CUSTOM;
}
return 0;
}
#define DEFAULT_SELECTION_STATE(name) \
(struct selection) { name, 0, NULL }
static char *skip_whitespace(char *str) {
while (*str != '\0' && isspace((unsigned char)*str)) {
str++;
}
return str;
}
static int convert_selections(const char *str, void *output) {
struct selection *sels = malloc(3 * sizeof(struct selection));
expect(sels);
sels[CM_SEL_CLIPBOARD] = DEFAULT_SELECTION_STATE("clipboard");
sels[CM_SEL_PRIMARY] = DEFAULT_SELECTION_STATE("primary");
sels[CM_SEL_SECONDARY] = DEFAULT_SELECTION_STATE("secondary");
_drop_(free) char *inner_str = strdup(str);
expect(inner_str);
char *next = skip_whitespace(inner_str);
while (*next != '\0') {
const char *token = next;
while (*next != '\0' && !isspace((unsigned char)*next)) {
next++;
}
if (*next != '\0') {
*next++ = '\0';
next = skip_whitespace(next);
}
bool found = false;
for (size_t i = 0; i < CM_SEL_MAX; i++) {
if (streq(token, sels[i].name)) {
sels[i].active = true;
found = true;
break;
}
}
if (!found) {
return -EINVAL;
}
}
*(struct selection **)output = sels;
return 0;
}
/**
* Constructs the path to the clipmenu configuration file. The user can
* manually specify a path with $CM_CONFIG, otherwise, it's inferred based on
* $XDG_CONFIG_HOME or ~/.config. It's typically
* ~/.config/clipmenu/clipmenu.conf.
*/
static int get_config_file(char *config_path, size_t config_path_len) {
const char *cm_config = getenv("CM_CONFIG");
const char *xdg_config_home = getenv("XDG_CONFIG_HOME");
const char *home = getenv("HOME");
_drop_(free) char *resolved = NULL;
if (cm_config) {
int ret = resolve_path(cm_config, &resolved);
if (ret != 0) {
fprintf(stderr,
"Invalid config path from $CM_CONFIG: expected absolute "
"path or leading ~\n");
return ret;
}
snprintf_safe(config_path, config_path_len, "%s", resolved);
} else if (xdg_config_home) {
int ret = resolve_path(xdg_config_home, &resolved);
if (ret != 0) {
fprintf(stderr,
"Invalid config path from $XDG_CONFIG_HOME: expected "
"absolute path or leading ~\n");
return ret;
}
snprintf_safe(config_path, config_path_len, "%s/clipmenu/clipmenu.conf",
resolved);
} else {
die_on(!home,
"None of $CM_CONFIG, $XDG_CONFIG_HOME, or $HOME is set\n");
int ret = resolve_path(home, &resolved);
if (ret != 0) {
fprintf(stderr,
"Invalid config path from $HOME: expected absolute path or "
"leading ~\n");
return ret;
}
snprintf_safe(config_path, config_path_len,
"%s/.config/clipmenu/clipmenu.conf", resolved);
}
return 0;
}
static int config_parse_env_vars(struct config_entry entries[],
size_t entries_len) {
for (size_t i = 0; i < entries_len; ++i) {
const char *env_var = entries[i].env_var;
if (!env_var) {
continue;
}
const char *env_value = getenv(env_var);
if (env_value && entries[i].convert(env_value, entries[i].value) != 0) {
fprintf(stderr, "Error parsing environment variable for $%s\n",
env_var);
return -EINVAL;
} else if (env_value) {
dbg("Config entry %s is set to %s by $%s\n", entries[i].config_key,
env_value, env_var);
entries[i].is_set = true;
}
}
return 0;
}
static int config_parse_file(FILE *file, struct config_entry entries[],
size_t entries_len) {
if (!file) {
return 0;
}
char line[256];
while (fgets(line, sizeof(line), file)) {
char *key = skip_whitespace(line);
if (*key == '\0' || *key == '#') {
continue;
}
char *value = key;
while (*value != '\0' && !isspace((unsigned char)*value)) {
value++;
}
if (*value == '\0') {
continue;
}
*value++ = '\0';
value = skip_whitespace(value);
char *newline = strpbrk(value, "\r\n");
if (newline) {
*newline = '\0';
}
for (size_t i = 0; i < entries_len; ++i) {
if (!entries[i].is_set && streq(entries[i].config_key, key)) {
if (entries[i].convert(value, entries[i].value) != 0) {
fprintf(stderr, "Error parsing config file for %s\n",
entries[i].config_key);
return -EINVAL;
}
dbg("Config entry %s is set to %s by config file\n",
entries[i].config_key, value);
entries[i].is_set = true;
break;
}
}
}
return 0;
}
static int config_apply_default_values(struct config_entry entries[],
size_t entries_len) {
for (size_t i = 0; i < entries_len; ++i) {
if (!entries[i].is_set) {
if (entries[i].convert(entries[i].default_value,
entries[i].value) != 0) {
fprintf(stderr, "Error setting default value for %s\n",
entries[i].config_key);
return -EINVAL;
}
dbg("Config entry %s is set to %s by fallback\n",
entries[i].config_key, entries[i].default_value);
}
}
return 0;
}
/**
* Parse the clipmenu configuration file and environment variables to set up
* the application configuration.
*
* Prior to version 7, clipmenu and friends could only be configured via
* environment variables, so these are supported for backwards compatibility.
* In general, it's more straightforward to use the config file nowadays.
*
* This is generally not expected to be called by applications -- call
* config_setup() instead, which provides the right file for you.
*/
static int config_setup_internal(FILE *file, struct config *cfg) {
struct config_entry entries[] = {
{"max_clips", "CM_MAX_CLIPS", &cfg->max_clips, convert_positive_int,
"1000", 0},
{"max_clips_batch", "CM_MAX_CLIPS_BATCH", &cfg->max_clips_batch,
convert_positive_int, "100", 0},
{"oneshot", "CM_ONESHOT", &cfg->oneshot, convert_positive_int, "0", 0},
{"deduplicate", "CM_DEDUPLICATE", &cfg->deduplicate, convert_bool, "0",
0},
{"own_clipboard", "CM_OWN_CLIPBOARD", &cfg->own_clipboard, convert_bool,
"0", 0},
{"selections", "CM_SELECTIONS", &cfg->selections, convert_selections,
"clipboard primary", 0},
{"own_selections", "CM_OWN_SELECTIONS", &cfg->owned_selections,
convert_selections, "clipboard", 0},
{"ignore_window", "CM_IGNORE_WINDOW", &cfg->ignore_window,
convert_ignore_window, NULL, 0},
{"launcher", "CM_LAUNCHER", &cfg->launcher, convert_launcher, "dmenu",
0},
{"launcher_pass_dmenu_args", "CM_LAUNCHER_PASS_DMENU_ARGS",
&cfg->launcher_pass_dmenu_args, convert_bool, "1", 0},
{"touch_on_select", NULL, &cfg->touch_on_select, convert_bool, "0", 0},
{"partial_merge_secs", NULL, &cfg->partial_merge_secs,
convert_positive_int, "2", 0},
{"cm_dir", "CM_DIR", &cfg->runtime_dir, convert_cm_dir, NULL, 0}};
size_t entries_len = arrlen(entries);
int ret = config_parse_env_vars(entries, entries_len);
if (ret < 0) {
return ret;
}
ret = config_parse_file(file, entries, entries_len);
if (ret < 0) {
return ret;
}
ret = config_apply_default_values(entries, entries_len);
if (ret < 0) {
return ret;
}
cfg->ready = true;
return 0;
}
/**
* Frees dynamically allocated memory within the config structure.
*/
void config_free(struct config *cfg) {
free(cfg->runtime_dir);
free(cfg->launcher.custom);
free(cfg->selections);
free(cfg->owned_selections);
if (cfg->ignore_window.set) {
regfree(&cfg->ignore_window.rgx);
}
}
/**
* Initialise the clipmenu configuration by loading settings from environment
* variables and the configuration file.
*/
static void config_setup(struct config *cfg) {
char config_path[PATH_MAX];
die_on(get_config_file(config_path, sizeof(config_path)) != 0,
"Invalid config\n");
_drop_(fclose) FILE *file = fopen(config_path, "r");
expect(file || errno == ENOENT);
die_on(config_setup_internal(file, cfg) != 0, "Invalid config\n");
}
static char stdout_buf[512];
const char *prog_name = "broken";
/**
* Performs initial setup for clipmenu applications, including setting the
* program name (used for dbg()), making sure stdout is line buffered, getting
* the config, and setting up X11 error handling.
*/
struct config setup(const char *inner_prog_name) {
struct config cfg;
prog_name = inner_prog_name;
expect(setvbuf(stdout, stdout_buf, _IOLBF, sizeof(stdout_buf)) == 0);
config_setup(&cfg);
XSetErrorHandler(xerror_handler);
return cfg;
}
void setup_selections(Display *dpy, struct cm_selections *sels) {
sels[CM_SEL_CLIPBOARD].selection = XInternAtom(dpy, "CLIPBOARD", False);
sels[CM_SEL_CLIPBOARD].storage =
XInternAtom(dpy, "CLIPMENUD_CUR_CLIPBOARD", False);
sels[CM_SEL_PRIMARY].selection = XA_PRIMARY;
sels[CM_SEL_PRIMARY].storage =
XInternAtom(dpy, "CLIPMENUD_CUR_PRIMARY", False);
sels[CM_SEL_SECONDARY].selection = XA_SECONDARY;
sels[CM_SEL_SECONDARY].storage =
XInternAtom(dpy, "CLIPMENUD_CUR_SECONDARY", False);
}
/**
* Maps an Atom to a selection_type based on the selection atoms in the
* provided cm_selections struct. Returns CM_SEL_INVALID on error if the
* selection type is not found.
*/
enum selection_type
selection_atom_to_selection_type(Atom atom, const struct cm_selections *sels) {
for (size_t i = 0; i < CM_SEL_MAX; ++i) {
if (sels[i].selection == atom) {
return i;
}
}
return CM_SEL_INVALID;
}
/**
* Maps an Atom to a selection_type based on the storage atoms in the provided
* cm_selections struct. Returns CM_SEL_INVALID on error if the storage type is
* not found.
*/
enum selection_type
storage_atom_to_selection_type(Atom atom, const struct cm_selections *sels) {
for (size_t i = 0; i < CM_SEL_MAX; ++i) {
if (sels[i].storage == atom) {
return i;
}
}
return CM_SEL_INVALID;
}
void exec_man(void) {
execlp("man", "man", prog_name, NULL);
die("Failed to exec man: %s\n", strerror(errno));
}
// cppcheck doesn't know this comes from an outside type
// cppcheck-suppress [constParameter,unmatchedSuppression]
void exec_man_on_help(int argc, char *argv[]) {
for (int i = 1; i < argc; i++) {
if (streq(argv[i], "--")) {
break;
}
if (streq(argv[i], "-h") || streq(argv[i], "--help")) {
exec_man();
}
}
}

104
src/config.h Normal file
View file

@ -0,0 +1,104 @@
#ifndef CM_CONFIG_H
#define CM_CONFIG_H
#include <X11/Xlib.h>
#include <limits.h>
#include <regex.h>
#include <stdbool.h>
#include <stdio.h>
#include "util.h"
#define CLIPMENU_VERSION 7
struct selection {
const char *name;
bool active;
Atom *atom;
};
enum selection_type {
CM_SEL_CLIPBOARD,
CM_SEL_PRIMARY,
CM_SEL_SECONDARY,
CM_SEL_MAX,
CM_SEL_INVALID
};
struct cm_selections {
Atom selection;
Atom storage;
};
struct ignore_window {
bool set;
regex_t rgx;
};
enum launcher_known {
LAUNCHER_ROFI,
LAUNCHER_CUSTOM,
};
struct launcher {
enum launcher_known ltype;
char *custom;
};
struct config {
bool ready;
bool debug;
char *runtime_dir;
int max_clips;
int max_clips_batch;
int oneshot;
bool deduplicate;
bool own_clipboard;
struct selection *owned_selections;
struct selection *selections;
struct ignore_window ignore_window;
struct launcher launcher;
bool launcher_pass_dmenu_args;
bool touch_on_select;
int partial_merge_secs;
};
typedef int (*conversion_func_t)(const char *, void *);
struct config_entry {
const char *config_key;
const char *env_var;
void *value;
conversion_func_t convert;
const char *default_value;
bool is_set;
};
char *get_cache_dir(struct config *cfg);
void exec_man(void);
void exec_man_on_help(int argc, char *argv[]);
/**
* Define a function that generates and caches a path within the application's
* cache directory.
*
* For example, DEFINE_GET_PATH_FUNCTION(foo) defines a function `get_foo_path`
* that returns the path to "foo" within the cache directory.
*/
#define DEFINE_GET_PATH_FUNCTION(name) \
static inline char *get_##name##_path(struct config *cfg) { \
static char path[PATH_MAX]; \
/* Just in case the config changed, do the write anyway */ \
snprintf_safe(path, PATH_MAX, "%s/" #name, get_cache_dir(cfg)); \
return path; \
}
DEFINE_GET_PATH_FUNCTION(line_cache)
DEFINE_GET_PATH_FUNCTION(enabled)
DEFINE_GET_PATH_FUNCTION(session_lock)
extern const char *prog_name;
struct config _nonnull_ setup(const char *inner_prog_name);
void _nonnull_ setup_selections(Display *dpy, struct cm_selections *sels);
enum selection_type _nonnull_
selection_atom_to_selection_type(Atom atom, const struct cm_selections *sels);
enum selection_type _nonnull_
storage_atom_to_selection_type(Atom atom, const struct cm_selections *sels);
void config_free(struct config *cfg);
DEFINE_DROP_FUNC_PTR(struct config, config_free)
#endif

302
src/menu_util.c Normal file
View file

@ -0,0 +1,302 @@
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include "config.h"
#include "menu_util.h"
#include "store.h"
#include "util.h"
#define MAX_ARGS 32
/* "[N] " prefix + snip line + " (N lines)\n" suffix + NUL */
#define LAUNCHER_LINE_MAX \
(1 + UINT64_MAX_STRLEN + 2 + (CS_SNIP_LINE_SIZE - 1) + 2 + \
UINT64_MAX_STRLEN + 7 + 1 + 1)
static int dmenu_user_argc;
static char **dmenu_user_argv;
/**
* Calculate the base 10 padding length for a number.
*/
static int get_padding_length(size_t num) {
int digits = 1;
while (num /= 10) {
digits++;
}
return digits;
}
/**
* Execute the launcher. Called after fork() is already done in the new child.
*/
static void _noreturn_ _nonnull_ exec_launcher(struct config *cfg,
const char *prompt,
int *input_pipe,
int *output_pipe) {
dup2(input_pipe[0], STDIN_FILENO);
close(input_pipe[1]);
close(output_pipe[0]);
dup2(output_pipe[1], STDOUT_FILENO);
const char *dmenu_args[] = {"-p", prompt, "-l", "20"};
const char **cmd = malloc(MAX_ARGS * sizeof(char *));
size_t d_i = 0;
switch (cfg->launcher.ltype) {
case LAUNCHER_ROFI:
cmd[d_i++] = "rofi";
cmd[d_i++] = "--";
cmd[d_i++] = "-dmenu";
break;
case LAUNCHER_CUSTOM:
cmd[d_i++] = cfg->launcher.custom;
break;
default:
die("Unreachable\n");
}
if (cfg->launcher_pass_dmenu_args) {
expect(d_i + arrlen(dmenu_args) < MAX_ARGS);
for (size_t i = 0; i < arrlen(dmenu_args); i++) {
cmd[d_i++] = dmenu_args[i];
}
}
for (int i = 1; i < dmenu_user_argc && d_i < MAX_ARGS - 1; i++, d_i++) {
cmd[d_i] = dmenu_user_argv[i];
}
cmd[d_i] = NULL;
execvp(cmd[0], (char *const *)cmd); // SUS says cmd unchanged
die("Failed to exec %s: %s\n", cmd[0], strerror(errno));
}
static int dprintf_ellipsise_long_snip_line(int fd, const char *line) {
size_t line_len = strlen(line);
if (line_len == CS_SNIP_LINE_SIZE - 1) {
return dprintf(fd, "%.*s...", (int)(CS_SNIP_LINE_SIZE - 4), line);
} else {
return dprintf(fd, "%s", line);
}
}
static int launcher_write_all(int fd, const char *buf, size_t count) {
while (count > 0) {
ssize_t written = write(fd, buf, count);
if (written < 0) {
if (errno == EINTR) {
continue;
}
return negative_errno();
}
buf += written;
count -= (size_t)written;
}
return 0;
}
static int wait_for_pid(pid_t pid, int *status) {
while (waitpid(pid, status, 0) < 0) {
if (errno == EINTR) {
continue;
}
return negative_errno();
}
return 0;
}
static int parse_sel_idx(const char *line, size_t cur_clips, size_t *out_idx) {
if (line[0] != '[') {
return -1;
}
const char *start = line + 1;
const char *end = strchr(start, ']');
if (!end) {
return -1;
}
char idx_str[UINT64_MAX_STRLEN + 1];
size_t len = (size_t)(end - start);
if (len >= sizeof(idx_str)) {
return -1;
}
memcpy(idx_str, start, len);
idx_str[len] = '\0';
uint64_t sel_idx;
if (str_to_uint64(idx_str, &sel_idx) < 0 || sel_idx == 0 ||
sel_idx > cur_clips) {
return -1;
}
*out_idx = (size_t)(sel_idx - 1);
return 0;
}
/**
* Writes the available clips to the launcher and reads back the user's
* selection(s), calling action for each selected clip.
*/
static int _nonnull_ interact_with_dmenu(struct config *cfg, int *input_pipe,
int *output_pipe, pid_t launcher_pid,
clip_action_fn action) {
close(input_pipe[0]);
close(output_pipe[1]);
_drop_(close) int content_dir_fd = open(get_cache_dir(cfg), O_RDONLY);
_drop_(close) int snip_fd =
open(get_line_cache_path(cfg), O_RDWR | O_CREAT, 0600);
expect(content_dir_fd >= 0 && snip_fd >= 0);
_drop_(cs_destroy) struct clip_store cs;
expect(cs_init(&cs, snip_fd, content_dir_fd) == 0);
struct ref_guard guard = cs_ref(&cs);
size_t cur_clips;
expect(cs_len(&cs, &cur_clips) == 0);
_drop_(free) uint64_t *idx_to_hash = malloc(cur_clips * sizeof(uint64_t));
expect(idx_to_hash);
int pad = get_padding_length(cur_clips);
size_t clip_idx = cur_clips;
bool launcher_closed_stdin = false;
int forced_ret = 0;
struct sigaction ignore_sa = {0};
ignore_sa.sa_handler = SIG_IGN;
sigemptyset(&ignore_sa.sa_mask);
struct sigaction old_sa;
expect(sigaction(SIGPIPE, &ignore_sa, &old_sa) == 0);
struct cs_snip *snip = NULL;
while (cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip)) {
size_t idx = --clip_idx;
idx_to_hash[idx] = snip->hash;
if (launcher_closed_stdin) {
continue;
}
char prefix[64];
size_t prefix_len =
snprintf_safe(prefix, sizeof(prefix), "[%*zu] ", pad, idx + 1);
int write_ret = launcher_write_all(input_pipe[1], prefix, prefix_len);
if (write_ret == -EPIPE) {
launcher_closed_stdin = true;
continue;
}
if (write_ret < 0) {
forced_ret = EXIT_FAILURE;
break;
}
write_ret = dprintf_ellipsise_long_snip_line(input_pipe[1], snip->line);
if (write_ret < 0 && errno == EPIPE) {
launcher_closed_stdin = true;
continue;
}
if (write_ret < 0) {
forced_ret = EXIT_FAILURE;
break;
}
if (snip->nr_lines > 1) {
char lines_suffix[64];
size_t lines_suffix_len =
snprintf_safe(lines_suffix, sizeof(lines_suffix),
" (%zu lines)", snip->nr_lines);
write_ret = launcher_write_all(input_pipe[1], lines_suffix,
lines_suffix_len);
if (write_ret == -EPIPE) {
launcher_closed_stdin = true;
continue;
}
if (write_ret < 0) {
forced_ret = EXIT_FAILURE;
break;
}
}
write_ret = launcher_write_all(input_pipe[1], "\n", 1);
if (write_ret == -EPIPE) {
launcher_closed_stdin = true;
continue;
}
if (write_ret < 0) {
forced_ret = EXIT_FAILURE;
break;
}
}
// We've written everything and have our own map, no need to hold any more
cs_unref(guard.cs);
close(input_pipe[1]);
expect(sigaction(SIGPIPE, &old_sa, NULL) == 0);
_drop_(fclose) FILE *output = fdopen(output_pipe[0], "r");
expect(output != NULL);
char line[LAUNCHER_LINE_MAX];
while (!forced_ret && fgets(line, sizeof(line), output) != NULL) {
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0';
len--;
}
if (len == 0) {
continue;
}
size_t idx;
if (parse_sel_idx(line, cur_clips, &idx) == 0) {
int ret = action(cfg, idx_to_hash[idx]);
if (ret != 0) {
forced_ret = ret;
}
} else {
forced_ret = EXIT_FAILURE;
}
}
int dmenu_status;
int wait_ret = wait_for_pid(launcher_pid, &dmenu_status);
if (forced_ret || wait_ret < 0 || !WIFEXITED(dmenu_status)) {
return EXIT_FAILURE;
}
return WEXITSTATUS(dmenu_status);
}
/**
* Prompts the user to select clip(s) via their configured launcher, and
* executes the provided action on each selected clip.
*/
int _nonnull_ menu_prompt_and_act(struct config *cfg, const char *prompt,
clip_action_fn action) {
int input_pipe[2], output_pipe[2];
expect(pipe(input_pipe) == 0 && pipe(output_pipe) == 0);
pid_t pid = fork();
expect(pid >= 0);
if (pid == 0) {
exec_launcher(cfg, prompt, input_pipe, output_pipe);
}
return interact_with_dmenu(cfg, input_pipe, output_pipe, pid, action);
}
/**
* Set the user-provided command-line arguments for launcher passthrough.
*
* This is janky and just mimics how clipmenu worked by itself, in the long run
* we should not rely on these.
*/
void menu_set_user_args(int argc, char **argv) {
dmenu_user_argc = argc;
dmenu_user_argv = argv;
}

18
src/menu_util.h Normal file
View file

@ -0,0 +1,18 @@
#ifndef CM_MENU_UTIL_H
#define CM_MENU_UTIL_H
#include <stdint.h>
#include "config.h"
/**
* Callback function type for handling selected clips.
*/
typedef int (*clip_action_fn)(struct config *cfg, uint64_t hash);
void menu_set_user_args(int argc, char **argv);
int _nonnull_ menu_prompt_and_act(struct config *cfg, const char *prompt,
clip_action_fn action);
#endif

847
src/store.c Normal file
View file

@ -0,0 +1,847 @@
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <sys/file.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "store.h"
/**
* TERMINOLOGY
*
* - Clip store: A pairing of a file containing snips and a content directory
* - Clip store entry: A single pair of snip and content entry
* - Snip file: A file which maps lines to content entries using hashes
* - Snip: A (hash, line) pair in the clip store, representing a content entry
* - Content entry: The full data stored from the clipboard
* - Content directory: The directory in which the content entries are stored
*
* KEY FUNCTIONS:
*
* - cs_add - add a clip store entry
* - cs_remove - remove a clip store entry by callback
* - cs_trim - trim to the newest/oldest N entries
* - cs_snip_iter - iterate over snip hashes and lines
* - cs_content_get - get the content for a snip hash
*
* CLIP STORE DESIGN
*
* Our primary focus is on achieving high efficiency in appending new snips,
* iterating over the entire list of snips, and replacing the final snip in the
* snip file. For this reason complexity is avoided in processing deletions,
* since deletions are usually rare. This avoids us having to implement (for
* example) tombstones, which would slow things down and complicate iteration.
*
* CONTENT DIRECTORY DESIGN
*
* The content directory is extremely simple: it contains files with the same
* name as the snip hash. This allows quickly going from the one line summary
* in the snip to the full contents.
*
* SYNCHRONISATION
*
* The clip store's size may be increased or decreased by another program using
* the library, so before each operation we must take a lock and check if the
* header was updated. If it was, we update the size of the mmapped area to
* suit. The lock is implemented using flock() on cs->snip_fd, see cs_ref(),
* cs_ref_no_update(), and cs_unref().
*/
/**
* Calculate the needed file size in bytes for @nr_snips snips, adding the
* header.
*
* @nr_snips: The number of snips to calculate for
*/
static size_t _must_use_ cs_file_size(size_t nr_snips) {
return (nr_snips + 1) * CS_SNIP_SIZE;
}
/**
* Validate the consistency of the clip store's header information.
*
* @cs: The clip store to operate on
* @file_size: The current size of the file
*/
static int _must_use_ _nonnull_ cs_header_validate(const struct clip_store *cs,
size_t file_size) {
if (cs->header->nr_snips > cs->header->nr_snips_alloc ||
(cs->header->nr_snips_alloc + 1) * CS_SNIP_SIZE != file_size) {
return -EINVAL;
}
return 0;
}
static int _must_use_ _nonnull_ cs_remap(struct clip_store *cs, size_t old_size,
size_t new_size) {
struct cs_header *new_header = mmap(NULL, new_size, PROT_READ | PROT_WRITE,
MAP_SHARED, cs->snip_fd, 0);
if (new_header == MAP_FAILED) {
return negative_errno();
}
if (munmap(cs->header, old_size) < 0) {
int ret = negative_errno();
munmap(new_header, new_size);
return ret;
}
cs->header = new_header;
cs->snips = (struct cs_snip *)(cs->header + 1);
cs->mapped_file_size = new_size;
return 0;
}
/**
* Decrease the reference count for the clip store lock, unrefing it if
* the refcount reaches zero.
*
* @cs: The clip store to operate on
*/
void cs_unref(struct clip_store *cs) {
expect(cs->refcount > 0);
cs->refcount--;
if (cs->refcount == 0) {
expect(flock(cs->snip_fd, LOCK_UN) == 0);
}
}
/**
* Increase the reference count for the clip store lock.
*
* @cs: The clip store to operate on
*/
static struct ref_guard _must_use_ _nonnull_
cs_ref_no_update(struct clip_store *cs) {
struct ref_guard guard = {.status = 0, .unref = cs_unref, .cs = cs};
if (cs->refcount == 0) {
expect(flock(cs->snip_fd, LOCK_EX) == 0);
}
static_assert(sizeof(cs->refcount) == sizeof(size_t),
"refcount type wrong");
expect(cs->refcount < SIZE_MAX);
cs->refcount++;
return guard;
}
/**
* Increase the reference count for the clip store lock, and remap as needed if
* the header values have changed.
*
* Even if the guard status indicates an error, you must still call cs_unref().
*
* @cs: The clip store to operate on
*/
struct ref_guard cs_ref(struct clip_store *cs) {
struct ref_guard guard = cs_ref_no_update(cs);
if (cs->refcount > 1) {
// We're an inner reference, so any necessary remapping has already
// been performed.
return guard;
}
if (cs->local_nr_snips != cs->header->nr_snips ||
cs->local_nr_snips_alloc != cs->header->nr_snips_alloc) {
struct stat st;
if (fstat(cs->snip_fd, &st) < 0) {
guard.status = negative_errno();
return guard;
}
int ret = cs_header_validate(cs, (size_t)st.st_size);
if (ret < 0) {
guard.status = ret;
return guard;
}
// If we shrank, no need to remap, since we'll just use the new bounds.
if (cs->local_nr_snips_alloc < cs->header->nr_snips_alloc) {
int remap_ret = cs_remap(cs, cs->mapped_file_size,
cs_file_size(cs->header->nr_snips_alloc));
if (remap_ret < 0) {
guard.status = remap_ret;
return guard;
}
}
cs->local_nr_snips = cs->header->nr_snips;
cs->local_nr_snips_alloc = cs->header->nr_snips_alloc;
}
return guard;
}
/**
* _drop_() function for when a `ref_guard` structure goes out of scope.
*
* @guard: The guard lock
*/
void drop_cs_unref(struct ref_guard *guard) {
guard->status = -EBADF;
guard->unref(guard->cs);
}
/**
* Destroy the clip store, releasing all of its resources.
*
* @cs: The clip store to operate on
*/
int cs_destroy(struct clip_store *cs) {
cs->ready = false;
if (munmap(cs->header, cs->mapped_file_size)) {
return negative_errno();
}
return 0;
}
/**
* _drop_() function for when a `clip_store` goes out of scope.
*
* @guard: The guard lock
*/
void drop_cs_destroy(struct clip_store *cs) { expect(cs_destroy(cs) == 0); }
/**
* Initialise a `struct clip_store` with snip_fd open to a file for snip
* storage and content_fd open to a directory for content entry storage.
*
* The snip file is extended and the header snip is written if the file size is
* zero. The file is mapped into memory until cs_destroy() is called.
*
* @cs: The clip store to initialise
* @snip_fd: Open file descriptor for the snip file
* @content_dir_fd: Open file descriptor for the content directory
*/
int cs_init(struct clip_store *cs, int snip_fd, int content_dir_fd) {
cs->ready = false;
cs->snip_fd = snip_fd;
cs->content_dir_fd = content_dir_fd;
cs->refcount = 0;
_drop_(cs_unref) struct ref_guard guard = cs_ref_no_update(cs);
struct stat st;
if (fstat(snip_fd, &st) < 0) {
return negative_errno();
}
if (st.st_size % CS_SNIP_SIZE != 0) {
return -EINVAL;
}
size_t file_size = (size_t)st.st_size;
if (file_size == 0) {
file_size = CS_SNIP_SIZE;
if (ftruncate(snip_fd, (off_t)file_size) < 0) {
return negative_errno();
}
}
cs->header =
mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, snip_fd, 0);
if (cs->header == MAP_FAILED) {
return negative_errno();
}
int ret = cs_header_validate(cs, file_size);
if (ret < 0) {
munmap(cs->header, file_size);
return ret;
}
cs->snips = (struct cs_snip *)(cs->header + 1);
cs->local_nr_snips = cs->header->nr_snips;
cs->local_nr_snips_alloc = cs->header->nr_snips_alloc;
cs->mapped_file_size = file_size;
cs->ready = true;
(void)guard; // Old clang will complain guard is unused, despite cleanup
return 0;
}
/**
* Round up a number to the nearest multiple of a specified step.
*
* @n: The number to round up
* @step: The step size to round up to
*/
static size_t round_up(size_t n, size_t step) {
return ((n + step - 1) / step) * step;
}
/**
* Adjust the size of the mmapped file used by the clip store to the specified
* new size. It first attempts to resize the file using ftruncate(). If this
* operation is successful, it then remaps the memory mapping to reflect the
* new size of the file.
*
* WARNING: cs->snips and cs->header may move after cs_file_resize(), so
* copied pointers from before invocation must not be used after calling this.
*
* @cs: The clip store to operate on
* @new_nr_snips: The new number of snips in the snip file
*/
static int _must_use_ _nonnull_ cs_file_resize(struct clip_store *cs,
size_t new_nr_snips) {
bool grow = new_nr_snips >= cs->header->nr_snips;
if (grow && new_nr_snips <= cs->header->nr_snips_alloc) {
cs->header->nr_snips = new_nr_snips;
return 0;
}
/* If this is a shrink, do it exactly: someone may be deleting sensitive
* snips and so it's best to remove them immediately. Otherwise, batch
* allocate. */
size_t new_nr_snips_alloc =
grow ? round_up(new_nr_snips, CS_SNIP_ALLOC_BATCH) : new_nr_snips;
size_t new_size = cs_file_size(new_nr_snips_alloc);
if (ftruncate(cs->snip_fd, (off_t)new_size) < 0) {
return negative_errno();
}
if (grow) {
size_t old_file_size = cs_file_size(cs->header->nr_snips_alloc);
int ret = cs_remap(cs, cs->mapped_file_size, new_size);
if (ret < 0) {
expect(ftruncate(cs->snip_fd, (off_t)old_file_size) == 0);
return ret;
}
}
cs->header->nr_snips = cs->local_nr_snips = new_nr_snips;
cs->header->nr_snips_alloc = cs->local_nr_snips_alloc = new_nr_snips_alloc;
return 0;
}
/**
* Update a clip store snip to contain the specified hash and line content.
*
* @snip: Pointer to the snip to modify
* @hash: The new hash value for the snip
* @line: The new line content for the snip
* @nr_lines: The number of lines in the line content
*/
static void _nonnull_ cs_snip_update(struct cs_snip *snip, uint64_t hash,
const char *line, uint64_t nr_lines) {
snip->hash = hash;
snip->doomed = false;
snip->nr_lines = nr_lines;
strncpy(snip->line, line, CS_SNIP_LINE_SIZE - 1);
snip->line[CS_SNIP_LINE_SIZE - 1] = '\0';
}
/**
* Extracts the first non-empty line from a given text buffer and copies it to
* the output buffer. Returns the total number of lines. A final line with no
* newline is considered a line for accounting purposes.
*
* @text: The input text buffer
* @out: The output buffer. Must be at least CS_SNIP_LINE_SIZE bytes
*/
size_t first_line(const char *text, char *out) {
bool found = false;
size_t nr_lines = 0;
const char *cur = text;
out[0] = '\0';
for (; *cur; cur++) {
nr_lines += (*cur == '\n');
if (!found && *cur != '\n') {
found = true;
snprintf(out, CS_SNIP_LINE_SIZE, "%.*s", (int)strcspn(cur, "\n"),
cur);
}
}
return nr_lines + (found && *--cur != '\n');
}
/**
* Add a new snip consisting of a hash value and a line of text to the end of
* the clip store. The snip file size is grown as necessary to accommodate the
* new snip.
*
* @cs: The clip store to operate on
* @hash: The hash value of the snip to add
* @line: The line content of the snip to add
* @nr_lines: The number of lines in the line content
*/
static int _must_use_ _nonnull_ cs_snip_add(struct clip_store *cs,
uint64_t hash, const char *line,
uint64_t nr_lines) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
int ret = cs_file_resize(cs, cs->header->nr_snips + 1);
if (ret < 0) {
return ret;
}
cs_snip_update(cs->snips + cs->header->nr_snips - 1, hash, line, nr_lines);
return 0;
}
/**
* Add content to the content directory using the hash as the filename.
*
* @cs: The clip store to operate on
* @hash: The hash of the content to add
* @content: The content to add to the file
* @dupe_policy: If set to CS_DUPE_KEEP_LAST, will return with -EEXIST when
* trying to insert duplicate entry.
*/
static int _must_use_ _nonnull_
cs_content_add(struct clip_store *cs, uint64_t hash, const char *content,
enum cs_dupe_policy dupe_policy) {
bool dupe = false;
size_t content_len = strlen(content);
char dir_path[CS_HASH_STR_MAX];
snprintf(dir_path, sizeof(dir_path), PRI_HASH, hash);
int ret = mkdirat(cs->content_dir_fd, dir_path, 0700);
if (ret < 0) {
if (errno != EEXIST || dupe_policy == CS_DUPE_KEEP_LAST) {
return negative_errno();
}
dupe = true;
}
char base_file_path[PATH_MAX];
snprintf(base_file_path, sizeof(base_file_path), "%s/1", dir_path);
if (dupe) {
// This clip already exists, just create a link for refcounting
struct stat st;
if (fstatat(cs->content_dir_fd, base_file_path, &st, 0) < 0) {
return negative_errno();
}
if ((size_t)st.st_size != content_len) {
// Extremely unlikely with FNV-1a 64-bit outside of artificial
// scenarios, but never hurts to be careful...
return -EFAULT;
}
size_t link_num = (size_t)st.st_nlink + 1;
char linkpath[PATH_MAX];
snprintf(linkpath, sizeof(linkpath), "%s/%zu", dir_path, link_num);
if (linkat(cs->content_dir_fd, base_file_path, cs->content_dir_fd,
linkpath, 0) < 0) {
return negative_errno();
}
return 0;
}
// This is a new clip
_drop_(close) int fd = openat(cs->content_dir_fd, base_file_path,
O_WRONLY | O_CREAT | O_EXCL, 0600);
if (fd < 0) {
return negative_errno();
}
const char *cur = content;
size_t remaining = content_len;
while (remaining > 0) {
ssize_t written = write(fd, cur, remaining);
if (written < 0) {
return negative_errno();
}
remaining -= (size_t)written;
cur += written;
}
return 0;
}
/**
* Clean up the backing mapped data and file descriptor for a `struct
* cs_content` object.
*
* @content: The content to unmap
*/
static int cs_content_unmap(struct cs_content *content) {
if (content && content->data) {
close(content->fd);
if (munmap(content->data, (size_t)content->size)) {
return negative_errno();
}
}
return 0;
}
/**
* _drop_ function for cs_content_unmap()
*
* @content: The content to unmap
*/
void drop_cs_content_unmap(struct cs_content *content) {
int ret = cs_content_unmap(content);
expect(ret == 0);
}
/**
* Retrieve the content associated with a given hash from the content directory
* and map it into memory.
*
* @cs: The clip store to operate on
* @hash: The hash of the content to retrieve
* @content: A pointer to a `struct cs_content` to populate. The caller must
* call cs_content_unmap() when done to free it
*/
int cs_content_get(struct clip_store *cs, uint64_t hash,
struct cs_content *content) {
memset(content, '\0', sizeof(struct cs_content));
char filename[PATH_MAX];
snprintf(filename, sizeof(filename), PRI_HASH "/1", hash);
_drop_(close) int fd = openat(cs->content_dir_fd, filename, O_RDONLY);
if (fd < 0) {
return negative_errno();
}
struct stat st;
if (fstat(fd, &st) < 0) {
return negative_errno();
}
char *data = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (data == MAP_FAILED) {
return negative_errno();
}
content->data = data;
content->fd = fd;
content->size = st.st_size;
fd = -1; // Disarm the _drop_, this will be handled by cs_content_unmap()
return 0;
}
/**
* Move the entry with the specified hash to the newest slot.
*
* @cs: The clip store to operate on
* @hash: The hash of the entry to move
*/
int cs_make_newest(struct clip_store *cs, uint64_t hash) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
for (int i = 0; i < (int)cs->local_nr_snips; ++i) {
if (cs->snips[i].hash == hash) {
struct cs_snip tmp = cs->snips[i];
memmove(cs->snips + i, cs->snips + i + 1,
(cs->local_nr_snips - (i + 1)) * sizeof(*cs->snips));
cs->snips[cs->local_nr_snips - 1] = tmp;
return 0;
}
}
return -ENOENT;
}
static int _must_use_ _nonnull_ cs_content_remove(struct clip_store *cs,
uint64_t hash);
/**
* Add a new content entry to the clip store and content directory.
*
* @cs: The clip store to operate on
* @content: The content to add
* @out_hash: Output for the generated hash, or NULL
* @dupe_policy: Policy to use for duplicate entries
*/
int cs_add(struct clip_store *cs, const char *content, uint64_t *out_hash,
enum cs_dupe_policy dupe_policy) {
uint64_t hash = fnv1a_64_hash(content);
char line[CS_SNIP_LINE_SIZE];
size_t nr_lines = first_line(content, line);
if (out_hash) {
*out_hash = hash;
}
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
int ret = cs_content_add(cs, hash, content, dupe_policy);
if (ret == -EEXIST && dupe_policy == CS_DUPE_KEEP_LAST) {
return cs_make_newest(cs, hash);
}
if (ret) {
return ret;
}
ret = cs_snip_add(cs, hash, line, nr_lines);
if (ret) {
int rm_ret = cs_content_remove(cs, hash);
if (rm_ret) {
warn("Failed to roll back leaked content for hash " PRI_HASH
" after cs_snip_add failed (%s): %s\n",
hash, strerror(-ret), strerror(-rm_ret));
return rm_ret;
}
}
return ret;
}
/**
* Iterate over the snips in the clip store. The function should be initially
* called with *snip set to NULL, which will set *snip to point to the newest
* snip (excluding the header). On subsequent calls, *snip is updated to point
* to the next snip in the snip file. The iteration stops when there are no
* more snips to process, indicated by the function returning false.
*
* @guard: The guard lock
* @snip: Pointer to a pointer to the current snip being iterated over
* @direction: Whether to iterate from the oldest to newest or vice versa
*/
bool cs_snip_iter(struct ref_guard *guard, enum cs_iter_direction direction,
struct cs_snip **snip) {
if (guard->status < 0 || guard->cs->header->nr_snips == 0) {
return false;
}
struct cs_snip *oldest = guard->cs->snips;
struct cs_snip *newest = oldest + (guard->cs->header->nr_snips - 1);
const struct cs_snip *stop =
direction == CS_ITER_NEWEST_FIRST ? oldest : newest;
if (!*snip) {
*snip = direction == CS_ITER_NEWEST_FIRST ? newest : oldest;
return true;
} else if (*snip != stop) {
*snip = *snip + (direction == CS_ITER_NEWEST_FIRST ? -1 : 1);
return true;
}
return false;
}
/**
* Remove content from the content directory using the hash as the filename.
*
* @cs: The clip store to operate on
* @hash: The hash of the content to remove
*/
static int _must_use_ _nonnull_ cs_content_remove(struct clip_store *cs,
uint64_t hash) {
char hash_dir_name[CS_HASH_STR_MAX];
snprintf(hash_dir_name, sizeof(hash_dir_name), PRI_HASH, hash);
_drop_(close) int hash_dir_fd =
openat(cs->content_dir_fd, hash_dir_name, O_RDONLY);
if (hash_dir_fd < 0) {
return negative_errno();
}
struct stat st;
if (fstatat(hash_dir_fd, "1", &st, 0) < 0) {
return negative_errno();
}
char nlink_path[PATH_MAX];
snprintf(nlink_path, sizeof(nlink_path), "%u", (unsigned)st.st_nlink);
if (unlinkat(hash_dir_fd, nlink_path, 0) < 0) {
return negative_errno();
}
if (st.st_nlink == 1 &&
unlinkat(cs->content_dir_fd, hash_dir_name, AT_REMOVEDIR) < 0) {
return negative_errno();
}
return 0;
}
/**
* Compacts the clip store by removing doomed snips, finalising their removal
* after being marked in cs_remove().
*
* @guard: The guard lock
*/
static size_t _nonnull_ cs_snip_remove_doomed(struct ref_guard *guard) {
size_t nr_doomed = 0;
struct cs_snip *snip = NULL;
while (cs_snip_iter(guard, CS_ITER_OLDEST_FIRST, &snip)) {
if (snip->doomed) {
nr_doomed++;
} else if (nr_doomed > 0) {
*(snip - nr_doomed) = *snip;
}
}
return nr_doomed;
}
/**
* Iterate over the specified number of snips in the clip store from newest
* to oldest and remove those for which the predicate function returns
* CS_ACTION_REMOVE (see `enum cs_remove_action` above).
*
* @cs: The clip store to operate on
* @should_remove: Function pointer to the predicate used to decide removal
* @private: Pointer to user-defined data passed to the predicate function
* @direction: Whether to iterate from the oldest to newest or vice versa
*/
int cs_remove(struct clip_store *cs, enum cs_iter_direction direction,
enum cs_remove_action (*should_remove)(uint64_t, const char *,
void *),
void *private) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
bool found = false;
struct cs_snip *snip = NULL;
while (cs_snip_iter(&guard, direction, &snip)) {
enum cs_remove_action action =
should_remove(snip->hash, snip->line, private);
if (action & CS_ACTION_REMOVE) {
found = true;
int ret = cs_content_remove(cs, snip->hash);
if (ret < 0) {
return ret;
}
snip->doomed = true;
}
if (action & CS_ACTION_STOP) {
break;
}
}
if (!found) {
return 0;
}
size_t nr_doomed = cs_snip_remove_doomed(&guard);
int ret = cs_file_resize(cs, cs->header->nr_snips - nr_doomed);
if (ret < 0) {
return ret;
}
return 0;
}
/**
* Callback function for deciding which snips to remove during trim operation.
*
* @hash: Unused
* @line: Unused
* @private: The count of remaining entries to trim
*/
static enum cs_remove_action _must_use_ _nonnull_
trim_callback(uint64_t hash, const char *line, void *private) {
(void)hash;
(void)line;
size_t *count = private;
if (*count == 0) {
return CS_ACTION_REMOVE;
}
(*count)--;
return CS_ACTION_KEEP;
}
/**
* Trim the clip store to only retain the specified number of snips.
*
* @cs: The clip store to operate on
* @direction: Whether to remove the N newest or N oldest
* @nr_keep: The number of newest snips to retain
*/
int cs_trim(struct clip_store *cs, enum cs_iter_direction direction,
size_t nr_keep) {
if (nr_keep >= cs->header->nr_snips) {
return 0; // No action needed if we're keeping everything or more
}
int ret = cs_remove(cs, direction, trim_callback, &nr_keep);
if (ret < 0) {
return ret;
}
return 0;
}
/**
* Replace the content and snip for an entry in the clip store, identified by
* its age.
*
* @cs: The clip store to operate on
* @age: The age of the snip to replace, with 0 being the newest
* @direction: Whether to iterate from the oldest to newest or vice versa
* @content: The content to replace this entry with
* @out_hash: Output for the generated hash, or NULL
*/
int cs_replace(struct clip_store *cs, enum cs_iter_direction direction,
size_t age, const char *content, uint64_t *out_hash) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
if (age >= cs->header->nr_snips) {
return -ERANGE;
}
size_t idx = direction == CS_ITER_NEWEST_FIRST
? cs->header->nr_snips - age - 1
: age;
struct cs_snip *snip = cs->snips + idx;
char line[CS_SNIP_LINE_SIZE];
size_t nr_lines = first_line(content, line);
uint64_t old_hash = snip->hash;
uint64_t hash = fnv1a_64_hash(content);
int ret = cs_content_add(cs, hash, content, CS_DUPE_KEEP_ALL);
if (ret) {
return ret;
}
ret = cs_content_remove(cs, old_hash);
if (ret) {
return ret;
}
cs_snip_update(snip, hash, line, nr_lines);
if (out_hash) {
*out_hash = hash;
}
return 0;
}
/**
* Get the current number of entries in the clip store.
*
* @cs: The clip store to operate on
* @out_len: Output for the length of the clip store
*/
int cs_len(struct clip_store *cs, size_t *out_len) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
if (guard.status < 0) {
return guard.status;
}
*out_len = cs->header->nr_snips;
return 0;
}

192
src/store.h Normal file
View file

@ -0,0 +1,192 @@
#ifndef CM_STORE_H
#define CM_STORE_H
#include <stdbool.h>
#include <stdint.h>
#include <sys/types.h>
#include "util.h"
#define CS_SNIP_SIZE 256 /* The size of each struct cs_snip */
#define CS_SNIP_ALLOC_BATCH 1024 /* How many snips to allocate when growing */
#define CS_HASH_STR_MAX 17 /* String length of 64bit hex + \0 */
#define PRI_HASH "%016" PRIX64
/**
* A single snip within the clip store.
*
* @hash: A 64-bit hash value associated with the content entry
* @doomed: Used during cs_remove to batch mark entries for removal
* @nr_lines: The number of lines in the content entry
* @line: A character array containing the first salient line, terminated by a
* null byte
*/
#define CS_SNIP_LINE_SIZE CS_SNIP_SIZE - (sizeof(uint64_t) * 2) - sizeof(bool)
struct _packed_ cs_snip {
uint64_t hash;
bool doomed;
uint64_t nr_lines;
char line[CS_SNIP_LINE_SIZE];
};
/**
* The header of the clip store. Must fit within the footprint of a regular
* `cs_snip`.
*
* @nr_snips: The total number of snips in the clip store, excluding the
* header
* @nr_snips_alloc: The total number of allocated snips in the clip store
* that can be used without _cs_file_resize(), excluding the
* header
* @_unused_padding: Padding to match the size of cs_snip
*/
#define CS_HEADER_PADDING_SIZE CS_SNIP_SIZE - (sizeof(uint64_t) * 2)
struct _packed_ cs_header {
uint64_t nr_snips;
uint64_t nr_snips_alloc;
char _unused_padding[CS_HEADER_PADDING_SIZE];
};
static_assert(sizeof(struct cs_snip) == CS_SNIP_SIZE, "cs_snip wrong size");
static_assert(sizeof(struct cs_snip) == sizeof(struct cs_header),
"cs_header and cs_snip must be the same size");
/**
* The main interface to the clip store for the user.
*
* @snip_fd: The file descriptor for the snip file
* @content_dir_fd: The file descriptor for the content directory
* @header: Pointer to the header in the mmapped file
* @snips: Pointer to the beginning of the clip store snips in the mmapped
* file, directly after the header
* @ready: Indicates if the clip store is ready for operations
* @refcount: The reference count for the fd flock
* @local_nr_snips: Our last known header->nr_snips
* @local_nr_snips_alloc: Our last known header->nr_snips_alloc
* @mapped_file_size: The actual size of our current mmap() in bytes
*/
struct clip_store {
/* FDs */
int snip_fd;
int content_dir_fd;
/* Pointers inside mmapped snip file */
struct cs_header *header;
struct cs_snip *snips;
/* Synchronisation */
size_t refcount;
size_t local_nr_snips;
size_t local_nr_snips_alloc;
size_t mapped_file_size;
bool ready;
};
/**
* Manages the lifecycle of a single clip store lock reference.
*
* @status: The result of trying update our mappings to new header values. If
* less than 0, the lock should not be used. Set to -EBADF on unref.
* @unref: The function to call to unref. This mostly exists to ensure that
* the ref_guard is used and is not optimised by the compiler.
* @cs: A pointer to the clip store structure on which the lock is held.
*
* Usage of this structure involves creating a ref guard instance at the
* beginning of a scope where a lock is needed. Functions which need a lock
* accept a `struct ref_guard *` as their first argument
*/
struct ref_guard {
int status;
struct clip_store *cs;
void (*unref)(struct clip_store *);
};
/**
* The memory-mapped content associated with a hash in the content directory.
*
* @data: A pointer to the memory-mapped data
* @fd: The file descriptor of the opened file from which the content is mapped
* @size: The size of the mapped data
*/
struct cs_content {
char *data;
int fd;
off_t size;
};
/**
* The direction in which to iterate over snips in the clip store.
*
* @CS_ITER_NEWEST_FIRST: Iterate over the snips starting from the newest.
* @CS_ITER_OLDEST_FIRST: Iterate over the snips starting from the oldest.
*/
enum cs_iter_direction { CS_ITER_NEWEST_FIRST, CS_ITER_OLDEST_FIRST };
/**
* Set the bit at position n.
*
* @n: The position of the bit to set
*/
#define BIT(n) (1UL << (n))
/**
* The action to take as returned by `cs_remove`'s `should_remove()` predicate
* function.
*
* @CS_ACTION_REMOVE: Remove this snip from the clip store
* @CS_ACTION_KEEP: Keep this snip in the clip store
* @CS_ACTION_STOP: Stop iteration after processing this snip
*
* If neither of CS_ACTION_REMOVE or CS_ACTION_KEEP are specified, the snip is
* kept.
*/
enum cs_remove_action {
CS_ACTION_REMOVE = BIT(0),
CS_ACTION_KEEP = BIT(1),
CS_ACTION_STOP = BIT(2),
};
/**
* What to do when there's a duplicate entry.
*
* @CS_DUPE_KEEP_ALL: Keep all duplicate entries.
* @CS_DUPE_KEEP_LAST: Only keep the newest, do not insert duplicate entries.
*/
enum cs_dupe_policy {
CS_DUPE_KEEP_ALL,
CS_DUPE_KEEP_LAST,
};
struct ref_guard _must_use_ _nonnull_ cs_ref(struct clip_store *cs);
void _nonnull_ cs_unref(struct clip_store *cs);
void _nonnull_ drop_cs_unref(struct ref_guard *guard);
int _must_use_ _nonnull_ cs_destroy(struct clip_store *cs);
int _must_use_ _nonnull_ cs_init(struct clip_store *cs, int snip_fd,
int content_dir_fd);
void drop_cs_content_unmap(struct cs_content *content);
void drop_cs_destroy(struct clip_store *cs);
int _must_use_ _nonnull_ cs_content_get(struct clip_store *cs, uint64_t hash,
struct cs_content *content);
int _must_use_ _nonnull_n_(1)
cs_make_newest(struct clip_store *cs, uint64_t hash);
int _must_use_ _nonnull_n_(1)
cs_add(struct clip_store *cs, const char *content, uint64_t *out_hash,
enum cs_dupe_policy dupe_policy);
bool _must_use_ _nonnull_ cs_snip_iter(struct ref_guard *guard,
enum cs_iter_direction direction,
struct cs_snip **snip);
int _must_use_ _nonnull_ cs_remove(
struct clip_store *cs, enum cs_iter_direction direction,
enum cs_remove_action (*should_remove)(uint64_t, const char *, void *),
void *private);
int _must_use_ _nonnull_ cs_trim(struct clip_store *cs,
enum cs_iter_direction direction,
size_t nr_keep);
int _must_use_ _nonnull_n_(1, 4)
cs_replace(struct clip_store *cs, enum cs_iter_direction direction,
size_t age, const char *content, uint64_t *out_hash);
int _nonnull_ cs_len(struct clip_store *cs, size_t *out_len);
size_t _nonnull_ first_line(const char *text, char *out);
#endif

165
src/util.c Normal file
View file

@ -0,0 +1,165 @@
#include <errno.h>
#include <stdarg.h>
#include <stdbool.h>
#include <string.h>
#include "config.h"
#include "store.h"
#include "util.h"
/**
* Write data to a file descriptor, ensuring all bytes are written.
*/
void write_safe(int fd, const char *buf, size_t count) {
while (count > 0) {
ssize_t chunk_size = write(fd, buf, count);
expect(chunk_size >= 0);
buf += chunk_size;
expect(chunk_size <= (ssize_t)count);
count -= (size_t)chunk_size;
}
}
/**
* Read data from a file descriptor into a buffer safely, ensuring correct
* handling of partial reads.
*/
size_t read_safe(int fd, char *buf, size_t count) {
size_t count_start = count;
while (count > 0) {
ssize_t chunk_size = read(fd, buf, count);
expect(chunk_size >= 0);
if (chunk_size == 0) { // EOF
break;
}
buf += chunk_size;
count -= (size_t)chunk_size;
}
expect(count_start >= count);
return count_start - count;
}
/**
* Performs safe, bounded string formatting into a buffer. On error or
* truncation, expect() aborts.
*/
size_t snprintf_safe(char *buf, size_t len, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
int needed = vsnprintf(buf, len, fmt, args);
va_end(args);
expect(needed >= 0 && (size_t)needed < len);
return (size_t)needed;
}
/**
* Runs clipserve to handle selection requests for a hash in the clip store.
*/
#define CLIPSERVE_MAX_ARGS (CM_SEL_MAX * 2 + 3)
void run_clipserve(uint64_t hash, const struct selection *selections) {
char hash_str[CS_HASH_STR_MAX];
snprintf(hash_str, sizeof(hash_str), PRI_HASH, hash);
const char *cmd[CLIPSERVE_MAX_ARGS];
size_t cmd_idx = 0;
cmd[cmd_idx++] = "clipserve";
if (selections) {
for (size_t i = 0; i < CM_SEL_MAX; i++) {
if (!selections[i].active) {
continue;
}
expect(cmd_idx + 2 < CLIPSERVE_MAX_ARGS);
cmd[cmd_idx++] = "--selection";
cmd[cmd_idx++] = selections[i].name;
}
}
cmd[cmd_idx++] = hash_str;
cmd[cmd_idx++] = NULL;
pid_t pid = fork();
expect(pid >= 0);
if (pid > 0) {
return;
}
execvp(cmd[0], (char *const *)cmd);
die("Failed to exec %s: %s\n", cmd[0], strerror(errno));
}
/**
* Convert a positive errno value to a negative error code, ensuring a
* non-zero value is returned.
*
* This is needed because clang-tidy and gcc may complain when doing plain
* "return -errno" because the compiler does not know that errno cannot be 0
* (and thus that later checks with func() == 0 cannot pass in error
* situations).
*/
int negative_errno(void) { return errno > 0 ? -errno : -EINVAL; }
/**
* Convert a string to an unsigned 64-bit integer in given base, validating the
* format and range of the input.
*/
static int str_to_uint64_base(const char *input, uint64_t *output, int base) {
char *endptr;
errno = 0;
uint64_t val = strtoull(input, &endptr, base);
if (errno > 0) {
return negative_errno();
}
if (!endptr || endptr == input || *endptr != 0) {
return -EINVAL;
}
if (val != 0 && input[0] == '-') {
return -ERANGE;
}
*output = val;
return 0;
}
/**
* Convert a string to an unsigned 64-bit integer, validating the format and
* range of the input.
*/
int str_to_uint64(const char *input, uint64_t *output) {
return str_to_uint64_base(input, output, 10);
}
/**
* Convert a hex string to an unsigned 64-bit integer, validating the format
* and range of the input.
*/
int str_to_hex64(const char *input, uint64_t *output) {
return str_to_uint64_base(input, output, 16);
}
/**
* Check whether debug mode is enabled and cache the result. Used for dbg().
*/
bool debug_mode_enabled(void) {
static int debug_enabled = -1;
if (debug_enabled == -1) {
const char *dbg_env = getenv("CM_DEBUG");
debug_enabled = dbg_env && streq(dbg_env, "1");
}
return debug_enabled;
}
/**
* Compute a 64-bit FNV-1a hash for a NULL-terminated string.
*/
uint64_t fnv1a_64_hash(const char *buf) {
const uint64_t fnv_offset_basis = 0xcbf29ce484222325ULL;
const uint64_t fnv_prime = 0x100000001b3ULL;
uint64_t hash = fnv_offset_basis;
const uint8_t *src = (const uint8_t *)buf;
while (*src) {
hash ^= *src++;
hash *= fnv_prime;
}
return hash;
}

111
src/util.h Normal file
View file

@ -0,0 +1,111 @@
#ifndef CM_UTIL_H
#define CM_UTIL_H
#include <dirent.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
struct selection;
#define _drop_(x) __attribute__((__cleanup__(drop_##x)))
#define _must_use_ __attribute__((warn_unused_result))
#define _nonnull_ __attribute__((nonnull))
#define _nonnull_n_(...) __attribute__((nonnull(__VA_ARGS__)))
#define _noreturn_ __attribute__((noreturn))
#define _packed_ __attribute__((packed))
#define _printf_(a, b) __attribute__((__format__(printf, a, b)))
#define _unused_ __attribute__((__unused__))
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
#ifndef __cplusplus
#define static_assert _Static_assert
#endif
#define UINT64_MAX_STRLEN 20 // (1 << 64) - 1 is 20 digits wide
#define streq(a, b) (strcmp((a), (b)) == 0)
#define strceq(a, b) (strcasecmp((a), (b)) == 0)
#define strnull(s) (s) ? (s) : "[null]"
#define arrlen(x) \
(__builtin_choose_expr( \
!__builtin_types_compatible_p(typeof(x), typeof(&*(x))), \
sizeof(x) / sizeof((x)[0]), (void)0 /* decayed, compile error */))
#define _msg(level, fmt, ...) fprintf(stderr, level ": " fmt, ##__VA_ARGS__)
#define warn(fmt, ...) _msg("WARN", fmt, ##__VA_ARGS__)
#define _die(dump, fmt, ...) \
do { \
_msg("FATAL", fmt, ##__VA_ARGS__); \
if (dump) { \
abort(); \
} \
exit(1); \
} while (0)
#define die(fmt, ...) _die(0, fmt, ##__VA_ARGS__)
#define die_on(cond, fmt, ...) \
do { \
if (unlikely(cond)) { \
die(fmt, ##__VA_ARGS__); \
} \
} while (0)
#define expect(x) \
do { \
if (!likely(x)) { \
_die(1, "!(%s) at %s:%s:%d\n", #x, __FILE__, __func__, __LINE__); \
} \
} while (0)
#define dbg(fmt, ...) \
do { \
if (debug_mode_enabled()) { \
fprintf(stderr, "%s:%ld:%s:%s:%d: " fmt, prog_name, \
(long)getpid(), __FILE__, __func__, __LINE__, \
##__VA_ARGS__); \
} \
} while (0)
void _nonnull_ write_safe(int fd, const char *buf, size_t count);
size_t _nonnull_ read_safe(int fd, char *buf, size_t count);
size_t _printf_(3, 4)
snprintf_safe(char *buf, size_t len, const char *fmt, ...);
void run_clipserve(uint64_t hash, const struct selection *selections);
uint64_t _nonnull_ fnv1a_64_hash(const char *buf);
/**
* __attribute__((cleanup)) functions
*/
#define DEFINE_DROP_FUNC_PTR(type, func) \
static inline void drop_##func(type *p) { func(p); }
#define DEFINE_DROP_FUNC(type, func) \
static inline void drop_##func(type *p) { \
if (*p) \
func(*p); \
}
#define DEFINE_DROP_FUNC_VOID(func) \
static inline void drop_##func(void *p) { \
void **pp = p; \
if (*pp) \
func(*pp); \
}
static inline void drop_close(int *fd) {
if (*fd >= 0) {
close(*fd);
}
}
DEFINE_DROP_FUNC_VOID(free)
DEFINE_DROP_FUNC(FILE *, fclose)
DEFINE_DROP_FUNC(DIR *, closedir)
int _must_use_ negative_errno(void);
int _nonnull_ str_to_uint64(const char *input, uint64_t *output);
int _nonnull_ str_to_hex64(const char *input, uint64_t *output);
bool debug_mode_enabled(void);
#endif

121
src/x.c Normal file
View file

@ -0,0 +1,121 @@
#include <X11/Xatom.h>
#include <X11/Xproto.h>
#include <stddef.h>
#include "x.h"
/**
* Fetch the title of the window with the specified window ID.
*/
char *get_window_title(Display *dpy, Window owner) {
Atom props[] = {XInternAtom(dpy, "_NET_WM_NAME", False), XA_WM_NAME};
Atom utf8_string = XInternAtom(dpy, "UTF8_STRING", False);
Atom actual_type;
int format;
unsigned long nr_items, bytes_after;
unsigned char *prop = NULL;
for (size_t i = 0; i < arrlen(props); i++) {
if (XGetWindowProperty(dpy, owner, props[i], 0, (~0L), False,
(props[i] == XA_WM_NAME) ? AnyPropertyType
: utf8_string,
&actual_type, &format, &nr_items, &bytes_after,
&prop) == Success &&
prop) {
return (char *)prop;
}
}
return NULL;
}
/**
* Certain X11 operations may fail in expected ways. For example, when
* attempting to interact with a window that has been closed. This handler
* avoids the application terminating in such cases.
*
* The cppcheck suppression is for a false positive: this is a callback and
* cannot be changed.
*/
// cppcheck-suppress [constParameterPointer,unmatchedSuppression]
int xerror_handler(Display *dpy _unused_, XErrorEvent *ee) {
if (ee->error_code == BadWindow ||
(ee->request_code == X_SetInputFocus && ee->error_code == BadMatch) ||
(ee->request_code == X_PolyText8 && ee->error_code == BadDrawable) ||
(ee->request_code == X_PolyFillRectangle &&
ee->error_code == BadDrawable) ||
(ee->request_code == X_PolySegment && ee->error_code == BadDrawable) ||
(ee->request_code == X_ConfigureWindow && ee->error_code == BadMatch) ||
(ee->request_code == X_GrabButton && ee->error_code == BadAccess) ||
(ee->request_code == X_GrabKey && ee->error_code == BadAccess) ||
(ee->request_code == X_CopyArea && ee->error_code == BadDrawable)) {
return 0;
}
die("X error with request code=%d, error code=%d\n", ee->request_code,
ee->error_code);
}
#define FALLBACK_CHUNK_BYTES 4 * 1024
/**
* Return a quarter of the maximum request size in bytes.
*
* XMaxRequestSize and XExtendedMaxRequestSize return units of 4-byte words, so
* the word count already equals 1/4 of the maximum request size in bytes.
*/
size_t get_incr_threshold(Display *dpy) {
size_t max_request_words = XExtendedMaxRequestSize(dpy);
if (max_request_words == 0) {
max_request_words = XMaxRequestSize(dpy);
}
return max_request_words ? max_request_words : FALLBACK_CHUNK_BYTES;
}
/**
* Calculate an appropriate INCR payload size.
*
* The request size word count is a sensible threshold for deciding when to use
* INCR at all, but using that same value as the payload of each
* XChangeProperty request is too aggressive in practice.
*
* This is an interoperability constraint, not a protocol-limit one, because:
*
* - XMaxRequestSize/XExtendedMaxRequestSize only tell us what request size the
* X server will accept.
* - INCR has no chunk-size negotiation, so if a requestor dislikes large
* payloads it can simply stop deleting the property, leaving the sender
* waiting forever.
*
* Other implementations do not agree on a single rule here either: xclip uses
* a heuristic, some clipboard managers use a fixed cap, and Emacs couples
* large chunks with a timeout.
*/
size_t get_chunk_size(Display *dpy) {
size_t incr_threshold = get_incr_threshold(dpy);
return incr_threshold / 4 ? incr_threshold / 4 : FALLBACK_CHUNK_BYTES;
}
/**
* Add a new INCR transfer to the active list.
*/
void it_add(struct incr_transfer **it_list, struct incr_transfer *it) {
if (*it_list) {
(*it_list)->prev = it;
}
it->next = *it_list;
it->prev = NULL;
*it_list = it;
}
/**
* Remove an INCR transfer from the active list.
*/
void it_remove(struct incr_transfer **it_list, struct incr_transfer *it) {
if (it->prev) {
it->prev->next = it->next;
}
if (it->next) {
it->next->prev = it->prev;
}
if (*it_list == it) {
*it_list = it->next;
}
}

34
src/x.h Normal file
View file

@ -0,0 +1,34 @@
#ifndef CM_X_H
#define CM_X_H
#include <X11/Xlib.h>
#include "util.h"
DEFINE_DROP_FUNC_VOID(XFree)
size_t _nonnull_ get_incr_threshold(Display *dpy);
size_t _nonnull_ get_chunk_size(Display *dpy);
char _nonnull_ *get_window_title(Display *dpy, Window owner);
int xerror_handler(Display *dpy _unused_, XErrorEvent *ee);
struct incr_transfer {
struct incr_transfer *next;
struct incr_transfer *prev;
Window requestor;
Atom property;
Atom target;
int format;
char *data;
size_t data_size;
size_t data_capacity;
size_t offset;
};
#define it_dbg(it, fmt, ...) \
dbg("[incr 0x%lx] " fmt, (unsigned long)(it)->requestor, ##__VA_ARGS__)
void _nonnull_ it_add(struct incr_transfer **it_list, struct incr_transfer *it);
void _nonnull_ it_remove(struct incr_transfer **it_list,
struct incr_transfer *it);
#endif

848
tests/test_store.c Normal file
View file

@ -0,0 +1,848 @@
#undef NDEBUG
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "../src/store.h"
#include "../src/util.h"
#define COL_NORMAL "\x1B[0m"
#define COL_GREEN "\x1B[32m"
#define COL_RED "\x1B[31m"
#define t_assert(test) \
do { \
if (!(test)) { \
printf(" %s[FAIL:%d] %s%s\n", COL_RED, __LINE__, #test, \
COL_NORMAL); \
return false; \
} \
printf(" %s[PASS:%d] %s%s\n", COL_GREEN, __LINE__, #test, \
COL_NORMAL); \
} while (0)
#define t_run(test) \
do { \
printf("%s:\n", #test); \
bool ret = test(); \
printf("\n"); \
if (!ret) { \
return ret; \
} \
} while (0)
#define TEST_SNIP_FILE "/clip_store_snip_test"
#define TEST_CONTENT_DIR "/dev/shm/clip_store_content_dir_test"
static int create_test_snip_fd(void) {
shm_unlink(TEST_SNIP_FILE);
int snip_fd = shm_open(TEST_SNIP_FILE, O_RDWR | O_CREAT | O_EXCL, 0600);
assert(snip_fd >= 0);
return snip_fd;
}
static void drop_remove_test_snip_fd(int *snip_fd) {
close(*snip_fd);
int ret = shm_unlink(TEST_SNIP_FILE);
assert(ret == 0);
}
static void _drop_remove_test_content_dir_fd(int *dir_fd_ptr) {
int dir_fd = *dir_fd_ptr;
DIR *dir = fdopendir(dir_fd);
if (dir == NULL) {
close(dir_fd);
return;
}
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (streq(entry->d_name, ".") || streq(entry->d_name, "..")) {
continue;
}
if (entry->d_type == DT_REG) {
int ret = unlinkat(dir_fd, entry->d_name, 0);
assert(ret == 0);
} else if (entry->d_type == DT_DIR) {
int subdir_fd =
openat(dir_fd, entry->d_name, O_RDONLY | O_DIRECTORY);
assert(subdir_fd >= 0);
_drop_remove_test_content_dir_fd(&subdir_fd);
int ret = unlinkat(dir_fd, entry->d_name, AT_REMOVEDIR);
assert(ret == 0);
}
}
closedir(dir);
}
static void drop_remove_test_content_dir_fd(int *dir_fd_ptr) {
_drop_remove_test_content_dir_fd(dir_fd_ptr);
int ret = rmdir(TEST_CONTENT_DIR);
assert(ret == 0);
}
static void remove_test_content_dir(const char *path) {
int dir_fd = open(path, O_RDONLY);
if (dir_fd < 0) {
assert(errno == ENOENT);
return;
}
drop_remove_test_content_dir_fd(&dir_fd);
}
static int create_test_content_dir_fd(void) {
remove_test_content_dir(TEST_CONTENT_DIR);
int ret = mkdir(TEST_CONTENT_DIR, 0700);
assert(ret == 0);
int dir_fd = open(TEST_CONTENT_DIR, O_RDONLY);
assert(dir_fd >= 0);
return dir_fd;
}
static int count_test_snip_maps(void) {
#ifdef __linux__
_drop_(fclose) FILE *maps = fopen("/proc/self/maps", "r");
assert(maps != NULL);
char line[4096];
int count = 0;
while (fgets(line, sizeof(line), maps) != NULL) {
if (strstr(line, TEST_SNIP_FILE) != NULL) {
count++;
}
}
return count;
#else
return -1;
#endif
}
/* Test callables */
static enum cs_remove_action remove_if_five(uint64_t hash, const char *line,
void *private) {
(void)hash;
size_t *count = private;
enum cs_remove_action ret = 0;
if (*count == 0) {
ret |= CS_ACTION_STOP;
}
(*count)--;
if (line[0] == '5') {
ret |= CS_ACTION_REMOVE;
}
return ret;
}
static struct clip_store setup_test(void) {
struct clip_store cs;
int snip_fd = create_test_snip_fd();
int content_dir_fd = create_test_content_dir_fd();
int ret = cs_init(&cs, snip_fd, content_dir_fd);
assert(ret == 0);
return cs;
}
static void drop_teardown_test(struct clip_store *cs) {
int snip_fd = cs->snip_fd;
int content_dir_fd = cs->content_dir_fd;
int ret = cs_destroy(cs);
assert(ret == 0);
close(snip_fd);
shm_unlink(TEST_SNIP_FILE);
close(content_dir_fd);
remove_test_content_dir(TEST_CONTENT_DIR);
}
static void add_ten_snips(struct clip_store *cs) {
for (char i = 0; i < 10; i++) {
char num[8];
snprintf(num, sizeof(num), "%d", i);
int ret = cs_add(cs, num, NULL, CS_DUPE_KEEP_ALL);
assert(ret == 0);
}
}
/* Tests */
static bool test__cs_init(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
t_assert(cs.snip_fd >= 0);
t_assert(cs.content_dir_fd >= 0);
/* Check header fields were set up and are correct */
struct stat st;
t_assert(fstat(cs.snip_fd, &st) == 0);
t_assert(st.st_size == CS_SNIP_SIZE);
t_assert(cs.header->nr_snips_alloc == 0);
return true;
}
static bool test__cs_init__bad_size(void) {
_drop_(remove_test_snip_fd) int snip_fd = create_test_snip_fd();
_drop_(remove_test_content_dir_fd) int content_dir_fd =
create_test_content_dir_fd();
t_assert(ftruncate(snip_fd, CS_SNIP_SIZE - 1) == 0);
struct clip_store cs;
t_assert(cs_init(&cs, snip_fd, content_dir_fd) == -EINVAL);
return true;
}
static bool test__cs_init__bad_size_aligned(void) {
_drop_(remove_test_snip_fd) int snip_fd = create_test_snip_fd();
_drop_(remove_test_content_dir_fd) int content_dir_fd =
create_test_content_dir_fd();
t_assert(ftruncate(snip_fd, CS_SNIP_SIZE * 2) == 0);
struct clip_store cs;
t_assert(cs_init(&cs, snip_fd, content_dir_fd) == -EINVAL);
return true;
}
static bool test__cs_add(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
for (char i = 0; i < 10; i++) {
char num[8];
snprintf(num, sizeof(num), "%d", i);
uint64_t hash;
int ret = cs_add(&cs, num, &hash, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
_drop_(cs_content_unmap) struct cs_content content;
ret = cs_content_get(&cs, hash, &content);
t_assert(ret == 0);
t_assert(strncmp(content.data, num, content.size) == 0);
}
t_assert(cs.header->nr_snips == 10);
return true;
}
static bool test__cs_content_get__keeps_fd_open(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash;
int ret = cs_add(&cs, "fd lifecycle", &hash, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
struct cs_content content;
ret = cs_content_get(&cs, hash, &content);
t_assert(ret == 0);
t_assert(fcntl(content.fd, F_GETFD) == 0);
int unrelated_fd = open("/dev/null", O_RDONLY);
t_assert(unrelated_fd >= 0);
drop_cs_content_unmap(&content);
t_assert(fcntl(unrelated_fd, F_GETFD) == 0);
close(unrelated_fd);
return true;
}
static bool test__cs_snip_iter(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
add_ten_snips(&cs);
struct cs_snip *snip = NULL;
int last_num = 9;
while (cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip)) {
_drop_(cs_content_unmap) struct cs_content content;
int ret = cs_content_get(&cs, snip->hash, &content);
t_assert(ret == 0);
/* add_ten_snips adds from 0-9, we should get the last one first */
int num = content.data[0] - '0';
t_assert(num == last_num--);
}
snip = NULL;
last_num = 0;
while (cs_snip_iter(&guard, CS_ITER_OLDEST_FIRST, &snip)) {
_drop_(cs_content_unmap) struct cs_content content;
int ret = cs_content_get(&cs, snip->hash, &content);
t_assert(ret == 0);
int num = content.data[0] - '0';
t_assert(num == last_num++);
}
return true;
}
static bool test__cs_remove(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
add_ten_snips(&cs);
size_t nr_iter = 1;
t_assert(cs_remove(&cs, CS_ITER_NEWEST_FIRST, remove_if_five, &nr_iter) ==
0);
t_assert(cs.header->nr_snips == 10);
nr_iter = SIZE_MAX;
t_assert(cs_remove(&cs, CS_ITER_NEWEST_FIRST, remove_if_five, &nr_iter) ==
0);
t_assert(cs.header->nr_snips == 9);
return true;
}
static bool test__cs_trim(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
add_ten_snips(&cs);
struct cs_snip *snip = NULL;
while (cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip))
;
uint64_t oldest_hash = snip->hash;
t_assert(cs_trim(&cs, CS_ITER_NEWEST_FIRST, 3) == 0);
t_assert(cs.header->nr_snips == 3);
/* Check we kept the most recently added ones */
int last_num = 9;
snip = NULL;
while (cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip)) {
_drop_(cs_content_unmap) struct cs_content content;
int ret = cs_content_get(&cs, snip->hash, &content);
t_assert(ret == 0);
int num = content.data[0] - '0';
t_assert(num == last_num--);
}
/* Check the oldest snip hash is gone */
_drop_(cs_content_unmap) struct cs_content content;
t_assert(cs_content_get(&cs, oldest_hash, &content) == -ENOENT);
return true;
}
static bool test__cs_replace(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
add_ten_snips(&cs);
const char *new = "new";
int ret = cs_replace(&cs, CS_ITER_NEWEST_FIRST, 1, new, NULL);
t_assert(ret == 0);
struct cs_snip *snip = NULL;
bool i_ret = cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip);
t_assert(i_ret == true);
t_assert(streq(snip->line, "9"));
i_ret = cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip);
t_assert(i_ret == true);
t_assert(streq(snip->line, new));
_drop_(cs_content_unmap) struct cs_content first_content;
ret = cs_content_get(&cs, snip->hash, &first_content);
t_assert(ret == 0);
t_assert(strncmp(first_content.data, new, first_content.size) == 0);
/* No other clips should be affected */
int last_num = 7;
while (cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip)) {
_drop_(cs_content_unmap) struct cs_content content;
ret = cs_content_get(&cs, snip->hash, &content);
t_assert(ret == 0);
int num = content.data[0] - '0';
t_assert(num == last_num--);
}
return true;
}
static bool test__reuse_cs(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
add_ten_snips(&cs);
t_assert(cs_destroy(&cs) == 0);
t_assert(cs_init(&cs, cs.snip_fd, cs.content_dir_fd) == 0);
t_assert(cs.header->nr_snips == 10);
return true;
}
static bool test__cs_add__exceeds_snip_line_size(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
/* Construct a string that exceeds CS_SNIP_LINE_SIZE */
char long_content[CS_SNIP_LINE_SIZE + 100];
memset(long_content, 'A', sizeof(long_content));
long_content[sizeof(long_content) - 1] = '\0';
int ret = cs_add(&cs, long_content, NULL, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
struct cs_snip *snip = NULL;
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
bool found = cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip);
t_assert(found);
t_assert(strlen(snip->line) == CS_SNIP_LINE_SIZE - 1);
return true;
}
static bool test__cs_trim__when_empty(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
int ret = cs_trim(&cs, CS_ITER_NEWEST_FIRST, 0);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 0);
return true;
}
static bool test__cs_remove___empty(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
/* Attempt to remove any entry, if present */
size_t dummy = 0;
int ret = cs_remove(&cs, CS_ITER_NEWEST_FIRST, remove_if_five, &dummy);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 0);
return true;
}
static bool test__cs_add__around_alloc_batch_threshold(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
for (size_t i = 0; i < CS_SNIP_ALLOC_BATCH - 1; i++) {
int ret = cs_add(&cs, "test content", NULL, CS_DUPE_KEEP_ALL);
assert(ret == 0);
}
t_assert(cs.header->nr_snips == CS_SNIP_ALLOC_BATCH - 1);
/* Add one more entry to exceed the batch threshold */
t_assert(cs_add(&cs, "test content", NULL, CS_DUPE_KEEP_ALL) == 0);
t_assert(cs.header->nr_snips == CS_SNIP_ALLOC_BATCH);
t_assert(cs.header->nr_snips_alloc >= CS_SNIP_ALLOC_BATCH);
return true;
}
static bool test__cs_trim__no_remove_when_still_referenced(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash;
for (size_t i = 0; i < 2; i++) {
int ret = cs_add(&cs, "test content", &hash, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
}
_drop_(cs_content_unmap) struct cs_content content;
t_assert(cs.header->nr_snips == 2);
t_assert(cs_content_get(&cs, hash, &content) == 0);
t_assert(cs_trim(&cs, CS_ITER_NEWEST_FIRST, 1) == 0);
t_assert(cs.header->nr_snips == 1);
t_assert(cs_content_get(&cs, hash, &content) == 0);
t_assert(cs_trim(&cs, CS_ITER_NEWEST_FIRST, 0) == 0);
t_assert(cs.header->nr_snips == 0);
t_assert(cs_content_get(&cs, hash, &content) == -ENOENT);
return true;
}
static bool test__cs_replace__out_of_bounds(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
add_ten_snips(&cs);
int ret = cs_replace(&cs, CS_ITER_NEWEST_FIRST, 10, "test content", NULL);
t_assert(ret == -ERANGE);
return true;
}
static bool test__cs_replace__add_fail_keeps_old_entry(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
const char *old_content = "OLD_CONTENT";
uint64_t old_hash;
int ret = cs_add(&cs, old_content, &old_hash, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
const char *new_content = "new";
uint64_t new_hash = fnv1a_64_hash(new_content);
char hash_dir[CS_HASH_STR_MAX];
snprintf(hash_dir, sizeof(hash_dir), PRI_HASH, new_hash);
ret = mkdirat(cs.content_dir_fd, hash_dir, 0700);
t_assert(ret == 0);
char fake_path[CS_HASH_STR_MAX + 3];
snprintf(fake_path, sizeof(fake_path), "%s/1", hash_dir);
_drop_(close) int fake_fd = openat(cs.content_dir_fd, fake_path,
O_WRONLY | O_CREAT | O_TRUNC, 0600);
t_assert(fake_fd >= 0);
t_assert(write(fake_fd, "X", 1) == 1);
ret = cs_replace(&cs, CS_ITER_NEWEST_FIRST, 0, new_content, NULL);
t_assert(ret == -EFAULT);
_drop_(cs_content_unmap) struct cs_content old_after;
ret = cs_content_get(&cs, old_hash, &old_after);
t_assert(ret == 0);
t_assert((size_t)old_after.size == strlen(old_content));
t_assert(strncmp(old_after.data, old_content, (size_t)old_after.size) == 0);
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
struct cs_snip *snip = NULL;
t_assert(cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip));
t_assert(snip->hash == old_hash);
t_assert(streq(snip->line, old_content));
return true;
}
static bool test__cs_snip__correct_nr_lines(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
add_ten_snips(&cs);
/* No need to do exhaustive ones, they're done in test__first_line_* */
uint64_t hash;
struct cs_snip *snip = NULL;
int ret =
cs_replace(&cs, CS_ITER_NEWEST_FIRST, 0, "one\ntwo\nthree", &hash);
t_assert(ret == 0);
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
t_assert(cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip));
t_assert(snip->hash == hash);
t_assert(snip->nr_lines == 3);
return true;
}
static bool test__first_line__empty(void) {
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("", line);
printf("%s\n", line);
t_assert(streq(line, ""));
t_assert(num_lines == 0);
num_lines = first_line("\n", line);
t_assert(streq(line, ""));
t_assert(num_lines == 1);
num_lines = first_line("\n\n\n", line);
t_assert(streq(line, ""));
t_assert(num_lines == 3);
return true;
}
static bool test__first_line__only_one(void) {
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("Foo bar\n", line);
t_assert(streq(line, "Foo bar"));
t_assert(num_lines == 1);
return true;
}
static bool test__first_line__multiple(void) {
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("Foo bar\nbaz\nqux\n", line);
t_assert(streq(line, "Foo bar"));
t_assert(num_lines == 3);
return true;
}
static bool test__first_line__no_final_newline(void) {
/* If the last line didn't end with a newline, still count it */
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("Foo bar", line);
t_assert(streq(line, "Foo bar"));
t_assert(num_lines == 1);
num_lines = first_line("Foo bar\nbaz", line);
t_assert(streq(line, "Foo bar"));
t_assert(num_lines == 2);
return true;
}
static bool test__first_line__ignore_blank_lines(void) {
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("\n\n\nFoo bar\n\n\n", line);
t_assert(streq(line, "Foo bar"));
t_assert(num_lines == 6);
return true;
}
static bool test__first_line__unicode(void) {
char line[CS_SNIP_LINE_SIZE];
size_t num_lines = first_line("道", line);
t_assert(streq(line, "道"));
t_assert(num_lines == 1);
num_lines = first_line("道\n", line);
t_assert(streq(line, "道"));
t_assert(num_lines == 1);
num_lines = first_line("道\n非", line);
t_assert(streq(line, "道"));
t_assert(num_lines == 2);
return true;
}
static bool test__synchronisation(void) {
_drop_(remove_test_snip_fd) int snip_fd1 = create_test_snip_fd();
_drop_(remove_test_content_dir_fd) int content_dir_fd1 =
create_test_content_dir_fd();
_drop_(close) int snip_fd2 = dup(snip_fd1);
_drop_(close) int content_dir_fd2 = dup(content_dir_fd1);
assert(snip_fd2 >= 0 && content_dir_fd2 >= 0);
struct clip_store cs1, cs2;
int ret = cs_init(&cs1, snip_fd1, content_dir_fd1);
t_assert(ret == 0);
ret = cs_init(&cs2, snip_fd2, content_dir_fd2);
t_assert(ret == 0);
uint64_t hash;
ret = cs_add(&cs1, "test content", &hash, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
bool found = false;
struct cs_snip *snip = NULL;
_drop_(cs_unref) struct ref_guard guard_cs2 = cs_ref(&cs2);
while (cs_snip_iter(&guard_cs2, CS_ITER_NEWEST_FIRST, &snip)) {
if (snip->hash == hash) {
found = true;
break;
}
}
t_assert(found);
ret = cs_trim(&cs2, CS_ITER_NEWEST_FIRST, 0);
t_assert(ret == 0);
found = false;
snip = NULL;
_drop_(cs_unref) struct ref_guard guard_cs1 = cs_ref(&cs1);
while (cs_snip_iter(&guard_cs1, CS_ITER_NEWEST_FIRST, &snip)) {
found = true;
}
t_assert(!found);
t_assert(cs_destroy(&cs1) == 0);
t_assert(cs_destroy(&cs2) == 0);
return true;
}
static bool test__cs_trim__regrow_does_not_leak_mapping(void) {
struct clip_store cs = setup_test();
bool added = true;
for (int i = 0; i < 1500; i++) {
char buf[32];
snprintf(buf, sizeof(buf), "clip-%d", i);
if (cs_add(&cs, buf, NULL, CS_DUPE_KEEP_ALL) != 0) {
added = false;
break;
}
}
t_assert(added);
int maps_after_growth = count_test_snip_maps();
if (maps_after_growth < 0) {
drop_teardown_test(&cs);
return true;
}
t_assert(maps_after_growth == 1);
t_assert(cs_trim(&cs, CS_ITER_NEWEST_FIRST, 0) == 0);
t_assert(count_test_snip_maps() == 1);
t_assert(cs_add(&cs, "regrow", NULL, CS_DUPE_KEEP_ALL) == 0);
t_assert(count_test_snip_maps() == 1);
int snip_fd = cs.snip_fd;
int content_dir_fd = cs.content_dir_fd;
t_assert(cs_destroy(&cs) == 0);
t_assert(count_test_snip_maps() == 0);
close(snip_fd);
shm_unlink(TEST_SNIP_FILE);
close(content_dir_fd);
remove_test_content_dir(TEST_CONTENT_DIR);
return true;
}
static bool test__cs_add__dupe_keep_all(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash1, hash2;
int ret = cs_add(&cs, "duplicate", &hash1, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
ret = cs_add(&cs, "duplicate", &hash2, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
t_assert(hash1 == hash2);
t_assert(cs.header->nr_snips == 2);
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
struct cs_snip *snip = NULL;
bool iter_ret = cs_snip_iter(&guard, CS_ITER_OLDEST_FIRST, &snip);
t_assert(iter_ret == true);
t_assert(snip->hash == hash1);
iter_ret = cs_snip_iter(&guard, CS_ITER_OLDEST_FIRST, &snip);
t_assert(iter_ret == true);
t_assert(snip->hash == hash2);
return true;
}
static bool test__cs_add__dupe_keep_last(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash1, hash2, hash3;
int ret = cs_add(&cs, "duplicate", &hash1, CS_DUPE_KEEP_LAST);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 1);
ret = cs_add(&cs, "duplicate", &hash2, CS_DUPE_KEEP_LAST);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 1);
ret = cs_add(&cs, "duplicate", &hash3, CS_DUPE_KEEP_LAST);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 1);
t_assert(hash1 == hash2);
t_assert(hash1 == hash3);
return true;
}
/* After adding a duplicate entry, ensure the duplicate is moved to the newest
* slot while other entries remain in order. */
static bool test__cs_add__dupe_keep_last_with_multiple_entries(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash_a, hash_dup;
int ret = cs_add(&cs, "A", &hash_a, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
ret = cs_add(&cs, "duplicate", &hash_dup, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
ret = cs_add(&cs, "B", NULL, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 3);
/* Now add a duplicate entry with KEEP_LAST which should move the duplicate
* to the newest slot */
ret = cs_add(&cs, "duplicate", NULL, CS_DUPE_KEEP_LAST);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 3);
_drop_(cs_unref) struct ref_guard guard = cs_ref(&cs);
struct cs_snip *snip = NULL;
bool iter_ret = cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip);
t_assert(iter_ret == true);
t_assert(snip->hash == hash_dup);
return true;
}
static bool check_order(struct clip_store *cs, uint64_t *order, size_t len) {
_drop_(cs_unref) struct ref_guard guard = cs_ref(cs);
struct cs_snip *snip = NULL;
for (size_t i = 0; i < len; ++i) {
bool iter_ret = cs_snip_iter(&guard, CS_ITER_NEWEST_FIRST, &snip);
t_assert(iter_ret == true);
t_assert(snip->hash == order[i]);
}
return 0;
}
/* After calling cs_make_newest make sure the entry is at the newest slot while
* other entries remain in order. */
static bool test__cs_make_newest(void) {
_drop_(teardown_test) struct clip_store cs = setup_test();
uint64_t hash_a, hash_b, hash_c;
int ret = cs_add(&cs, "A", &hash_a, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
ret = cs_add(&cs, "B", &hash_b, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
ret = cs_add(&cs, "C", &hash_c, CS_DUPE_KEEP_ALL);
t_assert(ret == 0);
t_assert(cs.header->nr_snips == 3);
uint64_t order_before[3] = {hash_c, hash_b, hash_a};
ret = check_order(&cs, order_before, 3);
t_assert(ret == 0);
/* Now the order should change to ["A", "C", "B"] */
ret = cs_make_newest(&cs, hash_a);
t_assert(ret == 0);
uint64_t order_after[3] = {hash_a, hash_c, hash_b};
ret = check_order(&cs, order_after, 3);
t_assert(ret == 0);
return true;
}
int main(void) {
t_run(test__cs_init);
t_run(test__cs_init__bad_size);
t_run(test__cs_init__bad_size_aligned);
t_run(test__cs_add);
t_run(test__cs_content_get__keeps_fd_open);
t_run(test__cs_snip_iter);
t_run(test__cs_remove);
t_run(test__cs_trim);
t_run(test__cs_replace);
t_run(test__reuse_cs);
t_run(test__cs_add__exceeds_snip_line_size);
t_run(test__cs_trim__when_empty);
t_run(test__cs_remove___empty);
t_run(test__cs_add__around_alloc_batch_threshold);
t_run(test__cs_replace__out_of_bounds);
t_run(test__cs_replace__add_fail_keeps_old_entry);
t_run(test__synchronisation);
t_run(test__cs_trim__regrow_does_not_leak_mapping);
t_run(test__cs_trim__no_remove_when_still_referenced);
t_run(test__cs_snip__correct_nr_lines);
t_run(test__first_line__empty);
t_run(test__first_line__only_one);
t_run(test__first_line__multiple);
t_run(test__first_line__no_final_newline);
t_run(test__first_line__ignore_blank_lines);
t_run(test__first_line__unicode);
t_run(test__cs_add__dupe_keep_all);
t_run(test__cs_add__dupe_keep_last);
t_run(test__cs_add__dupe_keep_last_with_multiple_entries);
t_run(test__cs_make_newest);
return 0;
}

350
tests/x_integration_tests Executable file
View file

@ -0,0 +1,350 @@
#!/usr/bin/env bash
set -exE -o errtrace
dump_cache() {
printf '\nTest failure: contents of cache:\n\n' >&2
cat "$l_out" >&2
}
expect_fail() {
set +e
"$@"
local status=$?
set -e
(( status != 0 ))
}
if ! (( NO_PID_NAMESPACE )) && (( EUID )); then
export _UNSHARED=1
exec unshare --user --map-root-user --pid --mount --fork --mount-proc "$0" "$@"
fi
if (( _UNSHARED )); then
# Get our own tmp
mount -t tmpfs unshared_tmp /tmp
fi
cd "${0%/*}"/..
make clean debug
cd src
export PATH=$PWD:$PATH
expect_fail env CM_CONFIG=relative CM_DIR="$(mktemp -d)" clipmenu
expect_fail env CM_DIR=relative CM_CONFIG="$(mktemp)" clipmenu
export CM_CONFIG=$(mktemp)
export CM_DIR=$(mktemp -d)
export CM_DEBUG=1
export ASAN_OPTIONS='halt_on_error=1:abort_on_error=1'
export UBSAN_OPTIONS='halt_on_error=1:abort_on_error=1'
uid=$(id -u)
[[ "$(clipctl version)" == 7 ]]
[[ "$(clipctl cache-dir)" == "$CM_DIR/clipmenu.7.$uid" ]]
launcher=$(mktemp)
l_out=$(mktemp)
trap dump_cache ERR
cat > "$launcher" << EOF
#!/bin/bash
cat > "$l_out"
if [[ \$SELECT ]]; then
printf '%s\\n' "\$SELECT"
fi
EOF
chmod a+x "$launcher"
export CM_LAUNCHER="$launcher"
tilde_home=$(mktemp -d)
tilde_dir="$tilde_home/clipmenu_tilde"
mkdir -p "$tilde_dir"
HOME="$tilde_home" CM_DIR="~/clipmenu_tilde" CM_CONFIG="$(mktemp)" \
clipmenu || true
[[ -d "$tilde_dir/clipmenu.7.$uid" ]]
rm -rf "$tilde_home"
tilde_conf_home=$(mktemp -d)
printf 'max_clips nope\n' > "$tilde_conf_home/bad.conf"
err=$(HOME="$tilde_conf_home" CM_CONFIG="~/bad.conf" CM_DIR="$(mktemp -d)" \
clipmenu 2>&1 || true)
[[ "$err" == *"for max_clips"* ]]
rm -rf "$tilde_conf_home"
kill_background_jobs() {
local -a bg
readarray -t bg < <(jobs -p)
(( ${#bg[@]} )) && kill -- "${bg[@]}" 2>/dev/null
}
primary() {
printf '%s' "${1?}" | xsel -p
}
trap 'kill_background_jobs' EXIT
settle() {
sleep 0.2
}
long_settle() {
sleep 2
}
check_nr_clips() {
if [[ $SELECT ]]; then
clipmenu
else
# On no selection, we exit 1. Avoid masking real problems by only doing
# it when no selection is specified.
clipmenu || true
fi
(( $(wc -l < "$l_out") == "${1?}" ))
}
wait_nr_clips() {
local want=${1?}
local deadline=$((SECONDS + 5))
while (( SECONDS < deadline )); do
if check_nr_clips "$want"; then
return 0
fi
sleep 0.1
done
check_nr_clips "$want"
}
if ! (( USE_CURRENT_DISPLAY )); then
export DISPLAY=:1911
Xvfb "$DISPLAY" &
sleep 2
fi
# Clear selections
xsel -bc
xsel -pc
xsel -sc
clipmenud &
clipmenud_pid=$!
settle
# Should be empty
check_nr_clips 0
primary foo
settle
check_nr_clips 1
# Put the same one repeatedly, should ignore it
primary foo
primary foo
primary foo
settle
settle
check_nr_clips 1
[[ "$(< "$l_out")" == "[1] foo" ]]
# Put possible partials, should update it
primary fooa
primary fooab
primary fooabc
# Should have replaced the old one
settle
check_nr_clips 1
[[ "$(< "$l_out")" == "[1] fooabc" ]]
primary fooab
primary fooa
primary foo
# Should have replaced the old one
settle
check_nr_clips 1
[[ "$(< "$l_out")" == "[1] foo" ]]
# Put some more content on the clipboard for testing
primary bar
primary baz
settle
check_nr_clips 3
# Nothing gets deleted, but we recognise the right clips
[[ $(clipdel a) == $'bar\nbaz' ]]
check_nr_clips 3
# Likewise but inversion
[[ $(clipdel -v a) == foo ]]
check_nr_clips 3
# Real delete with inversion
[[ $(clipdel -dv a) == foo ]]
check_nr_clips 2
# Test literal match
primary '*foo'
settle
check_nr_clips 3
[[ $(clipdel -dF '*') == '*foo' ]]
check_nr_clips 2
# Test -n
primary latest
settle
check_nr_clips 3
# Should delete the newest entry
[[ $(clipdel -dn 1) == 'latest' ]]
check_nr_clips 2
# Test -N
primary wibble
primary wobble
settle
check_nr_clips 4
# Should delete everything other than the oldest 2 entries
[[ $(clipdel -dvN 2) == $'wibble\nwobble' ]]
check_nr_clips 2
# Test clipdelmenu
primary test1
primary test2
primary test3
settle
check_nr_clips 5
# Should delete the selected entry
SELECT='[4] test2' clipdelmenu
settle
check_nr_clips 4
# Verify test2 is gone and test1, test3 remain
clipmenu || true
[[ "$(< "$l_out")" == *"test1"* ]]
[[ "$(< "$l_out")" == *"test3"* ]]
[[ "$(< "$l_out")" != *"test2"* ]]
# Delete the others
SELECT='[3] test1' clipdelmenu
SELECT='[3] test3' clipdelmenu
# Test multi-select deletion
primary multi1
primary multi2
settle
check_nr_clips 4
SELECT=$'[3] multi1\n[4] multi2' clipdelmenu
check_nr_clips 2
# Check selecting starts serving
xsel -pc
SELECT='[1] bar' clipmenu
settle
[[ "$(xsel -po)" == bar ]]
SELECT='[2] baz' clipmenu
settle
[[ "$(xsel -po)" == baz ]]
# Disable populates no new clips
clipctl disable
[[ "$(clipctl status)" == disabled ]]
primary wibble
primary wobble
settle
check_nr_clips 2
# Enable enables again
clipctl enable
[[ "$(clipctl status)" == enabled ]]
primary wibble
primary wobble
settle
check_nr_clips 4
# Toggle works both ways
clipctl toggle
[[ "$(clipctl status)" == disabled ]]
clipctl toggle
[[ "$(clipctl status)" == enabled ]]
# Test INCR support
set +x
printf '%.0sa' {1..9999999} | xsel -p
set -x
long_settle # This is a big one, give it some time
len_before=$(xsel -po | wc -c)
check_nr_clips 5
SELECT='[5] aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' \
clipmenu
long_settle
len_after=$(xsel -po | wc -c)
# Make sure we got the whole thing
(( len_before == len_after ))
# Issue #241. Put a large clipboard selection via INCR, then put something else
# small immediately after.
xsel -bc
settle
check_nr_clips 5
# Large selection (INCR)
printf '%.0sa' {1..4001} | xsel -b
wait_nr_clips 6
# After large selection is stored, put something small that's a possible #
# partial (non-INCR)
printf a | xsel -b
settle
check_nr_clips 6
# own_selections: default does not own PRIMARY
kill "$clipmenud_pid"
wait "$clipmenud_pid" || true
pkill -x clipserve 2>/dev/null || true
CM_OWN_CLIPBOARD=1 CM_OWN_SELECTIONS=clipboard clipmenud &
clipmenud_pid=$!
settle
xsel -bc
xsel -pc
printf 'primary_stale' | xsel -p
printf 'clip_default' | xsel -b
settle
[[ "$(xsel -bo)" == clip_default ]]
primary_out=$(xsel -po 2>/dev/null || true)
[[ "$primary_out" != clip_default ]]
# own_selections: owns all listed selections
kill "$clipmenud_pid"
wait "$clipmenud_pid" || true
pkill -x clipserve 2>/dev/null || true
CM_OWN_CLIPBOARD=1 CM_OWN_SELECTIONS='clipboard primary' clipmenud &
clipmenud_pid=$!
settle
xsel -bc
xsel -pc
printf 'primary_stale' | xsel -p
printf 'own_both' | xsel -b
settle
[[ "$(xsel -bo)" == own_both ]]
[[ "$(xsel -po)" == own_both ]]
if (( _UNSHARED )); then
umount -l /tmp
fi