No description
  • C 87.1%
  • Shell 9.3%
  • Makefile 3.6%
Find a file
2026-07-26 09:58:43 +11:00
scripts own actuation: route intent topics to local actuator scripts 2026-07-25 21:49:57 +11:00
.gitignore rewrite in c99 2026-07-26 09:58:43 +11:00
config.def.h rewrite in c99 2026-07-26 09:58:43 +11:00
install.sh rewrite in c99 2026-07-26 09:58:43 +11:00
Makefile rewrite in c99 2026-07-26 09:58:43 +11:00
README rewrite in c99 2026-07-26 09:58:43 +11:00
tdm-fake.c rewrite in c99 2026-07-26 09:58:43 +11:00
tdm-pub.c rewrite in c99 2026-07-26 09:58:43 +11:00
tdm.c rewrite in c99 2026-07-26 09:58:43 +11:00
tdm.h rewrite in c99 2026-07-26 09:58:43 +11:00
tdm.service Initial commit: tdm — Zig project 2026-07-19 16:51:52 +11:00

tdm — personal multi-device message hub (C99)
=============================================

One small hub that carries typed events and retained state between the devices
you own, and decides — from a compile-time config.h — what each message
*becomes*: a local overlay, an actuator run, a push to another device, or
nothing.

It is the layer that never belonged inside the window manager. dwm emits "focus
changed"; tdm decides whether that becomes a blue flash. The phone is just one
more node.

tdm never touches the X server. It has no Xlib link, no X connection, no
knowledge of windows. dwm is the sole X boundary: it speaks this protocol
directly over the local socket, publishing X matters (focus, tags, presence) as
topics. Everything tdm knows about the desktop arrived as a message.

This is a C99 port of the Zig original (../tdm), in the style of its C
siblings — dwm-flash, dwm-range, dwm-toast, dwm-grid. Same wire format, same
config vocabulary, same behaviour; see "Differences from the Zig hub" below.

  Language:   C99, POSIX.1-2008. Links nothing but libc.
  Topology:   star — tdm is the center; every other device is a leaf
  Transport:  unix socket for local apps, TCP for remote devices, same frame
  Network:    all devices on a Tailscale tailnet; addresses in config.h
  Philosophy: dwm-style. Nodes are dumb; all intelligence is config.h on the
              hub. Adding a behavior means editing config and recompiling.

Build & run
-----------
  make                  # cc -std=c99 -pedantic -Wall -Wextra -Os
  make install          # binaries + actuators to /usr/local/bin (override PREFIX=)
  ./install.sh          # the above, plus the systemd user service

No dependencies: no Xlib, no pkg-config, no threads. config.h is generated from
config.def.h on first build and is yours to edit — it is not tracked.

Programs
--------
  tdm                          the hub; run by systemd, logs to stderr
  tdm-pub <topic> <value...>   publish as this machine (production publisher)
  tdm-fake <src> <topic> <v..> publish as any node (test injector)

Model
-----
A node's entire vocabulary is: open a socket to tdm and write frames of the
form "here is a value on this topic, from me." That's it. A node cannot express
identity policy, retention, routing, or interest — those are all facts the hub
owns.

The hub, on every frame:

  1. Resolve source — who sent it (see Identity).
  2. Store, maybe   — if keep[topic] is Retain, save it as the last value.
  3. Route          — match routes[] and deliver to the matching sinks.

Two flavors of data, decided per-topic by the hub, not the sender:

  event  — transient, never stored. (volume key, build-finished)
  retain — the last value is kept. Behaves like an X property. (battery %)

This mirrors the existing stack: an X property is retained state; a spawn() is
a one-shot event. tdm is that pattern promoted off one X server onto a network.

Wire format
-----------
One frame type. No message kinds, no subscribe, no handshake.

  +-----+-------+----------+-------------+
  | src | topic |  len:u16 |  value[len] |
  +-----+-------+----------+-------------+
    u8     u8      LE          len bytes

  src    Node enum id of the origin device
  topic  Topic enum id
  len    value length, little-endian u16 (max 64 KB)
  value  opaque bytes; meaning is per-topic convention (usually ASCII)

4-byte header, self-delimiting on a stream: read 4 bytes, then read len bytes.
Little-endian throughout — every node is LE and you own them all. There is no
kind/SUB/attach field because retention, routing and interest are all resolved
from config keyed on (src, topic), so the wire carries nothing the config does
not already know.

Identity
--------
src rides on every frame, but how far it is trusted depends on the transport:

  unix socket   local user (fs perms)      the src byte IS the identity
  TCP           known tailnet IPs          Tailscale peer IP is the identity

Local apps have no meaningful peer IP, so they self-declare via src; this is
safe because 0700 on /run/user/<uid>/tdm.sock already gates who can open it.
Remote identity is the Tailscale peer IP mapped to a Node — tailnet IPs are
device-authenticated and stable, so "source IP = device" is not spoofable the
way it would be on a plain LAN. An unknown peer IP is refused at accept().

Either way a connection binds its identity on its first frame and keeps it:
later frames on the same connection cannot rename it.

Transport
---------
The hub listens on both; both feed the identical routing core.

   REMOTE (Tailscale TCP :9000)          LOCAL (unix socket)
   +--------+                            +---------------------------+
   | phone  |--+                         | dwm      (C socket write) |--+
   | laptop |--+                         | tdm-pub  (shell/scripts)  |--+
   | server |--+                         +---------------------------+  |
   +--------+  v                                                        v
        +---------------------------- tdm ----------------------------+
        |  resolve src . retain? . match routes . deliver to sinks     |
        +---------------+---------------------------------------------+
                        v   local sinks are the existing tools, unchanged:
                            spawn dwm-flash / dwm-range / dwm-toast

Local *consumers* (flash/range/toast) don't speak the socket at all — they stay
sinks the hub drives via fork+exec, reused byte-for-byte. The unix socket is
the local producer-inbound path only.

Config (config.h)
-----------------
All policy is compile-time, dwm-style. Nodes and topics are enums; names,
retention, addresses and routing are C99 designated-initializer tables indexed
by those enums, so an entry cannot silently drift out of line with the enum it
describes. Three compile-time assertions in tdm.c catch a table that falls
short of its enum, so that class of mistake fails the build rather than reading
past an array at runtime.

A sink is an internally tagged union — the same shape dwm's action.h uses:

  SinkSpawn   fork a local command. The argv is a *template*: the token "{}"
              is replaced by the frame value (split on spaces), every other
              token is literal. So producers send only the semantic value (70)
              and tdm frames it for the sink (dwm-range 0 100 70 Volume); the
              sink's argument format never leaks into the producer.
  SinkHttp    POST {"text": value} to a local HTTP daemon. No route uses it.
  SinkDevice  forward the frame over TCP to another device.

`when` is a presence predicate — Always / WhenActive / WhenIdle / WhenLocked —
evaluated against the last retained value of the presence topic, which dwm
publishes (tdm never queries X). That is the whole reason the phone appears in
routing and not in dwm: dwm reports whether you are at the machine as one more
message, and the hub decides what each event means in light of it — build-done
becomes a desktop toast when active, a phone push when idle.

Intent
------
A node that wants something does not do it. It says what it wants, and the hub
decides what that means — the same bargain as retention and routing, applied to
actions instead of data.

  dwm  --volume_step "+10"-->  tdm  --spawn-->  tdm-volume
                                                    |  wpctl set-volume, read back
  dwm-range  <--spawn--  tdm  <--volume "70"--------+

Two hops, and both are necessary:

  1. intent -> actuator. dwm publishes volume_step "+10". That is the whole of
     its knowledge. It does not know audio is wpctl, that the sink is
     @DEFAULT_AUDIO_SINK@, that stepping up must be capped at 100%, or that
     anything needs reading back.
  2. actuator -> state. Having applied the change, the actuator reads the real
     resulting level and publishes it as volume. The OSD is a sink on *that* —
     so it is correct whoever moved the level, keybinding or mixer or another
     device.

