v2 release prep: installer, README, packaging, notes; retire the v1 scripts

Installer rewritten around a preflight: distro, package manager, display
manager, window manager, terminal, tmux, cargo, git, screen locker, touch
device, device permissions and free disk are all checked BEFORE anything is
installed, and the total cost is printed once for a single confirmation.
Prompts read /dev/tty so they still work when the script is piped from curl,
and fall back to defaults with a notice when there is no terminal at all.

Several "[ test ] && action" statements were set -e landmines: under set -e an
AND-OR list that ends up false aborts the script, so a box with no lightdm, no
i3 or nothing to install would have exited silently partway through detection
-- which is exactly the fresh-Debian case the installer exists for. Rewritten
as if-statements and verified against a stripped PATH with no tmux, cargo, git
or package manager present. Also fixed cargo detection reporting blank instead
of NOT INSTALLED: the status of `cargo --version | cut` is cut's, and cut
succeeds on empty input, so the fallback never fired.

Device access now defaults to a udev rule matching touchscreens only, rather
than the input group, which grants access to every input device including the
keyboard and needs a full logout.

README rewritten for someone who has not seen the project: what the photo
shows, the hardware, install, then a config built up step by step, each step
with the YAML and the resulting map. Every example is verified verbatim
against the binary, and every relative link resolves. The mechanism and the
reasoning move to notes/: DESIGN.md, HARDWARE-NOTES.md, V1-BASH.md, TODO.md.

cad/README.md was a verbatim copy of the one inside
geeekpi_rack_adapter_release_v1/, so every path in it -- including the
screenshot -- was broken from where it sits. Corrected to its own level, and
it now states once that the 9-inch screen, the 10-inch mini-rack mount and the
19-inch rack are three different measurements.

The v1 shell implementation is removed; it stays recoverable at tag v1.2 and
notes/V1-BASH.md carries the setting-by-setting migration table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-09-09 13:08:42 -07:00
parent 9f082b52b7
commit ce6acec299
29 changed files with 1388 additions and 1089 deletions
+282 -235
View File
@@ -1,298 +1,345 @@
# socktop-swipe
Swipe-to-zoom touchscreen navigation for a wall-mounted
[socktop](https://crates.io/crates/socktop) dashboard.
A rack-mounted touchscreen you swipe through to see what your machines are doing.
Hosts are arranged in **groups**. Each group is one tmux window showing its
hosts tiled together. Swiping right-to-left on the panel zooms into each host
full-screen, one at a time, then moves on to the next group's overview; swiping
left-to-right walks back the same way.
Terminal dashboards are arranged in a grid. Swipe left and right to move along a
row; swipe up and down to move between rows. A group of hosts is one place in
that grid: it shows them tiled together first, then each one full-screen as you
keep swiping.
```
group A -> A1 -> A2 -> A3 -> A4 -> group B -> B1 -> B2
(quarters) zoomed zoomed zoomed zoomed (stacked) zoomed zoomed
<------------------------- swipe left-to-right -------------------------
```
With a single group this is simply overview -> each host -> back.
Optionally, swiping **down** from any screen shows a different TUI (an
[Uptime Kuma](https://github.com/louislam/uptime-kuma) status page on the rack
display), and swiping **up** returns to exactly the screen you left.
Built for a 10" touch panel on a server rack driving four Raspberry Pis, on a
low-power x86 box running i3 on X11.
## What it looks like
The panel in place, showing the four-Pi group overview:
![The touch panel mounted in the rack, showing all four Raspberry Pis tiled](media/rack-display.jpg)
Swiping through the carousel ([media/swipe-demo.mp4](media/swipe-demo.mp4)):
![The touch panel mounted in the rack, showing four Raspberry Pis tiled together](media/rack-display.jpg)
<video controls src="media/swipe-demo.mp4"></video>
## How it works, and why
Built for a 9" panel bolted into a 19" server rack, driven by a low-power x86 box
running i3 on X11.
**The carousel is tmux pane zoom, not extra socktop instances.** There is exactly
one socktop process per host. Zooming calls `tmux resize-pane -Z`, which makes one
pane fill the window and sends `SIGWINCH` so socktop repaints at the new size.
## What you're looking at
That matters for three reasons:
The photo is a 9" touchscreen in a 19" rack, showing four Raspberry Pis at once
via [socktop](https://github.com/jasonwitty/socktop). It is a wall display: no
keyboard, no mouse, nothing to log into. It comes back by itself after a power
cut and it never blanks.
- **Half the processes.** Separate full-screen instances would mean N extra
socktop processes and double the polling load on the monitored hosts. On a
1.9 GB display host, that is the difference between comfortable and not.
- **No reconnect delay.** Hidden panes keep running and stay connected, so
swiping back to a host shows current data, not a stale snapshot.
- **Constant load.** The monitored hosts see the same connections regardless of
what is on screen.
Everything on it is a terminal program. socktop-swipe does not draw anything
itself — it decides what is on screen and reads your finger.
**Gestures come from lisgd, below the terminal.** No Linux terminal maps
horizontal swipes to commands, so this cannot be done in terminal config.
Critically, **libinput deliberately emits gesture events only for touchpads,
never for touchscreens** — so `libinput-gestures` and similar tools cannot work
here at all. [lisgd](https://git.sr.ht/~mil/lisgd) reads raw touch events and
synthesises the swipes itself, reading `/dev/input` directly and bypassing X.
```
unifly -1x0
(UniFi network)
┌──────────────────────────────┐ ┌──────────────────┐
│ 4 Raspberry Pis │→ │ 2 more machines │ row 0
│ tiled, then each full-screen│ │ │
└──────────────────────────────┘ └──────────────────┘
0x0 0x1
uptime kuma 1x0
(service status)
```
## Requirements
Swipe forward through row 0 and you get: all four Pis tiled, then `rpi-master`
full-screen, then each of the others, then on to the next group. Swipe up at any
point for the UniFi network view; swipe back down and you are on the exact host
you left.
- `tmux`, `socktop`, and a working `~/.config/socktop/profiles.json`
- `lisgd` (the installer builds it)
- A C toolchain plus `libinput` and `libX11` headers, to build lisgd
- Membership of the `input` group (the installer adds you; needs a relogin)
## Hardware
| Part | What was used | Notes |
| --- | --- | --- |
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB) | Debian 11, i3 on X11 |
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB) | ~$35 used; 8/16 GB eMMC, DP out |
| Panel | GeeekPi 9" 1280x720 3U touchscreen | ILITEK USB touch |
| Mounting | [`cad/`](cad) — 19" rack adapter | 3D-print or laser-cut aluminium |
Any Linux box with a USB or DSI touchscreen will do. It is deliberately modest
hardware: the whole point is a display that idles at a few watts.
The panel is sold as a **10-inch mini-rack** part. [`cad/`](cad) has a pair of
side adapters — OpenSCAD source, STLs with tested print settings, and
SendCutSend-ready DXFs — that mount it in a standard **19-inch** rack, with three
keystone jack positions on each side. The geometry has been physically tested.
## Install
```sh
curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
```
It checks everything first and prints the whole cost — packages, toolchain, disk
— before changing anything. Then it asks, one thing at a time:
- which touch device to use (it looks for one and offers it)
- how to grant access to it: a udev rule for touchscreens only, or the `input`
group
- whether to install socktop, uptime-kuma-status or unifly
- whether to stop the screen blanking, sleeping and locking
- whether to autostart on login
- whether to log straight in at boot, so it comes back after a power cut
Every answer has a sensible default and nothing is required. Piping into `sh`
leaves no keyboard on standard input, so the script reads your terminal directly;
if there is no terminal at all it takes the defaults and says so. `--yes` skips
the questions.
There are no prebuilt binaries yet — it builds from source, so a Rust toolchain
is installed if you do not have one. Budget about 1.8 GB and, on an Atom, twenty
minutes or so.
<details>
<summary>From a checkout instead</summary>
```sh
git clone https://gt.wittyoneoff.com/jason/socktop-swipe
cd socktop-swipe
./install.sh --xignore --i3
./install.sh
```
</details>
Then **log out and back in** — the `input` group and the X rule both need a
fresh session.
## Your first config
| Flag | Effect |
| --- | --- |
| *(none)* | Build/install lisgd, install the three scripts and the default config |
| `--xignore` | Also tell X to ignore the touch panel (see below — usually wanted) |
| `--i3` | Also add i3 autostart lines (gesture daemon, fullscreen terminal, idle-pointer hiding via `unclutter-xfixes`), with a backup and `i3 -C` validation |
| `--noblank` | Also stop the screen blanking / DPMS power-down (wall displays) |
| `--autologin` | Also make lightdm log this user straight into i3 at boot (see [security note](#related-does-the-display-come-back-by-itself)) |
The config lives at `~/.config/socktop-swipe/config.yaml`. The installer writes a
starting point; this section builds one up from nothing.
Re-running is safe: an existing config file is never overwritten, and the i3
edit is skipped if already present.
### Verify
**Check every edit** with:
```sh
tools/find-device.sh # confirm TOUCH_DEV is right
tools/diag.sh # stage 1: device live? stage 2: what does lisgd see?
tools/test-foreground.sh # run the real daemon, watch the display, swipe
socktop-swipe validate
```
## Configuration
which resolves the grid, prints the map, and tells you if a program it would run
is not actually there.
Everything lives in **`/usr/local/etc/socktop-swipe.env`**. Edit it there, not in
the repo copy. After changing it, restart the daemon and rebuild the session:
### One group of hosts
```sh
socktop-gestures --replace & # restarts the daemon, running or not
socktop-rack
```yaml
touch:
device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00
width: 1280
height: 720
screens:
- at: "0x0"
type: socktop
socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3]
```
Use `--replace` rather than `pkill -x lisgd && socktop-gestures`: `pkill` exits
non-zero when it matches nothing, so that one-liner silently starts **nothing**
if no daemon was running — you would swipe, get no response, and reasonably
blame the config change.
That is five screens: the four hosts tiled, then each one full-screen.
| Option | Default | What it does |
```
0x0 tiled → rpi-master → rpi-worker-1 → rpi-worker-2 → rpi-worker-3
```
> **Quote the coordinate.** Unquoted, YAML reads `0x0` as the hexadecimal number
> zero. `socktop-swipe validate` will tell you so if you forget.
### Something above it
```yaml
- at: "-1x0"
type: unifly
```
Row `-1` is above row `0`. Now swiping up from any of those five screens shows
the UniFi TUI, and swiping down returns you to the exact host you were on.
### Something below, and more hosts alongside
```yaml
- at: "0x1"
type: socktop
socktop_group: "orangepi, trixie"
layout: even-vertical
- at: "1x0"
type: uptime-kuma-status
url: https://status.example.com/status/mine
```
`0x1` sits to the right of `0x0`, so you reach it by swiping forward past the
last Pi. `1x0` is below.
Full worked example with every option commented:
[`config.example.yaml`](config.example.yaml).
## How the grid works
**Row increases downward.** `-1x0` is above `0x0`; `1x0` is below it. Column
increases rightward. You start at `0x0`.
**Coordinates are ordering, not slots.** Only their sort order matters, so `0x1`
and `0x5` are interchangeable. You never have to count how many screens a group
produces in order to place something next to it — add a host to a group and
nothing else needs renumbering.
**A group of hosts is one place in the grid.** Swiping forward walks its screens
— tiled overview, then each host — and only moves on to the next place after the
last one. Coming back from the right lands you on that group's *last* host, not
its overview, so the row reads as one continuous strip.
**Swiping up or down returns you to where you were.** Each row remembers its
position. If you glance at the network view and come back, you are on the same
host, still zoomed.
**Nothing wraps around.** Swiping past either end of a row does nothing. On a
wall display, wrapping makes it impossible to tell where you are.
If a row has no screen in your current column, you land on the nearest one —
`0x3` swiping up with only `-1x0` above goes there. That only happens the first
time; after that the row remembers.
## Monitor types
Every type also accepts `command:` to replace the generated command outright, and
`title:` to change the label on the pane border.
| `type:` | Parameters | What it runs |
| --- | --- | --- |
| `SOCKTOP_GROUPS` | Pis quartered `;` Orange Pi + Debian stacked | Groups separated by `;`, each a list of socktop profile names **in swipe order**, plus an optional `@layout` (`tiled` default, `even-vertical` stacks, `even-horizontal` side-by-side, `main-*` also work). Any count of groups and hosts works. |
| `SOCKTOP_HOSTS` | empty | Legacy single-group form; used only when `SOCKTOP_GROUPS` is empty |
| `SOCKTOP_AUX_CMD` | `uptime-kuma-status …` | Command for the swipe-down "aux" screen. Full path. Empty disables the feature and its gestures. |
| `GESTURE_AUX_IN` / `GESTURE_AUX_OUT` | `UD` / `DU` | Swipe down shows aux, swipe up returns |
| `SOCKTOP_SESSION` | `socktop4` | tmux session name |
| `SOCKTOP_BIN` | `$HOME/.cargo/bin/socktop` | Full path — i3 does not have `~/.cargo/bin` on `PATH` |
| `TOUCH_DEV` | ILITEK by-id path | Touch device. **Always use a `/dev/input/by-id/` path**; `eventN` numbers change on reboot. |
| `SCREEN_W` / `SCREEN_H` | `1024` / `600` | The **panel's** resolution, not the X screen. See gotcha 2. |
| `SWIPE_THRESHOLD` | `80` | Pixels of travel before a drag counts as a swipe |
| `SWIPE_LENIENCY` | `30` | Degrees off-axis tolerated (max 45) |
| `FINGER_COUNTS` | `1 2 3` | Contact counts accepted. **The setting most likely to need changing** — see gotcha 1. |
| `GESTURE_IN` / `GESTURE_OUT` | `RL` / `LR` | Swap these to reverse swipe direction |
| `socktop` | `socktop_group` (list, or comma-separated string), `layout` | one [socktop](https://github.com/jasonwitty/socktop) per host |
| `uptime-kuma-status` | `url` | [uptime-kuma-status](https://github.com/jasonwitty/uptime-kuma-status) against a public [Uptime Kuma](https://github.com/louislam/uptime-kuma) status page |
| `unifly` | *(none yet)* | [unifly](https://github.com/jasonwitty/unifly) `tui` |
| `generic` | `command`, `title` | anything you like |
### Adding or removing hosts
`layout` takes any tmux layout name: `tiled` (the default), `even-horizontal`,
`even-vertical`, `main-horizontal`, `main-vertical`.
Edit `SOCKTOP_GROUPS` and run `socktop-rack`. Nothing else needs touching — the
carousel derives its length from the windows and panes at runtime. Every host
named must exist in `~/.config/socktop/profiles.json` on the display host.
```sh
# four Pis as quarters, then two more boxes stacked one above the other
SOCKTOP_GROUPS="rpi-master rpi-worker-1 rpi-worker-2 rpi-worker-3 ; orangepi trixie @even-vertical"
```yaml
- at: "1x1"
type: generic
title: k3s
command: journalctl -f -u k3s-agent
```
## Autostart
## Configuration reference
`./install.sh --i3` appends to `~/.config/i3/config`. For systemd user sessions
| Key | Default | What it does |
| --- | --- | --- |
| `session` | `socktop-swipe` | tmux session name |
| `terminal` | *(none)* | Terminal that `run` opens the dashboard in. Unset attaches in the current one |
| `indicator` | `false` | Show position — what is above, where you are, what is below — in the status line |
| `binaries.socktop` etc. | looked up on `PATH` | Full path to each program. `~/` is expanded |
| `touch.device` | — | **Always a `/dev/input/by-id/` path**; `eventN` numbers change on reboot |
| `touch.width` / `.height` | — | The **panel's** resolution, not the X screen |
| `touch.grab` | `true` | Take the panel exclusively so X never sees the touches |
| `touch.threshold` | `80` | Pixels of travel before a drag counts as a swipe |
| `touch.leniency` | `30` | Degrees off-axis tolerated, max 45 |
| `touch.fingers` | `[1, 2, 3]` | Contact counts accepted. **The setting most likely to need changing** |
| `gestures.forward` / `.back` | `RL` / `LR` | Named by finger motion. Swap to reverse |
| `gestures.up` / `.down` | `DU` / `UD` | |
### Commands
| Command | |
| --- | --- |
| `socktop-swipe run` | Build the session, open it in a terminal, read the panel. The one thing to autostart |
| `socktop-swipe validate` | Check the config and print the grid map |
| `socktop-swipe doctor` | Swipe, and be told what the panel reported |
| `socktop-swipe doctor --list` | List touch devices — works before any config exists |
| `socktop-swipe forward` / `back` / `up` / `down` | Move a running instance. For keybindings, or a box with no panel |
| `socktop-swipe build` / `attach` / `daemon` | The pieces of `run`, separately, for a systemd split |
## Unattended operation
A wall display has to survive a power cut with nobody in the room.
**Never blank.** The installer writes an Xorg `ServerFlags` snippet with all four
timeouts at zero, which covers every session including the display manager's
greeter and survives reboots, *and* adds an `xset` line to the session autostart,
because a session can re-enable blanking after X starts. Both are needed. Check
with `xset q | grep -A1 -E 'Screen Saver|DPMS'` — you want `timeout: 0`,
`prefer blanking: no`, `DPMS is Disabled`.
**Come back by itself.** Without autologin, a reboot leaves the panel at a login
prompt and nothing starts. The installer offers a lightdm drop-in. It is opt-in
and defaults to no, because **anyone with physical access to the panel gets that
user's session** — only do it where the account is nothing but the dashboard.
Delete `/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf` to undo it.
**Autostart.** `socktop-swipe run` is a single process that builds the session,
opens the terminal and reads the panel, so one line in your window manager's
autostart is enough. For a systemd user session,
[`packaging/socktop-swipe.service`](packaging/socktop-swipe.service) splits it up.
## Troubleshooting
Start here:
```sh
socktop-swipe doctor
```
It watches the panel and says what it saw in English — direction, contact count,
and why a gesture was ignored.
### Swipes are detected but nothing happens
`doctor` will say `saw 2 contact(s), config accepts [1]`. Many multipoint panels
report two or even three contacts for a physically one-finger swipe. Add them to
`touch.fingers`. This is the single most common problem on new hardware.
### Nothing is detected at all
In order: is the device path right (`socktop-swipe doctor --list`)? Can you read
it — is the udev rule installed, or are you in the `input` group and did you log
out and back in? Is the panel plugged in?
### Swipes work, but the terminal also reacts — text zooming, panes resizing
X is delivering touch to whatever is on screen as well. `touch.grab: true` (the
default) prevents this by taking the device exclusively. If you have set it to
`false` because you want touch in other applications, tell X to ignore the panel
instead:
```sh
cp socktop-swipe.service ~/.config/systemd/user/
systemctl --user enable --now socktop-swipe
```
Either way, **run exactly one gesture daemon**. lisgd does not grab the input
device exclusively, so two instances make every swipe fire twice.
`socktop-gestures` enforces this: it exits non-zero if one is already running,
unless given `--replace`.
## Stopping the screen blanking
A wall display must never blank. `./install.sh --noblank` applies two layers:
1. An Xorg `ServerFlags` snippet with all four timeouts at `0`, so it covers
every X session including the display-manager greeter, and survives reboots.
2. An `xset s off s noblank -dpms` line in the i3 autostart, because a session
or DM can re-enable the screensaver after X has started.
Both are needed. `xset` alone does not survive a reboot, and — the trap worth
knowing — **`xset` must run as the session user**. Running `sudo xset ...`, or a
`sudo bash noblank.sh` wrapper, targets root's X connection and silently does
nothing while appearing to succeed. Check the real state with:
```sh
xset q | grep -A1 -E 'Screen Saver|DPMS'
```
You want `timeout: 0`, `prefer blanking: no`, and `DPMS is Disabled`.
### Related: phantom outputs
Some boards report a display output that has no panel behind it. The LattePanda's
onboard DSI header shows up as `DSI-1 connected 1024x600` with **zero EDID bytes**
(`xrandr` prints `0mm x 0mm`), and X happily puts workspace 1 on it. Everything
then looks healthy from ssh -- i3 has the window fullscreen on its primary
output -- while the real panel shows an empty workspace. Check with:
```sh
xrandr | grep ' connected' # a real panel has physical mm, phantoms say 0mm x 0mm
for c in /sys/class/drm/card0-*; do echo "$c $(cat $c/status) edid=$(wc -c <$c/edid)"; done
```
Tell X to ignore the phantom and mark the real panel primary (adjust the names):
```sh
sudo tee /etc/X11/xorg.conf.d/20-outputs-socktop-swipe.conf >/dev/null <<'EOF'
Section "Monitor"
Identifier "DSI-1"
Option "Ignore" "true"
EndSection
Section "Monitor"
Identifier "HDMI-2"
Option "Primary" "true"
sudo tee /etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf >/dev/null <<'EOF'
Section "InputClass"
Identifier "ignore touchscreen (socktop-swipe)"
MatchProduct "ILITEK"
MatchIsTouchscreen "on"
Option "Ignore" "on"
EndSection
EOF
```
Then set `SCREEN_W` / `SCREEN_H` in the config to the real panel's mode.
Adjust `MatchProduct` to your panel, and restart X.
### Related: does the display come back by itself?
### A pane shows `[... exited: status 1]`
Blanking is only half of unattended operation. If the display manager has no
autologin configured, a reboot leaves the panel at a login prompt and nothing
autostarts. Check with:
That monitor program stopped. The pane is deliberately kept so the layout does
not reshuffle and you can see what happened. Check the path under `binaries:`,
and that the program works when you run it by hand.
```sh
grep -E '^autologin-(user|session)' /etc/lightdm/lightdm.conf
```
### The dashboard is on a screen that isn't there
No output means no autologin. `./install.sh --autologin` writes
`/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf` for the current
user into the `i3` session. It is opt-in, not a default: **anyone with physical
access to the panel gets that user's session**, so only do it where the display
user is nothing but the dashboard. Delete the file to get the login prompt back.
Some boards report an output with no panel behind it, and X will happily put the
dashboard on it — everything looks fine over ssh while the real panel shows an
empty desktop. See [`notes/HARDWARE-NOTES.md`](notes/HARDWARE-NOTES.md).
## Uninstall
```sh
./uninstall.sh # scripts, config, X rule
./uninstall.sh # binary, config, udev rule, X snippets, autologin
./uninstall.sh --keep-config # keep your settings
```
lisgd, the `input` group and your socktop profiles are deliberately left alone;
the script prints how to remove each.
The Rust toolchain, tmux, the monitor programs and your `input` group membership
are left alone; the script prints how to remove each.
## Troubleshooting
## Notes
Four gotchas account for nearly every failure. All were found the hard way.
Design decisions and the reasoning behind them are in [`notes/`](notes):
### 1. Gestures are recognised but never fire
- [`DESIGN.md`](notes/DESIGN.md) — why tmux, why evdev, why the grid works this way
- [`HARDWARE-NOTES.md`](notes/HARDWARE-NOTES.md) — panel quirks, phantom outputs, the `sudo xset` trap
- [`V1-BASH.md`](notes/V1-BASH.md) — the shell implementation this replaced, and how to migrate
- [`TODO.md`](notes/TODO.md)
Run `tools/diag.sh` and read stage 2:
## Related
```
[swipe]: Cfg(f=1/s=3/e=0/d=0) <=> Evt(f=2/s=3/e=1/d=2)
^ configured ^ what happened
```
- [socktop](https://github.com/jasonwitty/socktop) — the host monitor this was built around
- [uptime-kuma-status](https://github.com/jasonwitty/uptime-kuma-status) — Uptime Kuma status pages in a terminal
- [unifly](https://github.com/jasonwitty/unifly) — UniFi network TUI
Matching `s=` with differing `f=` means **your panel reports more contacts than
you configured**. Many multipoint panels report 2 or even 3 contacts for a
physically one-finger swipe. Add them to `FINGER_COUNTS`.
## License
Direction enum: `0=DU`, `1=UD`, `2=LR`, `3=RL`.
### 2. Swipes register, but erratically — resizing panes, zooming text
X is also delivering touch to whatever is on screen, so a single swipe hits
several consumers at once: tmux mouse mode drags pane borders, the terminal
reads 2-contact swipes as pinch-zoom, and socktop enables its own all-motion
mouse reporting (`?1003h`). Meanwhile lisgd fires too.
Fix with `./install.sh --xignore` and relog. lisgd is unaffected because it
reads the device directly. Trade-off: no tap or pinch-zoom on that panel —
terminal keyboard zoom (`Ctrl +` / `Ctrl -`) still works.
Also ensure tmux mouse mode is **off**; `socktop-rack` sets this.
### 3. Every swipe jumps two panes
Two lisgd instances are running. `pgrep -x lisgd` should show exactly one.
`socktop-gestures` refuses to start if a daemon is already running, so this
should not happen via the normal path; `--replace` restarts cleanly. It can
still occur if lisgd was launched by hand.
### 4. Swipes do nothing at all
In order: is the session running (`tmux ls`)? Is the daemon running
(`pgrep -x lisgd`)? Does `tools/diag.sh` stage 1 read bytes? Are you in the
`input` group (`id -nG | grep input`, needs a relogin)?
Note lisgd's verbose flag is **`-v`**, not `-D`.
### Multi-monitor note
If the display host has a second monitor, X reports the **combined** root window
(e.g. 2304x720). lisgd would scale its maths to that, so `SCREEN_W`/`SCREEN_H`
must describe the touch panel alone. `socktop-gestures` always passes them
explicitly.
## Known limitations
- **~300 ms repaint on zoom-in.** Between tmux making the pane full-screen and
socktop repainting, you briefly see the old small rendering anchored top-left.
This is socktop's redraw latency, not the gesture path; nothing outside
socktop can fix it. Zooming out has no visible artifact, since panes return to
sizes they were already drawn at.
- Deliberately **no wrap-around** at the ends of the carousel — on a wall display
wrapping makes it impossible to tell where you are.
- X11 only. The lisgd build disables its Wayland backend (`WITHOUT_WAYLAND=1`).
## Tested on
LattePanda (Atom x5-Z8350, 1.9 GB RAM, Cherry Trail graphics), 1024x600 ILITEK
DSI touch panel, Debian 11, i3 on X11, Alacritty 0.16.1, lisgd from git,
monitoring four Raspberry Pis.
Apache-2.0.
+29 -18
View File
@@ -2,9 +2,13 @@
A pair of side adapters for mounting the **GeeekPi 9-inch 1280x720 3U rack-mount touchscreen monitor** in a standard **19-inch equipment rack**.
Three sizes get mentioned here and they are three different things: the **screen**
is 9 inches, the mount it ships for is the **10-inch mini-rack** standard, and
these adapters convert that to a **19-inch** equipment rack.
The design also provides **three keystone-jack positions on each side of the display**.
![Installed adapter](docs/installed.jpg)
![Installed adapter](geeekpi_rack_adapter_release_v1/docs/installed.jpg)
## Release v1
@@ -18,22 +22,26 @@ The mounting geometry has been physically tested in a standard 19-inch rack.
## Files
```text
.
├── README.md
├── 3d-print/
│ ├── geeekpi_19in_adapter_LEFT_release-v1.stl
│ ├── geeekpi_19in_adapter_RIGHT_release-v1.stl
│ ├── geeekpi_19in_adapter_LEFT_release-v1_AnycubicMegaPro.gcode
── geeekpi_19in_adapter_RIGHT_release-v1_AnycubicMegaPro.gcode
├── sendcutsend/
│ ├── geeekpi_19in_adapter_LEFT_release-v1.dxf
── geeekpi_19in_adapter_RIGHT_release-v1.dxf
├── source/
── geeekpi_19in_adapter_release-v1.scad
└── docs/
└── installed.jpg
cad/
├── README.md this file
├── geeekpi_rack_adapter_release_v1/ <- use this one
│ ├── 3d-print/
│ ├── geeekpi_19in_adapter_LEFT_release-v1.stl
│ ├── geeekpi_19in_adapter_RIGHT_release-v1.stl
│ ├── geeekpi_19in_adapter_LEFT_release-v1_AnycubicMegaPro.gcode
│ │ └── geeekpi_19in_adapter_RIGHT_release-v1_AnycubicMegaPro.gcode
│ ├── sendcutsend/
│ ├── geeekpi_19in_adapter_LEFT_release-v1.dxf
│ │ └── geeekpi_19in_adapter_RIGHT_release-v1.dxf
── source/
│ │ └── geeekpi_19in_adapter_release-v1.scad
└── docs/
│ └── installed.jpg
└── geeekpi_v25_FINAL_candidate_package/ earlier candidate, superseded by v1
```
Paths below are relative to `geeekpi_rack_adapter_release_v1/`.
## Key dimensions
| Feature | Dimension |
@@ -70,7 +78,8 @@ Good bed leveling matters a lot for these parts because they are broad and very
## SendCutSend / metal version
The `sendcutsend/` directory contains separate left and right DXF files.
The `geeekpi_rack_adapter_release_v1/sendcutsend/` directory contains separate
left and right DXF files.
A good configuration for the metal adapters is:
@@ -98,7 +107,8 @@ Depending on the rack, display, and door clearance, small spacers or standoffs b
## Editing the design
The editable OpenSCAD source is in `source/`.
The editable OpenSCAD source is in
`geeekpi_rack_adapter_release_v1/source/`.
To generate a side, change:
@@ -108,7 +118,8 @@ side = "left";
to either `"left"` or `"right"`, then render/export the STL.
The DXF files in `sendcutsend/` are the intended files for sheet-metal cutting.
The DXF files in `geeekpi_rack_adapter_release_v1/sendcutsend/` are the intended
files for sheet-metal cutting.
## Notes
-91
View File
@@ -1,91 +0,0 @@
# socktop-swipe configuration.
#
# Installed to /usr/local/etc/socktop-swipe.env. Every script reads that file if
# it exists, so edit it there on the display host -- not this copy in the repo.
# After editing, restart the gesture daemon and rebuild the session (see README).
#
# Values use ${VAR:-default} so an environment variable of the same name always
# wins over this file. That keeps the file editable in the obvious way while
# still allowing one-off overrides, e.g. SOCKTOP_SESSION=test socktop-rack
# ---------------------------------------------------------------------------
# What to monitor
# ---------------------------------------------------------------------------
# What to show, as GROUPS separated by ";". Each group is one screen of the
# carousel: a tiled overview of its hosts, then each host zoomed full-screen in
# turn, then on to the next group. Host names are socktop profile names from
# ~/.config/socktop/profiles.json.
#
# overview(A) -> A1 -> A2 -> ... -> overview(B) -> B1 -> B2 -> ...
#
# An optional "@layout" token in a group sets its tmux overview layout
# (tiled, even-horizontal, even-vertical, main-horizontal, main-vertical).
# Default is tiled: 4 hosts become quarters, 2 become side-by-side.
# "@even-vertical" stacks the panes one above the other instead.
#
# The example below is the rack display: the four Pis quartered and zoomable,
# then the Orange Pi and the Debian box stacked and zoomable.
SOCKTOP_GROUPS="${SOCKTOP_GROUPS:-rpi-master rpi-worker-1 rpi-worker-2 rpi-worker-3 ; orangepi trixie @even-vertical}"
# Legacy single-group form. Used only when SOCKTOP_GROUPS is empty.
SOCKTOP_HOSTS="${SOCKTOP_HOSTS:-}"
# Optional "aux" screen: a different TUI reached by swiping DOWN from any
# carousel screen; swiping UP returns to exactly the screen you left. It runs in
# its own tmux window, kept alive like the socktop panes, so switching is
# instant. Leave empty to disable. Use full paths -- i3 has no ~/.cargo/bin.
SOCKTOP_AUX_CMD="${SOCKTOP_AUX_CMD:-$HOME/.cargo/bin/uptime-kuma-status https://status.wittyoneoff.com/status/wittyoneoff}"
# tmux session name. Change only if it collides with something else.
SOCKTOP_SESSION="${SOCKTOP_SESSION:-socktop4}"
# Absolute path to the socktop binary. i3 and non-login shells do not have
# ~/.cargo/bin on PATH, so a full path is safer than relying on lookup.
SOCKTOP_BIN="${SOCKTOP_BIN:-$HOME/.cargo/bin/socktop}"
# ---------------------------------------------------------------------------
# Touch panel
# ---------------------------------------------------------------------------
# The touchscreen event device. ALWAYS prefer a /dev/input/by-id/ symlink --
# /dev/input/eventN numbers get reshuffled on reboot or USB re-enumeration.
# Find yours with: tools/find-device.sh
TOUCH_DEV="${TOUCH_DEV:-/dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00}"
# The touch panel's own resolution -- NOT the X screen size. lisgd otherwise
# asks X, which reports the full root window across all monitors (e.g. 2304x720
# with a second display attached) and skews its edge and distance maths.
SCREEN_W="${SCREEN_W:-1024}"
SCREEN_H="${SCREEN_H:-600}"
# ---------------------------------------------------------------------------
# Gesture tuning
# ---------------------------------------------------------------------------
# Pixels of travel before a drag counts as a swipe. Too low and stray contact
# triggers it; too high and normal swipes are ignored. Measured on the ILITEK
# panel: real swipes land at 150-260px, accidental ones under 70px.
SWIPE_THRESHOLD="${SWIPE_THRESHOLD:-80}"
# Degrees of leniency off the axis, max 45. Real finger swipes came in 1-25
# degrees off true horizontal, so 30 leaves comfortable margin.
SWIPE_LENIENCY="${SWIPE_LENIENCY:-30}"
# Contact counts to accept for one logical swipe.
#
# THIS IS THE SETTING THAT MOST OFTEN NEEDS CHANGING ON NEW HARDWARE. Many
# multipoint panels report 2 or even 3 contacts for what is physically a
# one-finger swipe (palm, or ghost contacts). If gestures are recognised but
# never fire, run tools/diag.sh and compare Cfg(f=N) against Evt(f=N).
FINGER_COUNTS="${FINGER_COUNTS:-1 2 3}"
# Swipe directions. RL = right-to-left = zoom in; LR = left-to-right = zoom out.
# Swap these two values to reverse the direction of travel.
GESTURE_IN="${GESTURE_IN:-RL}"
GESTURE_OUT="${GESTURE_OUT:-LR}"
# Aux screen swipes. UD = up-to-down (swipe down) shows it; DU = down-to-up
# (swipe up) returns. Only bound when SOCKTOP_AUX_CMD is set.
GESTURE_AUX_IN="${GESTURE_AUX_IN:-UD}"
GESTURE_AUX_OUT="${GESTURE_AUX_OUT:-DU}"
+399 -198
View File
@@ -1,238 +1,439 @@
#!/bin/sh
# socktop-swipe installer.
#
# ./install.sh scripts + config + lisgd (build if missing)
# ./install.sh --xignore ...and tell X to ignore the touch panel
# ./install.sh --i3 ...and add i3 autostart lines
# ./install.sh --noblank ...and stop the screen blanking
# ./install.sh --autologin ...and make lightdm log this user straight into i3
# ./install.sh --xignore --i3 --noblank --autologin full wall-display setup
# curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
#
# Safe to re-run: an existing config file is never overwritten, and the i3 edit
# is skipped if already present.
# or, from a checkout:
#
# ./install.sh
#
# Everything is checked BEFORE anything is installed, and the full cost is
# printed once. Nothing touches the system before you answer the summary.
#
# --yes accept every default without asking
# --uninstall hand over to uninstall.sh
# --prefix DIR install to DIR/bin instead of /usr/local/bin
set -eu
PREFIX=${PREFIX:-/usr/local}
BIN="$PREFIX/bin"
ETC="$PREFIX/etc"
CONF="$ETC/socktop-swipe.env"
XCONF=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
BCONF=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
LCONF=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
SRC="$HOME/src/lisgd"
REPO=https://gt.wittyoneoff.com/jason/socktop-swipe
PREFIX=/usr/local
ASSUME_YES=no
SRC=""
here=$(cd "$(dirname "$0")" && pwd)
do_xignore=no
do_i3=no
do_noblank=no
do_autologin=no
for a in "$@"; do
case "$a" in
--xignore) do_xignore=yes ;;
--i3) do_i3=yes ;;
--noblank) do_noblank=yes ;;
--autologin) do_autologin=yes ;;
--yes | -y) ASSUME_YES=yes ;;
--prefix=*) PREFIX=${a#--prefix=} ;;
--uninstall) exec sh -c "$(dirname "$0")/uninstall.sh" ;;
-h | --help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $a" >&2; exit 2 ;;
esac
done
# --- lisgd ------------------------------------------------------------------
# libinput deliberately emits gesture events only for touchpads, never for
# touchscreens, so libinput-gestures and friends cannot work here. lisgd reads
# raw touch events and synthesises the swipes itself.
if command -v lisgd >/dev/null 2>&1; then
echo "==> lisgd already installed: $(command -v lisgd)"
else
echo "==> building lisgd"
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y build-essential pkg-config git libinput-dev libx11-dev
else
echo "!! Non-Debian system: ensure a C toolchain, libinput and libX11 headers"
echo "!! are present, then re-run."
BIN="$PREFIX/bin"
CONF_DIR="$HOME/.config/socktop-swipe"
CONF="$CONF_DIR/config.yaml"
UDEV=/etc/udev/rules.d/70-socktop-swipe.rules
XBLANK=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
LIGHTDM=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
# --------------------------------------------------------------------------
# Asking
# --------------------------------------------------------------------------
# Piping into sh leaves stdin holding the SCRIPT, not a keyboard, so prompts
# must read the terminal directly. With no terminal at all we take defaults and
# say so rather than silently guessing.
if [ -r /dev/tty ] && [ -t 1 ]; then HAVE_TTY=yes; else HAVE_TTY=no; fi
say() { printf '%s\n' "$*"; }
step() { printf '\n==> %s\n' "$*"; }
warn() { printf ' !! %s\n' "$*" >&2; }
# ask "question" default(y|n) -> 0 for yes
ask() {
_q=$1
_d=$2
if [ "$ASSUME_YES" = yes ] || [ "$HAVE_TTY" = no ]; then
[ "$_d" = y ] && return 0 || return 1
fi
[ "$_d" = y ] && _hint="[Y/n]" || _hint="[y/N]"
while :; do
printf ' %s %s ' "$_q" "$_hint" >/dev/tty
read -r _a </dev/tty || _a=""
[ -z "$_a" ] && _a=$_d
case "$_a" in
[Yy] | [Yy][Ee][Ss]) return 0 ;;
[Nn] | [Nn][Oo]) return 1 ;;
esac
done
}
# askval "question" "default" -> echoes the answer
askval() {
if [ "$ASSUME_YES" = yes ] || [ "$HAVE_TTY" = no ]; then
printf '%s' "$2"
return
fi
printf ' %s [%s] ' "$1" "$2" >/dev/tty
read -r _a </dev/tty || _a=""
[ -z "$_a" ] && _a=$2
printf '%s' "$_a"
}
have() { command -v "$1" >/dev/null 2>&1; }
# --------------------------------------------------------------------------
# Preflight
# --------------------------------------------------------------------------
say "socktop-swipe installer"
say "======================="
OS_NAME=$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-unknown}" || true)
[ -n "$OS_NAME" ] || OS_NAME=unknown
ARCH=$(uname -m)
PKG=""
PKG_INSTALL=""
for p in apt-get dnf pacman zypper apk; do
if have $p; then
PKG=$p
break
fi
done
case "$PKG" in
apt-get) PKG_INSTALL="sudo apt-get install -y" ;;
dnf) PKG_INSTALL="sudo dnf install -y" ;;
pacman) PKG_INSTALL="sudo pacman -S --needed --noconfirm" ;;
zypper) PKG_INSTALL="sudo zypper install -y" ;;
apk) PKG_INSTALL="sudo apk add" ;;
esac
# Display manager and window manager, for the autostart and autologin steps.
# NB: written as `if`, not `[ x ] && y`. Under `set -e` an AND-OR list that
# ends up false aborts the script, so a box with no lightdm would exit here.
DM=none
if [ -d /etc/lightdm ]; then DM=lightdm
elif [ -d /etc/gdm3 ] || [ -d /etc/gdm ]; then DM=gdm
elif [ -f /etc/sddm.conf ] || [ -d /etc/sddm.conf.d ]; then DM=sddm
fi
WM=none
if have i3; then WM=i3; fi
TERMINAL=""
for t in alacritty foot kitty xterm; do
if have $t; then
TERMINAL=$t
break
fi
done
LOCKER=""
for l in xss-lock light-locker xscreensaver gnome-screensaver; do
if have $l; then
LOCKER="$LOCKER $l"
fi
done
# Space for the toolchain and the build. Both land under $HOME unless
# CARGO_HOME says otherwise, which is exactly the 8GB-of-eMMC case.
avail_mb() { df -Pm "$1" 2>/dev/null | awk 'NR==2 {print $4}'; }
HOME_FREE=$(avail_mb "$HOME")
PREFIX_FREE=$(avail_mb "$(dirname "$PREFIX")")
step "Found"
say " system $OS_NAME ($ARCH)"
say " packages ${PKG:-none detected}"
say " display mgr $DM"
say " window mgr $WM"
say " terminal ${TERMINAL:-none found}"
say " tmux $(tmux -V 2>/dev/null || echo 'NOT INSTALLED')"
# Not `cargo --version | cut ... || echo`: the pipeline's status is cut's, and
# cut succeeds on empty input, so the fallback would never fire.
if have cargo; then
say " cargo $(cargo --version 2>/dev/null | cut -d' ' -f1-2)"
else
say " cargo NOT INSTALLED"
fi
say " git $(git --version 2>/dev/null || echo 'NOT INSTALLED')"
say " free space ${HOME_FREE:-?} MB in \$HOME, ${PREFIX_FREE:-?} MB on $PREFIX"
if [ -n "$LOCKER" ]; then say " screen locker $LOCKER"; fi
# What we are about to need.
NEED_PKGS=""
NEED_RUSTUP=no
COST_MB=10
if ! have tmux; then NEED_PKGS="$NEED_PKGS tmux"; fi
if ! have git; then NEED_PKGS="$NEED_PKGS git"; fi
if ! have cargo; then
NEED_RUSTUP=yes
COST_MB=$((COST_MB + 1200))
fi
# The build itself: a debug-free release build of this crate and its deps.
COST_MB=$((COST_MB + 600))
if [ -z "$TERMINAL" ]; then
warn "no terminal emulator found. socktop-swipe can attach in an existing"
warn "terminal, but unattended autostart needs one. alacritty is the tested choice."
fi
if [ -z "$PKG" ]; then
warn "no supported package manager. Nothing will be installed for you;"
warn "make sure tmux, git and a Rust toolchain are present."
fi
if [ -n "$HOME_FREE" ] && [ "$HOME_FREE" -lt "$COST_MB" ]; then
warn "only ${HOME_FREE} MB free in \$HOME but about ${COST_MB} MB is needed."
warn "A source build will not fit. Free space first, or set CARGO_HOME and"
warn "CARGO_TARGET_DIR to somewhere with room."
fi
step "This will"
if [ -n "$NEED_PKGS" ]; then say " install packages:$NEED_PKGS"; fi
if [ "$NEED_RUSTUP" = yes ]; then say " install the Rust toolchain via rustup (~1.2 GB)"; fi
say " build socktop-swipe from source (~600 MB of build artifacts)"
say " install the binary to $BIN"
say " write a starter config to $CONF"
say " ...then ask about the touch device, autostart, autologin and blanking."
say ""
say " About ${COST_MB} MB of disk. Nothing has been changed yet."
say ""
if [ "$HAVE_TTY" = no ]; then
say "No terminal available, so every question takes its default."
say "Run the script directly for the interactive version:"
say " curl -fsSLO $REPO/raw/branch/main/install.sh && sh install.sh"
say ""
fi
ask "Continue?" y || { say "Nothing done."; exit 0; }
# --------------------------------------------------------------------------
# Dependencies
# --------------------------------------------------------------------------
if [ -n "$NEED_PKGS" ]; then
step "Installing:$NEED_PKGS"
if [ -z "$PKG_INSTALL" ]; then
warn "no package manager -- install$NEED_PKGS yourself and re-run."
exit 1
fi
if [ "$PKG" = apt-get ]; then sudo apt-get update; fi
# shellcheck disable=SC2086
$PKG_INSTALL $NEED_PKGS
fi
if [ "$NEED_RUSTUP" = yes ]; then
step "Installing the Rust toolchain"
if ask "Install rustup now?" y; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path
# shellcheck disable=SC1091
. "$HOME/.cargo/env"
else
say "Install Rust yourself, then re-run this script."
exit 1
fi
fi
if ! have cargo; then warn "cargo still not on PATH -- open a new shell and re-run."; exit 1; fi
# --------------------------------------------------------------------------
# Source
# --------------------------------------------------------------------------
here=$(CDPATH='' cd -- "$(dirname -- "$0")" 2>/dev/null && pwd || echo "")
if [ -n "$here" ] && [ -f "$here/Cargo.toml" ]; then
SRC=$here
step "Building from this checkout: $SRC"
else
SRC="$HOME/.local/src/socktop-swipe"
step "Fetching the source to $SRC"
mkdir -p "$(dirname "$SRC")"
if [ -d "$SRC/.git" ]; then git -C "$SRC" pull --ff-only; else
git clone https://git.sr.ht/~mil/lisgd "$SRC"
if [ -d "$SRC/.git" ]; then
git -C "$SRC" pull --ff-only
else
git clone "$REPO" "$SRC"
fi
# WITHOUT_WAYLAND: lisgd builds both backends by default and would otherwise
# need libwayland-dev for code that never runs on an X11-only host.
make -C "$SRC" WITHOUT_WAYLAND=1
sudo make -C "$SRC" install WITHOUT_WAYLAND=1 PREFIX="$PREFIX"
fi
# --- scripts and config -----------------------------------------------------
echo "==> installing scripts to $BIN"
sudo install -d "$BIN" "$ETC"
sudo install -m 755 "$here/socktop-rack" "$here/socktop-swipe" "$here/socktop-gestures" "$BIN/"
step "Building (this is the slow part on a low-power box)"
( cd "$SRC" && cargo build --release )
sudo install -d "$BIN"
sudo install -m 755 "$SRC/target/release/socktop-swipe" "$BIN/socktop-swipe"
say " installed $BIN/socktop-swipe"
if [ -e "$CONF" ]; then
echo "==> keeping existing $CONF (not overwritten)"
# --------------------------------------------------------------------------
# Touch device
# --------------------------------------------------------------------------
step "Touch panel"
DEVICE=""
# Prefer the by-id path: eventN numbers get reshuffled on reboot.
for d in /dev/input/by-id/*event*; do
[ -e "$d" ] || continue
if udevadm info --query=property --name="$d" 2>/dev/null | grep -q '^ID_INPUT_TOUCHSCREEN=1'; then
say " found touchscreen: $d"
if [ -z "$DEVICE" ]; then DEVICE=$d; fi
fi
done
if [ -z "$DEVICE" ]; then
warn "no touchscreen found under /dev/input/by-id/."
warn "If the panel is not plugged in yet, that is fine -- set touch.device"
warn "in the config later. 'socktop-swipe doctor --list' will show it."
DEVICE=/dev/input/by-id/CHANGE-ME
else
sudo install -m 644 "$here/config.env" "$CONF"
echo "==> installed default config to $CONF"
echo " EDIT IT before first run if your hardware differs."
DEVICE=$(askval "Use which device?" "$DEVICE")
fi
# --- input group ------------------------------------------------------------
if id -nG | tr ' ' '\n' | grep -qx input; then
echo "==> already in the 'input' group"
relogin=no
else
echo "==> adding $(id -un) to the 'input' group (lisgd reads /dev/input directly)"
step "Device access"
say " Reading touch events needs permission on the device."
say " A udev rule grants it for touchscreens only and takes effect at once."
say " The 'input' group grants access to every input device, keyboard included,"
say " and needs a full logout."
RELOGIN=no
if ask "Install the udev rule?" y; then
sudo install -m 644 "$SRC/packaging/70-socktop-swipe.rules" "$UDEV"
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match=input
say " wrote $UDEV"
if ! id -nG | tr ' ' '\n' | grep -qx input; then
warn "the rule uses GROUP=input and you are not in it."
if ask "Add $(id -un) to the 'input' group as well?" y; then
sudo usermod -aG input "$(id -un)"
RELOGIN=yes
fi
fi
elif ask "Add $(id -un) to the 'input' group instead?" n; then
sudo usermod -aG input "$(id -un)"
relogin=yes
RELOGIN=yes
fi
# --- optional: make X ignore the panel --------------------------------------
if [ "$do_xignore" = yes ]; then
# shellcheck disable=SC1090
. "$CONF"
product=${XIGNORE_MATCH:-ILITEK}
echo "==> telling X to ignore touch devices matching '$product'"
sudo mkdir -p /etc/X11/xorg.conf.d
sudo tee "$XCONF" >/dev/null <<EOF
# Installed by socktop-swipe.
# The panel is driven by lisgd via /dev/input, NOT through X. Letting X also
# deliver touch makes the terminal, tmux and socktop react to the same swipes
# the gesture daemon is interpreting. MatchProduct is narrow on purpose so it
# cannot match keyboards or other HID devices.
Section "InputClass"
Identifier "ignore touchscreen (socktop-swipe)"
MatchProduct "$product"
MatchIsTouchscreen "on"
Option "Ignore" "on"
EndSection
EOF
echo " wrote $XCONF (takes effect after X restarts)"
relogin=yes
# --------------------------------------------------------------------------
# Config
# --------------------------------------------------------------------------
step "Configuration"
mkdir -p "$CONF_DIR"
if [ -e "$CONF" ]; then
say " keeping the existing $CONF"
else
sed "s|^ device: .*| device: $DEVICE|" "$SRC/config.example.yaml" >"$CONF"
say " wrote $CONF"
say " EDIT IT: the example screens are the author's rack, not yours."
fi
# --- optional: stop the screen blanking -------------------------------------
if [ "$do_noblank" = yes ]; then
echo "==> disabling screen blanking and DPMS"
# Two layers on purpose:
# 1. An Xorg ServerFlags snippet, so it applies to every X session including
# the display manager greeter, and survives reboots.
# 2. An xset call at i3 startup, because a session or DM can re-enable the
# screensaver after X starts.
# Note `xset` alone is NOT enough, and must run as the session user -- running
# it under sudo targets root's X connection and silently does nothing.
sudo mkdir -p /etc/X11/xorg.conf.d
sudo tee "$BCONF" >/dev/null <<'EOF'
# Installed by socktop-swipe. This is a wall display: it must never blank.
Section "ServerFlags"
Option "BlankTime" "0"
Option "StandbyTime" "0"
Option "SuspendTime" "0"
Option "OffTime" "0"
EndSection
EOF
echo " wrote $BCONF"
# --------------------------------------------------------------------------
# Optional monitors
# --------------------------------------------------------------------------
step "Monitor programs (all optional -- socktop-swipe runs whatever you configure)"
if ! have socktop && ask "Install socktop (the point of the project)?" y; then
cargo install socktop
fi
if ! have uptime-kuma-status && ask "Install uptime-kuma-status?" n; then
cargo install uptime-kuma-status
fi
if ! have unifly && ask "Build unifly (UniFi TUI) from source?" n; then
U="$HOME/.local/src/unifly"
mkdir -p "$(dirname "$U")"
if [ -d "$U/.git" ]; then git -C "$U" pull --ff-only; else
git clone https://github.com/jasonwitty/unifly "$U"
fi
( cd "$U" && cargo build --profile release-small -p unifly )
say " built $U/target/release-small/unifly"
say " put that path under 'binaries: unifly:' in $CONF"
fi
# Apply immediately too, if a session is available.
if [ -n "${DISPLAY:-}" ]; then
# --------------------------------------------------------------------------
# Unattended operation
# --------------------------------------------------------------------------
step "Wall-display behaviour"
if ask "Stop the screen blanking, sleeping and locking?" y; then
sudo mkdir -p /etc/X11/xorg.conf.d
sudo tee "$XBLANK" >/dev/null <<-'EOF'
# Installed by socktop-swipe. This is a wall display: it must never blank.
Section "ServerFlags"
Option "BlankTime" "0"
Option "StandbyTime" "0"
Option "SuspendTime" "0"
Option "OffTime" "0"
EndSection
EOF
say " wrote $XBLANK"
# The Xorg snippet covers every session including the greeter and survives
# reboots; xset covers a session that re-enables blanking after X starts.
# Both are needed, and xset MUST run as the session user -- under sudo it
# targets root's X connection and silently does nothing.
if [ -n "${DISPLAY:-}" ] && have xset; then
xset s off s noblank -dpms 2>/dev/null || true
echo " applied to the running session"
say " applied to the running session"
fi
i3conf="$HOME/.config/i3/config"
if [ -e "$i3conf" ] && ! grep -q "xset s off" "$i3conf"; then
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
cp "$i3conf" "$bak"
printf '\n# Wall display: never blank (socktop-swipe --noblank)\nexec --no-startup-id xset s off s noblank -dpms\n' >>"$i3conf"
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
echo " added xset to i3 autostart (backup: $bak)"
else
cp "$bak" "$i3conf"
echo "!! i3 rejected the config; restored $bak" >&2
fi
fi
relogin=yes
for l in $LOCKER; do
warn "$l is installed and may still lock the screen. Disable it in your"
warn "session's autostart."
done
fi
# --- optional: lightdm autologin --------------------------------------------
if [ "$do_autologin" = yes ]; then
# A wall display must come back by itself after a power cut. Without this a
# reboot leaves the panel at the greeter with nothing autostarted.
# SECURITY: anyone with physical access to the screen gets this user's
# session. Only use it on a display whose user account is nothing but the
# dashboard. Debian's lightdm-autologin PAM stack needs no extra group.
if [ ! -d /etc/lightdm ]; then
echo "!! /etc/lightdm not found; skipping autologin (not lightdm?)" >&2
else
session=i3
[ -e /usr/share/xsessions/$session.desktop ] ||
echo "!! /usr/share/xsessions/$session.desktop missing; check autologin-session in $LCONF" >&2
echo "==> enabling lightdm autologin for $(id -un) into '$session'"
if [ "$WM" = i3 ] && [ -e "$HOME/.config/i3/config" ]; then
if ask "Add the i3 autostart lines?" y; then
i3conf="$HOME/.config/i3/config"
if grep -q socktop-swipe "$i3conf"; then
say " already present; leaving it alone"
else
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
cp "$i3conf" "$bak"
{
printf '\n# --- socktop-swipe ---\n'
printf '# One process: builds the session, opens it in a terminal and reads the panel.\n'
printf 'exec --no-startup-id %s/socktop-swipe run\n' "$BIN"
printf '# Never blank (the Xorg snippet covers reboots; this covers this session).\n'
printf 'exec --no-startup-id xset s off s noblank -dpms\n'
if have unclutter; then
printf '# With the panel grabbed, nothing ever moves the pointer, so hide it.\n'
printf 'exec --no-startup-id unclutter --timeout 1 --ignore-scrolling\n'
fi
} >>"$i3conf"
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
say " added the autostart (backup: $bak)"
else
cp "$bak" "$i3conf"
warn "i3 rejected the config; restored $bak"
fi
fi
fi
elif [ "$DM" != none ] || [ "$WM" != none ]; then
say " no i3 config found. For a systemd user session instead:"
say " cp $SRC/packaging/socktop-swipe.service ~/.config/systemd/user/"
say " systemctl --user enable --now socktop-swipe"
fi
if [ "$DM" = lightdm ]; then
say ""
say " Autologin makes the display come back by itself after a power cut."
say " ANYONE WITH PHYSICAL ACCESS TO THE PANEL GETS THIS USER'S SESSION."
say " Only do this where the account is nothing but the dashboard."
if ask "Log $(id -un) straight into $WM at boot?" n; then
sudo mkdir -p /etc/lightdm/lightdm.conf.d
sudo tee "$LCONF" >/dev/null <<EOF
# Installed by socktop-swipe (--autologin). Wall display: come back unattended
# after a reboot. Remove this file to restore the login prompt.
[Seat:*]
autologin-user=$(id -un)
autologin-user-timeout=0
autologin-session=$session
EOF
echo " wrote $LCONF (takes effect at next boot)"
sudo tee "$LIGHTDM" >/dev/null <<-EOF
# Installed by socktop-swipe. Delete this file to restore the login prompt.
[Seat:*]
autologin-user=$(id -un)
autologin-user-timeout=0
autologin-session=$WM
EOF
say " wrote $LIGHTDM (takes effect at next boot)"
fi
elif [ "$DM" != none ]; then
say " Autologin is only automated for lightdm; yours is $DM. Configure it there"
say " if the display must come back unattended after a power cut."
fi
# --- optional: i3 autostart -------------------------------------------------
if [ "$do_i3" = yes ]; then
i3conf="$HOME/.config/i3/config"
if ! command -v unclutter >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
echo "==> installing unclutter-xfixes (hides the idle pointer on the wall display)"
sudo apt-get install -y unclutter-xfixes || echo "!! unclutter-xfixes not installed; the pointer will stay visible" >&2
fi
if [ ! -e "$i3conf" ]; then
echo "!! $i3conf not found; skipping i3 autostart" >&2
elif grep -q "socktop-gestures" "$i3conf"; then
echo "==> i3 autostart already present; leaving it alone"
else
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
cp "$i3conf" "$bak"
cat >>"$i3conf" <<EOF
# --- socktop-swipe --------------------------------------------------------
# Exactly ONE gesture daemon: lisgd does not grab the device exclusively, so a
# second instance makes every swipe fire twice.
exec --no-startup-id $BIN/socktop-gestures
# Start the dashboard on the primary output and fullscreen (which also hides
# the i3 bar), so no keyboard is needed after a reboot and a second monitor
# cannot capture it. --class gives the window a distinct instance name for the
# rule; alacritty syntax -- adapt for other terminals.
for_window [instance="^socktop-rack\$"] move window to output primary, fullscreen enable
exec --no-startup-id \$TERMINAL --class socktop-rack -e $BIN/socktop-rack
# Hide the idle pointer: with X ignoring the touch panel nothing ever moves it,
# so it would sit at screen centre forever. (apt install unclutter-xfixes)
exec --no-startup-id unclutter --timeout 1 --ignore-scrolling
EOF
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
echo "==> added i3 autostart (backup: $bak)"
echo " NOTE: set \$TERMINAL in your i3 config, or edit those lines"
echo " to your terminal's full path."
else
cp "$bak" "$i3conf"
echo "!! i3 rejected the config; restored $bak" >&2
i3 -C -c "$i3conf" || true
fi
fi
fi
echo
echo "=============================================================="
echo "Installed."
if [ "$relogin" = yes ]; then
echo "LOG OUT and back in before use (group and/or X changes)."
fi
# --------------------------------------------------------------------------
say ""
say "=============================================================="
say "Installed."
if [ "$RELOGIN" = yes ]; then say "LOG OUT AND BACK IN before use (group membership changed)."; fi
cat <<EOF
Start it by hand with:
$BIN/socktop-gestures & # exactly one instance
$BIN/socktop-rack # builds and attaches the session
Next:
1. Edit $CONF -- the screens are the author's, not yours.
2. socktop-swipe validate check it, and see the grid map
3. socktop-swipe doctor swipe, and see what the panel reports
4. socktop-swipe run build the session and start swiping
Configuration: $CONF
Diagnostics: tools/diag.sh
EOF
echo "=============================================================="
say "=============================================================="
+127
View File
@@ -0,0 +1,127 @@
# Design notes
Why the thing is built the way it is. The README says what to do; this says why,
so neither has to carry both jobs.
## The carousel is tmux pane zoom, not extra processes
There is exactly one monitor process per host. Zooming calls
`tmux resize-pane -Z`, which makes one pane fill the window and sends `SIGWINCH`
so the program repaints at the new size.
Three reasons that matters:
- **Half the processes.** Separate full-screen instances would mean N extra
processes and double the polling load on the monitored hosts. On a 1.9 GB
display host that is the difference between comfortable and not.
- **No reconnect delay.** Hidden panes keep running and stay connected, so
swiping back shows current data, not a stale snapshot with a spinner.
- **Constant load.** The monitored hosts see the same connections regardless of
what is on screen.
## Why the grid is sparse ordinals
The obvious design is that a coordinate names a physical slot. It falls apart on
the first socktop group: a group of four hosts is five screens, so putting
anything to its right means writing `0x5`, and adding a fifth host means
renumbering the rest of the row.
Making coordinates pure ordering removes that entirely. Only the sort order
matters, so `0x1` and `0x5` are the same thing, and a group grows without
disturbing its neighbours. The cost is that the file is not a literal map of the
screen — which is what `socktop-swipe validate` is for.
## Why return memory beats spatial snapping
Vertical movement had two plausible rules and they disagree. Purely spatial: from
`0x1`, up to `-1x0`, down again lands on `0x0`. Return memory: it lands back on
`0x1`.
Return memory won because the thing you actually do with a wall display is glance
away and glance back. Losing your place on every glance is the worse failure, and
snapping is only ever needed to decide the *first* entry into a row. A layout
where every row has a cell in the same column never snaps at all.
## Why evdev instead of lisgd
libinput deliberately emits gesture events only for touchpads, never for
touchscreens, so `libinput-gestures` and everything built on it cannot work here
at all. Something has to read raw touch events. v1 used
[lisgd](https://git.sr.ht/~mil/lisgd); v2 does it in-process.
What that bought:
- **No C toolchain in the install path.** No `libinput-dev`, no `libX11-dev`, no
`git clone` and `make`. On the Wyse's 8 GB of eMMC that is not a small thing.
- **`grab: true` replaces the X ignore rule.** `EVIOCGRAB` takes the device
exclusively, so X never sees the touches — which is what v1's
`Option "Ignore"` InputClass was faking, except this needs no X restart and no
logout. The X rule is still documented in the README as a fallback for anyone
who wants touch to reach other applications.
- **One process, so the double-instance bug cannot happen.** lisgd does not grab
the device, so two copies made every swipe fire twice and the carousel appeared
to skip. Now the second copy fails to grab and says so.
- **The contact-count workaround became honest.** Instead of binding three
separate lisgd gestures per direction, the peak contact count is a field on the
detected swipe, and `doctor` prints it in English.
Averaging rather than summing the contacts' travel matters here: a panel
reporting one physical finger as three contacts must not look like three times
the displacement. There is a test for exactly that.
## Why panes are addressed by id, and kept alive
Pane *indices* renumber when a pane dies. Pane *ids* (`%12`) do not, so every
lookup uses them.
That leaves the question of what happens when a monitor exits — a typo in a
`generic` command, a socktop that cannot reach its agent. tmux destroys a window
when its last pane goes, which during construction breaks the next
`split-window` with a baffling "no current target", and afterwards silently
reshuffles the display.
v1 used `remain-on-exit`, which cannot actually do the job: it is a **per-window**
option that new windows do not inherit, so there is always a gap between creating
a window and setting it. v2 wraps each command instead:
```sh
<command>; s=$?; printf '\n[%s exited: status %s]\n' <name> "$s"; while :; do sleep 86400; done
```
The pane outlives the command, and the failure is visible *on the wall display*
with its exit status — which is what a wall display is for. tmux already runs
each command under `sh`, so this costs one shell that stays resident per pane
rather than one that execs away.
## Why `at: "0x0"` must be quoted
YAML reads an unquoted `0x0` as the hexadecimal number 0. The nasty part is that
`1x0` is *not* valid hex and arrives as a string, so only the row-0 entries break
and the failure looks arbitrary. Deserialization catches the integer case and
prints the fix rather than a type error.
## Multiplexer: tmux, with zellij shelved
`src/session/` is a `Multiplexer` trait with a tmux implementation behind it, so
the question is cheap to reopen. It was shelved rather than rejected, for reasons
worth recording so it is not re-litigated:
1. **"Available as a crate" is not an embedding API.** `zellij-server`,
`zellij-client` and `zellij-utils` are published, but they are workspace
crates for the binary, not a supported library surface. Realistic integration
is the CLI or a WASM plugin — so it would still be a subprocess driven over a
CLI, exactly like tmux.
2. **Addressability is what we depend on.** The grid needs *"focus cell 0x1,
sub-screen 3, zoomed"* as one deterministic call. tmux gives that directly
(`select-pane -t %12`, `resize-pane -Z`). zellij's CLI is direction-oriented
(`move-focus left`), which would mean counting relative moves and tracking
state we cannot verify.
3. **Footprint runs the wrong way.** zellij is a client/server async multiplexer
with a wasmtime plugin runtime, heavier at idle than tmux's C implementation.
On 2 GB boxes that is the binding constraint, and `apt install tmux` versus a
zellij source build on an Atom is a much worse story for the install guide.
The one idea worth keeping on the shelf: running the navigation state machine as
a **WASM plugin inside zellij** via `zellij-tile`, which would give real event
subscriptions instead of driving a CLI. Revisit only if tmux becomes the
bottleneck.
+105
View File
@@ -0,0 +1,105 @@
# Hardware notes
Things about specific hardware that cost time to work out. Kept out of the README
because they are not install steps.
## Screen size and rack size are different measurements
Three numbers get confused because they all describe "how big":
- **9 inch** — the GeeekPi touchscreen itself, 1280x720.
- **10 inch** — the *mini-rack* standard its bracket is made for.
- **19 inch** — the standard equipment rack, which is what `cad/` adapts it to.
So "a 9-inch screen on a 10-inch mount, adapted to a 19-inch rack" is three
correct numbers, not a contradiction. The README uses the screen size when
talking about the panel and the rack size when talking about the adapter.
## The ILITEK panel reports 2-3 contacts for one finger
A physically one-finger swipe arrives as two, sometimes three, simultaneous
contacts. In v1 this was the single most expensive failure: lisgd detected the
direction correctly every time and then rejected it on
`Cfg(f=1) <=> Evt(f=2)`, which reads as noise unless you know what it means.
`touch.fingers: [1, 2, 3]` accepts all three. `socktop-swipe doctor` now reports
it in English, and the travel is *averaged* across contacts rather than summed —
otherwise a ghost-contact panel looks like it swiped three times as far.
## Phantom display outputs (LattePanda DSI-1)
The LattePanda's onboard DSI header shows up as `DSI-1 connected 1024x600` with
**zero EDID bytes** (`xrandr` reports `0mm x 0mm`), and X happily puts workspace 1
on it. Everything then looks healthy over ssh — the window is fullscreen on the
primary output — while the real panel shows an empty workspace.
```sh
xrandr | grep ' connected' # a real panel has physical mm; phantoms say 0mm x 0mm
for c in /sys/class/drm/card0-*; do echo "$c $(cat "$c"/status) edid=$(wc -c <"$c"/edid)"; done
```
Fix, adjusting the names:
```sh
sudo tee /etc/X11/xorg.conf.d/20-outputs-socktop-swipe.conf >/dev/null <<'EOF'
Section "Monitor"
Identifier "DSI-1"
Option "Ignore" "true"
EndSection
Section "Monitor"
Identifier "HDMI-2"
Option "Primary" "true"
EndSection
EOF
```
Then set `touch.width`/`touch.height` to the real panel's mode. On the LattePanda
that is **1280x720**, not the 1024x600 the phantom claims.
## `sudo xset` silently does nothing
`xset` talks to the X connection of the user running it, so `sudo xset s off`
targets *root's* X connection and succeeds while changing nothing for your
session. A `sudo bash noblank.sh` wrapper looks like it worked and blanking stays
armed. It must run as the session user.
Also: `xset` is per-session and dies on reboot. The durable fix is the Xorg
`ServerFlags` snippet with all four timeouts at 0 — which also covers the display
manager's greeter — *plus* an `xset` line in the session autostart, because a
session can re-enable the screensaver after X starts. Both, not either.
Check the real state:
```sh
xset q | grep -A1 -E 'Screen Saver|DPMS' # want timeout 0, prefer blanking no, DPMS Disabled
```
## Panel resolution is not the X screen size
With a second display attached, X reports the combined root window (e.g.
2304x720). `touch.width`/`touch.height` must describe the touch panel alone or
the edge and distance maths is scaled to the wrong thing. v1 hit this because
lisgd asks X when not told; v2 always requires the values in the config.
## LattePanda specifics
Atom x5-Z8350 @ 1.44 GHz, 1.9 GB RAM, Cherry Trail Gen8 graphics, Debian 11,
i3 on X11. Binaries in `~/.cargo/bin`, which is **not** on the PATH that i3 or a
non-login ssh gives you — hence the `binaries:` block in the config.
Memory is the binding constraint. Prefer the lightweight option and count
processes; reach for tmux pane zoom rather than duplicate program instances. A
Wayland compositor with native touch gestures is not an answer here: Hyprland is
not packaged in Debian, so it means a source build on a 1.44 GHz Atom, and it
would not help anyway — libinput emits no gesture events for touchscreens on any
version, so a raw-touch reader is required regardless.
## Wyse 3040
Same silicon as the LattePanda (Atom x5-Z8350, Cherry Trail), so the stack is
already validated. Differences: 8/16 GB soldered eMMC and no M.2, so a minimal
Debian netinst and a careful eye on disk; DisplayPort out, so a DP-to-HDMI
adapter for the panel; roughly 3-4 W idle, 101x101x28 mm, VESA holes.
The eMMC is why the installer checks free space before starting a build: a Rust
toolchain plus a target directory is around 1.8 GB.
+3 -1
View File
@@ -1,6 +1,8 @@
# socktop-swipe v2 — release plan
Status: **draft for review**, 2026-09-09. Nothing implemented yet.
Status: **implemented**, 2026-09-09. Milestones 1-7 are done on branch `v2-rust`;
milestone 8 (the Wyse 3040 validation) is the remaining acceptance gate. Where the
built thing differs from this plan, `notes/DESIGN.md` is authoritative.
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
+31
View File
@@ -0,0 +1,31 @@
# TODO
## Before calling v2.0 done
- [ ] **Wyse 3040 validation.** Debian minimal to working rack display,
following only the README. Every stumble is a README fix, then re-run.
This is the acceptance gate, not a nice-to-have.
- [ ] Switch the LattePanda over from v1. Its v2 config path and session name
differ, so both can be installed side by side while testing.
- [ ] Add the tested-hardware table to the README once the Wyse is done.
- [ ] Decide on the position indicator (`indicator: true`). Implemented; keep or
cut based on whether it actually helps on the wall.
## Open questions from the plan
- **Snap tie-break.** From `0x2`, with cells at `-1x1` and `-1x3` both at
distance 1, the lower column wins. Only fires on the first entry into a row.
- **Indicator.** Off by default. See above.
## Later
- Prebuilt binaries. Deliberately not in v2.0: source-only until the Wyse install
shows how bad a build on an Atom really is. `[package.metadata.deb]` is already
in `Cargo.toml`, so `cargo deb` is the cheap next step if it turns out to hurt.
- `unifly` parameters. `site` and `controller` are accepted by the config and
passed as `--site` / `--controller`, but the fork does not implement them yet.
Verify against the flags it actually grows.
- A second monitor type that needs a multi-pane sub-sequence would prove the
`Vec<Pane>` model generalises. Nothing needs it yet.
- SIGTERM handling to remove the control socket on exit. Not urgent: a stale
socket is detected and replaced on the next start, which is tested behaviour.
+48
View File
@@ -0,0 +1,48 @@
# What v1 was
v1 was three POSIX shell scripts plus a `.env` file, installed to
`/usr/local/bin`. Tagged **`v1.2`** — `git show v1.2` has the whole thing, and it
still works.
- `socktop-rack` built a tmux session: one window per group, one pane per host.
- `socktop-swipe next|prev|down|up` walked a linear carousel, re-deriving its
position from tmux on every single invocation.
- `socktop-gestures` wrapped [lisgd](https://git.sr.ht/~mil/lisgd) with the right
arguments and enforced a single instance.
- `config.env` at `/usr/local/etc/socktop-swipe.env` held `SOCKTOP_GROUPS`,
`SOCKTOP_AUX_CMD`, `TOUCH_DEV` and the gesture tuning.
It worked well for a year on the rack display. What made it worth replacing:
- **The layout was hard-coded as one line.** Groups ran left to right and that
was the only shape available. The swipe-down "aux screen" existed because there
was nowhere else to put a second kind of monitor — a special case bolted on,
not a position in a layout.
- **Every new monitor type was another special case.** `SOCKTOP_AUX_CMD` is a
single command with no type, no parameters and no room for a second one.
- **The navigation logic could not be tested.** It lived in a shell script that
queried a live tmux server, so the only way to check a change was to install it
on the display and swipe.
## Migration
There is no converter — deliberately, for a project with one known deployment.
The mapping is direct:
| v1 | v2 |
| --- | --- |
| `SOCKTOP_GROUPS="a b ; c d @even-vertical"` | one `type: socktop` screen per group, at `"0x0"`, `"0x1"`, …, with `socktop_group` and `layout` |
| `SOCKTOP_AUX_CMD="… uptime-kuma-status URL"` | a `type: uptime-kuma-status` screen at `"1x0"` |
| `SOCKTOP_BIN` | `binaries: socktop:` |
| `TOUCH_DEV` | `touch.device` |
| `SCREEN_W` / `SCREEN_H` | `touch.width` / `touch.height` |
| `SWIPE_THRESHOLD` / `SWIPE_LENIENCY` | `touch.threshold` / `touch.leniency` |
| `FINGER_COUNTS="1 2 3"` | `touch.fingers: [1, 2, 3]` |
| `GESTURE_IN` / `GESTURE_OUT` | `gestures.forward` / `gestures.back` |
| `GESTURE_AUX_IN` / `GESTURE_AUX_OUT` | `gestures.down` / `gestures.up` |
| the X `Option "Ignore"` rule | `touch.grab: true` |
| `remain-on-exit` | the pane keep-alive wrapper (see DESIGN.md) |
Run `./uninstall.sh` from a v1 checkout first, or remove
`/usr/local/bin/socktop-{rack,swipe,gestures}` and
`/usr/local/etc/socktop-swipe.env` by hand.
+13
View File
@@ -0,0 +1,13 @@
# Installed by socktop-swipe.
#
# Reading touch events means reading /dev/input directly. The usual advice is
# to join the 'input' group, but that grants access to EVERY input device --
# including the keyboard, which is a keylogger's worth of access -- and it needs
# a full logout to take effect.
#
# This grants access to touchscreens only, and applies the moment the device
# appears. Change GROUP to a group the dashboard user is in if 'input' is not
# suitable; the point is the narrower device match, not the group name.
#
# Reload with: sudo udevadm control --reload && sudo udevadm trigger
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_TOUCHSCREEN}=="1", GROUP="input", MODE="0640"
+29
View File
@@ -0,0 +1,29 @@
# systemd --user unit for the gesture daemon, as an alternative to starting
# socktop-swipe from the window manager's autostart.
#
# cp packaging/socktop-swipe.service ~/.config/systemd/user/
# systemctl --user enable --now socktop-swipe
#
# This runs the DAEMON only: it drives a session that something else built and
# attached (see socktop-swipe build / attach). If you would rather have one unit
# do everything, change ExecStart to `socktop-swipe run` and drop ExecStartPre --
# but then the terminal is a child of the unit, and restarting the unit closes
# the dashboard.
[Unit]
Description=socktop-swipe touchscreen gesture daemon
Documentation=https://gt.wittyoneoff.com/jason/socktop-swipe
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
# Only needed if `terminal:` is set and the terminal is an X client. The gesture
# path itself reads evdev directly and needs no display connection at all.
Environment=DISPLAY=:0
ExecStartPre=/usr/local/bin/socktop-swipe build
ExecStart=/usr/local/bin/socktop-swipe daemon
Restart=always
RestartSec=2
[Install]
WantedBy=graphical-session.target
-99
View File
@@ -1,99 +0,0 @@
#!/bin/sh
# Touch gesture daemon for the socktop display. Pass -v to log each detection.
#
# Single source of truth for the lisgd arguments -- referenced by the i3 autostart,
# the systemd unit and tools/test-foreground.sh, so they cannot drift apart.
#
# IMPORTANT: run exactly ONE instance. lisgd does not grab the input device
# exclusively, so two running copies make every swipe fire twice.
set -eu
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
[ -r "$c" ] && . "$c" && break
done
: "${TOUCH_DEV:?no TOUCH_DEV -- is the config installed?}"
: "${SCREEN_W:=1024}"
: "${SCREEN_H:=600}"
: "${SWIPE_THRESHOLD:=80}"
: "${SWIPE_LENIENCY:=30}"
: "${FINGER_COUNTS:=1 2 3}"
: "${GESTURE_IN:=RL}"
: "${GESTURE_OUT:=LR}"
: "${SOCKTOP_AUX_CMD:=}"
: "${GESTURE_AUX_IN:=UD}"
: "${GESTURE_AUX_OUT:=DU}"
SWIPE_CMD=${SWIPE_CMD:-$(dirname "$0")/socktop-swipe}
[ -x /usr/local/bin/socktop-swipe ] && SWIPE_CMD=/usr/local/bin/socktop-swipe
verbose=
replace=no
for a in "$@"; do
case "$a" in
-v) verbose=-v ;;
--replace) replace=yes ;;
-h | --help)
echo "usage: socktop-gestures [-v] [--replace]"
echo " -v log each detected gesture"
echo " --replace stop an already-running daemon first"
exit 0
;;
*)
echo "socktop-gestures: unknown option '$a'" >&2
exit 2
;;
esac
done
# Single-instance guard. lisgd does not grab the input device exclusively, so a
# second copy makes every swipe fire twice -- the carousel appears to skip panes.
# Enforced here rather than left to the caller: the obvious restart one-liner
# `pkill -x lisgd && socktop-gestures` silently starts NOTHING when no daemon was
# running, because pkill exits non-zero when it matches nothing.
running=$(pgrep -x -u "$(id -u)" lisgd 2>/dev/null || true)
if [ -n "$running" ]; then
if [ "$replace" = yes ]; then
pkill -x -u "$(id -u)" lisgd || true
n=0
while pgrep -x -u "$(id -u)" lisgd >/dev/null 2>&1 && [ "$n" -lt 30 ]; do
n=$((n + 1))
sleep 0.1
done
if pgrep -x -u "$(id -u)" lisgd >/dev/null 2>&1; then
echo "socktop-gestures: existing lisgd would not exit" >&2
exit 1
fi
else
echo "socktop-gestures: lisgd is already running (pid: $(echo "$running" | tr '\n' ' '))" >&2
echo " Two instances make every swipe fire twice. Use --replace to restart it." >&2
exit 1
fi
fi
if [ ! -e "$TOUCH_DEV" ]; then
echo "socktop-gestures: $TOUCH_DEV not present" >&2
exit 1
fi
if [ ! -r "$TOUCH_DEV" ]; then
echo "socktop-gestures: cannot read $TOUCH_DEV -- are you in the 'input' group?" >&2
echo " sudo usermod -aG input $(id -un) then log out and back in" >&2
exit 1
fi
# Bind every configured contact count to the same action; see config.env for why.
set --
for f in $FINGER_COUNTS; do
set -- "$@" -g "$f,$GESTURE_IN,*,*,R,$SWIPE_CMD next"
set -- "$@" -g "$f,$GESTURE_OUT,*,*,R,$SWIPE_CMD prev"
if [ -n "$SOCKTOP_AUX_CMD" ]; then
set -- "$@" -g "$f,$GESTURE_AUX_IN,*,*,R,$SWIPE_CMD down"
set -- "$@" -g "$f,$GESTURE_AUX_OUT,*,*,R,$SWIPE_CMD up"
fi
done
exec lisgd $verbose \
-d "$TOUCH_DEV" \
-w "$SCREEN_W" -h "$SCREEN_H" \
-t "$SWIPE_THRESHOLD" -r "$SWIPE_LENIENCY" \
"$@"
-91
View File
@@ -1,91 +0,0 @@
#!/bin/sh
# Build the socktop display session: one tmux window per GROUP, one pane per
# host inside it. Window order and pane index order together are the swipe
# order (see socktop-swipe).
set -eu
# shellcheck source=config.env
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
[ -r "$c" ] && . "$c" && break
done
: "${SOCKTOP_GROUPS:=}"
: "${SOCKTOP_HOSTS:=}"
: "${SOCKTOP_SESSION:=socktop4}"
: "${SOCKTOP_BIN:=socktop}"
: "${SOCKTOP_AUX_CMD:=}"
# Legacy form: SOCKTOP_HOSTS alone is a single tiled group.
if [ -z "$SOCKTOP_GROUPS" ]; then
: "${SOCKTOP_HOSTS:?no SOCKTOP_GROUPS or SOCKTOP_HOSTS -- is the config installed?}"
SOCKTOP_GROUPS=$SOCKTOP_HOSTS
fi
tmux kill-session -t "$SOCKTOP_SESSION" 2>/dev/null || true
# Pane titles are set on the session, so do it up front; windows inherit.
build_group() {
# $1 = window name, remaining args = host names and an optional @layout
name=$1
shift
layout=tiled
hosts=
for tok in "$@"; do
case "$tok" in
@*) layout=${tok#@} ;;
*) hosts="$hosts $tok" ;;
esac
done
# shellcheck disable=SC2086
set -- $hosts
[ $# -gt 0 ] || return 0
if ! tmux has-session -t "$SOCKTOP_SESSION" 2>/dev/null; then
tmux new-session -d -s "$SOCKTOP_SESSION" -n "$name" "$SOCKTOP_BIN -P $1"
else
tmux new-window -d -t "$SOCKTOP_SESSION" -n "$name" "$SOCKTOP_BIN -P $1"
fi
win="$SOCKTOP_SESSION:$name"
tmux select-pane -t "$win.0" -T "$1"
shift
for h in "$@"; do
# Split the most recently created pane so creation order == index order.
tmux split-window -t "$win" "$SOCKTOP_BIN -P $h"
tmux select-pane -t "$win.$(tmux display-message -t "$win" -p '#{pane_index}')" -T "$h"
done
tmux select-layout -t "$win" "$layout"
tmux select-pane -t "$win.0"
}
n=0
old_ifs=$IFS
IFS=';'
for group in $SOCKTOP_GROUPS; do
IFS=$old_ifs
# shellcheck disable=SC2086
build_group "g$n" $group
n=$((n + 1))
IFS=';'
done
IFS=$old_ifs
# The aux window sits outside the carousel; socktop-swipe recognises it by name.
if [ -n "$SOCKTOP_AUX_CMD" ]; then
tmux new-window -d -t "$SOCKTOP_SESSION" -n aux "$SOCKTOP_AUX_CMD"
fi
tmux select-window -t "$SOCKTOP_SESSION:g0"
tmux set-option -t "$SOCKTOP_SESSION" pane-border-status top
tmux set-option -t "$SOCKTOP_SESSION" pane-border-format ' #{pane_title} '
tmux set-option -t "$SOCKTOP_SESSION" status off
# Keep dead panes so pane indices stay stable if a socktop exits.
tmux set-option -t "$SOCKTOP_SESSION" remain-on-exit on
# Mouse mode MUST stay off. With it on, every touch swipe is ALSO delivered to
# tmux as a click-drag: dragging across a pane border resizes it and taps
# reselect panes, which fights the gesture daemon. See README, "Why no tapping".
tmux set-option -t "$SOCKTOP_SESSION" mouse off
exec tmux attach -t "$SOCKTOP_SESSION"
-121
View File
@@ -1,121 +0,0 @@
#!/bin/sh
# socktop-swipe next|prev|down|up
#
# Walks a linear "zoom carousel" over the socktop session. Each tmux window is
# one group of hosts; within a group the line is the tiled overview, then each
# pane zoomed full-screen in index order; then the next window's overview:
#
# overview(g0) <-> g0.0 <-> ... <-> g0.N <-> overview(g1) <-> g1.0 <-> ...
#
# "next" moves right and stops at the last pane of the last window. "prev"
# moves back and stops at the first overview. Deliberately no wrap-around: on
# a wall display, wrapping makes it impossible to tell where you are.
#
# "down" jumps to the optional aux window (see SOCKTOP_AUX_CMD) and remembers
# where you were; "up" returns there. The aux window is not part of the
# horizontal carousel, and horizontal swipes while on it do nothing.
#
# This is tmux pane zoom, NOT extra socktop instances. All panes keep running
# and stay connected while hidden, so swiping back shows current data with no
# reconnect, and the polling load on the monitored hosts is constant.
set -eu
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
[ -r "$c" ] && . "$c" && break
done
: "${SOCKTOP_SESSION:=socktop4}"
dir=${1:?usage: socktop-swipe next|prev|down|up}
# Ordered window ids (@N) of the carousel windows, the aux window if any, and
# the currently active window.
all=$(tmux list-windows -t "$SOCKTOP_SESSION" -F '#{window_id} #{window_name}' 2>/dev/null) || exit 0
[ -n "$all" ] || exit 0
wins= aux=
while read -r id name; do
if [ "$name" = aux ]; then aux=$id; else wins="$wins $id"; fi
done <<EOF
$all
EOF
WIN=$(tmux display-message -t "$SOCKTOP_SESSION" -p '#{window_id}') || exit 0
case "$dir" in
down)
[ -n "$aux" ] || exit 0
[ "$WIN" = "$aux" ] && exit 0
tmux set-option -t "$SOCKTOP_SESSION" @return_to "$WIN"
tmux select-window -t "$aux"
exit 0
;;
up)
[ "$WIN" = "$aux" ] || exit 0
back=$(tmux show-option -qv -t "$SOCKTOP_SESSION" @return_to)
[ -n "$back" ] && tmux has-session -t "$back" 2>/dev/null || back=${wins# }
back=${back%% *}
tmux select-window -t "$back"
exit 0
;;
esac
# On the aux screen, horizontal swipes are deliberately inert.
[ "$WIN" = "$aux" ] && exit 0
prev_win= next_win= seen=no
for w in $wins; do
if [ "$seen" = yes ]; then next_win=$w; break; fi
if [ "$w" = "$WIN" ]; then seen=yes; else prev_win=$w; fi
done
state=$(tmux display-message -t "$WIN" -p '#{window_zoomed_flag} #{pane_index} #{window_panes}') || exit 0
zoomed=${state%% *}
rest=${state#* }
idx=${rest%% *}
count=${rest##* }
last=$((count - 1))
zoom_to() {
# $1 = window id, $2 = pane index.
# Selecting a different pane auto-unzooms, so zoom explicitly afterwards.
tmux select-window -t "$1"
tmux select-pane -t "$1.$2"
tmux resize-pane -Z -t "$1.$2"
}
overview() {
# Show window $1 unzoomed.
tmux select-window -t "$1"
if [ "$(tmux display-message -t "$1" -p '#{window_zoomed_flag}')" = 1 ]; then
tmux resize-pane -Z -t "$1"
fi
}
if [ "$zoomed" = 0 ]; then
case "$dir" in
next) zoom_to "$WIN" 0 ;;
prev)
# Back out of this group's overview onto the previous group's last pane.
if [ -n "$prev_win" ]; then
pl=$(tmux display-message -t "$prev_win" -p '#{window_panes}')
zoom_to "$prev_win" $((pl - 1))
fi
;;
esac
else
case "$dir" in
next)
if [ "$idx" -lt "$last" ]; then
zoom_to "$WIN" $((idx + 1))
elif [ -n "$next_win" ]; then
tmux resize-pane -Z -t "$WIN.$idx" # unzoom before leaving
overview "$next_win"
fi
;;
prev)
if [ "$idx" -gt 0 ]; then
zoom_to "$WIN" $((idx - 1))
else
tmux resize-pane -Z -t "$WIN.$idx" # unzoom -> this group's overview
fi
;;
esac
fi
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=socktop-swipe touchscreen gesture daemon
Documentation=https://gt.wittyoneoff.com/jason/socktop-swipe
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
# lisgd queries X for screen geometry unless -w/-h are given (socktop-gestures
# always passes them), but keep DISPLAY set so it starts cleanly either way.
Environment=DISPLAY=:0
ExecStart=/usr/local/bin/socktop-gestures
Restart=always
RestartSec=2
[Install]
WantedBy=graphical-session.target
+31 -8
View File
@@ -48,9 +48,11 @@ impl std::str::FromStr for Coord {
fn from_str(s: &str) -> Result<Self, Self::Err> {
let t = s.trim();
// Split on the separator 'x', which cannot be part of either number.
let (row, col) = t
.split_once('x')
.ok_or_else(|| format!("{t:?} is not a coordinate -- expected \"<row>x<col>\", e.g. \"0x0\" or \"-1x0\""))?;
let (row, col) = t.split_once('x').ok_or_else(|| {
format!(
"{t:?} is not a coordinate -- expected \"<row>x<col>\", e.g. \"0x0\" or \"-1x0\""
)
})?;
let parse = |part: &str, which: &str| -> Result<i32, String> {
part.trim().parse::<i32>().map_err(|_| {
format!("{t:?} is not a coordinate -- the {which} {part:?} is not a whole number")
@@ -122,22 +124,43 @@ mod tests {
assert_eq!(Coord::new(1, -2).window_name(), "r1cm2");
for c in [Coord::new(0, 0), Coord::new(-1, -1), Coord::new(9, 9)] {
let n = c.window_name();
assert!(!n.contains(':') && !n.contains('.') && !n.contains('-'), "{n}");
assert!(
!n.contains(':') && !n.contains('.') && !n.contains('-'),
"{n}"
);
}
}
#[test]
fn unquoted_hex_coordinate_gets_a_useful_error() {
// This is what YAML actually hands us for `at: 0x0`.
let err = serde_yaml::from_str::<Coord>("0x0").unwrap_err().to_string();
let err = serde_yaml::from_str::<Coord>("0x0")
.unwrap_err()
.to_string();
assert!(err.contains("hexadecimal"), "unhelpful error: {err}");
assert!(err.contains("at: \"0x0\""), "error should show the fix: {err}");
assert!(
err.contains("at: \"0x0\""),
"error should show the fix: {err}"
);
}
#[test]
fn sorts_by_row_then_column() {
let mut v = vec![Coord::new(1, 0), Coord::new(-1, 5), Coord::new(0, 2), Coord::new(0, -1)];
let mut v = vec![
Coord::new(1, 0),
Coord::new(-1, 5),
Coord::new(0, 2),
Coord::new(0, -1),
];
v.sort();
assert_eq!(v, vec![Coord::new(-1, 5), Coord::new(0, -1), Coord::new(0, 2), Coord::new(1, 0)]);
assert_eq!(
v,
vec![
Coord::new(-1, 5),
Coord::new(0, -1),
Coord::new(0, 2),
Coord::new(1, 0)
]
);
}
}
+1 -3
View File
@@ -360,9 +360,7 @@ impl Config {
or a comma-separated string"
.into(),
),
Some(g) if g.0.is_empty() => {
wrong("socktop_group is empty".into())
}
Some(g) if g.0.is_empty() => wrong("socktop_group is empty".into()),
_ => {}
}
if s.url.is_some() {
+6 -2
View File
@@ -62,13 +62,17 @@ pub fn listen(session: &str, tx: Sender<Ctl>) -> Result<UnixListener> {
let listener = UnixListener::bind(&path)
.with_context(|| format!("cannot create control socket at {}", path.display()))?;
let accepting = listener.try_clone().context("cannot clone control socket")?;
let accepting = listener
.try_clone()
.context("cannot clone control socket")?;
std::thread::spawn(move || {
for stream in accepting.incoming() {
let Ok(stream) = stream else { continue };
let reply = stream.try_clone().ok();
let mut lines = BufReader::new(stream).lines();
let Some(Ok(line)) = lines.next() else { continue };
let Some(Ok(line)) = lines.next() else {
continue;
};
match parse_move(&line) {
Some(m) => {
if tx.send(Ctl::Go(m, reply)).is_err() {
+5 -1
View File
@@ -32,7 +32,11 @@ pub fn run(cfg: &Config) -> Result<()> {
println!("Watching {}", cfg.touch.device);
println!(
" panel {}x{}, threshold {}px, leniency {}\u{b0}, contacts accepted: {:?}",
cfg.touch.width, cfg.touch.height, cfg.touch.threshold, cfg.touch.leniency, cfg.touch.fingers
cfg.touch.width,
cfg.touch.height,
cfg.touch.threshold,
cfg.touch.leniency,
cfg.touch.fingers
);
println!(
" grab: {}\n",
+47 -14
View File
@@ -71,18 +71,23 @@ impl Grid {
let mut cells: Vec<Cell> =
cells.iter().filter(|c| c.coord.row == r).cloned().collect();
cells.sort_by_key(|c| c.coord.col);
Row { row: r, cells, cursor: 0, visited: false }
Row {
row: r,
cells,
cursor: 0,
visited: false,
}
})
.collect();
// Start at 0x0 when it exists; otherwise the leftmost cell of row 0, and
// failing that the top-left of the whole grid.
let cursor = rows
.iter()
.position(|r| r.row == 0)
.unwrap_or(0);
let cursor = rows.iter().position(|r| r.row == 0).unwrap_or(0);
let mut grid = Grid { rows, cursor };
let start_col = grid.rows[cursor].cells.iter().position(|c| c.coord.col == 0);
let start_col = grid.rows[cursor]
.cells
.iter()
.position(|c| c.coord.col == 0);
grid.rows[cursor].cursor = start_col.unwrap_or(0);
grid.rows[cursor].visited = true;
Ok(grid)
@@ -104,7 +109,11 @@ impl Grid {
pub fn position(&self, changed: bool) -> Position {
let c = self.cell();
Position { coord: c.coord, screen: c.cursor, changed }
Position {
coord: c.coord,
screen: c.cursor,
changed,
}
}
pub fn apply(&mut self, m: Move) -> Position {
@@ -218,7 +227,10 @@ mod tests {
kind: MonitorType::Socktop,
label: format!("{coord}"),
panes: (0..n)
.map(|i| Pane { title: format!("{coord}#{i}"), command: vec!["true".into()] })
.map(|i| Pane {
title: format!("{coord}#{i}"),
command: vec!["true".into()],
})
.collect(),
layout: Layout::Tiled,
cursor: 0,
@@ -319,8 +331,14 @@ mod tests {
let mut sparse = Grid::new(vec![cell("0x0", 1), cell("0x5", 1), cell("0x99", 1)]).unwrap();
let mut dense = Grid::new(vec![cell("0x0", 1), cell("0x1", 1), cell("0x2", 1)]).unwrap();
let moves = [Move::Forward, Move::Forward, Move::Back, Move::Forward];
let s: Vec<_> = walk(&mut sparse, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
let d: Vec<_> = walk(&mut dense, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
let s: Vec<_> = walk(&mut sparse, &moves)
.iter()
.map(|p| p.split('@').nth(1).unwrap().to_string())
.collect();
let d: Vec<_> = walk(&mut dense, &moves)
.iter()
.map(|p| p.split('@').nth(1).unwrap().to_string())
.collect();
assert_eq!(s, d);
assert_eq!(sparse.cell().coord, Coord::new(0, 99));
assert_eq!(dense.cell().coord, Coord::new(0, 2));
@@ -330,7 +348,10 @@ mod tests {
fn columns_out_of_order_in_the_file_still_sort() {
let mut g = Grid::new(vec![cell("0x2", 1), cell("0x0", 1), cell("0x1", 1)]).unwrap();
assert_eq!(at(&g), "0x0@0");
assert_eq!(walk(&mut g, &[Move::Forward, Move::Forward]), ["0x1@0", "0x2@0"]);
assert_eq!(
walk(&mut g, &[Move::Forward, Move::Forward]),
["0x1@0", "0x2@0"]
);
}
// -- vertical ------------------------------------------------------------
@@ -381,7 +402,11 @@ mod tests {
["0x0@1", "0x0@2", "0x1@0", "0x1@1", "0x1@2"]
);
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x0@0"], "snapped on first entry");
assert_eq!(
walk(&mut g, &[Move::Up]),
["-1x0@0"],
"snapped on first entry"
);
assert_eq!(
walk(&mut g, &[Move::Down]),
["0x1@2"],
@@ -400,7 +425,11 @@ mod tests {
.unwrap();
g.apply(Move::Forward); // to 0x8
assert_eq!(at(&g), "0x8@0");
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x9@0"], "9 is nearer to 8 than 1");
assert_eq!(
walk(&mut g, &[Move::Up]),
["-1x9@0"],
"9 is nearer to 8 than 1"
);
}
#[test]
@@ -434,7 +463,11 @@ mod tests {
assert_eq!(at(&g), "0x0@0");
g.apply(Move::Forward); // now at 0x1
g.apply(Move::Up);
assert_eq!(walk(&mut g, &[Move::Down]), ["0x1@0"], "memory follows the move");
assert_eq!(
walk(&mut g, &[Move::Down]),
["0x1@0"],
"memory follows the move"
);
}
// -- start position ------------------------------------------------------
+48 -14
View File
@@ -120,7 +120,10 @@ impl Touchpanel {
})?;
}
Ok(Self { device, cfg: cfg.clone() })
Ok(Self {
device,
cfg: cfg.clone(),
})
}
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
@@ -144,7 +147,11 @@ impl Touchpanel {
} else {
slots.insert(
current,
Slot { start: (0, 0), last: (0, 0), active: true },
Slot {
start: (0, 0),
last: (0, 0),
active: true,
},
);
peak = peak.max(slots.values().filter(|s| s.active).count());
}
@@ -161,8 +168,7 @@ impl Touchpanel {
// The gesture ends when the last contact lifts.
if !slots.is_empty() && slots.values().all(|s| !s.active) {
let tracks: Vec<Track> =
slots.values().map(|s| (s.start, s.last)).collect();
let tracks: Vec<Track> = slots.values().map(|s| (s.start, s.last)).collect();
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
if !on_event(ev) {
return Ok(());
@@ -173,7 +179,6 @@ impl Touchpanel {
}
}
}
}
/// One contact's journey: where it landed and where it lifted.
@@ -190,7 +195,10 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
return None;
}
let (dx, dy) = tracks.iter().fold((0.0, 0.0), |(ax, ay), (start, last)| {
(ax + (last.0 - start.0) as f64 / n, ay + (last.1 - start.1) as f64 / n)
(
ax + (last.0 - start.0) as f64 / n,
ay + (last.1 - start.1) as f64 / n,
)
});
let travel = (dx * dx + dy * dy).sqrt();
@@ -207,28 +215,44 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
if travel < cfg.threshold as f64 {
return Some(Event::Discarded(
direction,
Rejected::TooShort { travel, threshold: cfg.threshold },
Rejected::TooShort {
travel,
threshold: cfg.threshold,
},
));
}
// Angle away from the dominant axis.
let (along, across) = if horizontal { (dx.abs(), dy.abs()) } else { (dy.abs(), dx.abs()) };
let (along, across) = if horizontal {
(dx.abs(), dy.abs())
} else {
(dy.abs(), dx.abs())
};
let degrees = across.atan2(along).to_degrees();
if degrees > cfg.leniency as f64 {
return Some(Event::Discarded(
direction,
Rejected::OffAxis { degrees, leniency: cfg.leniency },
Rejected::OffAxis {
degrees,
leniency: cfg.leniency,
},
));
}
if !cfg.fingers.contains(&peak) {
return Some(Event::Discarded(
direction,
Rejected::WrongFingerCount { saw: peak, want: cfg.fingers.clone() },
Rejected::WrongFingerCount {
saw: peak,
want: cfg.fingers.clone(),
},
));
}
Some(Event::Swipe(Swipe { direction, fingers: peak }))
Some(Event::Swipe(Swipe {
direction,
fingers: peak,
}))
}
fn update(slots: &mut HashMap<i32, Slot>, current: i32, f: impl Fn(&mut (i32, i32))) {
@@ -251,7 +275,9 @@ pub fn list_touchscreens() -> Vec<(String, String)> {
let entries = std::fs::read_dir(by_id).into_iter().flatten().flatten();
for e in entries {
let path = e.path();
let Ok(dev) = Device::open(&path) else { continue };
let Ok(dev) = Device::open(&path) else {
continue;
};
let multitouch = dev
.supported_absolute_axes()
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
@@ -368,7 +394,11 @@ mod tests {
// nothing ever fires, because the panel reports 2 contacts and the
// config accepts only 1.
let cfg = touch(80, 30, vec![1]);
match swipe(&cfg, &[track((900, 300), -200, 0), track((905, 305), -200, 0)], 2) {
match swipe(
&cfg,
&[track((900, 300), -200, 0), track((905, 305), -200, 0)],
2,
) {
Event::Discarded(Direction::RL, r @ Rejected::WrongFingerCount { saw, .. }) => {
assert_eq!(saw, 2);
assert!(r.to_string().contains("touch.fingers"), "{r}");
@@ -382,7 +412,11 @@ mod tests {
// Otherwise a stray tap on a panel with ghost contacts reports the
// finger-count problem, sending you to fix the wrong setting.
let cfg = touch(80, 30, vec![1]);
match swipe(&cfg, &[track((640, 360), -5, 0), track((641, 361), -5, 0)], 2) {
match swipe(
&cfg,
&[track((640, 360), -5, 0), track((641, 361), -5, 0)],
2,
) {
Event::Discarded(_, Rejected::TooShort { .. }) => {}
other => panic!("expected the short-travel reason first: {other:?}"),
}
+19 -7
View File
@@ -104,7 +104,10 @@ fn real_main() -> Result<()> {
Cmd::Build => {
let (tmux, grid) = build(&cfg)?;
tmux.build(&grid)?;
println!("session {:?} is up. Attach with: tmux attach -t {}", cfg.session, cfg.session);
println!(
"session {:?} is up. Attach with: tmux attach -t {}",
cfg.session, cfg.session
);
Ok(())
}
Cmd::Attach => {
@@ -141,7 +144,11 @@ fn validate(cfg: &Config, path: &std::path::Path) -> Result<()> {
for row in &grid.rows {
println!("row {}:", row.row);
for cell in &row.cells {
let start = if cell.coord == config::Coord::new(0, 0) { " <- start" } else { "" };
let start = if cell.coord == config::Coord::new(0, 0) {
" <- start"
} else {
""
};
println!(
" {:<7} {:<20} {} screen{}{start}",
cell.coord.to_string(),
@@ -173,7 +180,10 @@ fn validate(cfg: &Config, path: &std::path::Path) -> Result<()> {
}
if !std::path::Path::new(&cfg.touch.device).exists() {
println!("touch: \u{2717} {} is not present right now", cfg.touch.device);
println!(
"touch: \u{2717} {} is not present right now",
cfg.touch.device
);
println!(" `socktop-swipe doctor --list` shows what is.\n");
} else {
println!("touch: \u{2713} {}\n", cfg.touch.device);
@@ -198,9 +208,7 @@ fn which(bin: &str) -> bool {
return std::path::Path::new(bin).is_file();
}
std::env::var_os("PATH")
.map(|p| {
std::env::split_paths(&p).any(|dir| dir.join(bin).is_file())
})
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
@@ -312,7 +320,11 @@ fn drive(tmux: &Tmux, grid: &mut Grid, rx: mpsc::Receiver<Ctl>) -> Result<()> {
answer(reply, &format!("error: {e:#}"));
return Err(e);
}
let edge = if pos.changed { "" } else { " (edge, nothing to move to)" };
let edge = if pos.changed {
""
} else {
" (edge, nothing to move to)"
};
answer(
reply,
&format!("{} {}{edge}", pos.coord, grid.cell().screen_label()),
+16 -4
View File
@@ -88,7 +88,10 @@ pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
coord: at,
kind: screen.kind,
label: title.clone(),
panes: vec![Pane { title, command: argv }],
panes: vec![Pane {
title,
command: argv,
}],
layout: screen.layout.unwrap_or_default(),
cursor: 0,
});
@@ -128,7 +131,10 @@ pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
c.extend(extra);
(
"uptime kuma".to_string(),
vec![Pane { title: "uptime kuma".into(), command: c }],
vec![Pane {
title: "uptime kuma".into(),
command: c,
}],
)
}
@@ -146,7 +152,10 @@ pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
c.extend(extra);
(
"unifly".to_string(),
vec![Pane { title: "unifly".into(), command: c }],
vec![Pane {
title: "unifly".into(),
command: c,
}],
)
}
@@ -165,5 +174,8 @@ pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
}
pub fn build_cells(cfg: &Config) -> Result<Vec<Cell>> {
cfg.screens.iter().map(|s| build_cell(s, &cfg.binaries)).collect()
cfg.screens
.iter()
.map(|s| build_cell(s, &cfg.binaries))
.collect()
}
+65 -14
View File
@@ -81,7 +81,13 @@ impl Tmux {
}
fn zoomed(&self, window: &str) -> Result<bool> {
Ok(self.run(&["display-message", "-p", "-t", window, "#{window_zoomed_flag}"])? == "1")
Ok(self.run(&[
"display-message",
"-p",
"-t",
window,
"#{window_zoomed_flag}",
])? == "1")
}
fn place(&self, cell: &Cell, first: bool) -> Result<Placed> {
@@ -90,19 +96,33 @@ impl Tmux {
let window = if first {
self.run(&[
"new-session", "-d", "-s", &self.session, "-n", &name,
"-P", "-F", "#{window_id}", &head,
"new-session",
"-d",
"-s",
&self.session,
"-n",
&name,
"-P",
"-F",
"#{window_id}",
&head,
])?
} else {
self.run(&[
"new-window", "-d", "-t", &self.session, "-n", &name,
"-P", "-F", "#{window_id}", &head,
"new-window",
"-d",
"-t",
&self.session,
"-n",
&name,
"-P",
"-F",
"#{window_id}",
&head,
])?
};
let first_pane = self.run(&[
"display-message", "-p", "-t", &window, "#{pane_id}",
])?;
let first_pane = self.run(&["display-message", "-p", "-t", &window, "#{pane_id}"])?;
self.run(&["select-pane", "-t", &first_pane, "-T", &cell.panes[0].title])?;
let mut panes = vec![first_pane];
@@ -110,7 +130,12 @@ impl Tmux {
// Split the most recently created pane so creation order matches
// the visual order the layout will impose.
let id = self.run(&[
"split-window", "-t", panes.last().unwrap(), "-P", "-F", "#{pane_id}",
"split-window",
"-t",
panes.last().unwrap(),
"-P",
"-F",
"#{pane_id}",
&Self::shell_command(&pane.command),
])?;
self.run(&["select-pane", "-t", &id, "-T", &pane.title])?;
@@ -131,7 +156,11 @@ impl Tmux {
/// daemon would cheerfully drive a stale layout.
fn adopt(&self, grid: &Grid) -> Result<()> {
let listing = self.run(&[
"list-windows", "-t", &self.session, "-F", "#{window_id} #{window_name}",
"list-windows",
"-t",
&self.session,
"-F",
"#{window_id} #{window_name}",
])?;
let mut by_name: HashMap<&str, &str> = HashMap::new();
for line in listing.lines() {
@@ -166,7 +195,13 @@ impl Tmux {
cell.panes.len()
);
}
placed.insert(cell.coord, Placed { window: (*window).to_owned(), panes });
placed.insert(
cell.coord,
Placed {
window: (*window).to_owned(),
panes,
},
);
}
*self.placed.borrow_mut() = placed;
Ok(())
@@ -191,7 +226,12 @@ impl Tmux {
parts.push(format!("{} \u{25b2}", u.label));
}
let here = if cell.screens() > 1 {
format!(" {} [{}/{}] ", cell.screen_label(), cell.cursor + 1, cell.screens())
format!(
" {} [{}/{}] ",
cell.screen_label(),
cell.cursor + 1,
cell.screens()
)
} else {
format!(" {} ", cell.screen_label())
};
@@ -226,7 +266,13 @@ impl Multiplexer for Tmux {
let s = &self.session;
self.run(&["set-option", "-t", s, "pane-border-status", "top"])?;
self.run(&["set-option", "-t", s, "pane-border-format", " #{pane_title} "])?;
self.run(&[
"set-option",
"-t",
s,
"pane-border-format",
" #{pane_title} ",
])?;
// Mouse mode MUST stay off. With it on, a touch swipe is also delivered
// to tmux as a click-drag: dragging across a pane border resizes it and
@@ -298,6 +344,11 @@ impl Multiplexer for Tmux {
}
fn attach_argv(&self) -> Vec<String> {
vec!["tmux".into(), "attach".into(), "-t".into(), self.session.clone()]
vec![
"tmux".into(),
"attach".into(),
"-t".into(),
self.session.clone(),
]
}
}
+23 -5
View File
@@ -94,7 +94,13 @@ fn session_layout_and_navigation_match_the_grid() {
mux.build(&grid).expect("session should build");
// One window per cell, in grid order, named by coordinate.
let windows = tmux(&["list-windows", "-t", SESSION, "-F", "#{window_name} #{window_panes}"]);
let windows = tmux(&[
"list-windows",
"-t",
SESSION,
"-F",
"#{window_name} #{window_panes}",
]);
assert_eq!(
windows.lines().collect::<Vec<_>>(),
vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"],
@@ -120,14 +126,20 @@ fn session_layout_and_navigation_match_the_grid() {
let pos = grid.apply(Move::Forward);
mux.show(&grid, &pos).unwrap();
let (win, _, zoomed) = visible();
assert_eq!(win, "r0c5", "0x5 follows 0x0 despite the gap in column numbers");
assert_eq!(
win, "r0c5",
"0x5 follows 0x0 despite the gap in column numbers"
);
assert!(!zoomed);
// Back must land on 0x0's LAST host, not its overview.
let pos = grid.apply(Move::Back);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
assert_eq!((win.as_str(), title.as_str(), zoomed), ("r0c0", "delta", true));
assert_eq!(
(win.as_str(), title.as_str(), zoomed),
("r0c0", "delta", true)
);
// Up to unifly: a single-pane cell, so nothing to zoom.
let pos = grid.apply(Move::Up);
@@ -174,9 +186,15 @@ screens:
let cells = monitor::build_cells(&cfg).unwrap();
let argv = &cells[0].panes[0].command;
assert_eq!(argv, &["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]);
assert_eq!(
argv,
&["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]
);
let quoted = shell_words::join(argv.iter().map(String::as_str));
let reparsed = shell_words::split(&quoted).unwrap();
assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh");
assert_eq!(
&reparsed, argv,
"quoting must survive the round trip through sh"
);
}
-56
View File
@@ -1,56 +0,0 @@
#!/bin/sh
# Gesture diagnostic. Two stages, each answering exactly one question.
#
# Stage 1: is the panel delivering events to us at all?
# Stage 2: which direction and how many contacts does lisgd see?
#
# Stage 2 is the important one. Each line reads:
# [swipe]: Cfg(f=1/s=3/e=0/d=0) <=> Evt(f=2/s=3/e=1/d=2)
# ^ what you configured ^ what actually happened
# f=fingers, s=direction, e=edge, d=distance. Direction enum:
# 0=DU (down-to-up) 1=UD 2=LR (left-to-right) 3=RL (right-to-left)
#
# If Cfg and Evt agree on s= but differ on f=, your panel reports more contacts
# than you configured -- add them to FINGER_COUNTS in the config.
set -eu
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/../config.env"; do
[ -r "$c" ] && . "$c" && break
done
: "${TOUCH_DEV:?no TOUCH_DEV configured}"
: "${SCREEN_W:=1024}"
: "${SCREEN_H:=600}"
: "${SWIPE_THRESHOLD:=80}"
: "${SWIPE_LENIENCY:=30}"
echo "=============================================================="
echo "STAGE 1 -- is the panel delivering events at all?"
echo "Touch and drag on the TOUCHSCREEN for the next 6 seconds..."
echo "=============================================================="
bytes=$(timeout 6 cat "$TOUCH_DEV" 2>/dev/null | wc -c)
echo
echo " read $bytes bytes from $TOUCH_DEV"
if [ "$bytes" -eq 0 ]; then
cat <<-EOF
-> NOTHING. Stage 2 cannot work. Check, in order:
* are you in the 'input' group? (id -nG | grep input; needs a relogin)
* is TOUCH_DEV the right device? (tools/find-device.sh)
EOF
exit 1
fi
echo " -> device is live."
echo
echo "=============================================================="
echo "STAGE 2 -- what does lisgd actually see?"
echo "All four directions are bound, for 1, 2 and 3 contacts."
echo "Swipe LEFT, RIGHT, UP, DOWN. Ctrl-C when done."
echo "=============================================================="
set --
for f in 1 2 3; do
for g in RL LR DU UD; do
set -- "$@" -g "$f,$g,*,*,R,echo \" >>> FIRED: ${f}-contact $g\""
done
done
exec lisgd -v -d "$TOUCH_DEV" -w "$SCREEN_W" -h "$SCREEN_H" \
-t "$SWIPE_THRESHOLD" -r "$SWIPE_LENIENCY" "$@"
-36
View File
@@ -1,36 +0,0 @@
#!/bin/sh
# Identify the touchscreen and print the stable by-id path for config.env.
set -eu
echo "=== input devices reporting a touch-ish name ==="
found=
for ev in /dev/input/event*; do
name=$(cat "/sys/class/input/$(basename "$ev")/device/name" 2>/dev/null || true)
case "$name" in
*[Tt]ouch* | *TOUCH*)
found=yes
echo
echo " device: $ev"
echo " name: $name"
for l in /dev/input/by-id/* /dev/input/by-path/*; do
[ -e "$l" ] || continue
if [ "$(readlink -f "$l")" = "$(readlink -f "$ev")" ]; then
echo " stable: $l"
fi
done
;;
esac
done
if [ -z "$found" ]; then
echo " (none matched by name)"
echo
echo "Fall back to listing everything:"
for ev in /dev/input/event*; do
printf ' %-22s %s\n' "$ev" "$(cat "/sys/class/input/$(basename "$ev")/device/name" 2>/dev/null)"
done
fi
echo
echo "Put the 'stable:' path (prefer /dev/input/by-id/) into TOUCH_DEV."
echo "Event numbers change across reboots; by-id symlinks do not."
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# Run the real gesture daemon in the foreground so you can watch the display
# while you swipe. Ctrl-C to stop.
#
# Expect: swipe right-to-left -> zooms in one host at a time
# swipe left-to-right -> walks back out to the overview
# "Execute ..." in the output means a gesture matched and fired.
#
# Pass --replace to take over from an already-running daemon; without it,
# socktop-gestures refuses to start a second instance (which would make every
# swipe fire twice).
set -eu
exec "$(dirname "$0")/../socktop-gestures" -v "$@"
+61 -41
View File
@@ -1,56 +1,76 @@
#!/bin/sh
# Remove socktop-swipe. Leaves lisgd, the 'input' group membership and your
# socktop profiles alone -- those are useful independently and removing them
# could break other things.
# Remove socktop-swipe.
#
# ./uninstall.sh remove scripts, config and the X ignore rule
# ./uninstall.sh --keep-config leave the config file in place
# ./uninstall.sh binary, config, udev rule, X snippets, autologin
# ./uninstall.sh --keep-config leave the config file alone
#
# The Rust toolchain, tmux, socktop and friends, and your 'input' group
# membership are all left in place: they are useful independently, and removing
# them could break something else. What was skipped is printed at the end.
set -eu
PREFIX=${PREFIX:-/usr/local}
BIN="$PREFIX/bin"
CONF="$PREFIX/etc/socktop-swipe.env"
XCONF=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
BCONF=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
LCONF=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
BIN="$PREFIX/bin/socktop-swipe"
CONF_DIR="$HOME/.config/socktop-swipe"
CONF="$CONF_DIR/config.yaml"
UDEV=/etc/udev/rules.d/70-socktop-swipe.rules
XBLANK=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
XIGNORE=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
LIGHTDM=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
UNIT="$HOME/.config/systemd/user/socktop-swipe.service"
keep_config=no
[ "${1:-}" = "--keep-config" ] && keep_config=yes
for a in "$@"; do
case "$a" in
--keep-config) keep_config=yes ;;
-h | --help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $a" >&2; exit 2 ;;
esac
done
# Stop anything running. -x matches the exact process name; -f would also match
# this script's own command line and kill the shell running it.
pkill -x lisgd 2>/dev/null || true
say() { printf '%s\n' "$*"; }
for f in socktop-rack socktop-swipe socktop-gestures; do
if [ -e "$BIN/$f" ]; then
sudo rm -f "$BIN/$f"
echo "removed $BIN/$f"
# Stop it first, so nothing is holding the touch device or the tmux session.
if command -v systemctl >/dev/null 2>&1 && [ -e "$UNIT" ]; then
systemctl --user disable --now socktop-swipe 2>/dev/null || true
rm -f "$UNIT"
systemctl --user daemon-reload 2>/dev/null || true
say "removed $UNIT"
fi
pkill -x socktop-swipe 2>/dev/null || true
# Session name comes from the config, which may be about to be deleted.
if [ -r "$CONF" ] && command -v tmux >/dev/null 2>&1; then
session=$(sed -n 's/^session:[[:space:]]*//p' "$CONF" | head -1)
[ -n "${session:-}" ] || session=socktop-swipe
tmux kill-session -t "$session" 2>/dev/null || true
fi
for f in "$BIN" "$UDEV" "$XBLANK" "$XIGNORE" "$LIGHTDM"; do
if [ -e "$f" ]; then
sudo rm -f "$f"
say "removed $f"
fi
done
if [ -e "$UDEV" ] || command -v udevadm >/dev/null 2>&1; then
sudo udevadm control --reload 2>/dev/null || true
fi
if [ "$keep_config" = no ] && [ -e "$CONF" ]; then
sudo rm -f "$CONF"
echo "removed $CONF"
rm -f "$CONF"
rmdir "$CONF_DIR" 2>/dev/null || true
say "removed $CONF"
fi
if [ -e "$XCONF" ]; then
sudo rm -f "$XCONF"
echo "removed $XCONF (touch returns to X after the next X restart)"
fi
if [ -e "$BCONF" ]; then
sudo rm -f "$BCONF"
echo "removed $BCONF (screen blanking returns after the next X restart)"
fi
if [ -e "$LCONF" ]; then
sudo rm -f "$LCONF"
echo "removed $LCONF (login prompt returns at next boot)"
fi
echo
echo "Left in place on purpose:"
echo " * lisgd -- remove with: sudo make -C ~/src/lisgd uninstall"
echo " * 'input' group -- remove with: sudo gpasswd -d $(id -un) input"
echo " * i3 autostart -- delete the 'socktop-swipe' block in ~/.config/i3/config"
echo " * socktop profiles -- ~/.config/socktop/profiles.json"
say ""
say "Left in place on purpose:"
say " * the Rust toolchain -- remove with: rustup self uninstall"
say " * tmux -- your package manager installed it"
say " * socktop and friends -- cargo uninstall socktop uptime-kuma-status"
say " * the source checkout -- rm -rf ~/.local/src/socktop-swipe"
say " * 'input' group -- sudo gpasswd -d $(id -un) input"
say " * i3 autostart -- delete the 'socktop-swipe' block in ~/.config/i3/config"
say ""
say "Screen blanking and the login prompt come back after the next X restart"
say "or reboot."