Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions Sources/JumpCallKit/Install/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
}

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)")
}

Expand All @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions Tests/TestRunner/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -239,6 +285,7 @@ axTitleTests()
axPickTests()
chordStringTests()
micPriorityTests()
installBundleTests()

print("\(passed) passed, \(failed) failed")
exit(failed == 0 ? 0 : 1)
Loading