apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit 4ac999d25ef7da1a28293e5940822a937b570eb2
parent 6a782e7c4a85b37e5fe730a868339d40cd4a084e
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 14:50:23 -0700

external-actions: add Apple implementation

Diffstat:
ASources/RadrootsKit/RadrootsAppleExternalActions.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleExternalActionsTests.swift | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } +}