From 9d16e454409a223e69ff26dfcc5949bd2d3dcd2e Mon Sep 17 00:00:00 2001 From: Lukas Romsicki <3951690+lfroms@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:48:54 -0500 Subject: [PATCH] Update `tophatctl` so that it can wait for a response from Tophat (#43) --- Tophat.xcodeproj/project.pbxproj | 28 ++--- Tophat/TophatApp.swift | 24 ++-- Tophat/Utilities/NotificationHandler.swift | 91 -------------- Tophat/Utilities/RemoteControlReceiver.swift | 96 +++++++++++++++ TophatCtl/Commands/Apps.swift | 2 +- TophatCtl/Commands/Apps/Apps+Add.swift | 14 +-- TophatCtl/Commands/Apps/Apps+Remove.swift | 8 +- TophatCtl/Commands/Install.swift | 37 +++--- TophatCtl/TophatCtl.swift | 2 +- TophatModules/Package.swift | 14 +-- .../TophatControlServices/Logging.swift | 11 ++ ...ecifiedQuickLaunchEntryConfiguration.swift | 4 +- .../UserSpecifiedRecipeConfiguration.swift | 5 +- .../Requests/AddQuickLaunchEntryRequest.swift | 21 ++++ .../Requests/InstallFromRecipesRequest.swift | 21 ++++ .../Requests/InstallFromURLRequest.swift | 23 ++++ .../Requests/InstallationRequestReply.swift | 11 ++ .../RemoveQuickLaunchEntryRequest.swift | 21 ++++ .../TophatRemoteControlReceivedRequest.swift | 27 +++++ .../TophatRemoteControlRequest.swift | 26 ++++ .../TophatRemoteControlService.swift | 113 ++++++++++++++++++ ...ophatAddQuickLaunchEntryNotification.swift | 28 ----- ...phatInstallConfigurationNotification.swift | 27 ----- .../TophatInstallURLNotification.swift | 29 ----- ...atRemoveQuickLaunchEntryNotification.swift | 27 ----- .../TophatInterProcessNotification.swift | 15 --- .../TophatInterProcessNotifier.swift | 44 ------- 27 files changed, 433 insertions(+), 336 deletions(-) delete mode 100644 Tophat/Utilities/NotificationHandler.swift create mode 100644 Tophat/Utilities/RemoteControlReceiver.swift create mode 100644 TophatModules/Sources/TophatControlServices/Logging.swift rename TophatModules/Sources/{TophatUtilities/Shared => TophatControlServices/Objects}/UserSpecifiedQuickLaunchEntryConfiguration.swift (82%) rename TophatModules/Sources/{TophatUtilities/Shared => TophatControlServices/Objects}/UserSpecifiedRecipeConfiguration.swift (73%) create mode 100644 TophatModules/Sources/TophatControlServices/Requests/AddQuickLaunchEntryRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/Requests/InstallFromRecipesRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/Requests/InstallFromURLRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/Requests/InstallationRequestReply.swift create mode 100644 TophatModules/Sources/TophatControlServices/Requests/RemoveQuickLaunchEntryRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/TophatRemoteControlReceivedRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/TophatRemoteControlRequest.swift create mode 100644 TophatModules/Sources/TophatControlServices/TophatRemoteControlService.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift delete mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift diff --git a/Tophat.xcodeproj/project.pbxproj b/Tophat.xcodeproj/project.pbxproj index 63b0241..80bbdb5 100644 --- a/Tophat.xcodeproj/project.pbxproj +++ b/Tophat.xcodeproj/project.pbxproj @@ -13,10 +13,10 @@ 8005282F2CB718C200226174 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8005282E2CB718C200226174 /* TophatKit */; }; 800528312CB718C700226174 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 800528302CB718C700226174 /* TophatKit */; }; 8006E7F02943D3090089805E /* VisualEffects in Frameworks */ = {isa = PBXBuildFile; productRef = 8006E7EF2943D3090089805E /* VisualEffects */; }; + 802635052CFE68720020D841 /* TophatControlServices in Frameworks */ = {isa = PBXBuildFile; productRef = 802635042CFE68720020D841 /* TophatControlServices */; }; + 802635072CFE6B930020D841 /* TophatControlServices in Frameworks */ = {isa = PBXBuildFile; productRef = 802635062CFE6B930020D841 /* TophatControlServices */; }; 80346F042BEBD527002F54BC /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 80346F032BEBD527002F54BC /* AsyncAlgorithms */; }; 803B874B290055C70062F070 /* AndroidDeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 803B874A290055C70062F070 /* AndroidDeviceKit */; }; - 805543CC2CB715EB004E1D18 /* TophatUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = 805543CB2CB715EB004E1D18 /* TophatUtilities */; }; - 805543CE2CB715F1004E1D18 /* TophatUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = 805543CD2CB715F1004E1D18 /* TophatUtilities */; }; 807D7B0F29835762007942B4 /* TophatFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 807D7B0E29835762007942B4 /* TophatFoundation */; }; 807D7B1629835795007942B4 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 807D7B1529835795007942B4 /* ArgumentParser */; }; 807D7B1A298357C6007942B4 /* tophatctl in Copy tophatctl */ = {isa = PBXBuildFile; fileRef = 807D7B0729835756007942B4 /* tophatctl */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -189,10 +189,10 @@ files = ( 80F74E252909E8EA0040F026 /* AppleDeviceKit in Frameworks */, B6AA44CF296DF8EF0017321C /* ZIPFoundation in Frameworks */, + 802635052CFE68720020D841 /* TophatControlServices in Frameworks */, 8006E7F02943D3090089805E /* VisualEffects in Frameworks */, 7F4532AD251A6C4700F2CFC8 /* Logging in Frameworks */, 8090E268296775BE003106B9 /* Collections in Frameworks */, - 805543CC2CB715EB004E1D18 /* TophatUtilities in Frameworks */, 80C18345290232D1008D3B80 /* TophatFoundation in Frameworks */, 80346F042BEBD527002F54BC /* AsyncAlgorithms in Frameworks */, 80DC0FD82C82225600E5C9EE /* Sparkle in Frameworks */, @@ -217,7 +217,7 @@ files = ( 807D7B1629835795007942B4 /* ArgumentParser in Frameworks */, 807D7B0F29835762007942B4 /* TophatFoundation in Frameworks */, - 805543CE2CB715F1004E1D18 /* TophatUtilities in Frameworks */, + 802635072CFE6B930020D841 /* TophatControlServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -305,8 +305,8 @@ B6AA44CE296DF8EF0017321C /* ZIPFoundation */, 80346F032BEBD527002F54BC /* AsyncAlgorithms */, 80DC0FD72C82225600E5C9EE /* Sparkle */, - 805543CB2CB715EB004E1D18 /* TophatUtilities */, 8005282E2CB718C200226174 /* TophatKit */, + 802635042CFE68720020D841 /* TophatControlServices */, ); productName = Tophat; productReference = 7F35024F24A5060500EE76EA /* Tophat.app */; @@ -352,7 +352,7 @@ packageProductDependencies = ( 807D7B0E29835762007942B4 /* TophatFoundation */, 807D7B1529835795007942B4 /* ArgumentParser */, - 805543CD2CB715F1004E1D18 /* TophatUtilities */, + 802635062CFE6B930020D841 /* TophatControlServices */, ); productName = tophatctl; productReference = 807D7B0729835756007942B4 /* tophatctl */; @@ -1141,6 +1141,14 @@ package = 8006E7EE2943D3090089805E /* XCRemoteSwiftPackageReference "VisualEffects" */; productName = VisualEffects; }; + 802635042CFE68720020D841 /* TophatControlServices */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatControlServices; + }; + 802635062CFE6B930020D841 /* TophatControlServices */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatControlServices; + }; 80346F032BEBD527002F54BC /* AsyncAlgorithms */ = { isa = XCSwiftPackageProductDependency; package = 80346F022BEBD527002F54BC /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; @@ -1150,14 +1158,6 @@ isa = XCSwiftPackageProductDependency; productName = AndroidDeviceKit; }; - 805543CB2CB715EB004E1D18 /* TophatUtilities */ = { - isa = XCSwiftPackageProductDependency; - productName = TophatUtilities; - }; - 805543CD2CB715F1004E1D18 /* TophatUtilities */ = { - isa = XCSwiftPackageProductDependency; - productName = TophatUtilities; - }; 807D7B0E29835762007942B4 /* TophatFoundation */ = { isa = XCSwiftPackageProductDependency; productName = TophatFoundation; diff --git a/Tophat/TophatApp.swift b/Tophat/TophatApp.swift index 6175986..284ae63 100644 --- a/Tophat/TophatApp.swift +++ b/Tophat/TophatApp.swift @@ -77,7 +77,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { let extensionHost = ExtensionHost() private let server = TophatServer() private let urlHandler = URLReader() - private let notificationHandler = NotificationHandler() + private let remoteControlReceiver = RemoteControlReceiver() let deviceManager: DeviceManager let utilityPathPreferences: UtilityPathPreferences @@ -138,7 +138,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { configureEventSubscriptions() self.server.delegate = self - self.notificationHandler.delegate = self + self.remoteControlReceiver.delegate = self self.taskStatusReporter.delegate = self } @@ -269,10 +269,10 @@ extension AppDelegate: TophatServerDelegate { } } -// MARK: - NotificationHandlerDelegate +// MARK: - RemoteControlReceiverDelegate -extension AppDelegate: NotificationHandlerDelegate { - func notificationHandler(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) { +extension AppDelegate: RemoteControlReceiverDelegate { + func remoteControlReceiver(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) { let context = ModelContext(modelContainer) let existingID = quickLaunchEntry.id @@ -302,7 +302,7 @@ extension AppDelegate: NotificationHandlerDelegate { } } - func notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) { + func remoteControlReceiver(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) { let context = ModelContext(modelContainer) do { @@ -315,16 +315,12 @@ extension AppDelegate: NotificationHandlerDelegate { } } - func notificationHandler(didOpenURL url: URL, launchArguments: [String]) { - Task { - await launchApp(artifactURL: url, launchArguments: launchArguments) - } + func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async { + await launchApp(artifactURL: url, launchArguments: launchArguments) } - func notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) { - Task { - await launchApp(recipes: recipes) - } + func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async { + await launchApp(recipes: recipes) } } diff --git a/Tophat/Utilities/NotificationHandler.swift b/Tophat/Utilities/NotificationHandler.swift deleted file mode 100644 index 553206d..0000000 --- a/Tophat/Utilities/NotificationHandler.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// NotificationHandler.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import Combine -import TophatFoundation -import TophatUtilities - -protocol NotificationHandlerDelegate: AnyObject { - func notificationHandler(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) - func notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) - func notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) - func notificationHandler(didOpenURL url: URL, launchArguments: [String]) -} - -final class NotificationHandler { - weak var delegate: NotificationHandlerDelegate? - - private let notifier = TophatInterProcessNotifier() - private var cancellables: Set = [] - - init() { - notifier - .publisher(for: TophatInstallURLNotification.self) - .sink { [weak self] payload in - self?.delegate?.notificationHandler(didOpenURL: payload.url, launchArguments: payload.launchArguments) - } - .store(in: &cancellables) - - notifier - .publisher(for: TophatInstallConfigurationNotification.self) - .sink { [weak self] payload in - let recipes = payload.installRecipes.map { recipe in - let artifactProviderMetadata = ArtifactProviderMetadata( - id: recipe.artifactProviderID, - parameters: recipe.artifactProviderParameters - ) - - return InstallRecipe( - source: .artifactProvider(metadata: artifactProviderMetadata), - launchArguments: recipe.launchArguments, - platformHint: recipe.platformHint, - destinationHint: recipe.destinationHint - ) - } - - self?.delegate?.notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes: recipes) - } - .store(in: &cancellables) - - notifier - .publisher(for: TophatAddQuickLaunchEntryNotification.self) - .sink { [weak self] payload in - let configuration = payload.configuration - - let quickLaunchEntry = QuickLaunchEntry( - id: configuration.id, - name: configuration.name, - recipes: configuration.recipes.map { source in - let artifactProviderMetadata = ArtifactProviderMetadata( - id: source.artifactProviderID, - parameters: source.artifactProviderParameters - ) - - return QuickLaunchEntryRecipe( - artifactProviderID: artifactProviderMetadata.id, - artifactProviderParameters: artifactProviderMetadata.parameters, - launchArguments: source.launchArguments, - platformHint: source.platformHint, - destinationHint: source.destinationHint - ) - } - ) - - self?.delegate?.notificationHandler(didReceiveRequestToAddQuickLaunchEntry: quickLaunchEntry) - } - .store(in: &cancellables) - - notifier - .publisher(for: TophatRemoveQuickLaunchEntryNotification.self) - .sink { [weak self] payload in - self?.delegate?.notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier: payload.id) - } - .store(in: &cancellables) - } -} diff --git a/Tophat/Utilities/RemoteControlReceiver.swift b/Tophat/Utilities/RemoteControlReceiver.swift new file mode 100644 index 0000000..56d6894 --- /dev/null +++ b/Tophat/Utilities/RemoteControlReceiver.swift @@ -0,0 +1,96 @@ +// +// RemoteControlReceiver.swift +// Tophat +// +// Created by Lukas Romsicki on 2023-01-27. +// Copyright © 2023 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation +import TophatControlServices + +protocol RemoteControlReceiverDelegate: AnyObject { + func remoteControlReceiver(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) + func remoteControlReceiver(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) + func remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) async + func remoteControlReceiver(didOpenURL url: URL, launchArguments: [String]) async +} + +final class RemoteControlReceiver { + weak var delegate: RemoteControlReceiverDelegate? + + private let service = TophatRemoteControlService() + + init() { + Task { + for await request in service.requests(for: InstallFromURLRequest.self) { + let requestValue = request.value + + await delegate?.remoteControlReceiver(didOpenURL: requestValue.url, launchArguments: requestValue.launchArguments) + request.reply(.init()) + } + } + + Task { + for await request in service.requests(for: InstallFromRecipesRequest.self) { + let requestValue = request.value + + let recipes = requestValue.recipes.map { recipe in + let artifactProviderMetadata = ArtifactProviderMetadata( + id: recipe.artifactProviderID, + parameters: recipe.artifactProviderParameters + ) + + return InstallRecipe( + source: .artifactProvider(metadata: artifactProviderMetadata), + launchArguments: recipe.launchArguments, + platformHint: recipe.platformHint, + destinationHint: recipe.destinationHint + ) + } + + await delegate?.remoteControlReceiver(didReceiveRequestToLaunchApplicationWithRecipes: recipes) + request.reply(.init()) + } + } + + Task { + for await request in service.requests(for: AddQuickLaunchEntryRequest.self) { + let requestValue = request.value + + let configuration = requestValue.configuration + + let quickLaunchEntry = QuickLaunchEntry( + id: configuration.id, + name: configuration.name, + recipes: configuration.recipes.map { source in + let artifactProviderMetadata = ArtifactProviderMetadata( + id: source.artifactProviderID, + parameters: source.artifactProviderParameters + ) + + return QuickLaunchEntryRecipe( + artifactProviderID: artifactProviderMetadata.id, + artifactProviderParameters: artifactProviderMetadata.parameters, + launchArguments: source.launchArguments, + platformHint: source.platformHint, + destinationHint: source.destinationHint + ) + } + ) + + delegate?.remoteControlReceiver(didReceiveRequestToAddQuickLaunchEntry: quickLaunchEntry) + } + } + + Task { + for await request in service.requests(for: RemoveQuickLaunchEntryRequest.self) { + let requestValue = request.value + delegate?.remoteControlReceiver( + didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier: requestValue.quickLaunchEntryID + ) + } + } + } +} diff --git a/TophatCtl/Commands/Apps.swift b/TophatCtl/Commands/Apps.swift index 0aa40ec..f40a285 100644 --- a/TophatCtl/Commands/Apps.swift +++ b/TophatCtl/Commands/Apps.swift @@ -8,7 +8,7 @@ import ArgumentParser -struct Apps: ParsableCommand { +struct Apps: AsyncParsableCommand { static var configuration = CommandConfiguration( abstract: "Adds, removes, or modifies Quick Launch entries.", subcommands: [ diff --git a/TophatCtl/Commands/Apps/Apps+Add.swift b/TophatCtl/Commands/Apps/Apps+Add.swift index 6a94f23..30997a4 100644 --- a/TophatCtl/Commands/Apps/Apps+Add.swift +++ b/TophatCtl/Commands/Apps/Apps+Add.swift @@ -9,11 +9,11 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatUtilities +import TophatControlServices import AppKit extension Apps { - struct Add: ParsableCommand { + struct Add: AsyncParsableCommand { static var configuration = CommandConfiguration( abstract: "Adds a new application to Quick Launch.", discussion: "If an existing item with the same identifier already exists, the item will be updated with new information." @@ -22,7 +22,7 @@ extension Apps { @Argument(help: "The path to the configuration file for the app.") var path: URL - func run() throws { + func run() async throws { if !NSRunningApplication.isTophatRunning { print("Warning: Tophat must be running for this command to succeed, but it is not running.") } @@ -30,12 +30,8 @@ extension Apps { let data = try Data(contentsOf: path) let configuration = try JSONDecoder().decode(UserSpecifiedQuickLaunchEntryConfiguration.self, from: data) - let payload = TophatAddQuickLaunchEntryNotification.Payload( - configuration: configuration - ) - - let notification = TophatAddQuickLaunchEntryNotification(payload: payload) - TophatInterProcessNotifier().send(notification: notification) + let request = AddQuickLaunchEntryRequest(configuration: configuration) + try TophatRemoteControlService().send(request: request) } } } diff --git a/TophatCtl/Commands/Apps/Apps+Remove.swift b/TophatCtl/Commands/Apps/Apps+Remove.swift index 27b77d5..fa9c380 100644 --- a/TophatCtl/Commands/Apps/Apps+Remove.swift +++ b/TophatCtl/Commands/Apps/Apps+Remove.swift @@ -9,11 +9,11 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatUtilities +import TophatControlServices import AppKit extension Apps { - struct Remove: ParsableCommand { + struct Remove: AsyncParsableCommand { static var configuration = CommandConfiguration( abstract: "Removes an application from Quick Launch." ) @@ -26,8 +26,8 @@ extension Apps { print("Warning: Tophat must be running for this command to succeed, but it is not running.") } - let notification = TophatRemoveQuickLaunchEntryNotification(payload: .init(id: id)) - TophatInterProcessNotifier().send(notification: notification) + let request = RemoveQuickLaunchEntryRequest(quickLaunchEntryID: id) + try TophatRemoteControlService().send(request: request) } } } diff --git a/TophatCtl/Commands/Install.swift b/TophatCtl/Commands/Install.swift index ad64938..030b139 100644 --- a/TophatCtl/Commands/Install.swift +++ b/TophatCtl/Commands/Install.swift @@ -9,9 +9,9 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatUtilities +import TophatControlServices -struct Install: ParsableCommand { +struct Install: AsyncParsableCommand { static var configuration = CommandConfiguration( abstract: "Installs an application.", discussion: "This command infers platform and build type after the artifact has been downloaded. It is ideal for local artifacts that don't take any time to download." @@ -26,7 +26,7 @@ struct Install: ParsableCommand { @Option(parsing: .upToNextOption, help: "Arguments to pass to the application on launch when using --url.") var launchArguments: [String] = [] - func run() throws { + func run() async throws { guard url != nil || configuration != nil else { throw ValidationError("You must specify one of --url or --configuration.") } @@ -39,28 +39,25 @@ struct Install: ParsableCommand { throw ValidationError("--launch-arguments can only be used with --url. When using --configuration, launch arguments are specified in the configuration file.") } - let notification: (any TophatInterProcessNotification)? = if let url { - TophatInstallURLNotification( - payload: TophatInstallURLNotification.Payload( - url: url, - launchArguments: launchArguments - ) + let service = TophatRemoteControlService() + + if let url { + let request = InstallFromURLRequest( + url: url, + launchArguments: launchArguments ) + + try await service.send(request: request, timeout: 60) + } else if let configuration { - TophatInstallConfigurationNotification( - payload: TophatInstallConfigurationNotification.Payload( - installRecipes: try JSONDecoder().decode( - [UserSpecifiedRecipeConfiguration].self, - from: Data(contentsOf: configuration) - ) + let request = InstallFromRecipesRequest( + recipes: try JSONDecoder().decode( + [UserSpecifiedRecipeConfiguration].self, + from: Data(contentsOf: configuration) ) ) - } else { - nil - } - if let notification { - TophatInterProcessNotifier().send(notification: notification) + try await service.send(request: request, timeout: 60) } } } diff --git a/TophatCtl/TophatCtl.swift b/TophatCtl/TophatCtl.swift index 5f29030..821d75d 100644 --- a/TophatCtl/TophatCtl.swift +++ b/TophatCtl/TophatCtl.swift @@ -9,7 +9,7 @@ import ArgumentParser @main -struct TophatCtl: ParsableCommand { +struct TophatCtl: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "tophatctl", abstract: "A utility for interacting with Tophat from command line applications.", diff --git a/TophatModules/Package.swift b/TophatModules/Package.swift index 8d9e1be..d70c116 100644 --- a/TophatModules/Package.swift +++ b/TophatModules/Package.swift @@ -1,18 +1,18 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.10 import PackageDescription let package = Package( name: "TophatModules", platforms: [ - .macOS(.v13) + .macOS(.v14) ], products: [ .library(name: "AndroidDeviceKit", targets: ["AndroidDeviceKit"]), .library(name: "AppleDeviceKit", targets: ["AppleDeviceKit"]), .library(name: "ShellKit", targets: ["ShellKit"]), + .library(name: "TophatControlServices", targets: ["TophatControlServices"]), .library(name: "TophatFoundation", targets: ["TophatFoundation"]), - .library(name: "TophatUtilities", targets: ["TophatUtilities"]), .library(name: "TophatServer", targets: ["TophatServer"]) ], dependencies: [ @@ -42,13 +42,11 @@ let package = Package( .product(name: "Logging", package: "swift-log") ] ), - .target(name: "TophatFoundation"), .target( - name: "TophatUtilities", - dependencies: [ - .target(name: "TophatFoundation") - ] + name: "TophatControlServices", + dependencies: [.product(name: "Logging", package: "swift-log")] ), + .target(name: "TophatFoundation"), .target( name: "TophatServer", dependencies: [ diff --git a/TophatModules/Sources/TophatControlServices/Logging.swift b/TophatModules/Sources/TophatControlServices/Logging.swift new file mode 100644 index 0000000..ee1408b --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Logging.swift @@ -0,0 +1,11 @@ +// +// Logging.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Logging + +let log = Logger(label: "TophatControlServices") diff --git a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift b/TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedQuickLaunchEntryConfiguration.swift similarity index 82% rename from TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift rename to TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedQuickLaunchEntryConfiguration.swift index e936717..4fd92ec 100644 --- a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift +++ b/TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedQuickLaunchEntryConfiguration.swift @@ -1,8 +1,8 @@ // // UserSpecifiedQuickLaunchEntryConfiguration.swift -// TophatUtilities +// TophatControlServices // -// Created by Lukas Romsicki on 2024-11-21. +// Created by Lukas Romsicki on 2024-12-02. // Copyright © 2024 Shopify. All rights reserved. // diff --git a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedRecipeConfiguration.swift b/TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedRecipeConfiguration.swift similarity index 73% rename from TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedRecipeConfiguration.swift rename to TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedRecipeConfiguration.swift index eff72aa..05e5aee 100644 --- a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedRecipeConfiguration.swift +++ b/TophatModules/Sources/TophatControlServices/Objects/UserSpecifiedRecipeConfiguration.swift @@ -1,8 +1,9 @@ // // UserSpecifiedRecipeConfiguration.swift -// TophatUtilities +// TophatControlServices // -// Created by Lukas Romsicki on 2024-11-21. +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. // import TophatFoundation diff --git a/TophatModules/Sources/TophatControlServices/Requests/AddQuickLaunchEntryRequest.swift b/TophatModules/Sources/TophatControlServices/Requests/AddQuickLaunchEntryRequest.swift new file mode 100644 index 0000000..04fe61b --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Requests/AddQuickLaunchEntryRequest.swift @@ -0,0 +1,21 @@ +// +// AddQuickLaunchEntryRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct AddQuickLaunchEntryRequest: TophatRemoteControlRequest { + public typealias Reply = Never + + public let id: UUID + public let configuration: UserSpecifiedQuickLaunchEntryConfiguration + + public init(configuration: UserSpecifiedQuickLaunchEntryConfiguration) { + self.id = UUID() + self.configuration = configuration + } +} diff --git a/TophatModules/Sources/TophatControlServices/Requests/InstallFromRecipesRequest.swift b/TophatModules/Sources/TophatControlServices/Requests/InstallFromRecipesRequest.swift new file mode 100644 index 0000000..749df9f --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Requests/InstallFromRecipesRequest.swift @@ -0,0 +1,21 @@ +// +// InstallFromRecipesRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct InstallFromRecipesRequest: TophatRemoteControlRequest { + public typealias Reply = InstallationRequestReply + + public let id: UUID + public let recipes: [UserSpecifiedRecipeConfiguration] + + public init(recipes: [UserSpecifiedRecipeConfiguration]) { + self.id = UUID() + self.recipes = recipes + } +} diff --git a/TophatModules/Sources/TophatControlServices/Requests/InstallFromURLRequest.swift b/TophatModules/Sources/TophatControlServices/Requests/InstallFromURLRequest.swift new file mode 100644 index 0000000..421b4db --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Requests/InstallFromURLRequest.swift @@ -0,0 +1,23 @@ +// +// InstallFromURLRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct InstallFromURLRequest: TophatRemoteControlRequest { + public typealias Reply = InstallationRequestReply + + public let id: UUID + public let url: URL + public let launchArguments: [String] + + public init(url: URL, launchArguments: [String]) { + self.id = UUID() + self.url = url + self.launchArguments = launchArguments + } +} diff --git a/TophatModules/Sources/TophatControlServices/Requests/InstallationRequestReply.swift b/TophatModules/Sources/TophatControlServices/Requests/InstallationRequestReply.swift new file mode 100644 index 0000000..5c77ce6 --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Requests/InstallationRequestReply.swift @@ -0,0 +1,11 @@ +// +// InstallationRequestReply.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +public struct InstallationRequestReply: Codable { + public init() {} +} diff --git a/TophatModules/Sources/TophatControlServices/Requests/RemoveQuickLaunchEntryRequest.swift b/TophatModules/Sources/TophatControlServices/Requests/RemoveQuickLaunchEntryRequest.swift new file mode 100644 index 0000000..bd489fa --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/Requests/RemoveQuickLaunchEntryRequest.swift @@ -0,0 +1,21 @@ +// +// RemoveQuickLaunchEntryRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct RemoveQuickLaunchEntryRequest: TophatRemoteControlRequest { + public typealias Reply = Never + + public let id: UUID + public let quickLaunchEntryID: String + + public init(quickLaunchEntryID: String) { + self.id = UUID() + self.quickLaunchEntryID = quickLaunchEntryID + } +} diff --git a/TophatModules/Sources/TophatControlServices/TophatRemoteControlReceivedRequest.swift b/TophatModules/Sources/TophatControlServices/TophatRemoteControlReceivedRequest.swift new file mode 100644 index 0000000..ae9d5c5 --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/TophatRemoteControlReceivedRequest.swift @@ -0,0 +1,27 @@ +// +// TophatRemoteControlReceivedRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct TophatRemoteControlReceivedRequest { + public let value: T + + public func reply(_ reply: T.Reply) { + guard let userInfo = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(reply)) as? [AnyHashable: Any] else { + log.warning("[TophatRemoteControlReceivedRequest] Warning: The reply data cannot be represented as JSON! It will not be sent.") + return + } + + DistributedNotificationCenter.default().postNotificationName( + .init(type(of: value).replyNotificationName), + object: value.id.uuidString, + userInfo: userInfo, + deliverImmediately: true + ) + } +} diff --git a/TophatModules/Sources/TophatControlServices/TophatRemoteControlRequest.swift b/TophatModules/Sources/TophatControlServices/TophatRemoteControlRequest.swift new file mode 100644 index 0000000..da22509 --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/TophatRemoteControlRequest.swift @@ -0,0 +1,26 @@ +// +// TophatRemoteControlRequest.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +/// The protocol you use to define notifications to be sent between Tophat processes. +public protocol TophatRemoteControlRequest: Codable { + associatedtype Reply: Codable + + var id: UUID { get } +} + +extension TophatRemoteControlRequest { + static var notificationName: String { + "Tophat.\(String(describing: self))" + } + + static var replyNotificationName: String { + "\(notificationName).Reply" + } +} diff --git a/TophatModules/Sources/TophatControlServices/TophatRemoteControlService.swift b/TophatModules/Sources/TophatControlServices/TophatRemoteControlService.swift new file mode 100644 index 0000000..39deb3d --- /dev/null +++ b/TophatModules/Sources/TophatControlServices/TophatRemoteControlService.swift @@ -0,0 +1,113 @@ +// +// TophatRemoteControlService.swift +// TophatControlServices +// +// Created by Lukas Romsicki on 2024-12-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public final class TophatRemoteControlService { + private let notificationCenter = DistributedNotificationCenter.default() + + public init() {} + + public func send(request: T) throws where T.Reply == Never { + try sendAndForget(request: request) + } + + @discardableResult + public func send(request: T, timeout: TimeInterval = 10) async throws -> T.Reply { + try sendAndForget(request: request) + + let waitForReplyTask = Task { + let notification = await notificationCenter + .notifications(named: .init(type(of: request).replyNotificationName), object: nil) + .first { ($0.object as? String) == request.id.uuidString } + + guard let notification else { + throw TophatRemoteControlServiceError.replyNotReceived + } + + do { + let replyData = try JSONSerialization.data(withJSONObject: notification.userInfo as Any) + let reply = try JSONDecoder().decode(T.Reply.self, from: replyData) + + try Task.checkCancellation() + + return reply + + } catch { + throw TophatRemoteControlServiceError.invalidResponse + } + } + + let timeoutTask = Task { + try await Task.sleep(for: .seconds(timeout)) + waitForReplyTask.cancel() + } + + do { + let result = try await waitForReplyTask.value + timeoutTask.cancel() + return result + } catch { + throw TophatRemoteControlServiceError.replyTimedOut + } + } + + func sendAndForget(request: some TophatRemoteControlRequest) throws { + let userInfo = try JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any] + + notificationCenter.postNotificationName( + .init(type(of: request).notificationName), + object: nil, + userInfo: userInfo?.compactMapValues { $0 }, + deliverImmediately: true + ) + } + + public func requests( + for notificationType: T.Type + ) -> AsyncStream> { + let notifications = notificationCenter + .notifications(named: .init(notificationType.notificationName), object: nil) + + return AsyncStream { continuation in + let task = Task { + for await notification in notifications { + guard + let data = try? JSONSerialization.data(withJSONObject: notification.userInfo as Any), + let request = try? JSONDecoder().decode(T.self, from: data) + else { + continue + } + + continuation.yield(.init(value: request)) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +enum TophatRemoteControlServiceError: Error { + case replyNotReceived + case replyTimedOut + case invalidResponse +} + +extension TophatRemoteControlServiceError: LocalizedError { + var errorDescription: String? { + switch self { + case .replyTimedOut: + "The operation timed out." + default: + "An unexpected error occurred." + } + } +} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift deleted file mode 100644 index db57e5f..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// TophatAddQuickLaunchEntryNotification.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -public struct TophatAddQuickLaunchEntryNotification: TophatInterProcessNotification { - public static let name = "TophatAddQuickLaunchEntry" - - public struct Payload: Codable { - public let configuration: UserSpecifiedQuickLaunchEntryConfiguration - - public init(configuration: UserSpecifiedQuickLaunchEntryConfiguration) { - self.configuration = configuration - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift deleted file mode 100644 index 1ea1903..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TophatInstallConfigurationNotification.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2024-11-21. -// Copyright © 2024 Shopify. All rights reserved. -// - -import Foundation - -public struct TophatInstallConfigurationNotification: TophatInterProcessNotification { - public static let name = "TophatInstallConfiguration" - - public struct Payload: Codable { - public let installRecipes: [UserSpecifiedRecipeConfiguration] - - public init(installRecipes: [UserSpecifiedRecipeConfiguration]) { - self.installRecipes = installRecipes - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift deleted file mode 100644 index 53d7a0c..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// TophatInstallURLNotification.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation - -public struct TophatInstallURLNotification: TophatInterProcessNotification { - public static let name = "TophatInstallApplicationGeneric" - - public struct Payload: Codable { - public let url: URL - public let launchArguments: [String] - - public init(url: URL, launchArguments: [String]) { - self.url = url - self.launchArguments = launchArguments - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift deleted file mode 100644 index e51502f..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TophatRemoveQuickLaunchEntryNotification.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation - -public struct TophatRemoveQuickLaunchEntryNotification: TophatInterProcessNotification { - public static let name = "TophatRemoveQuickLaunchEntry" - - public struct Payload: Codable { - public let id: String - - public init(id: String) { - self.id = id - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift deleted file mode 100644 index e21e2b5..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// TophatInterProcessNotification.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -/// The protocol you use to define notifications to be sent between Tophat processes. -public protocol TophatInterProcessNotification { - associatedtype Payload: Codable - - static var name: String { get } - var payload: Payload { get } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift deleted file mode 100644 index db19af2..0000000 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// TophatInterProcessNotifier.swift -// TophatUtilities -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import Combine - -public final class TophatInterProcessNotifier { - private let notificationCenter = DistributedNotificationCenter.default() - - public init() {} - - public func send(notification: some TophatInterProcessNotification) { - let notificationName = type(of: notification).name - let userInfo = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(notification.payload)) as? [String: Any] - - notificationCenter.postNotificationName( - .init(notificationName), - object: nil, - userInfo: userInfo?.compactMapValues { $0 }, - deliverImmediately: true - ) - } - - public func publisher(for notificationType: any TophatInterProcessNotification.Type) -> AnyPublisher { - notificationCenter - .publisher(for: .init(notificationType.name), object: nil) - .compactMap { notification in - guard - let data = try? JSONSerialization.data(withJSONObject: notification.userInfo as Any), - let payload = try? JSONDecoder().decode(T.self, from: data) - else { - return nil - } - - return payload - } - .eraseToAnyPublisher() - } -}