I have been typing timestamps by hand for years now. File names, notes in a scratch buffer, markers in a log I keep while working on a server.. always the same little finger dance. Last week I decided this is silly and that Ctrl+Super+T should simply type it for me. It sounded like a five minute job.
It was NOT a five minute job. My box is Ubuntu 24.04.4 LTS with GNOME Shell 46.0 on Wayland, kernel 6.14.0-34-generic. Almost every recipe you find for this is written for X11, and on Wayland those recipes do exactly nothing. That is when I started digging, and the digging turned out to be rather more interesting than the feature itself.
Know what you are actually running
First check the session type. Half of the advice you will find online assumes the wrong one, and that is exactly where the wasted evenings begin.
$ echo $XDG_SESSION_TYPE
wayland
$ gnome-shell --version
GNOME Shell 46.0
$ gsettings get org.gnome.desktop.input-sources sources
[('xkb', 'us'), ('xkb', 'tr')]
I keep both a US and a Turkish layout, and that second line will matter a lot later. Note it down if you also switch layouts, it bites in a very sneaky way.
Why the usual tools are dead here
I reached for xdotool first, purely out of habit. It is an X11 tool and it cannot talk to a Wayland compositor at all. wtype is the Wayland answer, but it needs the zwp_virtual_keyboard_manager_v1 protocol, and Mutter does not implement that. So on GNOME it fails as well.
That leaves the kernel. The uinput device lets you create a virtual input device and feed it key events. The compositor cannot tell it apart from a real keyboard, which is exactly what we want. It is a bit low level, but it is the only door that is actually open.
I tried ydotool next, since it is the usual wrapper around uinput. On Noble it is version 0.1.8-3build1 and the package ships only /usr/bin/ydotool, no daemon at all. Its type command works fine. Unfortunately, its key command returns exit code 0 while injecting absolutely nothing, which cost me a good half hour of confusion. I ended up dropping it and writing the uinput calls myself.
1. Give yourself access to /dev/uinput
By default the device is locked down tight:
$ ls -l /dev/uinput
crw------- 1 root root 10, 223 Aug 27 09:10 /dev/uinput
Create /etc/udev/rules.d/60-uinput-uaccess.rules:
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", \
RUN+="/usr/bin/setfacl -m u:YOURUSER:rw /dev/uinput"
Then reload and trigger it:
sudo udevadm control --reload-rules
sudo udevadm trigger --sysname-match=uinput
I put both mechanisms in that rule on purpose. The uaccess tag is the clean way to hand a device to the logged-in seat user. Still, /dev/uinput is a static node, and I do not fully trust seat ACLs to be re-applied on those at every boot. The explicit setfacl line makes it certain.
I got a nice surprise on this kernel. Here uinput is compiled in and not a module at all:
$ sudo modprobe -r uinput
modprobe: FATAL: Module uinput is builtin.
So there is no modules-load.d file needed. I had added one and then removed it again (Silly me!).
Please resist the temptation to just add yourself to the input group. That grants READ access to every input device on the machine, which means any process running as you can quietly log your keystrokes. Writing to uinput does not need that at all.
2. Bind the shortcut in GNOME
I set the binding from the command line, since I was scripting the rest anyway. GNOME custom shortcuts live in a relocatable schema, so the commands look a bit ugly.
BASE=org.gnome.settings-daemon.plugins.media-keys
KEY=/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/timestamp/
gsettings set $BASE custom-keybindings "['$KEY']"
gsettings set $BASE.custom-keybinding:$KEY name 'Type timestamp'
gsettings set $BASE.custom-keybinding:$KEY command "$HOME/.local/bin/type-timestamp"
gsettings set $BASE.custom-keybinding:$KEY binding '<Control><Super>t'
If you already have custom shortcuts, append to that list instead of replacing it, otherwise you will wipe the ones you had. I lost my flameshot binding once this way and it took me a minute to work out what I had done.
3. The first version, and the mayhem
My first script waited 350 ms and then typed. I pressed the shortcut, and my desktop went completely mad. Windows jumped around, Firefox launched twice, and almost nothing was typed anywhere.
The journal explained it immediately:
$ journalctl --user -b | grep gsd-media-keys
Started app-gnome-firefox_firefox-62080.scope - Application launched by gsd-media-keys.
Here is the thing. When the shortcut fires, you are still physically holding Ctrl and Super. GNOME binds Super+1 through Super+9 to switch-to-application-1..9. My timestamp is mostly digits. So every digit became an application switch, and the ones that were not bound arrived as control characters. The shortcut was launching my dock, not typing text.
4. Injecting a release does not work
My next idea felt clever. Just inject key-UP events for Ctrl and Super first, then type. I wrote it, and the result was only slightly less broken.
I typed plain letters instead of digits, to see clearly what was happening. Letters make the modifier state obvious in a way that digits really do not. With Ctrl held, abcdef came out as:
^[^A^[^B
That is Ctrl+A and Ctrl+B. So Mutter had happily ignored my injected release and kept the modifier latched, because the REAL key was still down. This is the single most important thing I learned in the whole exercise. You cannot talk a compositor out of a modifier that is physically held.
Once that sank in, the conclusion was obvious. There is no delay value that is correct, because the delay is a guess about a human finger. The script has to wait for the actual release.
5. Wait for the real release
To know when the keys are up you have to read the keyboard devices, and that needs root. I did not want a permanent read ACL on my keyboard, so I put that one check in a tiny root helper instead.
Save this as /usr/local/bin/type-timestamp-modwait, owned by root, mode 0755:
#!/usr/bin/env python3
import fcntl, glob, os, time
KEY_MAX = 0x2ff
NBYTES = (KEY_MAX + 7) // 8
EVIOCGKEY = (2 << 30) | (NBYTES << 16) | (0x45 << 8) | 0x18
EVIOCGBIT_KEY = (2 << 30) | (NBYTES << 16) | (0x45 << 8) | 0x21
MODS = [29, 97, 125, 126, 56, 100] # l/r ctrl, l/r meta, l/r alt
def down(buf, code):
return bool(buf[code // 8] & (1 << (code % 8)))
def keyboards():
out = []
for path in sorted(glob.glob('/dev/input/event*')):
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
continue
buf = bytearray(NBYTES)
try:
fcntl.ioctl(fd, EVIOCGBIT_KEY, buf)
if down(buf, 30) and down(buf, 31): # reports A and S, so a keyboard
out.append(fd)
continue
except OSError:
pass
os.close(fd)
return out
def mods_down(fds):
for fd in fds:
buf = bytearray(NBYTES)
try:
fcntl.ioctl(fd, EVIOCGKEY, buf)
except OSError:
continue
if any(down(buf, c) for c in MODS):
return True
return False
fds = keyboards()
deadline = time.time() + 5.0
while time.time() < deadline:
if not mods_down(fds):
time.sleep(0.05)
if not mods_down(fds):
break
time.sleep(0.02)
I like this one because it is cheap. The EVIOCGKEY ioctl asks the kernel for the current pressed-key bitmap, and that is a state query rather than an event stream.
I call it with sudo -n from the main script. On my laptop I already have passwordless sudo, so this adds no privilege I did not have. If that is not true for you, add a single NOPASSWD line for this one binary and nothing else.
6. The layout trap
Now the sneaky part I promised. uinput sends raw KEYCODES, not characters. What you get on screen depends on the XKB layout that is active at that moment.
My timestamp contains -, which is KEY_MINUS. On the US layout that is a hyphen. On the Turkish Q layout the same physical key gives you *. So the very same code produced 20260827*101734*0300 when my tr layout happened to be active. Very confusing until you realise what is going on.
The fix is small (might sound dumb but it is practical). Switch to us layout, type, then switch back:
gsettings set org.gnome.desktop.input-sources current 0
I was quite pleased that this key is writable and that GNOME really does follow it. The whole detour lasts a couple hundred milliseconds, and I have never once noticed it while using the shortcut.
7. The script itself
I keep the main script in ~/.local/bin and it runs as my normal user. It waits, forces the layout, opens uinput, sends the key events, restores the layout and writes a one line log.
#!/usr/bin/env python3
import fcntl, os, re, struct, subprocess, time
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
EV_KEY, EV_SYN = 0x01, 0x00
SCHEMA = 'org.gnome.desktop.input-sources'
CHARS = {'0': 11, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6,
'6': 7, '7': 8, '8': 9, '9': 10, '-': 12}
def gset(*a):
return subprocess.run(['/usr/bin/gsettings', *a],
capture_output=True, text=True).stdout.strip()
def emit(fd, t, c, v):
os.write(fd, struct.pack('llHHi', 0, 0, t, c, v))
def type_text(text):
fd = os.open('/dev/uinput', os.O_WRONLY | os.O_NONBLOCK)
try:
fcntl.ioctl(fd, UI_SET_EVBIT, EV_KEY)
for kc in range(1, 256):
fcntl.ioctl(fd, UI_SET_KEYBIT, kc)
os.write(fd, b'type-timestamp'.ljust(80, b'\0')
+ struct.pack('HHHH', 3, 0x1d1d, 0x0001, 1)
+ struct.pack('I', 0) + b'\0' * (64 * 4 * 4))
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(0.25) # let the compositor attach the device
for ch in text:
emit(fd, EV_KEY, CHARS[ch], 1); emit(fd, EV_SYN, 0, 0)
emit(fd, EV_KEY, CHARS[ch], 0); emit(fd, EV_SYN, 0, 0)
time.sleep(0.012)
time.sleep(0.05)
finally:
fcntl.ioctl(fd, UI_DEV_DESTROY)
os.close(fd)
subprocess.run(['/usr/bin/sudo', '-n', '/usr/local/bin/type-timestamp-modwait'],
capture_output=True, timeout=10)
text = subprocess.run(['/usr/bin/date', '+%Y%m%d-%H%M%S%z'], capture_output=True,
text=True, check=True).stdout.strip().replace('+', '-')
layouts = re.findall(r"'([^',]+)'\)", gset('get', SCHEMA, 'sources'))
cur = gset('get', SCHEMA, 'current').split()[-1]
us = str(layouts.index('us')) if 'us' in layouts else None
switched = us is not None and cur != us
if switched:
gset('set', SCHEMA, 'current', us)
time.sleep(0.2)
try:
type_text(text)
finally:
if switched:
time.sleep(0.1)
gset('set', SCHEMA, 'current', cur)
I started with date -Iseconds, which gives 2026-08-27T10:17:34+03:00. After using it for a while I got annoyed, because : and + are useless in file names and in quite a few other places (like docker image tags). Now it produces 20260827-101734-0300, which is date +%Y%m%d-%H%M%S%z | tr '+' '-'. Sorts nicely, works anywhere.
I mapped only digits and the hyphen in the character table. If the format ever grows a character I have not mapped, the script stops with an error instead of typing something wrong. I prefer a loud failure here.
8. Test it properly, not by feel
I did not trust any of this until I could watch the characters land somewhere I control. My little harness opens a terminal running cat into a file, with the line discipline turned off so nothing is buffered:
kitty sh -c 'echo $$ > /tmp/sink.pid; stty -icanon min 1; cat > /tmp/sink.txt'
(Note that I LOVE Kitty terminal)
Then a second uinput device holds Ctrl and Super down for a couple of seconds, exactly like a slow finger would, while the script runs. Reading /tmp/sink.txt afterwards tells you the truth. I ran it five times in a row and got five clean timestamps, which is when I finally believed it.
A warning from that same afternoon. Do not clean up those test terminals with pkill -f claude-sink or similar. The pattern also matches the shell that is running the very command, so you kill yourself and get a confusing exit code 144. Write the PID to a file and kill that instead, as above.
Step for future:
I left two rough edges in here. The helper gives up after five seconds and types anyway, so if you hold the keys down longer than that you get the the mayhem of applications switching, opening and so on. I log the wait result to ~/.cache/type-timestamp.log, so I can see it when something looks odd. I also lose the sign of the UTC offset, since tr turns +0300 into -0300. That is fine in Istanbul, though I would fix it before travelling west.
This was a lot of work for something that types twenty characters. Still, I use it many times a day now, and I learned more about Mutter and uinput than I expected to. My beloved little shortcut earns its keep.
Do you have a neater way to do this on GNOME 46? Tell me all about it in the comments..
Hope that helps.. Take care..