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
3 changes: 2 additions & 1 deletion Tophat/Utilities/ErrorNotifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ final class ErrorNotifier {
title: formatted.title,
content: formatted.detail,
style: styledError?.alertStyle ?? .critical,
buttonText: "Dismiss"
buttonText: "Dismiss",
technicalDetails: (error as? DiagnosticError)?.technicalDetails
)
}
}
Expand Down
15 changes: 13 additions & 2 deletions Tophat/Utilities/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,25 @@ enum Notifications {
}

@MainActor
static func alert(title: String, content: String, style: NSAlert.Style, buttonText: String) {
static func alert(title: String, content: String, style: NSAlert.Style, buttonText: String, technicalDetails: String? = nil) {
NSApp.activate(ignoringOtherApps: true)

let alert = NSAlert()
alert.messageText = title
alert.informativeText = content
alert.alertStyle = style
alert.addButton(withTitle: buttonText)
alert.runModal()

if technicalDetails != nil {
alert.addButton(withTitle: "Show Details")
}

let response = alert.runModal()

if response == .alertSecondButtonReturn, let technicalDetails {
let panel = ErrorDetailPanel(detail: technicalDetails)
panel.center()
panel.makeKeyAndOrderFront(nil)
}
}
}
16 changes: 13 additions & 3 deletions Tophat/Utilities/RemoteControlReceiver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ struct RemoteControlReceiver {
self.modelContainer = modelContainer
}

private func errorMessage(for error: Error) -> String {
var message = String(describing: FormattedError(error))

if let technicalDetails = (error as? DiagnosticError)?.technicalDetails {
message += "\n\nUnderlying Error:\n\n\(technicalDetails)"
}

return message
}

