Skip to content

Commit 24714d5

Browse files
authored
Forward errors to CLI when using tophatctl (#123)
1 parent 94f613a commit 24714d5

10 files changed

Lines changed: 177 additions & 69 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// QuickLaunchEntry+InstallRecipe.swift
3+
// Tophat
4+
//
5+
// Created by Lukas Romsicki on 2026-03-09.
6+
// Copyright © 2026 Shopify. All rights reserved.
7+
//
8+
9+
import TophatFoundation
10+
11+
extension QuickLaunchEntry {
12+
var installRecipes: [InstallRecipe] {
13+
recipes.map { source in
14+
InstallRecipe(
15+
source: .artifactProvider(
16+
metadata: ArtifactProviderMetadata(
17+
id: source.artifactProviderID,
18+
parameters: source.artifactProviderParameters
19+
)
20+
),
21+
launchArguments: source.launchArguments,
22+
deviceInfo: .hinted(InstallRecipe.DeviceHints(
23+
platformHint: source.platformHint,
24+
destinationHint: source.destinationHint
25+
))
26+
)
27+
}
28+
}
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// URL+ArtifactSource.swift
3+
// Tophat
4+
//
5+
// Created by Lukas Romsicki on 2026-03-09.
6+
// Copyright © 2026 Shopify. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import TophatFoundation
11+
12+
extension URL {
13+
var artifactSource: ArtifactSource {
14+
if isFileURL {
15+
.file(url: self)
16+
} else {
17+
.artifactProvider(
18+
metadata: ArtifactProviderMetadata(
19+
id: "http",
20+
parameters: ["url": absoluteString]
21+
)
22+
)
23+
}
24+
}
25+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// QuickLaunchEntryNotFoundError.swift
3+
// Tophat
4+
//
5+
// Created by Lukas Romsicki on 2026-03-09.
6+
// Copyright © 2026 Shopify. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
struct QuickLaunchEntryNotFoundError: LocalizedError {
12+
let identifier: String
13+
14+
var errorDescription: String? {
15+
"App Not Found"
16+
}
17+
18+
var failureReason: String? {
19+
"No app with identifier \"\(identifier)\" exists."
20+
}
21+
22+
var recoverySuggestion: String? {
23+
"Verify the identifier and try again."
24+
}
25+
}

Tophat/TophatApp.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -330,24 +330,29 @@ extension AppDelegate: RemoteControlReceiverDelegate {
330330
}
331331
}
332332

333-
func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async {
334-
await launchApp(artifactURL: url, launchArguments: launchArguments)
333+
func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async throws {
334+
try await installCoordinator.install(
335+
recipes: [InstallRecipe(source: url.artifactSource, launchArguments: launchArguments)]
336+
)
335337
}
336338

337-
func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async {
338-
await launchApp(recipes: recipes)
339+
func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async throws {
340+
try await installCoordinator.install(recipes: recipes)
339341
}
340342

341-
func remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) async {
342-
let context = ModelContext(modelContainer)
343+
func remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) async throws {
344+
let modelContext = ModelContext(modelContainer)
343345

344346
let fetchDescriptor = FetchDescriptor<QuickLaunchEntry>(
345347
predicate: #Predicate { $0.id == quickLaunchEntryIdentifier }
346348
)
347349

348-
if let entry = try? context.fetch(fetchDescriptor).first {
349-
await launchApp(quickLaunchEntry: entry)
350+
guard let entry = try modelContext.fetch(fetchDescriptor).first else {
351+
throw QuickLaunchEntryNotFoundError(identifier: quickLaunchEntryIdentifier)
350352
}
353+
354+
let operationContext = OperationContext(quickLaunchEntryID: entry.id, applicationDisplayName: entry.name)
355+
try await installCoordinator.install(recipes: entry.installRecipes, context: operationContext)
351356
}
352357
}
353358

Tophat/Utilities/ErrorNotifier.swift

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,14 @@ import TophatFoundation
1212

1313
final class ErrorNotifier {
1414
func notify(error: Error) {
15-
let localizedError = error as? LocalizedError
16-
let styledError = error as? StyledAlertError
17-
18-
alertInBackground(
19-
title: localizedError?.errorDescription,
20-
style: styledError?.alertStyle,
21-
content: [localizedError?.failureReason, localizedError?.recoverySuggestion].joinedWithSpaces()
22-
)
23-
}
24-
25-
private func alertInBackground(title: String?, style: NSAlert.Style?, content: String) {
26-
let contentIsEmpty = content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
27-
2815
Task.detached {
16+
let styledError = error as? StyledAlertError
17+
let formatted = FormattedError(error)
18+
2919
await Notifications.alert(
30-
title: title ?? "An unexpected error has occurred",
31-
content: contentIsEmpty ? "The application could not be installed due to an unexpected error. Please try again." : content,
32-
style: style ?? .critical,
20+
title: formatted.title,
21+
content: formatted.detail,
22+
style: styledError?.alertStyle ?? .critical,
3323
buttonText: "Dismiss"
3424
)
3525
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// FormattedError.swift
3+
// Tophat
4+
//
5+
// Created by Lukas Romsicki on 2026-03-09.
6+
// Copyright © 2026 Shopify. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
struct FormattedError: CustomStringConvertible {
12+
let title: String
13+
let detail: String
14+
15+
var description: String {
16+
let punctuatedTitle = title.last?.isPunctuation == true ? title : "\(title)."
17+
return "\(punctuatedTitle) \(detail)"
18+
}
19+
20+
init(_ error: Error) {
21+
let localizedError = error as? LocalizedError
22+
23+
self.title = localizedError?.errorDescription ?? "An unexpected error has occurred"
24+
25+
let content = [localizedError?.failureReason, localizedError?.recoverySuggestion].joinedWithSpaces()
26+
let contentIsEmpty = content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
27+
28+
self.detail = contentIsEmpty
29+
? "The application could not be installed due to an unexpected error. Please try again."
30+
: content
31+
}
32+
}

Tophat/Utilities/LaunchAppAction.swift

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,12 @@ struct LaunchAppAction {
1818

1919
func callAsFunction(quickLaunchEntry entry: QuickLaunchEntry) async {
2020
let context = OperationContext(quickLaunchEntryID: entry.id, applicationDisplayName: entry.name)
21-
22-
let recipes = entry.recipes.map { source in
23-
let deviceHints = InstallRecipe.DeviceHints(
24-
platformHint: source.platformHint,
25-
destinationHint: source.destinationHint
26-
)
27-
28-
return InstallRecipe(
29-
source: .artifactProvider(
30-
metadata: ArtifactProviderMetadata(
31-
id: source.artifactProviderID,
32-
parameters: source.artifactProviderParameters
33-
)
34-
),
35-
launchArguments: source.launchArguments,
36-
deviceInfo: .hinted(deviceHints)
37-
)
38-
}
39-
40-
await callAsFunction(recipes: recipes, context: context)
21+
await callAsFunction(recipes: entry.installRecipes, context: context)
4122
}
4223

4324
func callAsFunction(artifactURL: URL, launchArguments: [String] = [], context: OperationContext? = nil) async {
44-
let source: ArtifactSource = if artifactURL.isFileURL {
45-
.file(url: artifactURL)
46-
} else {
47-
.artifactProvider(
48-
metadata: ArtifactProviderMetadata(
49-
id: "http",
50-
parameters: ["url": artifactURL.absoluteString]
51-
)
52-
)
53-
}
54-
5525
await callAsFunction(
56-
recipes: [InstallRecipe(source: source, launchArguments: launchArguments)],
26+
recipes: [InstallRecipe(source: artifactURL.artifactSource, launchArguments: launchArguments)],
5727
context: context
5828
)
5929
}

Tophat/Utilities/RemoteControlReceiver.swift

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import TophatControlServices
1515
protocol RemoteControlReceiverDelegate: AnyObject, Sendable {
1616
func remoteControlReceiver(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry)
1717
func remoteControlReceiver(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID)
18-
func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async
19-
func remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) async
20-
func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async
18+
func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async throws
19+
func remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) async throws
20+
func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async throws
2121
}
2222

2323
struct RemoteControlReceiver {
@@ -35,8 +35,12 @@ struct RemoteControlReceiver {
3535
for await request in service.requests(for: InstallFromURLRequest.self) {
3636
let requestValue = request.value
3737

38-
await delegate.remoteControlReceiver(didOpenURL: requestValue.url, launchArguments: requestValue.launchArguments)
39-
request.reply(.init())
38+
do {
39+
try await delegate.remoteControlReceiver(didOpenURL: requestValue.url, launchArguments: requestValue.launchArguments)
40+
request.reply(.init())
41+
} catch {
42+
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
43+
}
4044
}
4145
}
4246

@@ -70,8 +74,12 @@ struct RemoteControlReceiver {
7074
)
7175
}
7276

73-
await delegate.remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes: recipes)
74-
request.reply(.init())
77+
do {
78+
try await delegate.remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes: recipes)
79+
request.reply(.init())
80+
} catch {
81+
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
82+
}
7583
}
7684
}
7785

@@ -160,8 +168,12 @@ struct RemoteControlReceiver {
160168

161169
Task {
162170
for await request in service.requests(for: InstallFromQuickLaunchRequest.self) {
163-
await delegate.remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier: request.value.quickLaunchEntryID)
164-
request.reply(.init())
171+
do {
172+
try await delegate.remoteControlReceiver(didReceiveRequestToLaunchQuickLaunchEntryWithIdentifier: request.value.quickLaunchEntryID)
173+
request.reply(.init())
174+
} catch {
175+
request.reply(.init(errorMessage: String(describing: FormattedError(error))))
176+
}
165177
}
166178
}
167179
}

