diff --git a/Sources/parrot/Input/HotkeyMonitor.swift b/Sources/parrot/Input/HotkeyMonitor.swift index b3bc132c..e5743cf9 100644 --- a/Sources/parrot/Input/HotkeyMonitor.swift +++ b/Sources/parrot/Input/HotkeyMonitor.swift @@ -35,10 +35,17 @@ final class HotkeyMonitor { throw HotkeyError.tapCreateFailed } - let mask: CGEventMask = - (1 << CGEventType.flagsChanged.rawValue) - | (1 << CGEventType.keyDown.rawValue) - | (1 << CGEventType.keyUp.rawValue) + // Only flagsChanged is ever acted on in `handle`. keyDown/keyUp are + // subscribed *only* under --debug-hotkey: listening to every keystroke + // system-wide costs main-thread work, raises the odds macOS disables + // the tap for timeout, and needlessly exposes typed content (including + // password fields) to an Accessibility-privileged process. + var mask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue) + if debug { + mask |= + (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.keyUp.rawValue) + } let userInfo = Unmanaged.passUnretained(self).toOpaque() // .cgSessionEventTap is the right level for an accessibility-granted @@ -76,6 +83,17 @@ final class HotkeyMonitor { onEvent = nil } + /// Called from the tap callback when macOS disables our tap. Without this + /// the process keeps running, the menu bar icon stays put, and the hotkey + /// silently does nothing until the user restarts parrot. + fileprivate func reEnable() { + guard let tap else { return } + CGEvent.tapEnable(tap: tap, enable: true) + FileHandle.standardError.write(Data( + "event tap was disabled by the system — re-enabled\n".utf8 + )) + } + fileprivate func handle(type: CGEventType, event: CGEvent) { if debug { let flags = event.flags @@ -104,8 +122,8 @@ private func hotkeyCallback( let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - // System disabled our tap; we'll need to re-enable. For now just no-op - // and let the user restart parrot. + // The tap must be re-enabled from the run loop that owns it. + DispatchQueue.main.async { monitor.reEnable() } return Unmanaged.passUnretained(event) } diff --git a/scripts/install.sh b/scripts/install.sh index b4f09e14..070b81ae 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -62,6 +62,16 @@ trap 'rm -rf "$TMP"' EXIT dim "→ downloading ${ASSET}..." curl -fsSL "$URL" -o "$TMP/${ASSET}" +# The release workflow publishes ${ASSET}.sha256 alongside the archive. +# Verify it: this script installs an unsigned binary and strips its +# quarantine flag, so the checksum is the only integrity check there is. +dim "→ verifying checksum..." +curl -fsSL "${URL}.sha256" -o "$TMP/${ASSET}.sha256" +( cd "$TMP" && shasum -a 256 -c "${ASSET}.sha256" >/dev/null ) || { + red "checksum mismatch for ${ASSET} — refusing to install" + exit 1 +} + dim "→ extracting..." tar -xzf "$TMP/${ASSET}" -C "$TMP"