The actuators (scripts/tdm-volume, scripts/tdm-brightness) are ten-line shell
scripts, and deliberately so: they are the only place in the system that knows
a vendor tool's name.

This is the part that scales past one machine. Applying an intent elsewhere is
a one-line edit — swap the actuator's SinkSpawn for { SinkDevice, Laptop } and
the laptop's hub runs the same script against its own hardware. Nothing about
dwm, the keybinding, or the OSD changes, because none of them ever knew where
the volume lived.

Concurrency
-----------
None. One poll() loop over the two listeners and every open connection, with
frames parsed incrementally out of a per-client buffer, so a value split across
reads (or two frames arriving in one read) is handled without a reassembly
special case. There are no threads, no locks, and no allocation on the hot path
— the only malloc is the retained store, one per Retain topic.

The loop must therefore never block, which shapes two decisions: sinks are
forked without waiting (SIGCHLD is SIG_IGN, so the kernel reaps them), and
SIGPIPE is ignored so a device that hung up mid-forward cannot kill the hub.

Differences from the Zig hub
----------------------------
Behaviour is intended to be identical; these are the deliberate divergences.

  * Concurrency model. The Zig hub runs a task per connection behind a mutex.
    This one is a single poll() loop. Same semantics, no locks; at this message
    rate the difference is unobservable.
  * Client slots. Both cap at 32. Past the cap Zig logged "client table full"
    but went on reading a connection it had no slot for — so the peer could
    still publish yet could never be forwarded to. Here the connection is
    closed at accept(), which is the same outcome stated honestly.
  * Bad src byte. Zig dropped the connection silently; this logs it first.
  * config.def.h -> config.h. The Zig version has a single tracked config.zig.
    This follows dwm proper: the repo ships config.def.h and your config.h is
    untracked, so a pull never clobbers your policy.

Both share these gaps with the Zig original, unchanged by the port:

  * Retained state is NOT replayed to a node when it connects, though the model
    calls for it. On a unix socket there is nothing to replay *to* until the
    first frame arrives — identity binds on that frame, not at accept() — so
    replay needs a rule about when a connection is considered attached. That
    rule is not written yet.
  * SinkDevice forwards only to a node that already has a connection open.
    Outbound dial to addrs[] is not implemented, so a device that has never
    called in cannot be reached and the frame is dropped (with a log line).
  * No producer publishes presence yet, so every WhenActive route fires on the
    default and no WhenIdle route ever does.

Install
-------
tdm runs as a systemd user service — it must be your user (its socket lives at
/run/user/<uid>/tdm.sock) and inside the graphical session (it spawns X
overlays). ./install.sh does the build, the /usr/local/bin copy (the only step
needing sudo), the unit file, and enable --now. Watch it with
`journalctl --user -u tdm -f`.

The service is anchored to graphical-session.target, so it starts and stops
with your desktop. For the overlays to reach X the user manager needs DISPLAY
and XAUTHORITY; if your session does not import them automatically (e.g.
startx), add before `exec dwm` in ~/.xinitrc:

  systemctl --user import-environment DISPLAY XAUTHORITY
  systemctl --user restart tdm.service

Note that only one hub can own /run/user/<uid>/tdm.sock. Running this and the
Zig tdm at once means the second to start takes the socket from the first;
disable one before enabling the other.

Testing without the desktop
---------------------------
tdm-fake forges any src over the local socket, which is exactly what the unix
transport's trust model permits, so routing can be exercised end to end from
one machine:

  tdm-fake desktop window_focus 100 200 800 600
  tdm-fake desktop volume_step +10
  tdm-fake phone   battery 82
  tdm-fake desktop presence idle
  tdm-fake server  build_status ok

To exercise it without X overlays popping up, point the route templates in
config.h at a script that logs its arguments, and change sockname/tcpport so
the test hub cannot collide with a running one.