-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathmidscroll-apply.py
More file actions
executable file
·257 lines (223 loc) · 9.56 KB
/
Copy pathmidscroll-apply.py
File metadata and controls
executable file
·257 lines (223 loc) · 9.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/python3
"""midscroll-apply - write /etc/midscroll.conf and restart the daemon.
Run as root, normally through pkexec from the settings GUI. Takes the
tunables as KEY=VALUE arguments, validates every one (so an untrusted
caller can only ever produce a well-formed midscroll config, never write
arbitrary text to /etc), regenerates a clean commented config file and
restarts midscroll.service so the change takes effect.
midscroll-apply DEADZONE_PX=15 SPEED_MULT=0.008 ... TOGGLE_MODE=false
Pass --no-restart to only write the file.
One key is deliberately out of reach here: ALLOW_KEYBOARDS, which lets the
daemon grab keyboard-class devices. This program is reachable by anyone
who can pass its polkit check, so it refuses to set that key and only
carries an existing setting through - turning it on takes a root edit of
the config file. See SECURITY.md.
"""
import math
import os
import re
import subprocess
import sys
import tempfile
CONFIG_PATH = "/etc/midscroll.conf"
# Bounds on our own argv: it comes from an unprivileged caller.
MAX_ARGS = 64
MAX_ARG_LEN = 4096
# Bounds on device specs, matching the daemon's.
MAX_SPECS = 32
MAX_SPEC_LEN = 128
MAX_SPECS_LEN = 1024
# Never settable from here, only preserved.
ROOT_ONLY_KEYS = ("ALLOW_KEYBOARDS",)
# Float tunables and whether they must be strictly positive (a zero dead
# zone is fine; a zero rate or multiplier would break the daemon).
FLOATS = {
"DEADZONE_PX": False,
"SPEED_MULT": True,
"SPEED_EXP": True,
"MAX_PX_PER_SEC": True,
"PX_PER_NOTCH": True,
"MAX_DRAG_PX": True,
"TICK_HZ": True,
"GHOST_SCALE": True,
}
BOOLS = ("NATURAL", "TOGGLE_MODE", "DESKTOP_SCROLL", "GHOST_CURSOR")
DEVICES = ("EXTRA_DEVICES", "IGNORE_DEVICES")
KEYS = tuple(FLOATS) + BOOLS + DEVICES + ("BLACKLIST",)
# Defaults for any key the caller omits.
DEFAULTS = {
"DEADZONE_PX": 15.0, "SPEED_MULT": 0.008, "SPEED_EXP": 2.2,
"MAX_PX_PER_SEC": 30000.0, "PX_PER_NOTCH": 55.0, "MAX_DRAG_PX": 1200.0,
"TICK_HZ": 90.0, "GHOST_SCALE": 1.0, "NATURAL": False,
"TOGGLE_MODE": False, "DESKTOP_SCROLL": False, "GHOST_CURSOR": True,
"BLACKLIST": "freecad, orcaslicer, minecraft",
"EXTRA_DEVICES": "", "IGNORE_DEVICES": "",
}
TEMPLATE = """\
# midscroll tuning - edit here or use the midscroll-settings GUI, then:
# sudo systemctl restart midscroll
#
# Speed curve is Chromium/Edge's Windows autoscroll formula:
# speed (px/sec) = SPEED_MULT * (pixels dragged) ^ SPEED_EXP per axis
DEADZONE_PX = {DEADZONE_PX:g} # per-axis dead zone in pixels
SPEED_MULT = {SPEED_MULT:g} # overall speed knob (bigger = faster)
SPEED_EXP = {SPEED_EXP:g} # curve shape (bigger = more extreme far out)
MAX_PX_PER_SEC = {MAX_PX_PER_SEC:g} # safety cap
PX_PER_NOTCH = {PX_PER_NOTCH:g} # px one wheel notch scrolls in your apps
MAX_DRAG_PX = {MAX_DRAG_PX:g} # max effective drag distance
TICK_HZ = {TICK_HZ:g} # scroll smoothness (event rate)
NATURAL = {NATURAL} # true = inverted / touchscreen-style direction
# Click the middle button once to start autoscroll and again (or any click)
# to stop, Windows-Explorer style, instead of holding it and dragging.
TOGGLE_MODE = {TOGGLE_MODE}
# Autoscroll over the desktop and panels too. Off by default, so a middle-drag
# on the desktop/taskbar (plasmashell, xfdesktop, waybar, ...) is left alone.
DESKTOP_SCROLL = {DESKTOP_SCROLL}
# While you drag, the real pointer stays anchored so the scroll can't leak to
# another window, and the session helper draws a cursor that follows your hand.
# GHOST_SCALE is how far that drawn cursor travels per unit of mouse motion.
GHOST_CURSOR = {GHOST_CURSOR}
GHOST_SCALE = {GHOST_SCALE:g}
# Apps that use middle-drag themselves: while one of these is the focused
# window, midscroll pauses. Comma-separated, case-insensitive window-class
# substrings; leave empty to disable.
BLACKLIST = {BLACKLIST}
# Devices to use as a mouse even though midscroll doesn't detect one, and
# devices never to use even if it does. Comma-separated; each entry is a
# /dev/input path (the by-id and by-path links are the stable ones), a hex
# vendor:product, or part of the device name. `midscroll --list-devices'
# prints all three for every device.
EXTRA_DEVICES = {EXTRA_DEVICES}
IGNORE_DEVICES = {IGNORE_DEVICES}
{ALLOW_KEYBOARDS}"""
# Appended only when it is already on: midscroll-apply never sets it.
KEYBOARDS_BLOCK = """
# Lets midscroll grab keyboard-class devices listed in EXTRA_DEVICES. This
# means a root daemon reads every key on them, and they lose key repeat and
# LEDs on the way through its virtual mirror. The settings GUI cannot set
# this; it is only ever changed by editing this file as root.
ALLOW_KEYBOARDS = true
"""
def die(msg):
sys.stderr.write(f"midscroll-apply: {msg}\n")
sys.exit(2)
def parse_bool(text):
t = text.strip().lower()
if t in ("1", "true", "yes", "on"):
return True
if t in ("0", "false", "no", "off"):
return False
raise ValueError(f"not a boolean: {text!r}")
def clean_list(val, charset, max_len, max_parts, total):
"""A comma list normalised to a bounded, well-formed config value.
Anything that isn't a plausible character is dropped - including '#',
'=' and newlines, which the daemon's parser would read as structure -
so no argument can turn into extra config lines, and the length caps
stop a caller pushing something huge into a root-written file.
"""
parts = [re.sub(charset, "", p.strip(), flags=re.ASCII)[:max_len]
for p in val.replace("\n", ",").split(",")]
kept = [p for p in parts if p][:max_parts]
return ", ".join(kept)[:total]
def keyboards_enabled(path=CONFIG_PATH):
"""Whether ALLOW_KEYBOARDS is already on in a trustworthy config.
Read the way the daemon reads it (no symlink, root-owned, not writable
by anyone else); anything else is not carried forward, because the
daemon wouldn't honour it either.
"""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
return False
with os.fdopen(fd, "r", errors="replace") as f:
st = os.fstat(f.fileno())
if st.st_uid != 0 or st.st_mode & 0o022:
return False
for line in f.read(65536).splitlines():
line = line.split("#", 1)[0].strip()
if "=" not in line:
continue
key, val = (p.strip() for p in line.split("=", 1))
if key == "ALLOW_KEYBOARDS":
return val.lower() in ("1", "true", "yes", "on")
return False
def main(argv):
os.umask(0o077)
if len(argv) > MAX_ARGS:
die(f"too many arguments (limit {MAX_ARGS})")
for arg in argv:
if len(arg) > MAX_ARG_LEN:
die(f"argument longer than {MAX_ARG_LEN} characters")
restart = True
values = dict(DEFAULTS)
for arg in argv:
if arg == "--no-restart":
restart = False
continue
if "=" not in arg:
die(f"expected KEY=VALUE, got {arg!r}")
key, val = arg.split("=", 1)
key = key.strip()
if key in ROOT_ONLY_KEYS:
die(f"{key} can only be changed by editing {CONFIG_PATH} as root")
if key not in KEYS:
die(f"unknown key {key!r}")
if key in FLOATS:
try:
num = float(val)
except ValueError:
die(f"{key}: not a number: {val!r}")
if not math.isfinite(num):
die(f"{key}: must be finite")
if FLOATS[key] and num <= 0:
die(f"{key}: must be strictly greater than zero")
if num < 0:
die(f"{key}: must not be negative")
values[key] = num
elif key in BOOLS:
try:
values[key] = parse_bool(val)
except ValueError as err:
die(f"{key}: {err}")
elif key in DEVICES:
# Device specs need '/' and ':' as well, for /dev/input paths
# and vendor:product; the daemon re-validates every entry.
values[key] = clean_list(val, r"[^\w./:\- ]", MAX_SPEC_LEN,
MAX_SPECS, MAX_SPECS_LEN)
else: # BLACKLIST
values[key] = clean_list(val, r"[^\w.\- ]", 64, 32, 512)
text = TEMPLATE.format(
NATURAL="true" if values["NATURAL"] else "false",
TOGGLE_MODE="true" if values["TOGGLE_MODE"] else "false",
DESKTOP_SCROLL="true" if values["DESKTOP_SCROLL"] else "false",
GHOST_CURSOR="true" if values["GHOST_CURSOR"] else "false",
BLACKLIST=values["BLACKLIST"],
EXTRA_DEVICES=values["EXTRA_DEVICES"],
IGNORE_DEVICES=values["IGNORE_DEVICES"],
# Carried through untouched: this program can't turn it on or off.
ALLOW_KEYBOARDS=KEYBOARDS_BLOCK if keyboards_enabled() else "",
**{k: values[k] for k in FLOATS},
)
directory = os.path.dirname(CONFIG_PATH) or "/"
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".midscroll.conf.")
try:
with os.fdopen(fd, "w") as f:
f.write(text)
os.chmod(tmp, 0o644)
os.replace(tmp, CONFIG_PATH)
except OSError as err:
try:
os.unlink(tmp)
except OSError:
pass
die(f"cannot write {CONFIG_PATH}: {err}")
print(f"wrote {CONFIG_PATH}")
if restart:
try:
subprocess.run(["systemctl", "restart", "midscroll.service"],
check=True)
except (OSError, subprocess.CalledProcessError) as err:
die(f"restart failed: {err}")
print("restarted midscroll.service")
if __name__ == "__main__":
main(sys.argv[1:])