diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/URLClassifier.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/URLClassifier.swift index e5789d52..a23a9178 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/URLClassifier.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/URLClassifier.swift @@ -2,6 +2,7 @@ import Foundation public enum URLClassification: Equatable, Sendable { case comments(String) + case walletPass case macmagazinePost case appStore case youTube @@ -26,6 +27,11 @@ public enum URLClassifier { return .comments(slug) } + // Apple Wallet pass (e.g. event tickets), regardless of host + if url.pathExtension.lowercased() == "pkpass" { + return .walletPass + } + let host = url.host?.lowercased() ?? "" // Instagram diff --git a/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift b/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift index 86304116..f2ee4432 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift @@ -2,7 +2,6 @@ import Foundation @testable import MacMagazineLibrary import Testing -// swiftlint:disable force_unwrapping @Suite("URLClassifier Tests") struct URLClassifierTests { @@ -59,5 +58,16 @@ struct URLClassifierTests { let url = URL(string: "https://www.google.com")! #expect(URLClassifier.classify(url) == .external) } + + @Test("pkpass URL returns .walletPass") + func walletPass() { + let url = URL(string: "https://macmagazine.com.br/wp-content/uploads/2026/06/wwdc26.pkpass")! + #expect(URLClassifier.classify(url) == .walletPass) + } + + @Test("pkpass URL is classified before the macmagazine host check") + func walletPassTakesPrecedenceOverHost() { + let url = URL(string: "https://macmagazine.com.br/passes/keynote.PKPASS")! + #expect(URLClassifier.classify(url) == .walletPass) + } } -// swiftlint:enable force_unwrapping diff --git a/MacMagazine/Features/MacMagazineUILibrary/Package.swift b/MacMagazine/Features/MacMagazineUILibrary/Package.swift index 663ca6bd..71638f01 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Package.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Package.swift @@ -16,7 +16,8 @@ let package = Package( targets: [ .target(name: "MacMagazineUILibrary", dependencies: ["MacMagazineLibrary", - .product(name: "UIComponents", package: "Libraries") + .product(name: "UIComponents", package: "Libraries"), + .product(name: "Network", package: "Libraries") ]), .testTarget(name: "MacMagazineUILibraryTests", dependencies: ["MacMagazineUILibrary"]) diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMNavigationDecider.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMNavigationDecider.swift index a5387854..6e031029 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMNavigationDecider.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMNavigationDecider.swift @@ -6,6 +6,7 @@ import WebKit final class MMNavigationDecider: WebPage.NavigationDeciding { var onOpenComments: ((String) -> Void)? var onOpenInternalLink: ((URL) -> Void)? + var onOpenWalletPass: ((URL) -> Void)? func decidePolicy( for action: WebPage.NavigationAction, @@ -20,6 +21,8 @@ final class MMNavigationDecider: WebPage.NavigationDeciding { switch URLClassifier.classify(url) { case let .comments(slug): onOpenComments?(slug) + case .walletPass: + onOpenWalletPass?(url) case .macmagazinePost: if let handler = onOpenInternalLink { handler(url) diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift index 7ddb7ee5..c39be63e 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift @@ -9,6 +9,7 @@ public struct MMWebView: View { @State private var commentsURL = "" @State private var internalLinkURL: URL? + @State private var walletPassURL: URL? @State private var page: WebPage? @State private var navigationDecider = MMNavigationDecider() @State private var galleryStateHandler = GalleryStateMessageHandler() @@ -64,6 +65,14 @@ public struct MMWebView: View { commentsURL = "" } } + .sheet(isPresented: Binding(get: { walletPassURL != nil }, + set: { _ in walletPassURL = nil })) { + if let walletPassURL { + WalletPassSheet(url: walletPassURL) { + self.walletPassURL = nil + } + } + } .onChange(of: colorScheme) { isGalleryOpen = false page?.reload() @@ -107,6 +116,7 @@ private extension MMWebView { navigationDecider.onOpenComments = { [self] slug in commentsURL = slug } navigationDecider.onOpenInternalLink = { [self] url in internalLinkURL = url } + navigationDecider.onOpenWalletPass = { [self] url in walletPassURL = url } galleryStateHandler.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen } let configuration = makeConfiguration() diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassService.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassService.swift new file mode 100644 index 00000000..a6cbe36c --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassService.swift @@ -0,0 +1,21 @@ +import Foundation +import NetworkLibrary +import PassKit + +/// Downloads and parses an Apple Wallet pass (`.pkpass`) linked from web content. +/// +/// Marked `@MainActor` because `PKPass` is not `Sendable` — parsing it must stay on the +/// same actor as the SwiftUI view that presents `PKAddPassesViewController`. +@MainActor +struct WalletPassService { + private let network: Network & Sendable + + init(network: (Network & Sendable)? = nil) { + self.network = network ?? NetworkFactory.make() + } + + func fetchPass(from url: URL) async throws -> PKPass { + let data = try await network.get(url: url, headers: [:]) + return try PKPass(data: data) + } +} diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassSheet.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassSheet.swift new file mode 100644 index 00000000..6a32e28e --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassSheet.swift @@ -0,0 +1,71 @@ +import PassKit +import SwiftUI +import UIKit + +/// Presents the system "Add to Wallet" flow for a `.pkpass` link tapped inside a `MMWebView`. +struct WalletPassSheet: View { + let url: URL + let onDismiss: () -> Void + + @State private var addPassesController: PKAddPassesViewController? + @State private var status = WebViewStatus.idle + + var body: some View { + ZStack { + if let addPassesController { + AddPassesView(controller: addPassesController, onDismiss: onDismiss) + } + WebViewStatusOverlay(status: status) + } + .task { + await loadPass() + } + } +} + +private extension WalletPassSheet { + func loadPass() async { + status = .loading + do { + let pass = try await WalletPassService().fetchPass(from: url) + guard let controller = PKAddPassesViewController(pass: pass) else { + status = .error("Não foi possível adicionar este tíquete à Carteira.") + return + } + addPassesController = controller + status = .done + } catch { + status = .error("Não foi possível adicionar este tíquete à Carteira.") + } + } +} + +// MARK: - UIKit bridge + +private struct AddPassesView: UIViewControllerRepresentable { + let controller: PKAddPassesViewController + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> PKAddPassesViewController { + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: PKAddPassesViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onDismiss: onDismiss) + } + + final class Coordinator: NSObject, PKAddPassesViewControllerDelegate { + private let onDismiss: () -> Void + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + + func addPassesViewControllerDidFinish(_ controller: PKAddPassesViewController) { + onDismiss() + } + } +} diff --git a/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift b/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift new file mode 100644 index 00000000..b7a82e06 --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import MacMagazineUILibrary +import NetworkLibrary +import Testing + +@Suite("WalletPassService Tests") +@MainActor +struct WalletPassServiceTests { + + @Test("fetchPass propagates a network failure") + func fetchPassNetworkFailure() async { + let sut = WalletPassService(network: FailingNetworkStub()) + + await #expect(throws: NetworkAPIError.self) { + _ = try await sut.fetchPass(from: URL(string: "https://macmagazine.com.br/pass.pkpass")!) + } + } + + @Test("fetchPass throws when the downloaded data is not a valid pass") + func fetchPassInvalidData() async { + let sut = WalletPassService(network: StubNetwork(data: Data("not a pkpass".utf8))) + + await #expect(throws: (any Error).self) { + _ = try await sut.fetchPass(from: URL(string: "https://macmagazine.com.br/pass.pkpass")!) + } + } +} + +// MARK: - Test Doubles + +private struct StubNetwork: Network, Sendable { + let customHost: CustomHost? = nil + let data: Data + + func get(url: URL, headers: [String: String]?) async throws -> Data { data } + func post(url: URL, headers: [String: String]?, body: Data) async throws -> Data { data } + func ping(url: URL) async throws {} +} + +private struct FailingNetworkStub: Network, Sendable { + let customHost: CustomHost? = nil + + func get(url: URL, headers: [String: String]?) async throws -> Data { throw NetworkAPIError.network } + func post(url: URL, headers: [String: String]?, body: Data) async throws -> Data { throw NetworkAPIError.network } + func ping(url: URL) async throws { throw NetworkAPIError.network } +}