Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

public enum URLClassification: Equatable, Sendable {
case comments(String)
case walletPass
case macmagazinePost
case appStore
case youTube
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import Foundation
@testable import MacMagazineLibrary
import Testing

// swiftlint:disable force_unwrapping
@Suite("URLClassifier Tests")
struct URLClassifierTests {

Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion MacMagazine/Features/MacMagazineUILibrary/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading