commit 4ac999d25ef7da1a28293e5940822a937b570eb2
parent 6a782e7c4a85b37e5fe730a868339d40cd4a084e
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 14:50:23 -0700
external-actions: add Apple implementation
Diffstat:
2 files changed, 242 insertions(+), 0 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleExternalActions.swift b/Sources/RadrootsKit/RadrootsAppleExternalActions.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+#if canImport(AppKit)
+@preconcurrency import AppKit
+#endif
+
+#if canImport(UIKit)
+@preconcurrency import UIKit
+#endif
+
+public struct RadrootsAppleExternalActionsAdapters: Sendable {
+ public let appSettingsURL: @Sendable () async -> URL?
+ public let canOpenURL: @Sendable (URL) async -> Bool
+ public let openURL: @Sendable (URL) async -> Bool
+
+ public init(
+ appSettingsURL: @escaping @Sendable () async -> URL?,
+ canOpenURL: @escaping @Sendable (URL) async -> Bool,
+ openURL: @escaping @Sendable (URL) async -> Bool
+ ) {
+ self.appSettingsURL = appSettingsURL
+ self.canOpenURL = canOpenURL
+ self.openURL = openURL
+ }
+
+ public static var live: Self {
+ #if canImport(UIKit)
+ Self(
+ appSettingsURL: {
+ await MainActor.run {
+ URL(string: UIApplication.openSettingsURLString)
+ }
+ },
+ canOpenURL: { url in
+ await MainActor.run {
+ UIApplication.shared.canOpenURL(url)
+ }
+ },
+ openURL: { url in
+ await Self.openUIKitURL(url)
+ }
+ )
+ #elseif canImport(AppKit)
+ Self(
+ appSettingsURL: {
+ nil
+ },
+ canOpenURL: { url in
+ await MainActor.run {
+ NSWorkspace.shared.urlForApplication(toOpen: url) != nil
+ }
+ },
+ openURL: { url in
+ await MainActor.run {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ )
+ #else
+ Self(
+ appSettingsURL: {
+ nil
+ },
+ canOpenURL: { _ in
+ false
+ },
+ openURL: { _ in
+ false
+ }
+ )
+ #endif
+ }
+}
+
+public final class RadrootsAppleExternalActions: RadrootsExternalActions, Sendable {
+ private let adapters: RadrootsAppleExternalActionsAdapters
+
+ public init(adapters: RadrootsAppleExternalActionsAdapters = .live) {
+ self.adapters = adapters
+ }
+
+ public func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability {
+ let url = await resolvedURL(for: destination)
+ guard let url else {
+ return RadrootsExternalActionCapability(destination: destination, canOpen: false)
+ }
+ let canOpen = await adapters.canOpenURL(url)
+ return RadrootsExternalActionCapability(
+ destination: destination,
+ canOpen: canOpen
+ )
+ }
+
+ public func open(_ request: RadrootsExternalActionRequest) async throws {
+ guard let url = await resolvedURL(for: request.destination) else {
+ throw RadrootsExternalActionError.unavailable(
+ "\(request.destination.kind.rawValue) external action is unavailable"
+ )
+ }
+ let success = await adapters.openURL(url)
+ guard success else {
+ throw RadrootsExternalActionError.transientFailure(
+ "failed to open \(request.destination.kind.rawValue) external action"
+ )
+ }
+ }
+
+ private func resolvedURL(for destination: RadrootsExternalActionDestination) async -> URL? {
+ switch destination.kind {
+ case .appSettings:
+ await adapters.appSettingsURL()
+ case .web, .nostr, .appleMaps:
+ destination.url
+ }
+ }
+}
+
+#if canImport(UIKit)
+private extension RadrootsAppleExternalActionsAdapters {
+ @MainActor
+ static func openUIKitURL(_ url: URL) async -> Bool {
+ await withCheckedContinuation { continuation in
+ UIApplication.shared.open(url, options: [:]) { success in
+ continuation.resume(returning: success)
+ }
+ }
+ }
+}
+#endif
diff --git a/Tests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift b/Tests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift
@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func appleExternalActionsOpensAppSettingsThroughAdapter() async throws {
+ let settingsURL = try #require(URL(string: "app-settings:radroots"))
+ let probe = RadrootsExternalActionAdapterProbe(appSettingsURL: settingsURL)
+ let service = RadrootsAppleExternalActions(adapters: probe.adapters())
+
+ let capability = await service.canOpen(.appSettings)
+ try await service.open(RadrootsExternalActionRequest(destination: .appSettings))
+
+ #expect(capability.destination == .appSettings)
+ #expect(capability.canOpen)
+ #expect(await probe.canOpenURLs == [settingsURL])
+ #expect(await probe.openedURLs == [settingsURL])
+}
+
+@Test func appleExternalActionsReportsUnavailableAppSettingsWithoutAPlatformUrl() async {
+ let probe = RadrootsExternalActionAdapterProbe(appSettingsURL: nil)
+ let service = RadrootsAppleExternalActions(adapters: probe.adapters())
+
+ let capability = await service.canOpen(.appSettings)
+
+ #expect(!capability.canOpen)
+ await #expect(throws: RadrootsExternalActionError.unavailable("appSettings external action is unavailable")) {
+ try await service.open(RadrootsExternalActionRequest(destination: .appSettings))
+ }
+ #expect(await probe.canOpenURLs.isEmpty)
+ #expect(await probe.openedURLs.isEmpty)
+}
+
+@Test func appleExternalActionsMapsPlatformOpenFailure() async throws {
+ let destination = try RadrootsExternalActionDestination.web("https://radroots.org")
+ let url = try #require(destination.url)
+ let probe = RadrootsExternalActionAdapterProbe(openResult: false)
+ let service = RadrootsAppleExternalActions(adapters: probe.adapters())
+
+ await #expect(throws: RadrootsExternalActionError.transientFailure("failed to open web external action")) {
+ try await service.open(RadrootsExternalActionRequest(destination: destination))
+ }
+
+ #expect(await probe.openedURLs == [url])
+}
+
+@Test func appleExternalActionsChecksExternalDestinationCapabilities() async throws {
+ let destination = try RadrootsExternalActionDestination.nostr("nostr:npub1qqqqqq")
+ let url = try #require(destination.url)
+ let probe = RadrootsExternalActionAdapterProbe(canOpenResult: false)
+ let service = RadrootsAppleExternalActions(adapters: probe.adapters())
+
+ let capability = await service.canOpen(destination)
+
+ #expect(capability.destination == destination)
+ #expect(!capability.canOpen)
+ #expect(await probe.canOpenURLs == [url])
+}
+
+private actor RadrootsExternalActionAdapterProbe {
+ private let appSettingsURLValue: URL?
+ private let canOpenResult: Bool
+ private let openResult: Bool
+ private var canOpenURLsValue: [URL]
+ private var openedURLsValue: [URL]
+
+ init(
+ appSettingsURL: URL? = URL(string: "app-settings:radroots"),
+ canOpenResult: Bool = true,
+ openResult: Bool = true
+ ) {
+ self.appSettingsURLValue = appSettingsURL
+ self.canOpenResult = canOpenResult
+ self.openResult = openResult
+ self.canOpenURLsValue = []
+ self.openedURLsValue = []
+ }
+
+ nonisolated func adapters() -> RadrootsAppleExternalActionsAdapters {
+ RadrootsAppleExternalActionsAdapters(
+ appSettingsURL: {
+ await self.appSettingsURL()
+ },
+ canOpenURL: { url in
+ await self.canOpen(url)
+ },
+ openURL: { url in
+ await self.open(url)
+ }
+ )
+ }
+
+ private func appSettingsURL() -> URL? {
+ appSettingsURLValue
+ }
+
+ private func canOpen(_ url: URL) -> Bool {
+ canOpenURLsValue.append(url)
+ return canOpenResult
+ }
+
+ private func open(_ url: URL) -> Bool {
+ openedURLsValue.append(url)
+ return openResult
+ }
+
+ var canOpenURLs: [URL] {
+ canOpenURLsValue
+ }
+
+ var openedURLs: [URL] {
+ openedURLsValue
+ }
+}