diff --git a/README.md b/README.md index 8eb52a3..0d5281c 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,12 @@ Download the DMG from [Releases](https://github.com/Mastersam07/OpenDeviceHub/re OpenDeviceHub to Applications and launch it. The app is signed with a Developer ID certificate and notarized, so there is no Gatekeeper warning, and it updates itself from the app menu. -The `odhub` command line tool ships inside the bundle. To use it from a terminal, put it on your -`PATH`: +The `odhub` command line tool ships inside the bundle. Choose **OpenDeviceHub → Install Command Line +Tool** and it links `odhub` into a folder already on your `PATH`, asking for your password only if +every such folder belongs to the system. **Remove Command Line Tool** takes it away again. -```sh -echo 'export PATH="/Applications/OpenDeviceHub.app/Contents/MacOS:$PATH"' >> ~/.zshrc -``` - -Then `odhub doctor` reports what it found and `odhub list` shows your simulators. +Then, in a new terminal, `odhub doctor` reports what it found and `odhub list` shows your +simulators. ## Features @@ -45,7 +43,7 @@ Then `odhub doctor` reports what it found and `odhub list` shows your simulators - **Debug helpers** — slow animations, shake, simulated memory warning, the system log and app data in the Finder, and a click to frame latency overlay. - **A command line tool** — `odhub` drives a simulator from a script: tap, swipe, pinch, type, - buttons, rotation. + buttons, rotation. One menu item puts it on your `PATH`.
Keyboard shortcuts diff --git a/engine/Sources/ODHubViewerApp/CommandLineToolInstaller.swift b/engine/Sources/ODHubViewerApp/CommandLineToolInstaller.swift new file mode 100644 index 0000000..c4b3401 --- /dev/null +++ b/engine/Sources/ODHubViewerApp/CommandLineToolInstaller.swift @@ -0,0 +1,165 @@ +import AppKit +import OpenDeviceHubEngine +import OpenDeviceHubViewer + +/// Installs and removes the `odhub` link on the user's behalf. +/// +/// Prefers a directory the person already owns, so on most developer Macs this costs no password. +/// Only when there is no such directory on their `PATH` does it ask, and asking is a dialog of ours +/// before the one macOS shows, so the authentication prompt is never a surprise. +@MainActor +enum CommandLineToolInstaller { + /// The `odhub` that ships beside this app, or nil for a source build, which has no bundle and + /// whose binary lives in a build directory that gets deleted. + static var bundledTool: String? { + guard Bundle.main.bundleIdentifier != nil, + let running = ExecutableLocator.runningExecutableURL() else { return nil } + let tool = ExecutableLocator.siblingURL(of: running, named: Brand.commandName) + let path = tool.path(percentEncoded: false) + return FileManager.default.isExecutableFile(atPath: path) ? path : nil + } + + static func state() -> CommandLineToolState { + guard let bundledTool else { return .missing } + let links = CommandLineTool.candidateDirectories(home: NSHomeDirectory()).map { directory in + let link = "\(directory)/\(Brand.commandName)" + return (link: link, destination: try? FileManager.default.destinationOfSymbolicLink(atPath: link)) + } + return CommandLineTool.state(links: links, expecting: bundledTool) + } + + static func install() { + guard let bundledTool else { return } + let location = CommandLineTool.chooseLocation( + candidates: CommandLineTool.candidateDirectories(home: NSHomeDirectory()), + onPath: loginShellPath(), + isWritable: { FileManager.default.isWritableFile(atPath: $0) } + ) + + if !location.needsAuthorization, writeLink(to: bundledTool, at: location.link) { + report( + title: "\(Brand.commandName) is ready.", + detail: """ + Open a new terminal and run \(Brand.commandName) doctor. + + It is a link at \(location.link), which is already on your PATH. No password was \ + needed because that folder is yours. + """ + ) + return + } + + askThenRun( + command: CommandLineTool.privilegedCommand(binary: bundledTool, link: location.link), + manual: CommandLineTool.manualCommand(binary: bundledTool, link: location.link), + question: """ + Every folder on your PATH belongs to the system on this Mac, so putting \ + \(Brand.commandName) in \(location.directory) needs your administrator password. \ + macOS will ask for it next. + + Nothing else changes: it creates one link, and Remove Command Line Tool deletes it. + """, + done: "\(Brand.commandName) is ready. Open a new terminal and run \(Brand.commandName) doctor." + ) + } + + static func remove() { + guard case .installed(let link) = state() else { + report( + title: "\(Brand.commandName) was not installed.", + detail: "There is no link to remove." + ) + return + } + if (try? FileManager.default.removeItem(atPath: link)) != nil { + report(title: "\(Brand.commandName) was removed.", detail: "The link at \(link) is gone.") + return + } + askThenRun( + command: CommandLineTool.privilegedRemoval(link: link), + manual: "sudo rm -f \(CommandLineTool.shellQuoted(link))", + question: """ + Removing the link at \(link) needs your administrator password, because that folder \ + belongs to the system on this Mac. + """, + done: "The link at \(link) is gone." + ) + } + + /// The `PATH` a terminal would have, not the one this process was launched with. An app started + /// by launchd gets a minimal `PATH` that says nothing about where a person's tools live, so the + /// login shell is asked instead. + private static func loginShellPath() -> Set { + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-l", "-c", "printf %s \"$PATH\""] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return CommandLineTool.pathEntries(ProcessInfo.processInfo.environment["PATH"] ?? "") + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return CommandLineTool.pathEntries(String(data: data, encoding: .utf8) ?? "") + } + + private static func writeLink(to binary: String, at link: String) -> Bool { + let manager = FileManager.default + // An existing link is replaced rather than refused, so moving the app and installing again + // repairs it instead of failing. + try? manager.removeItem(atPath: link) + do { + try manager.createSymbolicLink(atPath: link, withDestinationPath: binary) + return true + } catch { + return false + } + } + + private static func askThenRun(command: String, manual: String, question: String, done: String) { + let ask = NSAlert() + ask.alertStyle = .informational + ask.messageText = "Administrator access is needed." + ask.informativeText = question + ask.addButton(withTitle: "Continue") + ask.addButton(withTitle: "Copy Command Instead") + ask.addButton(withTitle: "Cancel") + + switch ask.runModal() { + case .alertFirstButtonReturn: + var failure: NSDictionary? + NSAppleScript(source: CommandLineTool.authorizingScript(command))? + .executeAndReturnError(&failure) + if let failure { + // Cancelling the system prompt is error -128, which is an answer, not a fault. + guard (failure[NSAppleScript.errorNumber] as? Int) != -128 else { return } + report( + title: "That did not work.", + detail: (failure[NSAppleScript.errorMessage] as? String) + ?? "The command could not be run.\n\n\(manual)" + ) + return + } + report(title: "Done.", detail: done) + case .alertSecondButtonReturn: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(manual, forType: .string) + report(title: "Copied.", detail: "Paste this into a terminal:\n\n\(manual)") + default: + return + } + } + + private static func report(title: String, detail: String) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title + alert.informativeText = detail + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 5efc10b..7bd8182 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -249,7 +249,12 @@ struct ODHubViewer: ParsableCommand { } }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } } - ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu) + ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu, + commandLineTool: CommandLineToolInstaller.bundledTool == nil ? nil : CommandLineToolMenu( + state: { CommandLineToolInstaller.state() }, + install: { CommandLineToolInstaller.install() }, + remove: { CommandLineToolInstaller.remove() } + )) if let updates { print("updates: \(updates.feedURL ?? "configured, feed unreadable")") } else { diff --git a/engine/Sources/OpenDeviceHubViewer/CommandLineTool.swift b/engine/Sources/OpenDeviceHubViewer/CommandLineTool.swift new file mode 100644 index 0000000..01aae35 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/CommandLineTool.swift @@ -0,0 +1,113 @@ +import Foundation +import OpenDeviceHubEngine + +public enum CommandLineToolState: Equatable, Sendable { + case installed(at: String) + /// Something is already on the `PATH` under this name, pointing somewhere else. Usually an older + /// copy of the app, sometimes another tool that happens to share the name. + case pointsElsewhere(link: String, destination: String) + case missing +} + +/// Where the link goes, and whether putting it there needs a password. +public struct InstallLocation: Equatable, Sendable { + public let directory: String + public let needsAuthorization: Bool + + public init(directory: String, needsAuthorization: Bool) { + self.directory = directory + self.needsAuthorization = needsAuthorization + } + + public var link: String { "\(directory)/\(Brand.commandName)" } +} + +/// Putting `odhub` on the `PATH` without asking anyone to edit a shell profile. +/// +/// A link in a directory that is already on the `PATH`, rather than a line in `~/.zshrc`: it works +/// in every shell and it is one file to remove afterwards. Editing someone's profile behind their +/// back is not this app's business. +/// +/// A directory the person already owns is preferred over `/usr/local/bin`, which belongs to root on +/// a Mac that has never had Homebrew near it. Most developer Macs have a writable one already on the +/// `PATH`, and then this costs no password at all. +public enum CommandLineTool { + /// Tried in order. `/usr/local/bin` is last because it is the one that usually needs a password, + /// not because it is the worst place. + public static func candidateDirectories(home: String) -> [String] { + ["/opt/homebrew/bin", "\(home)/.local/bin", "\(home)/bin", "/usr/local/bin"] + } + + /// `/usr/local/bin` is on the default `PATH` through `/etc/paths` on every Mac, so it counts as + /// on the path whether or not a particular shell mentions it. + public static let alwaysOnPath = "/usr/local/bin" + + public static func chooseLocation( + candidates: [String], + onPath: Set, + isWritable: (String) -> Bool + ) -> InstallLocation { + for directory in candidates + where (onPath.contains(directory) || directory == alwaysOnPath) && isWritable(directory) { + return InstallLocation(directory: directory, needsAuthorization: false) + } + return InstallLocation(directory: alwaysOnPath, needsAuthorization: true) + } + + public static func state( + links: [(link: String, destination: String?)], + expecting binary: String + ) -> CommandLineToolState { + for entry in links where entry.destination == binary { + return .installed(at: entry.link) + } + for entry in links { + if let destination = entry.destination { + return .pointsElsewhere(link: entry.link, destination: destination) + } + } + return .missing + } + + /// Splits a shell's `PATH` the way a shell does. + public static func pathEntries(_ path: String) -> Set { + Set(path.split(separator: ":").map(String.init).filter { !$0.isEmpty }) + } + + /// The command that does what the menu item does, for when the app cannot and a person has to. + public static func manualCommand(binary: String, link: String) -> String { + let directory = (link as NSString).deletingLastPathComponent + return "sudo mkdir -p \(shellQuoted(directory)) && sudo ln -sf \(shellQuoted(binary)) \(shellQuoted(link))" + } + + /// The same command without `sudo`, to be run by an AppleScript that asks for authorisation. + public static func privilegedCommand(binary: String, link: String) -> String { + let directory = (link as NSString).deletingLastPathComponent + return "mkdir -p \(shellQuoted(directory)) && ln -sf \(shellQuoted(binary)) \(shellQuoted(link))" + } + + public static func privilegedRemoval(link: String) -> String { + "rm -f \(shellQuoted(link))" + } + + /// Wraps a path for `sh`, so a space or a quote in it cannot end the argument. + public static func shellQuoted(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Wraps a shell command as an AppleScript string literal. + static func appleScriptQuoted(_ command: String) -> String { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + /// Authorization Services' own prompt. It asks for a password rather than Touch ID: biometrics + /// prove who is sitting there, which is not the same as holding a privilege, and bridging the + /// two needs a helper installed as root. One symlink does not justify a permanent root + /// component, so the better answer is the branch above that needs no password at all. + public static func authorizingScript(_ command: String) -> String { + "do shell script \(appleScriptQuoted(command)) with administrator privileges" + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 9d3adcb..94fe022 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -1,6 +1,24 @@ import AppKit import OpenDeviceHubEngine +/// The one menu item whose wording depends on what is on disk, so it is asked rather than told. +@MainActor +public struct CommandLineToolMenu { + public var state: () -> CommandLineToolState + public var install: () -> Void + public var remove: () -> Void + + public init( + state: @escaping () -> CommandLineToolState, + install: @escaping () -> Void, + remove: @escaping () -> Void + ) { + self.state = state + self.install = install + self.remove = remove + } +} + /// Builds the application menu. Shortcuts follow the classic Simulator where the action exists. @MainActor public enum ViewerMenu { @@ -66,9 +84,10 @@ public enum ViewerMenu { into application: NSApplication, actions: Actions, capabilities: Capabilities, - openSimulatorMenu: NSMenu? = nil + openSimulatorMenu: NSMenu? = nil, + commandLineTool: CommandLineToolMenu? = nil ) -> MenuTarget { - let target = MenuTarget(actions: actions) + let target = MenuTarget(actions: actions, commandLineTool: commandLineTool) let bar = NSMenu() let appItem = NSMenuItem() @@ -77,6 +96,12 @@ public enum ViewerMenu { appMenu.addItem(target.item("Check for Updates\u{2026}", #selector(MenuTarget.checkForUpdates), "", [])) appMenu.addItem(.separator()) } + if commandLineTool != nil { + let item = target.item("", #selector(MenuTarget.commandLineTool(_:)), "", []) + target.trackCommandLineToolItem(item, in: appMenu) + appMenu.addItem(item) + appMenu.addItem(.separator()) + } appMenu.addItem(withTitle: "Quit \(Brand.productName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") appItem.submenu = appMenu bar.addItem(appItem) @@ -183,14 +208,54 @@ private func disable(_ item: NSMenuItem, unless available: Bool, reason: String) /// Holds the menu actions. AppKit keeps menu targets weakly, so the caller retains this. @MainActor -public final class MenuTarget: NSObject { +public final class MenuTarget: NSObject, NSMenuDelegate { private let actions: ViewerMenu.Actions + private let commandLineTool: CommandLineToolMenu? + private weak var commandLineToolItem: NSMenuItem? private var isDark = false private var isSlowAnimations = false private var isLatencyVisible = false - init(actions: ViewerMenu.Actions) { + init(actions: ViewerMenu.Actions, commandLineTool: CommandLineToolMenu? = nil) { self.actions = actions + self.commandLineTool = commandLineTool + } + + /// The title has to be read off the disk each time the menu opens, because the link can be + /// removed, or repointed by installing another copy of the app, while this one is running. + func trackCommandLineToolItem(_ item: NSMenuItem, in menu: NSMenu) { + commandLineToolItem = item + menu.delegate = self + retitleCommandLineToolItem() + } + + public func menuNeedsUpdate(_ menu: NSMenu) { + retitleCommandLineToolItem() + } + + private func retitleCommandLineToolItem() { + guard let commandLineToolItem, let commandLineTool else { return } + switch commandLineTool.state() { + case .installed(let link): + commandLineToolItem.title = "Remove Command Line Tool" + commandLineToolItem.toolTip = "Deletes the \(link) link." + case .pointsElsewhere(let link, let destination): + commandLineToolItem.title = "Repair Command Line Tool\u{2026}" + commandLineToolItem.toolTip = "\(link) points at \(destination)." + case .missing: + commandLineToolItem.title = "Install Command Line Tool\u{2026}" + commandLineToolItem.toolTip = "Puts \(Brand.commandName) on your PATH." + } + } + + @objc func commandLineTool(_ sender: NSMenuItem) { + guard let commandLineTool else { return } + if case .installed = commandLineTool.state() { + commandLineTool.remove() + } else { + commandLineTool.install() + } + retitleCommandLineToolItem() } func item(_ title: String, _ action: Selector, _ key: String, _ modifiers: NSEvent.ModifierFlags) -> NSMenuItem { diff --git a/engine/Tests/OpenDeviceHubViewerTests/CommandLineToolTests.swift b/engine/Tests/OpenDeviceHubViewerTests/CommandLineToolTests.swift new file mode 100644 index 0000000..4983fc8 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/CommandLineToolTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +final class CommandLineToolTests: XCTestCase { + private let binary = "/Applications/OpenDeviceHub.app/Contents/MacOS/odhub" + private let candidates = CommandLineTool.candidateDirectories(home: "/Users/someone") + + func testPrefersAFolderThePersonOwnsOverOneNeedingAPassword() { + let location = CommandLineTool.chooseLocation( + candidates: candidates, + onPath: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"], + isWritable: { $0 == "/opt/homebrew/bin" } + ) + XCTAssertEqual(location.directory, "/opt/homebrew/bin") + XCTAssertFalse(location.needsAuthorization) + } + + func testAWritableUsrLocalBinNeedsNoPassword() { + let location = CommandLineTool.chooseLocation( + candidates: candidates, + onPath: ["/usr/bin"], + isWritable: { $0 == "/usr/local/bin" } + ) + XCTAssertEqual(location.directory, "/usr/local/bin") + XCTAssertFalse(location.needsAuthorization) + } + + func testAWritableFolderThatIsNotOnThePathIsNoUse() { + let location = CommandLineTool.chooseLocation( + candidates: candidates, + onPath: ["/usr/bin"], + isWritable: { $0 == "/Users/someone/bin" } + ) + XCTAssertEqual(location.directory, "/usr/local/bin") + XCTAssertTrue(location.needsAuthorization) + } + + func testWithNothingWritableItFallsBackToAskingForAPassword() { + let location = CommandLineTool.chooseLocation( + candidates: candidates, + onPath: ["/opt/homebrew/bin", "/usr/local/bin"], + isWritable: { _ in false } + ) + XCTAssertEqual(location.directory, "/usr/local/bin") + XCTAssertTrue(location.needsAuthorization) + } + + func testNoLinkAnywhereMeansNotInstalled() { + let state = CommandLineTool.state( + links: candidates.map { ("\($0)/odhub", nil) }, + expecting: binary + ) + XCTAssertEqual(state, .missing) + } + + func testALinkToOurBinaryIsFoundWhicheverFolderItIsIn() { + let state = CommandLineTool.state( + links: [ + ("/opt/homebrew/bin/odhub", nil), + ("/usr/local/bin/odhub", binary), + ], + expecting: binary + ) + XCTAssertEqual(state, .installed(at: "/usr/local/bin/odhub")) + } + + func testOurOwnLinkWinsOverSomeoneElsesWithTheSameName() { + let state = CommandLineTool.state( + links: [ + ("/opt/homebrew/bin/odhub", "/somewhere/else/odhub"), + ("/usr/local/bin/odhub", binary), + ], + expecting: binary + ) + XCTAssertEqual(state, .installed(at: "/usr/local/bin/odhub")) + } + + func testALinkSomewhereElseIsReportedWithBothPaths() { + let stale = "/Users/someone/old/OpenDeviceHub.app/Contents/MacOS/odhub" + let state = CommandLineTool.state( + links: [("/opt/homebrew/bin/odhub", stale)], + expecting: binary + ) + XCTAssertEqual(state, .pointsElsewhere(link: "/opt/homebrew/bin/odhub", destination: stale)) + } + + func testPathEntriesIgnoreEmptySegments() { + XCTAssertEqual( + CommandLineTool.pathEntries("/usr/bin::/bin:"), + ["/usr/bin", "/bin"] + ) + } + + /// Asks a real shell what the quoting produces, because the only thing that matters is what `sh` + /// makes of it, and a path with a quote in it is exactly where hand written escaping goes wrong. + func testAwkwardPathsSurviveARealShellIntact() throws { + for path in [ + "/Users/someone/My Apps/odhub", + "/Users/someone/it's mine/odhub", + "/Users/someone/a \"quoted\" name/odhub", + "/Users/someone/back\\slash/odhub", + "/Users/someone/$(whoami)/odhub", + "/Users/someone/semi;colon && true/odhub", + ] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "printf %s \(CommandLineTool.shellQuoted(path))"] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual(String(data: data, encoding: .utf8), path, "quoting mangled \(path)") + } + } + + func testTheAppleScriptStringEscapesQuotesAndBackslashes() { + let quoted = CommandLineTool.appleScriptQuoted("ln -sf \"a\\b\" /usr/local/bin/odhub") + XCTAssertEqual(quoted, "\"ln -sf \\\"a\\\\b\\\" /usr/local/bin/odhub\"") + } + + func testTheAuthorizingScriptAsksForAdministratorPrivileges() { + let script = CommandLineTool.authorizingScript("rm -f '/usr/local/bin/odhub'") + XCTAssertEqual( + script, + "do shell script \"rm -f '/usr/local/bin/odhub'\" with administrator privileges" + ) + } + + func testTheManualCommandIsTheOneAPersonWouldType() { + XCTAssertEqual( + CommandLineTool.manualCommand(binary: binary, link: "/usr/local/bin/odhub"), + "sudo mkdir -p '/usr/local/bin' && sudo ln -sf '\(binary)' '/usr/local/bin/odhub'" + ) + } +}