func start(delegate: RemoteControlReceiverDelegate) {
Task {
for await request in service.requests(for: InstallFromURLRequest.self) {
Expand All @@ -39,7 +49,7 @@ struct RemoteControlReceiver {
try await delegate.remoteControlReceiver(didOpenURL: requestValue.url, launchArguments: requestValue.launchArguments)
request.reply(.init())
} catch {
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
request.reply(.init(errorMessage: errorMessage(for: error)))
}
}
}
Expand Down Expand Up @@ -78,7 +88,7 @@ struct RemoteControlReceiver {
try await delegate.remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes: recipes)
request.reply(.init())
} catch {
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
request.reply(.init(errorMessage: errorMessage(for: error)))
}
}
}
Expand Down Expand Up @@ -172,7 +182,7 @@ struct RemoteControlReceiver {
try await delegate.remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier: request.value.quickLaunchEntryID)
request.reply(.init())
} catch {
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
request.reply(.init(errorMessage: errorMessage(for: error)))
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions Tophat/Views/Generic/ErrorDetailPanel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// ErrorDetailPanel.swift
// Tophat
//
// Created by Lukas Romsicki on 2026-03-11.
// Copyright © 2026 Shopify. All rights reserved.
//

import AppKit

/// A panel that displays technical error details in a scrollable,
/// selectable text view. Intended to be shown from an alert's
/// "Show Details" button.
final class ErrorDetailPanel: NSPanel {
init(detail: String) {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: true
)

title = "Error Details"
isReleasedWhenClosed = false
isFloatingPanel = true
hidesOnDeactivate = false
minSize = NSSize(width: 300, height: 150)

standardWindowButton(.zoomButton)?.isHidden = true

let scrollView = NSTextView.scrollableTextView()
scrollView.frame = contentView!.bounds
scrollView.autoresizingMask = [.width, .height]

let textView = scrollView.documentView as! NSTextView
textView.isEditable = false
textView.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
textView.string = detail
textView.textContainerInset = NSSize(width: 8, height: 8)

contentView?.addSubview(scrollView)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ extension ConnectedDevice: Device {
do {
try Adb.install(serial: id, apkUrl: application.url)
} catch {
throw DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: type)
throw DiagnosticError(DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: type), technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand All @@ -79,7 +79,7 @@ extension ConnectedDevice: Device {
let componentName = try Adb.resolveActivity(serial: id, packageName: bundleIdentifier)
try Adb.launch(serial: id, componentName: componentName, arguments: arguments ?? [])
} catch {
throw DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: type)
throw DiagnosticError(DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: type), technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ extension AdbCommand: ShellCommand {
return ["-s", serial, "shell", "getprop", property]

case .waitForDevice(let serial):
return ["-s", serial, "wait-for-device", "shell", "'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'"]
return ["-s", serial, "wait-for-device"]

case .resolveActivity(let serial, let packageName):
return ["-s", serial, "shell", "pm", "resolve-activity", "--brief", packageName]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ extension ProxyVirtualDevice: Device {
func boot() async throws {
do {
let serial = try await Emulator.start(name: name)
try Adb.wait(forSerial: serial)
try await Adb.wait(forSerial: serial)

await connectedDeviceStore.update(serial: serial)
} catch {
throw DeviceError.failedToBoot
throw DiagnosticError(DeviceError.failedToBoot, technicalDetails: error.shellErrorDiagnosticMessage)
}

guard await connectedDeviceStore.connectedDevice != nil else {
Expand Down
25 changes: 23 additions & 2 deletions TophatModules/Sources/AndroidDeviceKit/Utilities/Adb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import RegexBuilder
import ShellKit

final class AdbError: Error {}
final class AdbBootTimedOutError: Error {}

struct Adb {
static func listDevices() -> [ConnectedDevice] {
Expand Down Expand Up @@ -56,8 +57,28 @@ struct Adb {
return value
}

static func wait(forSerial serial: String) throws {
try run(command: .adb(.waitForDevice(serial: serial)), log: log)
static func wait(forSerial serial: String) async throws {
try run(command: .adb(.waitForDevice(serial: serial)), timeout: 120, log: log)

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
while true {
if try firstLine(of: .adb(.getProp(serial: serial, property: "sys.boot_completed"))) == "1" {
return
}

try await Task.sleep(for: .seconds(1))
}
}

group.addTask {
try await Task.sleep(for: .seconds(300))
throw AdbBootTimedOutError()
}

try await group.next()
group.cancelAll()
}
Comment on lines +63 to +81
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a very Swift-y timeout mechanism. The first task to finish wins.

}

private static func firstLine(of command: ShellCommand) throws -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ extension ConnectedDevice: Device {
do {
try DeviceCtl.install(udid: id, bundleUrl: application.url)
} catch {
throw DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: type)
throw DiagnosticError(DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: type), technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand All @@ -66,10 +66,13 @@ extension ConnectedDevice: Device {
try DeviceCtl.launch(udid: id, bundleId: application.bundleIdentifier, arguments: arguments ?? [])
} catch {
let bundleIdentifier = try application.bundleIdentifier
throw DeviceError.failedToLaunchApp(
bundleId: bundleIdentifier,
reason: launchFailureReason(error: error),
deviceType: type
throw DiagnosticError(
DeviceError.failedToLaunchApp(
bundleId: bundleIdentifier,
reason: launchFailureReason(error: error),
deviceType: type
),
technicalDetails: error.shellErrorDiagnosticMessage
)
}
}
Expand Down
6 changes: 3 additions & 3 deletions TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ extension Simulator: Device {
try SimCtl.start(udid: id)
try focus()
} catch {
throw DeviceError.failedToBoot
throw DiagnosticError(DeviceError.failedToBoot, technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand All @@ -61,7 +61,7 @@ extension Simulator: Device {

try SimCtl.install(udid: id, bundleUrl: application.url)
} catch {
throw DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: .simulator)
throw DiagnosticError(DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: .simulator), technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand All @@ -71,7 +71,7 @@ extension Simulator: Device {
do {
try SimCtl.launch(udid: id, bundleIdentifier: bundleIdentifier, arguments: arguments ?? [])
} catch {
throw DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: .simulator)
throw DiagnosticError(DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: .simulator), technicalDetails: error.shellErrorDiagnosticMessage)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// Error+ShellErrorDiagnosticMessage.swift
// ShellKit
//
// Created by Lukas Romsicki on 2026-03-11.
// Copyright © 2026 Shopify. All rights reserved.
//

import Foundation

public extension Error {
/// The shell error diagnostic message if this error is a ``ShellError``, otherwise `nil`.
var shellErrorDiagnosticMessage: String? {
guard let shellError = self as? ShellError else {
return nil
}

let trimmed = shellError.message.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
11 changes: 11 additions & 0 deletions TophatModules/Sources/ShellKit/Extensions/Process+LaunchBash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public typealias StandardErrorHandler = @Sendable (String) -> Void
extension Process {
@discardableResult func launchBash(
command: String,
timeout: TimeInterval? = nil,
standardOutputHandler: StandardOutputHandler? = nil,
standardErrorHandler: StandardErrorHandler? = nil
) throws -> String {
Expand Down Expand Up @@ -62,6 +63,16 @@ extension Process {
}

launch()

if let timeout {
let process = self
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
if process.isRunning {
process.terminate()
}
}
}

waitUntilExit()

outputPipe.fileHandleForReading.readabilityHandler = nil
Expand Down
2 changes: 2 additions & 0 deletions TophatModules/Sources/ShellKit/ShellCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Logging
@discardableResult
public func run(
command: ShellCommand,
timeout: TimeInterval? = nil,
log: Logger? = nil,
standardOutputHandler: StandardOutputHandler? = nil,
standardErrorHandler: StandardErrorHandler? = nil
Expand All @@ -31,6 +32,7 @@ public func run(
do {
return try process.launchBash(
command: command.string,
timeout: timeout,
standardOutputHandler: standardOutputHandler,
standardErrorHandler: standardErrorHandler
)
Expand Down
38 changes: 38 additions & 0 deletions TophatModules/Sources/TophatFoundation/DiagnosticError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// DiagnosticError.swift
// TophatFoundation
//
// Created by Lukas Romsicki on 2026-03-11.
// Copyright © 2026 Shopify. All rights reserved.
//

import Foundation

/// The type you use to wrap an error in order to provide additional technical details
/// for debugging purposes.
public struct DiagnosticError: Error {
/// The error to present to the user.
public let error: Error

/// A technical description of the underlying cause.
public let technicalDetails: String?

public init(_ error: Error, technicalDetails: String? = nil) {
self.error = error
self.technicalDetails = technicalDetails
}
}

extension DiagnosticError: LocalizedError {
public var errorDescription: String? {
(error as? LocalizedError)?.errorDescription
}

public var failureReason: String? {
(error as? LocalizedError)?.failureReason
}

public var recoverySuggestion: String? {
(error as? LocalizedError)?.recoverySuggestion
}
}
Loading