From c9bf576ad32ef3646568770753ad365a59eff137 Mon Sep 17 00:00:00 2001 From: Jon Anders Date: Tue, 1 Sep 2026 01:49:14 -0400 Subject: [PATCH] install: resolve the Homebrew symlink to the Cellar bundle; never delete files we don't own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brew install joncode/tap/jumpcall && jumpcall install` — the README's recommended flow — failed with "could not locate a JumpCall.app bundle". The formula builds JumpCall.app into the Cellar and symlinks /opt/homebrew/bin/jumpcall into it, but sourceBundle() only checked Bundle.main.bundlePath, which reports the symlink's directory. Worse, the error suggested `--from `, and --from accepted any path unvalidated. Passing the CLI binary itself copied a bare file to ~/Applications/JumpCall.app, then symlinkCLI() deleted the Homebrew symlink at /opt/homebrew/bin/jumpcall (the install source!) and replaced it with a dangling link — and the command still printed "JumpCall is running". - resolve symlinks on the executable (and on --from arguments) and walk up to the enclosing .app, requiring Contents/MacOS/jumpcall inside - reject invalid --from paths with a clear error instead of copying them - symlinkCLI only ever replaces symlinks, never regular files - report launchctl bootstrap failures instead of piping them to nowhere - only print the success line when `open` actually succeeded Co-Authored-By: Claude Fable 5 --- .../JumpCallKit/Install/InstallCommand.swift | 67 ++++++++++++++++--- Tests/TestRunner/main.swift | 47 +++++++++++++ 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/Sources/JumpCallKit/Install/InstallCommand.swift b/Sources/JumpCallKit/Install/InstallCommand.swift index 70bedca..f8fa838 100644 --- a/Sources/JumpCallKit/Install/InstallCommand.swift +++ b/Sources/JumpCallKit/Install/InstallCommand.swift @@ -2,7 +2,7 @@ import AppKit import Foundation @MainActor -enum InstallCommand { +public enum InstallCommand { static let bundleID = "io.github.joncode.jumpcall" static var installedAppURL: URL { @@ -66,6 +66,9 @@ enum InstallCommand { open.arguments = [installedAppURL.path] try? open.run() open.waitUntilExit() + guard open.terminationStatus == 0 else { + fail("could not launch \(installedAppURL.path) — try opening it from Finder") + } print("JumpCall is running — look for the video icon in your menu bar.") } @@ -129,10 +132,40 @@ enum InstallCommand { private static func sourceBundle(args: [String]) -> URL? { if let i = args.firstIndex(of: "--from"), args.indices.contains(i + 1) { - return URL(fileURLWithPath: args[i + 1]).absoluteURL + guard let bundle = appBundle(containing: URL(fileURLWithPath: args[i + 1]).absoluteURL) + else { + fail(""" + --from expects a JumpCall.app bundle (or a path inside one), \ + got: \(args[i + 1]) + """) + } + return bundle } - let path = Bundle.main.bundlePath - return path.hasSuffix(".app") ? URL(fileURLWithPath: path) : nil + // Homebrew symlinks /opt/homebrew/bin/jumpcall into the Cellar's + // JumpCall.app, so Bundle.main reports /opt/homebrew/bin — resolve + // the real executable and walk up to the enclosing bundle instead. + let exec = (Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0])) + .absoluteURL + return appBundle(containing: exec) + } + + /// Resolves `url` — a bundle, a binary inside one, or a symlink to either — + /// to the enclosing .app bundle, requiring the jumpcall binary inside it. + public static func appBundle(containing url: URL) -> URL? { + let fm = FileManager.default + var candidate = url.resolvingSymlinksInPath() + while candidate.path != "/" { + if candidate.pathExtension == "app" { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: candidate.path, isDirectory: &isDir), + isDir.boolValue, + fm.fileExists(atPath: candidate.appending(path: "Contents/MacOS/jumpcall").path) + else { return nil } + return candidate + } + candidate.deleteLastPathComponent() + } + return nil } private static func terminateRunningInstances() { @@ -170,7 +203,15 @@ enum InstallCommand { } guard let dir else { return } let link = dir.appending(path: "jumpcall") - try? fm.removeItem(at: link) + // Only ever replace a symlink. A regular file at this path is not + // ours to delete (attributesOfItem does not traverse symlinks). + if let attrs = try? fm.attributesOfItem(atPath: link.path) { + guard attrs[.type] as? FileAttributeType == .typeSymbolicLink else { + print("note: \(link.path) exists and is not a symlink — leaving it alone") + return + } + try? fm.removeItem(at: link) + } do { try fm.createSymbolicLink(atPath: link.path, withDestinationPath: installedBinURL.path) print("cli: \(link.path)") @@ -216,7 +257,9 @@ enum InstallCommand { fail("could not write \(launchAgentURL.path): \(error.localizedDescription)") } launchctl(["bootout", "gui/\(getuid())/\(bundleID)"]) // ignore failures - launchctl(["bootstrap", "gui/\(getuid())", launchAgentURL.path]) + if launchctl(["bootstrap", "gui/\(getuid())", launchAgentURL.path]) != 0 { + print("warning: launchctl bootstrap failed — the agent will load at next login") + } print("launch agent installed: \(launchAgentURL.path)") } @@ -227,13 +270,19 @@ enum InstallCommand { print("removed \(launchAgentURL.path)") } - private static func launchctl(_ args: [String]) { + @discardableResult + private static func launchctl(_ args: [String]) -> Int32 { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/launchctl") proc.arguments = args proc.standardError = Pipe() - try? proc.run() - proc.waitUntilExit() + do { + try proc.run() + proc.waitUntilExit() + return proc.terminationStatus + } catch { + return 1 + } } private static func fail(_ message: String) -> Never { diff --git a/Tests/TestRunner/main.swift b/Tests/TestRunner/main.swift index 802b250..c53a616 100644 --- a/Tests/TestRunner/main.swift +++ b/Tests/TestRunner/main.swift @@ -230,6 +230,52 @@ var failed = 0 "no mic: config order preserved") } +// MARK: - Install bundle resolution + +@MainActor func installBundleTests() { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appending(path: "jumpcall-test-\(getpid())") + defer { try? fm.removeItem(at: tmp) } + + // A fake Cellar layout: Cellar/jumpcall/0.0.0/JumpCall.app with the binary + // inside, and bin/jumpcall symlinked to it — the Homebrew install shape. + let app = tmp.appending(path: "Cellar/jumpcall/0.0.0/JumpCall.app") + let macos = app.appending(path: "Contents/MacOS") + let bin = tmp.appending(path: "bin") + try! fm.createDirectory(at: macos, withIntermediateDirectories: true) + try! fm.createDirectory(at: bin, withIntermediateDirectories: true) + fm.createFile(atPath: macos.appending(path: "jumpcall").path, contents: Data("x".utf8)) + let link = bin.appending(path: "jumpcall") + try! fm.createSymbolicLink( + atPath: link.path, withDestinationPath: macos.appending(path: "jumpcall").path) + + let resolvedApp = app.resolvingSymlinksInPath() // /var/folders is a symlink to /private/var + expectEqual( + InstallCommand.appBundle(containing: link), resolvedApp, + "CLI symlink resolves to the enclosing Cellar bundle") + expectEqual( + InstallCommand.appBundle(containing: app), resolvedApp, + "bundle path resolves to itself") + expectEqual( + InstallCommand.appBundle(containing: macos.appending(path: "jumpcall")), resolvedApp, + "binary inside bundle resolves to the bundle") + expect( + InstallCommand.appBundle(containing: tmp.appending(path: "bin/nonexistent")) == nil, + "path outside any bundle -> nil") + + // A bare binary with no bundle around it (--from mistake) must not resolve. + let bare = tmp.appending(path: "bare-jumpcall") + fm.createFile(atPath: bare.path, contents: Data("x".utf8)) + expect(InstallCommand.appBundle(containing: bare) == nil, "bare binary -> nil") + + // An .app directory missing the binary must not resolve. + let empty = tmp.appending(path: "Empty.app/Contents/MacOS") + try! fm.createDirectory(at: empty, withIntermediateDirectories: true) + expect( + InstallCommand.appBundle(containing: tmp.appending(path: "Empty.app")) == nil, + "bundle without jumpcall binary -> nil") +} + // MARK: - Run keySpecTests() @@ -239,6 +285,7 @@ axTitleTests() axPickTests() chordStringTests() micPriorityTests() +installBundleTests() print("\(passed) passed, \(failed) failed") exit(failed == 0 ? 0 : 1)