diff --git a/README.md b/README.md index 6e34fa2..31d6824 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ the wrong window. yap holds the most recent one in memory and nowhere else. "Quit yap" stops the background daemon until your next login. `yap start` brings it back sooner. +One daemon holds the hotkey at a time. Start another — from Applications, from +a terminal, or because an upgrade restarted the login item — and the new one +takes over and the old one stops, so a press is never captured or typed twice. + If you run a menu bar manager — Ice, Thaw, Bartender — and the mark is nowhere to be seen, look in its hidden section. Managers that file newly-appeared items there catch yap the first time it shows up. Reveal that section, then hold diff --git a/Sources/yap/DaemonLock.swift b/Sources/yap/DaemonLock.swift new file mode 100644 index 0000000..f5c3f3c --- /dev/null +++ b/Sources/yap/DaemonLock.swift @@ -0,0 +1,110 @@ +import Darwin +import Foundation + +/// One daemon owns the hotkey. +/// +/// Nothing else enforces that, and there are two ways the daemon starts. The +/// login item is a LaunchAgent, so launchd execs the binary directly; the .app +/// in /Applications is launched by LaunchServices when you double-click it. +/// The two routes are invisible to each other — only the LaunchServices one +/// registers as an application, which is why `launchctl list` shows a +/// double-clicked yap as `application.com.terrifiedbug.yap.…` and the agent +/// merely as its label — so macOS's own "this app is already running" handling +/// never sees the pair. Both processes then install an event tap on the same +/// key, and every press is captured twice and typed twice. +/// +/// The cask makes that routine rather than exotic. Its postflight runs +/// `launchctl kickstart -k`, which starts the login job whether or not it was +/// already running, so anyone whose daemon came up by double-click — or who +/// used "Quit yap" and started it again from Applications — gets a second one +/// on their next `brew upgrade`, the old image and the new listening together. +/// +/// A POSIX record lock rather than a pid file: the kernel holds it, it is +/// released however the holder dies including a crash, and `F_GETLK` names the +/// holder, so there is no stale file to sweep up and no recorded pid that may +/// since have been recycled onto something unrelated. +enum DaemonLock { + /// Held for the life of the process. Closing this descriptor drops the + /// lock, so nothing ever closes it. + private static var descriptor: Int32 = -1 + + /// Take the hotkey, replacing a daemon that already holds it. + /// + /// Last launch wins, deliberately. You either double-clicked the app or + /// Homebrew replaced the binary under the running one, and both mean "this + /// image, from now on" — refusing to start would leave an upgrade running + /// the code it just replaced, which is the worst shape for a bug because + /// `yap --version` reads the new binary on disk and agrees with the + /// version you installed. + /// + /// The incumbent gets SIGTERM, which its own handler routes through + /// `applicationWillTerminate`: a meeting recording in flight is finalized + /// and transcribes on the next start rather than losing its meta.json, and + /// the mic gain lease is given back. It exits 0, so `KeepAlive + /// SuccessfulExit:false` leaves the job down instead of racing us for it. + /// + /// False means someone else still owns the hotkey and this process must + /// not run. + static func claim() -> Bool { + let path = Paths.daemonLock.path + let handle = open(path, O_CREAT | O_RDWR, 0o600) + guard handle >= 0 else { + // A lock we cannot take is not a reason to refuse to dictate. The + // guard exists to stop a duplicate typing everything twice, and + // losing it is strictly better than losing the daemon. + warn("warning: cannot open \(path) (\(errnoText())) — starting without the daemon lock") + return true + } + descriptor = handle + + if take(handle) { return true } + guard let holder = holder(of: handle) else { + // It was held a moment ago and is not held now, so the holder + // exited between the two calls. One more attempt, and if that + // fails something else took it in the same instant — treat it the + // same as a live incumbent that will not yield. + return take(handle) + } + + warn("another yap daemon has the hotkey (pid \(holder)) — replacing it") + // ESRCH means it exited while we were reading the lock, which is the + // outcome we wanted anyway; the wait below sees the lock go. + if kill(holder, SIGTERM) != 0, errno != ESRCH { + warn("could not signal pid \(holder) (\(errnoText())) — leaving it the hotkey") + return false + } + // Long enough for the other side to finalize a session it was writing, + // short enough that launch-at-login is not held up by a wedged + // process. Polling rather than waiting on the lock, because F_SETLKW + // would block forever against exactly that. + for _ in 0..<100 { + usleep(50_000) + if take(handle) { return true } + } + warn("pid \(holder) did not stop — leaving it the hotkey") + return false + } + + // MARK: - + + /// Whole-file write lock, non-blocking. + private static func take(_ handle: Int32) -> Bool { + var lock = Darwin.flock( + l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(F_WRLCK), l_whence: Int16(SEEK_SET)) + return fcntl(handle, F_SETLK, &lock) == 0 + } + + /// The pid holding the lock, straight from the kernel, or nil if nobody + /// holds it. + private static func holder(of handle: Int32) -> pid_t? { + var probe = Darwin.flock( + l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(F_WRLCK), l_whence: Int16(SEEK_SET)) + guard fcntl(handle, F_GETLK, &probe) == 0, probe.l_type != Int16(F_UNLCK), probe.l_pid > 0 + else { return nil } + return probe.l_pid + } + + private static func errnoText() -> String { + String(cString: strerror(errno)) + } +} diff --git a/Sources/yap/Paths.swift b/Sources/yap/Paths.swift index fcd1af6..efdca0d 100644 --- a/Sources/yap/Paths.swift +++ b/Sources/yap/Paths.swift @@ -23,6 +23,23 @@ enum Paths { static var stdoutLog: URL { logDirectory.appendingPathComponent("yap.out.log") } static var stderrLog: URL { logDirectory.appendingPathComponent("yap.err.log") } + /// `~/Library/Application Support/yap/daemon.lock`, the file whose write + /// lock means "this process owns the hotkey". See `DaemonLock`. + /// + /// Not in the log directory, tempting though that is for being created + /// already: someone clearing logs would delete the lock from under a live + /// daemon, and the next one to start would then see the hotkey as free. + static var daemonLock: URL { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/yap", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + return dir.appendingPathComponent("daemon.lock") + } + /// launchd creates the log files itself and does not honour a mode, so /// tighten them to 0600 after the agent is installed. Existing files are /// tightened too, which covers an agent installed by an earlier build. diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index 7f970fb..ba1394a 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -87,6 +87,12 @@ struct Run: ParsableCommand { try checkStartup(root: root, model: chosenModel, hotkey: key) } + // After the flags, the config and the doctor report have all had their + // say, so a typo or a missing grant never costs someone the daemon + // they already had — and before the model loads, so a takeover never + // holds two copies of it in memory at once. + guard DaemonLock.claim() else { throw ExitCode.success } + // One model, loaded once, before anything can ask for it. Warming up // front is the difference between a 60 ms press and a press that waits // out an ANE compile.