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
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.

<details>
<summary>Keyboard shortcuts</summary>
Expand Down
165 changes: 165 additions & 0 deletions engine/Sources/ODHubViewerApp/CommandLineToolInstaller.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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()
}
}
7 changes: 6 additions & 1 deletion engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/CommandLineTool.swift
Original file line number Diff line number Diff line change
@@ -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<String>,
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<String> {
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"
}
}
Loading
Loading