Skip to content
13 changes: 13 additions & 0 deletions engine/Sources/ODHubViewerApp/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ final class UpdateController {
controller.checkForUpdates(nil)
}

/// Sparkle's own background schedule. Exposed so Settings can turn it off without the window
/// needing to know Sparkle exists.
/// Checking and downloading move together, because "install updates automatically" that only
/// checks would be a switch that does half of what it says. Installing still waits for the
/// person, which is Sparkle's `SUAutomaticallyUpdate`, left off deliberately.
var checksAutomatically: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set {
controller.updater.automaticallyChecksForUpdates = newValue
controller.updater.automaticallyDownloadsUpdates = newValue
}
}

/// Whether the updater found the feed usable, for reporting rather than for control flow.
var feedURL: String? {
controller.updater.feedURL?.absoluteString
Expand Down
75 changes: 66 additions & 9 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ struct ODHubViewer: ParsableCommand {
let devices = try adapter.devices()
let recent = RecentDeviceStore()
let launchedFromAnIcon = udids.isEmpty
let settings = ViewerSettings()
let plan = launchedFromAnIcon
? StartupDevices.plan(devices: devices, remembered: recent.udid)
? StartupDevices.plan(
devices: devices,
remembered: recent.udid,
bootsMostRecent: settings.bootsMostRecentOnStart
)
: StartupDevices.Plan(udids: udids, boot: boot)

// Valid because a synchronous `run()` executes on the process's main thread.
Expand Down Expand Up @@ -141,6 +146,7 @@ struct ODHubViewer: ParsableCommand {
}
)

let deviceLinks = DefaultDeviceApplication()
let updates = UpdateController()
let menuTarget = ViewerMenu.install(into: application, actions: ViewerMenu.Actions(
setScaleMode: { manager.applyScaleMode($0) },
Expand All @@ -160,8 +166,7 @@ struct ODHubViewer: ParsableCommand {
}
},
saveScreenshot: {
let directory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let directory = recordingDirectory(settings)
for url in manager.saveScreenshots(into: directory) {
print("saved \(url.path(percentEncoded: false))")
}
Expand All @@ -170,8 +175,7 @@ struct ODHubViewer: ParsableCommand {
print(manager.copyScreenshotToClipboard() ? "screenshot copied" : "nothing to copy")
},
toggleRecording: {
let directory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let directory = recordingDirectory(settings)
let finished = manager.toggleRecording(into: directory)
if finished.isEmpty {
print("recording started")
Expand Down Expand Up @@ -273,12 +277,22 @@ struct ODHubViewer: ParsableCommand {
}
},
stopRecording: {
for url in manager.toggleRecording(into: recordingDirectory()) {
for url in manager.toggleRecording(into: recordingDirectory(settings)) {
print("recorded \(url.path(percentEncoded: false))")
}
},
isRecording: { manager.isRecording },
checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }
checkForUpdates: updates.map { updater in { updater.checkForUpdates() } },
showSettings: {
SettingsWindow.show(settings: settings, actions: SettingsActions(
automaticUpdates: updates.map { updater in { updater.checksAutomatically } },
setAutomaticUpdates: updates.map { updater in { updater.checksAutomatically = $0 } },
checkForUpdates: updates.map { updater in { updater.checkForUpdates() } },
forgetWindowPositions: { store.forgetAll() },
rememberedWindowCount: { store.rememberedCount },
openLinks: deviceLinks
))
}
), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu,
commandLineTool: CommandLineToolInstaller.bundledTool == nil ? nil : CommandLineToolMenu(
state: { CommandLineToolInstaller.state() },
Expand Down Expand Up @@ -322,6 +336,14 @@ struct ODHubViewer: ParsableCommand {
quitsWithLastWindow: !launchedFromAnIcon,
dockMenu: { chooser.dockMenu() },
reopen: { bootThenShow($0) },
openLink: { udid in
if manager.isOpen(udid) {
manager.bringToFront(udid)
NSApp.activate(ignoringOtherApps: true)
} else {
bootThenShow(udid)
}
},
deviceToReopen: { recent.udid ?? StartupDevices.plan(
devices: (try? adapter.devices()) ?? [],
remembered: nil
Expand Down Expand Up @@ -406,19 +428,22 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate {
private let quitsWithLastWindow: Bool
private let dockMenu: () -> NSMenu
private let reopen: (String) -> Void
private let openLink: (String) -> Void
private let deviceToReopen: () -> String?
private let onTerminate: () -> Void

init(
quitsWithLastWindow: Bool,
dockMenu: @escaping () -> NSMenu,
reopen: @escaping (String) -> Void,
openLink: @escaping (String) -> Void,
deviceToReopen: @escaping () -> String?,
onTerminate: @escaping () -> Void
) {
self.quitsWithLastWindow = quitsWithLastWindow
self.dockMenu = dockMenu
self.reopen = reopen
self.openLink = openLink
self.deviceToReopen = deviceToReopen
self.onTerminate = onTerminate
}
Expand All @@ -442,6 +467,32 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
onTerminate()
}

/// A devices:// link only reaches this app if someone chose it in Settings. One that names a
/// simulator opens here; anything else goes back to Device Hub whole, rather than being dropped
/// because this app did not understand it.
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
switch DeviceLink.destination(for: url) {
case .simulator(let udid):
openLink(udid)
case .deviceHub:
forwardToDeviceHub(url)
case .notADeviceLink:
continue
}
}
}

private func forwardToDeviceHub(_ url: URL) {
guard let deviceHub = NSWorkspace.shared.urlForApplication(
withBundleIdentifier: "com.apple.dt.Devices"
) else {
print("no Device Hub to pass \(url) to")
return
}
NSWorkspace.shared.open([url], withApplicationAt: deviceHub, configuration: NSWorkspace.OpenConfiguration())
}
}

/// The buttons above each device. Unlike the menu bar these act on one device, the one whose
Expand Down Expand Up @@ -530,7 +581,13 @@ private func swipeHome(_ session: any InputSession) async throws {

/// Recordings and screenshots land on the Desktop, falling back to a temporary folder on a machine
/// that has none.
private func recordingDirectory() -> URL {
FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
private func recordingDirectory(_ settings: ViewerSettings = ViewerSettings()) -> URL {
if let chosen = settings.captureDirectory,
FileManager.default.fileExists(atPath: chosen.path(percentEncoded: false)) {
return chosen
}
// A folder that has been moved or unplugged since it was chosen falls back rather than losing
// the capture.
return FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
}
3 changes: 3 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Brand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ public enum Brand {
public static let bundledViewerExecutableName = "OpenDeviceHub"
public static let identifierPrefix = "opendevicehub"

/// This app's own URL scheme, for links that name a simulator to open.
public static let urlScheme = "odhub"

/// Based on the GitHub handle rather than the product name, so the app can be renamed and the
/// repository moved without it changing. Changing it after a release makes macOS treat the app
/// as a different one, orphaning every copy already installed.
Expand Down
61 changes: 61 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/DeviceLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

/// Links that name a device.
///
/// Xcode and other tools open `devices://` links, which Device Hub owns. This app can take that
/// over, which is only useful if it can tell a link it understands from one it does not: a link
/// naming a physical device, or asking for something only Device Hub does, has to be handed back
/// rather than swallowed.
public enum DeviceLink {
public enum Destination: Equatable, Sendable {
/// A simulator this app can show, by UDID.
case simulator(udid: String)
/// Anything else on the `devices` scheme, to be passed to Device Hub untouched.
case deviceHub
case notADeviceLink
}

/// The routes Device Hub uses, learned by inspection. Anything else on the scheme is still a
/// Device Hub link, it is just not one this app claims to understand.
static let deviceHubRoutes = ["/device/open", "/manage/select"]

public static func destination(for url: URL, ownScheme: String = Brand.urlScheme) -> Destination {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased() else {
return .notADeviceLink
}
// A link carrying credentials, a port or a fragment is not one of ours, whatever it says.
guard components.user == nil, components.password == nil,
components.port == nil, components.fragment == nil else {
return scheme == "devices" ? .deviceHub : .notADeviceLink
}

let host = components.host?.lowercased() ?? ""
let route = (host.isEmpty ? "" : "/" + host) + components.path

if scheme == ownScheme.lowercased() {
let isOpen = host == "open" && ["", "/"].contains(components.path)
guard isOpen, let udid = onlyQueryValue(components, named: "udid") else {
return .notADeviceLink
}
return .simulator(udid: udid)
}

guard scheme == "devices" else { return .notADeviceLink }
guard deviceHubRoutes.contains(route),
let id = onlyQueryValue(components, named: "id") else {
return .deviceHub
}
return .simulator(udid: id)
}

/// Exactly one query item with the expected name, and a real UUID. More than one means the link
/// is asking for something extra that only Device Hub knows how to honour.
private static func onlyQueryValue(_ components: URLComponents, named name: String) -> String? {
guard let items = components.queryItems, items.count == 1, items[0].name == name,
let value = items[0].value, let uuid = UUID(uuidString: value) else {
return nil
}
return uuid.uuidString
}
}
114 changes: 114 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/DefaultDeviceApplication.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import AppKit
import OpenDeviceHubEngine

/// Who handles `devices://` links, and changing it.
///
/// Device Hub owns the scheme out of the box, but it is not the only app that can claim it: any
/// simulator viewer may, and more than one may be installed. So taking it over remembers who held
/// it, and giving it back means giving it back to *them*, not to whoever this app assumes was there.
@MainActor
public final class DefaultDeviceApplication: ObservableObject {
public static let scheme = "devices"
static let deviceHubBundleID = "com.apple.dt.Devices"

@Published public private(set) var handlerName: String?
@Published public private(set) var isOurs = false
@Published public private(set) var isBusy = false
@Published public private(set) var failure: String?
/// What the button offers to hand back to: whoever held the scheme before, by name.
@Published public private(set) var previousHandlerName: String?

private let storage: any PreferenceStorage
private let key: String

public init(
storage: any PreferenceStorage = UserDefaultsPreferenceStorage(),
key: String = "\(Brand.identifierPrefix).previousLinkHandler"
) {
self.storage = storage
self.key = key
refresh()
}

public func refresh() {
guard let url = URL(string: "\(Self.scheme)://"),
let handler = NSWorkspace.shared.urlForApplication(toOpen: url) else {
handlerName = nil
isOurs = false
previousHandlerName = nil
return
}
handlerName = Self.name(of: handler)
isOurs = Bundle(url: handler)?.bundleIdentifier == Bundle.main.bundleIdentifier
previousHandlerName = handBackTarget().map(Self.name(of:))
}

public func takeOver() {
// Recorded before the change, and only when it is somebody else's, so taking over twice
// cannot overwrite the memory with ourselves.
if let current = currentHandler(),
Bundle(url: current)?.bundleIdentifier != Bundle.main.bundleIdentifier,
let identifier = Bundle(url: current)?.bundleIdentifier {
storage.setText(identifier, forKey: key)
}
setHandler(Bundle.main.bundleURL)
}

/// Back to whoever had it, or to Device Hub when that app is gone or was never recorded. Device
/// Hub is the floor rather than the assumption: it ships with Xcode, so it is the one handler
/// that can be relied on to exist.
public func handBack() {
guard let target = handBackTarget() else {
failure = "Device Hub was not found on this Mac."
return
}
setHandler(target)
}

private func handBackTarget() -> URL? {
let wanted = Self.handBackIdentifier(
remembered: storage.text(forKey: key),
ours: Bundle.main.bundleIdentifier
)
if let wanted, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: wanted) {
return url
}
// The remembered app has been deleted since. Device Hub ships with Xcode, so it is the one
// handler that can be relied on to still be there.
return NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.deviceHubBundleID)
}

/// Who the scheme should go back to. Separated from the lookup so the decision can be tested
/// without a machine that happens to have the right apps installed.
static func handBackIdentifier(remembered: String?, ours: String?) -> String? {
guard let remembered, !remembered.isEmpty, remembered != ours else {
return deviceHubBundleID
}
return remembered
}

private func currentHandler() -> URL? {
URL(string: "\(Self.scheme)://").flatMap(NSWorkspace.shared.urlForApplication(toOpen:))
}

private static func name(of application: URL) -> String {
FileManager.default.displayName(atPath: application.path(percentEncoded: false))
}

private func setHandler(_ application: URL) {
isBusy = true
failure = nil
Task {
do {
try await NSWorkspace.shared.setDefaultApplication(
at: application,
toOpenURLsWithScheme: Self.scheme
)
} catch {
failure = error.localizedDescription
}
isBusy = false
refresh()
}
}
}
Loading
Loading