TophatCtl/Commands/Install.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,13 @@ struct Install: AsyncParsableCommand {
6464
urlParsedAsArgument.isFileURL ? urlParsedAsArgument.pathExtension != "" : scheme.hasPrefix("http")
6565
else {
6666
try assertLaunchArgumentsEmpty()
67-
try await service.send(request: InstallFromQuickLaunchRequest(quickLaunchEntryID: idOrPath), timeout: 60)
67+
let reply = try await service.send(request: InstallFromQuickLaunchRequest(quickLaunchEntryID: idOrPath), timeout: 60)
68+
69+
if let errorMessage = reply.errorMessage {
70+
print(errorMessage)
71+
throw ExitCode.failure
72+
}
73+
6874
return
6975
}
7076

@@ -83,7 +89,13 @@ struct Install: AsyncParsableCommand {
8389
}
8490

8591
let request = InstallFromRecipesRequest(recipes: recipes)
86-
try await service.send(request: request, timeout: 60)
92+
let reply = try await service.send(request: request, timeout: 60)
93+
94+
if let errorMessage = reply.errorMessage {
95+
print(errorMessage)
96+
throw ExitCode.failure
97+
}
98+
8799
return
88100
}
89101

@@ -92,7 +104,11 @@ struct Install: AsyncParsableCommand {
92104
launchArguments: launchArguments
93105
)
94106

95-
try await service.send(request: request, timeout: 60)
107+
let reply = try await service.send(request: request, timeout: 60)
108+
if let errorMessage = reply.errorMessage {
109+
print(errorMessage)
110+
throw ExitCode.failure
111+
}
96112
}
97113

98114
private func assertLaunchArgumentsEmpty() throws {

TophatModules/Sources/TophatControlServices/Requests/InstallationRequestReply.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@
77
//
88

99
public struct InstallationRequestReply: Codable, Sendable {
10-
public init() {}
10+
public let errorMessage: String?
11+
12+
public init(errorMessage: String? = nil) {
13+
self.errorMessage = errorMessage
14+
}
1115
}

0 commit comments

Comments
 (0)