AB6A TimeShift

a different clock for every rig

A macOS menu bar app that runs several WSJT-X instances at once, each on its own independently adjustable clock.
Nothing else on the Mac moves: system time stays exact.

Two rigs running, each shifted on its own. The icon turns green while any instance is up.

What it does

For the longest time I have been running several rigs off one Mac, with every WSJT-X instance sharing the one system clock, and that is fine right up until one of them wants a different clock from the others. macOS gives you a single clock for the whole machine, so the only lever is system time, and the moment you pull it everything else on the box goes with it: the other instances, the log, the lot.

So the offset lives in the process instead. WSJT-X reads the time you set for that instance, and the rest of the Mac carries on with the real one.

Sub-second, live

Offsets are nanosecond integers. Walk a rig ±5 s in 0.1 s steps while it runs, with no restart and no touching system time. The slider snaps to exact 0.1 s marks.

One rig, one clock

Up to six instances, each with its own --rig-name and its own control file. Leave the rig name blank to launch WSJT-X's default configuration.

The decoder comes too

jt9 is spawned per decode cycle and inherits the same shifted clock as the GUI that started it.

What a wrong clock costs

FT8 transmits in 15-second slots, and decodes past roughly ±2.5 s of DT fall off the edge. A clock a couple of seconds out puts every decode you want right there.

WSJT-X Band Activity. In the 161245 cycle every decode reports DT between +1.9 and
                +2.4 seconds. After a single offset adjustment, the 161300 cycle reports DT between
                -0.3 and -1.1 seconds, centred and decoding cleanly.
Real traffic on 20 m, one adjustment apart. Every decode in the 161245 cycle lands at DT +1.9 to +2.4, right at the edge. One offset change later, the 161300 cycle comes back centred at DT −0.3 to −1.1. Same band, same rig, same minute.

Drag the slider to see what that costs, cycle by cycle.

Band Activity · 14.074 MHz FT8 rig1 · shim armed
UTCdBDTFreqMessage
+0.0 s
−5.0 s0+5.0 s
$ timeshift-ctl set ~/…/rig1.ctl --offset 0s

How it works

A dylib injected with DYLD_INSERT_LIBRARIES, using dyld's __DATA,__interpose section, which rewrites bind targets in every loaded image except the one declaring the interpositions, so the library calls the real functions by their ordinary names.

out = anchor + (in − anchor) × rate + offset

anchor is that clock's value at injection, so rate diverges from the moment the program starts rather than from the epoch.

Interposed symbolReached by
gettimeofdayC code, and CoreFoundation's own clock source
clock_gettimemodern C/C++, Go, Rust, Swift, on CLOCK_REALTIME
clock_gettime_nsec_npApple's preferred fast path
timelegacy C
mach_absolute_time, mach_continuous_timemonotonic clocks and their approximates, opt-in only

NSDate, Swift's Date and CFAbsoluteTimeGetCurrent come along transitively, because CoreFoundation reads the clock through gettimeofday. Interposing CF as well double-shifts it, which is a real bug this project hit and removed.

Install

There is a signed and notarized build on the releases page. Unzip it, drag AB6A TimeShift.app to /Applications, and it opens on a double click. Building it yourself needs the Xcode command line tools, and Pillow if you want the icon regenerated.

# build the shim, the tools and the menu bar app
make
make menubar
open "build/AB6A TimeShift.app"

# make an injectable copy of WSJT-X, once
bin/timeshift-resign /Applications/wsjtx.app "/Applications/wsjtx shift.app"

Or drive it from the command line

# shift one program back 90 minutes
bin/timeshift --offset -90m -- ./yourprogram

# adjust a running process through a shared control file
bin/timeshift-ctl init /tmp/rig1.ctl --offset 0
bin/timeshift --control /tmp/rig1.ctl -- ./yourprogram &
bin/timeshift-ctl nudge /tmp/rig1.ctl --offset 100ms

Signing, and why it matters here

Two mechanisms stand between you and an injected dylib, and they are easy to conflate:

MechanismWhat it doesWaived by
Hardened runtime Marks the process restricted; dyld strips every DYLD_* variable, silently allow-dyld-environment-variables
Library validation Admits only dylibs signed by the same team, or platform binaries Same team, or disable-library-validation

With a Developer ID, timeshift-resign signs the WSJT-X copy and the shim with one identity. Library validation is then satisfied by the team match, the hardened runtime stays on, and the result is notarizable; only the dyld-environment waiver is needed. Without a certificate it falls back to ad-hoc, drops the hardened runtime and disables library validation instead.

A stable Developer ID identity also keeps TCC grants from resetting. An ad-hoc signature changes on every rebuild, so the microphone permission has to be granted again each time.

Run bin/timeshift-check <binary-or-.app> before anything else. It reports exactly which of the two mechanisms is in the way, and which team the shim must be signed by.

Gotchas that cost real time

Launch the inner executable, not open -a. open hands off to launchd, which starts the app in a clean environment where DYLD_INSERT_LIBRARIES never arrives.
Grant the microphone to the launcher. macOS attributes a child process's microphone request to the responsible process, which is the menu bar app or your terminal, and not WSJT-X. The menu shows the current grant. Builds before 1.2 were missing the audio-input entitlement, so macOS refused the request without ever prompting, and WSJT-X came up deaf.
Never tccutil reset the WSJT-X bundle ID. The copy shares it with your original install, so you would revoke the working app's permission too.
Leave monotonic clocks alone. WSJT-X aligns its 15-second slots off the wall clock; shifting mach_absolute_time only perturbs Qt's timers.
Your log moves with the clock. ADIF QSO times, PSK Reporter spots and ALL.TXT all record the shifted time. If you are correcting a clock that is genuinely wrong, that is the point of the exercise. If you are experimenting, log to a copy.

Limits

Nothing outside the process moves. File timestamps, timer firing, log timestamps and certificates checked in other processes all stay on real time.

Offsets jump rather than slewing. Lowering the offset makes the program's wall clock run backwards. For a gradual machine-wide correction, the call you want is adjtime(2).

A narrow unshifted window at startup. Clock reads from inside libSystem's own initializers pass through untransformed, deliberately. That context has no thread-local storage, a non-re-entrant os_once, and no malloc zone yet; all three crash a shim that tries to do real work there.