From eb119277a3bd1417fd0c545ee92f104a91f08ad3 Mon Sep 17 00:00:00 2001 From: Lukas Romsicki Date: Wed, 11 Mar 2026 22:05:22 -0400 Subject: [PATCH 1/2] Add ability to reveal underlying error messages in Tophat alerts --- Tophat/Utilities/ErrorNotifier.swift | 3 +- Tophat/Utilities/Notifications.swift | 15 ++++++- Tophat/Utilities/RemoteControlReceiver.swift | 16 +++++-- Tophat/Views/Generic/ErrorDetailPanel.swift | 43 +++++++++++++++++++ .../ConnectedDevice+Device.swift | 4 +- .../AndroidDeviceKit/ProxyVirtualDevice.swift | 2 +- .../ConnectedDevice+Device.swift | 13 +++--- .../AppleDeviceKit/Simulator+Device.swift | 6 +-- .../Error+ShellErrorDiagnosticMessage.swift | 21 +++++++++ .../TophatFoundation/DiagnosticError.swift | 38 ++++++++++++++++ 10 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 Tophat/Views/Generic/ErrorDetailPanel.swift create mode 100644 TophatModules/Sources/ShellKit/Extensions/Error+ShellErrorDiagnosticMessage.swift create mode 100644 TophatModules/Sources/TophatFoundation/DiagnosticError.swift diff --git a/Tophat/Utilities/ErrorNotifier.swift b/Tophat/Utilities/ErrorNotifier.swift index d960e9f..9a1289e 100644 --- a/Tophat/Utilities/ErrorNotifier.swift +++ b/Tophat/Utilities/ErrorNotifier.swift @@ -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 ) } } diff --git a/Tophat/Utilities/Notifications.swift b/Tophat/Utilities/Notifications.swift index bd25449..ca73768 100644 --- a/Tophat/Utilities/Notifications.swift +++ b/Tophat/Utilities/Notifications.swift @@ -28,7 +28,7 @@ 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() @@ -36,6 +36,17 @@ enum Notifications { 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) + } } } diff --git a/Tophat/Utilities/RemoteControlReceiver.swift b/Tophat/Utilities/RemoteControlReceiver.swift index 4cbc124..3593c9d 100644 --- a/Tophat/Utilities/RemoteControlReceiver.swift +++ b/Tophat/Utilities/RemoteControlReceiver.swift @@ -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) { @@ -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))) } } } @@ -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))) } } } @@ -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))) } } } diff --git a/Tophat/Views/Generic/ErrorDetailPanel.swift b/Tophat/Views/Generic/ErrorDetailPanel.swift new file mode 100644 index 0000000..fc7b23d --- /dev/null +++ b/Tophat/Views/Generic/ErrorDetailPanel.swift @@ -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) + } +} diff --git a/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift b/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift index 8981d97..2a610df 100644 --- a/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift +++ b/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift @@ -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) } } @@ -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) } } diff --git a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift index 2a96b77..f791ccb 100644 --- a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift +++ b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift @@ -76,7 +76,7 @@ extension ProxyVirtualDevice: Device { await connectedDeviceStore.update(serial: serial) } catch { - throw DeviceError.failedToBoot + throw DiagnosticError(DeviceError.failedToBoot, technicalDetails: error.shellErrorDiagnosticMessage) } guard await connectedDeviceStore.connectedDevice != nil else { diff --git a/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift b/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift index c16af2f..acb1064 100644 --- a/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift +++ b/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift @@ -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) } } @@ -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 ) } } diff --git a/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift b/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift index 5e70222..934807a 100644 --- a/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift +++ b/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift @@ -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) } } @@ -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) } } @@ -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) } } diff --git a/TophatModules/Sources/ShellKit/Extensions/Error+ShellErrorDiagnosticMessage.swift b/TophatModules/Sources/ShellKit/Extensions/Error+ShellErrorDiagnosticMessage.swift new file mode 100644 index 0000000..5f80a38 --- /dev/null +++ b/TophatModules/Sources/ShellKit/Extensions/Error+ShellErrorDiagnosticMessage.swift @@ -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 + } +} diff --git a/TophatModules/Sources/TophatFoundation/DiagnosticError.swift b/TophatModules/Sources/TophatFoundation/DiagnosticError.swift new file mode 100644 index 0000000..cd34c94 --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/DiagnosticError.swift @@ -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 + } +} From 245939a0038a5df155179ea37709a6183a2d044f Mon Sep 17 00:00:00 2001 From: Lukas Romsicki Date: Wed, 11 Mar 2026 22:34:38 -0400 Subject: [PATCH 2/2] Improve error handling while waiting for emulator boot --- .../Extensions/ShellCommand+Adb.swift | 2 +- .../AndroidDeviceKit/ProxyVirtualDevice.swift | 2 +- .../AndroidDeviceKit/Utilities/Adb.swift | 25 +++++++++++++++++-- .../Extensions/Process+LaunchBash.swift | 11 ++++++++ .../Sources/ShellKit/ShellCommandRunner.swift | 2 ++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/TophatModules/Sources/AndroidDeviceKit/Extensions/ShellCommand+Adb.swift b/TophatModules/Sources/AndroidDeviceKit/Extensions/ShellCommand+Adb.swift index 1bf7e7c..16cc582 100644 --- a/TophatModules/Sources/AndroidDeviceKit/Extensions/ShellCommand+Adb.swift +++ b/TophatModules/Sources/AndroidDeviceKit/Extensions/ShellCommand+Adb.swift @@ -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] diff --git a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift index f791ccb..729130a 100644 --- a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift +++ b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift @@ -72,7 +72,7 @@ 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 { diff --git a/TophatModules/Sources/AndroidDeviceKit/Utilities/Adb.swift b/TophatModules/Sources/AndroidDeviceKit/Utilities/Adb.swift index 48cedc3..b6e1dbc 100644 --- a/TophatModules/Sources/AndroidDeviceKit/Utilities/Adb.swift +++ b/TophatModules/Sources/AndroidDeviceKit/Utilities/Adb.swift @@ -11,6 +11,7 @@ import RegexBuilder import ShellKit final class AdbError: Error {} +final class AdbBootTimedOutError: Error {} struct Adb { static func listDevices() -> [ConnectedDevice] { @@ -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() + } } private static func firstLine(of command: ShellCommand) throws -> String? { diff --git a/TophatModules/Sources/ShellKit/Extensions/Process+LaunchBash.swift b/TophatModules/Sources/ShellKit/Extensions/Process+LaunchBash.swift index 350c2ba..78a7d92 100644 --- a/TophatModules/Sources/ShellKit/Extensions/Process+LaunchBash.swift +++ b/TophatModules/Sources/ShellKit/Extensions/Process+LaunchBash.swift @@ -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 { @@ -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 diff --git a/TophatModules/Sources/ShellKit/ShellCommandRunner.swift b/TophatModules/Sources/ShellKit/ShellCommandRunner.swift index db062e2..b52830d 100644 --- a/TophatModules/Sources/ShellKit/ShellCommandRunner.swift +++ b/TophatModules/Sources/ShellKit/ShellCommandRunner.swift @@ -20,6 +20,7 @@ import Logging @discardableResult public func run( command: ShellCommand, + timeout: TimeInterval? = nil, log: Logger? = nil, standardOutputHandler: StandardOutputHandler? = nil, standardErrorHandler: StandardErrorHandler? = nil @@ -31,6 +32,7 @@ public func run( do { return try process.launchBash( command: command.string, + timeout: timeout, standardOutputHandler: standardOutputHandler, standardErrorHandler: standardErrorHandler )