From b4e3116f02070433727faed5dd58a69d962d577a Mon Sep 17 00:00:00 2001 From: alejandrogs73 Date: Fri, 25 Sep 2026 10:55:49 +0200 Subject: [PATCH] Squashed 'clipmenu/' content from commit e0ec3bc6 git-subtree-dir: clipmenu git-subtree-split: e0ec3bc6608ecc70ed17e8da76e27bf90b1e8e90 --- .clang-format | 11 + .github/workflows/ci.yml | 28 ++ .gitignore | 9 + LICENSE | 21 + Makefile | 135 ++++++ README.md | 97 +++++ init/clipmenud.service.in | 21 + man/clipctl.1 | 56 +++ man/clipdel.1 | 53 +++ man/clipdelmenu.1 | 43 ++ man/clipmenu.1 | 42 ++ man/clipmenu.conf.5 | 124 ++++++ man/clipmenud.1 | 32 ++ man/clipserve.1 | 44 ++ src/clipctl.c | 152 +++++++ src/clipdel.c | 162 ++++++++ src/clipdelmenu.c | 45 ++ src/clipmenu.c | 42 ++ src/clipmenud.c | 765 ++++++++++++++++++++++++++++++++++ src/clipserve.c | 286 +++++++++++++ src/config.c | 500 ++++++++++++++++++++++ src/config.h | 104 +++++ src/menu_util.c | 302 ++++++++++++++ src/menu_util.h | 18 + src/store.c | 847 +++++++++++++++++++++++++++++++++++++ src/store.h | 192 +++++++++ src/util.c | 165 ++++++++ src/util.h | 111 +++++ src/x.c | 121 ++++++ src/x.h | 34 ++ tests/test_store.c | 848 ++++++++++++++++++++++++++++++++++++++ tests/x_integration_tests | 350 ++++++++++++++++ 32 files changed, 5760 insertions(+) create mode 100644 .clang-format create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 init/clipmenud.service.in create mode 100644 man/clipctl.1 create mode 100644 man/clipdel.1 create mode 100644 man/clipdelmenu.1 create mode 100644 man/clipmenu.1 create mode 100644 man/clipmenu.conf.5 create mode 100644 man/clipmenud.1 create mode 100644 man/clipserve.1 create mode 100644 src/clipctl.c create mode 100644 src/clipdel.c create mode 100644 src/clipdelmenu.c create mode 100644 src/clipmenu.c create mode 100644 src/clipmenud.c create mode 100644 src/clipserve.c create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/menu_util.c create mode 100644 src/menu_util.h create mode 100644 src/store.c create mode 100644 src/store.h create mode 100644 src/util.c create mode 100644 src/util.h create mode 100644 src/x.c create mode 100644 src/x.h create mode 100644 tests/test_store.c create mode 100755 tests/x_integration_tests diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..076d317 --- /dev/null +++ b/.clang-format @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b412fc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3668d31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +src/clipmenud +src/clipctl +src/clipdel +src/clipdelmenu +src/clipmenu +src/clipserve +tests/test +tests/test_store +*.o diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9019292 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..934c0d1 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf29fac --- /dev/null +++ b/README.md @@ -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 diff --git a/init/clipmenud.service.in b/init/clipmenud.service.in new file mode 100644 index 0000000..f68267b --- /dev/null +++ b/init/clipmenud.service.in @@ -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 diff --git a/man/clipctl.1 b/man/clipctl.1 new file mode 100644 index 0000000..1b95e05 --- /dev/null +++ b/man/clipctl.1 @@ -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 . diff --git a/man/clipdel.1 b/man/clipdel.1 new file mode 100644 index 0000000..1d93acd --- /dev/null +++ b/man/clipdel.1 @@ -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 . diff --git a/man/clipdelmenu.1 b/man/clipdelmenu.1 new file mode 100644 index 0000000..72f76ee --- /dev/null +++ b/man/clipdelmenu.1 @@ -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 . diff --git a/man/clipmenu.1 b/man/clipmenu.1 new file mode 100644 index 0000000..00cfd00 --- /dev/null +++ b/man/clipmenu.1 @@ -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 . diff --git a/man/clipmenu.conf.5 b/man/clipmenu.conf.5 new file mode 100644 index 0000000..2c93bed --- /dev/null +++ b/man/clipmenu.conf.5 @@ -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.. 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 . diff --git a/man/clipmenud.1 b/man/clipmenud.1 new file mode 100644 index 0000000..65f367a --- /dev/null +++ b/man/clipmenud.1 @@ -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 . diff --git a/man/clipserve.1 b/man/clipserve.1 new file mode 100644 index 0000000..72f6d9b --- /dev/null +++ b/man/clipserve.1 @@ -0,0 +1,44 @@ +.TH CLIPSERVE 1 +.SH NAME +clipserve \- serve a selected clipboard entry to X11 selections +.SH SYNOPSIS +.B clipserve +[\-s selection]... +.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 . diff --git a/src/clipctl.c b/src/clipctl.c new file mode 100644 index 0000000..7f5c961 --- /dev/null +++ b/src/clipctl.c @@ -0,0 +1,152 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 \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); +} diff --git a/src/clipdel.c b/src/clipdel.c new file mode 100644 index 0000000..30fd0b7 --- /dev/null +++ b/src/clipdel.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/src/clipdelmenu.c b/src/clipdelmenu.c new file mode 100644 index 0000000..255c7bf --- /dev/null +++ b/src/clipdelmenu.c @@ -0,0 +1,45 @@ +#include +#include +#include + +#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); +} diff --git a/src/clipmenu.c b/src/clipmenu.c new file mode 100644 index 0000000..a7e1982 --- /dev/null +++ b/src/clipmenu.c @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include +#include + +#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); +} diff --git a/src/clipmenud.c b/src/clipmenud.c new file mode 100644 index 0000000..446a2d7 --- /dev/null +++ b/src/clipmenud.c @@ -0,0 +1,765 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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)×tamp_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 diff --git a/src/clipserve.c b/src/clipserve.c new file mode 100644 index 0000000..9d490cd --- /dev/null +++ b/src/clipserve.c @@ -0,0 +1,286 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#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]... \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]... \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; +} diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..10ba62c --- /dev/null +++ b/src/config.c @@ -0,0 +1,500 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#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(); + } + } +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..949ebd8 --- /dev/null +++ b/src/config.h @@ -0,0 +1,104 @@ +#ifndef CM_CONFIG_H +#define CM_CONFIG_H + +#include +#include +#include +#include +#include + +#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 diff --git a/src/menu_util.c b/src/menu_util.c new file mode 100644 index 0000000..3358c43 --- /dev/null +++ b/src/menu_util.c @@ -0,0 +1,302 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/src/menu_util.h b/src/menu_util.h new file mode 100644 index 0000000..3bb15de --- /dev/null +++ b/src/menu_util.h @@ -0,0 +1,18 @@ +#ifndef CM_MENU_UTIL_H +#define CM_MENU_UTIL_H + +#include + +#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 diff --git a/src/store.c b/src/store.c new file mode 100644 index 0000000..fd2d80e --- /dev/null +++ b/src/store.c @@ -0,0 +1,847 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/src/store.h b/src/store.h new file mode 100644 index 0000000..3a0f3bd --- /dev/null +++ b/src/store.h @@ -0,0 +1,192 @@ +#ifndef CM_STORE_H +#define CM_STORE_H + +#include +#include +#include + +#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 diff --git a/src/util.c b/src/util.c new file mode 100644 index 0000000..e9cf994 --- /dev/null +++ b/src/util.c @@ -0,0 +1,165 @@ +#include +#include +#include +#include + +#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; +} diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..3aa1592 --- /dev/null +++ b/src/util.h @@ -0,0 +1,111 @@ +#ifndef CM_UTIL_H +#define CM_UTIL_H + +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/x.c b/src/x.c new file mode 100644 index 0000000..18809f3 --- /dev/null +++ b/src/x.c @@ -0,0 +1,121 @@ +#include +#include +#include + +#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; + } +} diff --git a/src/x.h b/src/x.h new file mode 100644 index 0000000..a56f6d1 --- /dev/null +++ b/src/x.h @@ -0,0 +1,34 @@ +#ifndef CM_X_H +#define CM_X_H + +#include + +#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 diff --git a/tests/test_store.c b/tests/test_store.c new file mode 100644 index 0000000..a1d598e --- /dev/null +++ b/tests/test_store.c @@ -0,0 +1,848 @@ +#undef NDEBUG + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/tests/x_integration_tests b/tests/x_integration_tests new file mode 100755 index 0000000..f24de8b --- /dev/null +++ b/tests/x_integration_tests @@ -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