From a7efcdedc70954acf4f19a72947e8adcc727a06f Mon Sep 17 00:00:00 2001 From: Cassio Rossi Date: Fri, 17 Jul 2026 18:47:31 +0100 Subject: [PATCH 1/2] fix(#305): add Apple Wallet pass support to the v5 WebView .pkpass ticket links (e.g. WWDC keynote tickets) fell through to URLClassification.external and were handed to UIApplication.open, which just rendered the raw file instead of the Wallet flow that release/v4's WebViewController handled via PassKit. Mirrors v4's approach on v5's WebPage-based navigation stack: URLClassifier now recognizes .pkpass links before host-based classification, MMNavigationDecider routes them through a new onOpenWalletPass callback, and MMWebView presents a WalletPassSheet that downloads the pass via the existing Network abstraction and hands it to PKAddPassesViewController. Co-Authored-By: Claude --- .../MacMagazineLibrary/URLClassifier.swift | 6 ++ .../URLClassifierTests.swift | 12 ++++ .../MacMagazineUILibrary/Package.swift | 3 +- .../Webview/MMNavigationDecider.swift | 3 + .../Webview/MMWebView.swift | 10 +++ .../WalletPass/WalletPassService.swift | 21 ++++++ .../Webview/WalletPass/WalletPassSheet.swift | 71 +++++++++++++++++++ .../WalletPassServiceTests.swift | 48 +++++++++++++ 8 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassService.swift create mode 100644 MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WalletPass/WalletPassSheet.swift create mode 100644 MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift 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..ba4d9a41 100644 --- a/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift +++ b/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift @@ -59,5 +59,17 @@ 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..7fabca9c --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift @@ -0,0 +1,48 @@ +import Foundation +@testable import MacMagazineUILibrary +import NetworkLibrary +import Testing + +// swiftlint:disable force_unwrapping +@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 } +} +// swiftlint:enable force_unwrapping From 85d39e990171d3ce34037bc1fff49e6c934aeff6 Mon Sep 17 00:00:00 2001 From: Cassio Rossi Date: Fri, 17 Jul 2026 18:53:28 +0100 Subject: [PATCH 2/2] fix(#305): remove superfluous force_unwrapping lint pragmas CI's SwiftLint (installed unpinned via brew, currently 0.65.0) no longer flags URL(string:)! as a force_unwrapping violation, unlike the 0.59.1 used locally. That made the pre-existing blanket disable in URLClassifierTests.swift, and the one I added in WalletPassServiceTests.swift, both superfluous under --strict. Removed both; verified 0 violations with swiftlint 0.65.0 to match what CI actually runs. Co-Authored-By: Claude --- .../Tests/MacMagazineLibraryTests/URLClassifierTests.swift | 2 -- .../MacMagazineUILibraryTests/WalletPassServiceTests.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift b/MacMagazine/Features/MacMagazineLibrary/Tests/MacMagazineLibraryTests/URLClassifierTests.swift index ba4d9a41..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 { @@ -72,4 +71,3 @@ struct URLClassifierTests { #expect(URLClassifier.classify(url) == .walletPass) } } -// swiftlint:enable force_unwrapping diff --git a/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift b/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift index 7fabca9c..b7a82e06 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Tests/MacMagazineUILibraryTests/WalletPassServiceTests.swift @@ -3,7 +3,6 @@ import Foundation import NetworkLibrary import Testing -// swiftlint:disable force_unwrapping @Suite("WalletPassService Tests") @MainActor struct WalletPassServiceTests { @@ -45,4 +44,3 @@ private struct FailingNetworkStub: Network, Sendable { func post(url: URL, headers: [String: String]?, body: Data) async throws -> Data { throw NetworkAPIError.network } func ping(url: URL) async throws { throw NetworkAPIError.network } } -// swiftlint:enable force_unwrapping