Lab note · from Tempo
Mac Dock animation speed: autohide-time-modifier and killall Dock
The speed of the Dock's auto-hide slide is set by autohide-time-modifier, a float in the com.apple.dock domain that Apple does not document. Run defaults write com.apple.dock autohide-time-modifier -float 0.4, then killall Dock, because a running app does not see defaults changed underneath it. The key only matters when auto-hide is on. If you change it on a schedule, round the value, relaunch the Dock only when the rounded value differs from the last one applied, and record that value before the first await, or two callers can both relaunch it.
Symptoms
Tempo is a menu bar app that moves this one key along a daily curve: 0.8× at 6 AM, 1.0× from noon to 5 PM and 1.3× at 9 PM, where 1.0× writes 0.5. It recomputes the curve on a 60-second timer and applies each value by relaunching the Dock, which, in the app's own words, "briefly disappears and reappears". How often that happens depends on the rule for applying:
- Writing and relaunching on every tick would restart the Dock 1,440 times a day.
- Relaunching whenever the interpolated speed changes is barely better. On the default curve the speed changes in 1,080 of the day's 1,440 minutes.
- With the committed rounding, the value changes 10 times a day. But in a harness built from the real
TickEngine,ScheduleManagerandAnimationControllerwith a mock shell, each of those changes, and the launch, sentdefaults writetwice andkillall Docktwice.
Why it happens
The Dock reads the key when it starts
man defaults says: "Since applications do access the defaults system while they're running, you shouldn't modify the defaults of a running application. If you change a default in a domain that belongs to a running application, the application won't see the change and might even overwrite the default." System Settings has no control for this speed. Apple's Desktop & Dock settings cover "Automatically hide and show the Dock", "Minimized windows animation" and "Animate opening applications", and nothing sets how fast the Dock slides. So the route is to write the key and relaunch the Dock.
The key is still there on macOS 27.2: autohide-time-modifier appears as a string in the Dock executable, and defaults read-type reports it as float. We did not write it on the test Mac, so we have not watched it take effect on that release.
A linear curve moves almost every minute
ScheduleManager.interpolatedSpeed(at:) interpolates linearly between the two control points either side of the current minute, wrapping at midnight. It is not cubic: the result is before.speed + (after.speed - before.speed) * t. Between 5 PM (1.0×) and 9 PM (1.3×) the speed rises by 0.00125× a minute, so almost every tick produces a new value.
Two callers, one suspension point
Commit 7aca443 made curve edits and presets apply at once. TickEngine now subscribes to $currentSpeed and calls applySpeed from a Task, and the 60-second tick still calls it as well. The commit message says "applySpeed stays idempotent so the overlapping tick is a cheap no-op." That holds when the calls run one after another. Here they overlap:
- The tick calls
scheduleManager.tick(), which setscurrentSpeed. Per Apple'sPublisheddocumentation, "publishing occurs in the property'swillSetblock", so the sink runs immediately and enqueues aTask. - The tick calls
applySpeed, passes the check and reachesawait shell.run(...). - The main actor is free while the shell command runs, so the queued
TaskcallsapplySpeedtoo.lastAppliedDockSpeedis only set after both commands finish, so it still holds the old value, and the second call goes ahead.
The Swift book says actor code "can be interrupted only in places where await marks a suspension point." The check and the update sit on opposite sides of one.
The fix
The minimal form of the problem, which never existed in the repository (its first commit already rounded and compared):
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
Task { @MainActor in
let value = 0.5 * scheduleManager.interpolatedSpeed(at: minuteOfDay())
try await shell.run("defaults write com.apple.dock autohide-time-modifier -float \(value)")
try await shell.run("killall Dock") // every tick
}
}
What AnimationController.swift does, trimmed:
/// Formula: dockValue = 0.5 * speedMultiplier, quantized to 0.05 steps.
public nonisolated static func dockValue(for speed: Double) -> Double {
let raw = 0.5 * speed
return (raw / 0.05).rounded() * 0.05
}
public func applySpeed(_ speed: Double) async {
let newDockValue = Self.dockValue(for: speed)
// Skip if already at this value
if let last = lastAppliedDockSpeed, abs(last - newDockValue) < 0.001 {
return
}
do {
try await shell.run(
"defaults write com.apple.dock autohide-time-modifier -float \(String(format: "%.2f", newDockValue))"
)
try await shell.run("killall Dock")
lastAppliedDockSpeed = newDockValue
} catch { }
}
The missing piece, which is not in the repository, is to claim the value before the first await:
let previous = lastAppliedDockSpeed
lastAppliedDockSpeed = newDockValue // claim the value before the first await
do {
try await shell.run("defaults write com.apple.dock autohide-time-modifier -float \(String(format: "%.2f", newDockValue))")
try await shell.run("killall Dock")
} catch {
lastAppliedDockSpeed = previous
}
Rounding to 0.05 steps turns the default curve's 1,080 changes a day into 10. For the other presets the counts are 8 (Snappy), 10 (Gentle) and 6 (Balanced). The tolerance in the comparison keeps floating-point noise from counting as a change. Claiming the value before suspending means the second caller finds it already set and returns. With the patch, the same harness sent one write and one killall Dock per change. It does not order two different values in flight, such as a curve edit that lands during a tick. Running every applySpeed through a single task chain would.
How to check you've fixed it
- Record commands with a mock
ShellExecuting, start the engine, and stepnowMinuteOfDayacross a change on the default curve (08:59, 09:30, 11:01, 11:02), callingperformTick()each time. Expect one write and onekillall Dockfor each of the first three and nothing at 11:02. The committed code sends two of each. - Count the relaunches for a whole day by evaluating
dockValue(for: interpolatedSpeed(at: m))for every minute from 0 to 1439 and counting how often the value changes. - Watch
pgrep -x Dock. Each relaunch gives the Dock a new PID. - Read the value back with
defaults read com.apple.dock autohide-time-modifier. It is stored as a single-precision float, so 0.6 reads back as0.6000000238418579, which is what our test Mac showed.
Caveats
- One key only. Tempo writes nothing but
com.apple.dock autohide-time-modifier, so it changes the auto-hide slide and no other animation. ItsAnimationTargetenum listscursorBlinkandkeyRepeat, but nothing uses it. With auto-hide off the popover warns "Dock autohide is off — speed changes won't be visible". - The mapping is Tempo's. A comment in
TimePoint.swiftsays "1.0 = system default", and Tempo maps that to 0.5. Apple documents neither the key's default nor its unit. On quit Tempo deletes the key and relaunches the Dock, and launching applies the current value, so launch and quit each cost a relaunch too. - Other keys. On macOS 27.2 the Dock executable contains the strings
autohide-delay,launchanimandmineffect.expose-animation-durationappears neither there nor anywhere in the dyld shared cache, so we could not confirm anything still reads it.NSWindowResizeTimeis documented:animationResizeTime(_:)uses it "as the time in seconds to resize by 150 pixels", with a default of 0.20 seconds. AppKit reads it inside each app, so an app has to be relaunched to see a change, not the Dock. - Platform. The package targets macOS 13. The app is unsandboxed because it runs
defaultsandkillallthrough/bin/zsh. We only read defaults on the test Mac. We did not write the key or relaunch the Dock.
More lab notes
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.