Skip to content
All lab notes

Lab note · from Tempo

Mac Dock animation speed: autohide-time-modifier and killall Dock

By Mark Santos · · 5 min read

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, ScheduleManager and AnimationController with a mock shell, each of those changes, and the launch, sent defaults write twice and killall Dock twice.

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:

  1. The tick calls scheduleManager.tick(), which sets currentSpeed. Per Apple's Published documentation, "publishing occurs in the property's willSet block", so the sink runs immediately and enqueues a Task.
  2. The tick calls applySpeed, passes the check and reaches await shell.run(...).
  3. The main actor is free while the shell command runs, so the queued Task calls applySpeed too. lastAppliedDockSpeed is 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 step nowMinuteOfDay across a change on the default curve (08:59, 09:30, 11:01, 11:02), calling performTick() each time. Expect one write and one killall Dock for 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 as 0.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. Its AnimationTarget enum lists cursorBlink and keyRepeat, 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.swift says "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, launchanim and mineffect. expose-animation-duration appears neither there nor anywhere in the dyld shared cache, so we could not confirm anything still reads it. NSWindowResizeTime is 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 defaults and killall through /bin/zsh. We only read defaults on the test Mac. We did not write the key or relaunch the Dock.

More lab notes