Skip to content

Instantly share code, notes, and snippets.

@heathdutton
Last active September 23, 2026 16:46
Show Gist options
  • Select an option

  • Save heathdutton/12466b3fe4117eb75d186e1b53d6d66f to your computer and use it in GitHub Desktop.

Select an option

Save heathdutton/12466b3fe4117eb75d186e1b53d6d66f to your computer and use it in GitHub Desktop.
Deskflow full reset to defaults (macOS) - run on both server and client - curl -sL xoop.install.id | bash
#!/usr/bin/env bash
# Fixes the endless "Deskflow would like to control this computer" prompt without wiping config or reinstalling.
# The logic lives in deskflow-reset.sh, this runs it in repair mode so the two can't drift apart.
set -euo pipefail
curl -fsSL https://gist.githubusercontent.com/heathdutton/12466b3fe4117eb75d186e1b53d6d66f/raw/deskflow-reset.sh \
| REPAIR=1 bash
#!/usr/bin/env bash
# Deskflow nuke-and-pave (macOS). Run on both the server and the client.
#
# Swaps to the nightly cask (deskflow-dev) and wipes config back to defaults, so both machines end up on the same
# build with fresh TLS certs. Then clears Deskflow's privacy permissions, walks through granting them once, adds a
# login agent, adds a sleep hook on laptops, and starts it.
#
# Env vars go on the bash side of the pipe: curl -sL <url> | REPAIR=1 bash
# CHANNEL=stable install the stable cask instead of the nightly one
# REPAIR=1 skip the wipe and reinstall, fix permissions (the endless Accessibility prompt) and agents
# AUTOSTART_ONLY=1 only set up the login agent and sleep hook, touch nothing else
# SLEEP_KILL=1|0 force the quit-on-sleep hook on or off (default: on for Macs with a lid)
set -euo pipefail
CHANNEL="${CHANNEL:-dev}"
REPAIR="${REPAIR:-0}"
AUTOSTART_ONLY="${AUTOSTART_ONLY:-0}"
SLEEP_KILL="${SLEEP_KILL:-auto}"
case "$CHANNEL" in
dev) CASK="deskflow-dev"; OTHER="deskflow" ;;
stable) CASK="deskflow"; OTHER="deskflow-dev" ;;
*) echo "CHANNEL must be 'dev' or 'stable', got '$CHANNEL'" >&2; exit 1 ;;
esac
[ "$(uname -s)" = "Darwin" ] || { echo "macOS only." >&2; exit 1; }
APP="/Applications/Deskflow.app"
BUNDLE_ID="org.deskflow.deskflow"
LABEL="org.deskflow.autostart"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
SLEEP_LABEL="org.deskflow.sleepkill"
SLEEP_PLIST="$HOME/Library/LaunchAgents/$SLEEP_LABEL.plist"
GUI="gui/$(id -u)"
need_brew() {
if ! command -v brew >/dev/null 2>&1; then
for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do
[ -x "$b" ] && eval "$("$b" shellenv)" && break
done
fi
command -v brew >/dev/null 2>&1 || { echo "Homebrew required: https://brew.sh" >&2; exit 1; }
}
# Unload the agents first so neither can relaunch or kill Deskflow in the middle of anything below.
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
launchctl bootout "$GUI/$SLEEP_LABEL" 2>/dev/null || true
if [ "$AUTOSTART_ONLY" != "1" ]; then
echo "==> Quitting Deskflow"
killall Deskflow deskflow-core deskflow-server 2>/dev/null || true
sleep 1
fi
if [ "$AUTOSTART_ONLY" != "1" ] && [ "$REPAIR" != "1" ]; then
need_brew
echo "==> Backing up config"
# Holds the TLS keypair, trusted-server/client fingerprints, and screen layout.
ts="$(date +%Y%m%d-%H%M%S)"
if [ -d "$HOME/Library/Deskflow" ]; then
mv "$HOME/Library/Deskflow" "$HOME/Deskflow-backup-$ts"
echo " saved to ~/Deskflow-backup-$ts"
else
echo " nothing to back up"
fi
echo "==> Removing existing install"
# Both casks lay down Deskflow.app, so the other channel has to go or the install collides.
for c in "$CASK" "$OTHER"; do
brew list --cask "$c" >/dev/null 2>&1 && brew uninstall --cask --force "$c" || true
done
# A hand-dragged copy isn't brew-managed and would still win the collision.
[ -e "$APP" ] && rm -rf "$APP" || true
echo "==> Installing $CASK"
brew tap deskflow/tap >/dev/null 2>&1 || true
brew install --cask "deskflow/tap/$CASK"
echo "==> Clearing GUI prefs"
# cfprefsd caches the plist in memory and rewrites it on quit if left running.
defaults delete org.deskflow.deskflow 2>/dev/null || true
rm -f "$HOME/Library/Preferences/org.deskflow.deskflow.plist" \
"$HOME/Library/Preferences/State/Deskflow.state"
killall cfprefsd 2>/dev/null || true
fi
[ -d "$APP" ] || { echo "$APP not found." >&2; exit 1; }
if [ "$AUTOSTART_ONLY" != "1" ]; then
echo "==> Checking signature"
# TCC ties a grant to the signature the app had when the box was ticked. A Developer ID build matches on Team ID,
# so its grant survives upgrades. An ad-hoc build matches on CDHash, and one whose seal doesn't verify can never
# match at all. Re-signing ad-hoc gives that build a valid seal. A build that already verifies is left alone, since
# re-signing it would throw away its Developer ID signature and notarization.
if codesign --verify --deep --strict "$APP" >/dev/null 2>&1; then
team="$(codesign -dv "$APP" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
echo " seal intact (team: ${team:-none}), leaving it alone"
else
echo " seal broken, re-signing ad-hoc"
codesign --force --deep --sign - "$APP" 2>&1 | sed 's/^/ /'
if ! codesign --verify --deep --strict "$APP" >/dev/null 2>&1; then
echo "Re-sign failed, seal still invalid." >&2
exit 1
fi
fi
echo "==> Clearing Deskflow's privacy permissions"
# A ticked row recorded against an earlier build's signature stays ticked in System Settings, but the current app
# no longer matches it, so macOS asks forever. Clearing it makes the next grant record the signature that's
# installed now. Accessibility = control the Mac, ListenEvent = Input Monitoring, PostEvent = send keystrokes.
for svc in Accessibility ListenEvent PostEvent; do
if tccutil reset "$svc" "$BUNDLE_ID" >/dev/null 2>&1; then echo " reset $svc"; else echo " skipped $svc"; fi
done
fi
echo "==> Installing login agent"
# Deskflow has no start-at-login setting, so launchd does it. Going through `open` makes TCC see the app bundle as the
# requester. No KeepAlive: Deskflow exits with an error while Accessibility is off, and respawning it would re-pop the
# permission prompt every few seconds.
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$PLIST" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>$APP</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/deskflow-autostart.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/deskflow-autostart.log</string>
</dict>
</plist>
PLIST_EOF
plutil -lint "$PLIST" >/dev/null || { echo "generated plist is invalid" >&2; exit 1; }
if [ "$SLEEP_KILL" = "auto" ]; then
# Only laptops carry the lid key. A desktop idling to sleep would take the client down with no keyboard left that
# could start it again, since that keyboard is the one Deskflow was sharing.
ioreg -r -k AppleClamshellState -d 1 | grep -q AppleClamshellState && SLEEP_KILL=1 || SLEEP_KILL=0
fi
if [ "$SLEEP_KILL" = "1" ]; then
echo "==> Installing sleep hook"
# Quits Deskflow whenever the Mac sleeps, so a stuck instance never outlives a lid close and nothing drains the
# battery. It stays off after wake until started by hand or at the next login. Clamshell mode with an external
# display keeps the Mac awake, so closing the lid there quits nothing.
need_brew
# stdout hidden because its caveats describe ~/.sleep scripts, which this agent doesn't use. Errors still show.
brew list sleepwatcher >/dev/null 2>&1 || brew install sleepwatcher >/dev/null
sw="$(brew --prefix)/sbin/sleepwatcher"
[ -x "$sw" ] || { echo "sleepwatcher missing at $sw" >&2; exit 1; }
# TERM first so it can shut down cleanly, then KILL for when it's wedged and ignores that. sleepwatcher hands this
# string to the shell, so the semicolons chain.
kill_cmd="/usr/bin/killall -q Deskflow deskflow-core; /bin/sleep 2; /usr/bin/killall -q -9 Deskflow deskflow-core"
cat > "$SLEEP_PLIST" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$SLEEP_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$sw</string>
<string>-V</string>
<string>-s</string>
<string>$kill_cmd</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/deskflow-sleepkill.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/deskflow-sleepkill.log</string>
</dict>
</plist>
PLIST_EOF
plutil -lint "$SLEEP_PLIST" >/dev/null || { echo "generated sleep plist is invalid" >&2; exit 1; }
launchctl bootstrap "$GUI" "$SLEEP_PLIST"
echo " Deskflow quits when this Mac sleeps and stays off until you start it"
else
rm -f "$SLEEP_PLIST"
echo "==> Skipping sleep hook (no lid, or SLEEP_KILL=0)"
fi
echo "==> Starting Deskflow"
# RunAtLoad starts it now as well as at every login.
launchctl bootstrap "$GUI" "$PLIST"
sleep 2
if [ "$AUTOSTART_ONLY" != "1" ]; then
echo "==> Granting permissions"
echo " Switch Deskflow on in Privacy & Security > Accessibility, and in Input Monitoring if it asks."
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" 2>/dev/null || true
# Under curl | bash the pipe is stdin, so the keypress has to come from the terminal.
if (exec </dev/tty) 2>/dev/null; then
printf ' Press Enter once it is on... '
read -r _ </dev/tty || true
# Restart so it picks up the grant, since it may have already exited without one.
killall Deskflow deskflow-core 2>/dev/null || true
sleep 1
open -a "$APP"
sleep 2
else
echo " No terminal to wait on. Once it's on, restart it: killall Deskflow; open -a Deskflow"
fi
fi
ver="$("$APP/Contents/MacOS/deskflow-core" --version 2>/dev/null | head -1)"
echo
echo "Done. Installed: ${ver:-unknown}"
pgrep -qf "$APP/Contents/MacOS/Deskflow" \
&& echo "Deskflow is running and starts at login." \
|| echo "Deskflow isn't running. Start it with: open -a Deskflow"
echo "Remove autostart with: launchctl bootout $GUI/$LABEL && rm $PLIST"
if [ -f "$SLEEP_PLIST" ]; then
echo "Remove the sleep hook with: launchctl bootout $GUI/$SLEEP_LABEL && rm $SLEEP_PLIST"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment