Skip to content
Open
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
@@ -0,0 +1,18 @@
import WebKit

/// Receives gallery open/close notifications posted by
/// `MMWebViewUserScripts.galleryStateObserver`.
@MainActor
final class GalleryStateMessageHandler: NSObject, WKScriptMessageHandler {
static let handlerName = "mmGalleryState"

var onGalleryStateChange: ((Bool) -> Void)?

func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let isOpen = message.body as? Bool else { return }
onGalleryStateChange?(isOpen)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import SwiftUI
import UIKit

public extension View {
/// Restores the interactive pop gesture (swipe from the leading edge to go back)
/// even when the system back button is hidden, and disables it on demand —
/// e.g. while an in-page image gallery is open and owns horizontal swipes.
func interactivePopGesture(enabled: Bool) -> some View {
background(InteractivePopGestureBridge(enabled: enabled))
}
}

private struct InteractivePopGestureBridge: UIViewControllerRepresentable {
let enabled: Bool

func makeUIViewController(context: Context) -> BridgeViewController {
BridgeViewController()
}

func updateUIViewController(_ controller: BridgeViewController, context: Context) {
controller.gestureEnabled = enabled
}
}

private final class BridgeViewController: UIViewController, UIGestureRecognizerDelegate {
var gestureEnabled = true

private weak var popGesture: UIGestureRecognizer?
private weak var originalDelegate: UIGestureRecognizerDelegate?

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard popGesture == nil,
let gesture = navigationController?.interactivePopGestureRecognizer else { return }
popGesture = gesture
originalDelegate = gesture.delegate
gesture.delegate = self
}

override func viewWillDisappear(_ animated: Bool) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardening: the system delegate is never restored if this controller deallocates without viewWillDisappear.

UIGestureRecognizer.delegate is weak, so if BridgeViewController dies on a teardown path that skips appearance callbacks, the pop gesture is left with a nil delegate — completely ungated. An edge swipe on a root view controller can then begin a pop with nothing to pop, which is the classic frozen-navigation bug.

Since the project is Swift 6.2, isolated deinit (SE-0371) makes this a clean fix — extract the restore logic and call it from both places:

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    restoreDelegate()
}

isolated deinit {
    restoreDelegate()
}

private func restoreDelegate() {
    if let popGesture, popGesture.delegate === self {
        popGesture.delegate = originalDelegate
    }
    popGesture = nil
    originalDelegate = nil
}

super.viewWillDisappear(animated)
restoreDelegate()
}

isolated deinit {
restoreDelegate()
}

private func restoreDelegate() {
if let popGesture, popGesture.delegate === self {
popGesture.delegate = originalDelegate
}
popGesture = nil
originalDelegate = nil
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let navigationController else { return false }
return gestureEnabled
&& navigationController.viewControllers.count > 1
&& navigationController.transitionCoordinator == nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public struct MMWebView: View {
@State private var internalLinkURL: URL?
@State private var page: WebPage?
@State private var navigationDecider = MMNavigationDecider()
@State private var galleryStateHandler = GalleryStateMessageHandler()
@State private var isGalleryOpen = false
@State private var reloadID = UUID()

private let url: String?
Expand Down Expand Up @@ -41,6 +43,7 @@ public struct MMWebView: View {
page: $page,
reloadTrigger: reloadID
)
.interactivePopGesture(enabled: !isGalleryOpen)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: isGalleryOpen gets stuck true across page reloads, permanently disabling the gesture this PR restores.

onChange(of: colorScheme) calls page?.reload() (and this can fire automatically, e.g. the sunset dark-mode switch). The new document's observer starts with lastState = false and only posts on change — so if a gallery was open at reload time, no "closed" message ever arrives and the native side keeps isGalleryOpen == true for the rest of that screen's life.

The cleanest fix is in the script: initialize lastState to null and run check() once right after observing, so every new document unconditionally posts its initial state. This also covers WebKit content-process crash reloads:

var lastState = null;
function check() {
    var open = !!document.querySelector(selectors);
    if (open !== lastState) {
        lastState = open;
        window.webkit.messageHandlers.mmGalleryState.postMessage(open);
    }
}
var observer = new MutationObserver(check);
observer.observe(document.documentElement, { ... });
check();

Optionally also reset on the native side next to the existing reload triggers:

.onChange(of: colorScheme) {
    isGalleryOpen = false
    page?.reload()
}
.onChange(of: removeAds) {
    if let cacheKey {
        WebPageCache.shared.removePage(for: cacheKey)
    }
    isGalleryOpen = false
    page = nil
    reloadID = UUID()
}

.navigationDestination(item: $internalLinkURL) { url in
MMWebView(url: url.absoluteString, dismissAction: dismissAction)
.toolbar {
Expand All @@ -62,12 +65,14 @@ public struct MMWebView: View {
}
}
.onChange(of: colorScheme) {
isGalleryOpen = false
page?.reload()
}
.onChange(of: removeAds) {
if let cacheKey {
WebPageCache.shared.removePage(for: cacheKey)
}
isGalleryOpen = false
page = nil
reloadID = UUID()
}
Expand Down Expand Up @@ -102,6 +107,7 @@ private extension MMWebView {

navigationDecider.onOpenComments = { [self] slug in commentsURL = slug }
navigationDecider.onOpenInternalLink = { [self] url in internalLinkURL = url }
galleryStateHandler.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen }

let configuration = makeConfiguration()
let page: WebPage
Expand All @@ -110,8 +116,11 @@ private extension MMWebView {
page = WebPageCache.shared.page(
for: cacheKey,
configurationProvider: { configuration },
navigationDecider: navigationDecider
navigationDecider: navigationDecider,
galleryStateHandler: galleryStateHandler
)
WebPageCache.shared.galleryHandler(for: cacheKey)?
.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen }
} else {
page = WebPage(
configuration: configuration,
Expand All @@ -138,6 +147,8 @@ private extension MMWebView {
contentController.addUserScript(MMWebViewUserScripts.disableGallery)
contentController.addUserScript(MMWebViewUserScripts.disableNewGallery)
contentController.addUserScript(MMWebViewUserScripts.removeBackToBlog)
contentController.addUserScript(MMWebViewUserScripts.galleryStateObserver)
contentController.add(galleryStateHandler, name: GalleryStateMessageHandler.handlerName)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: on a WebPageCache hit, this handler registration is thrown away.

makePage() builds a fresh configuration (including this add), but when WebPageCache.shared.page(for:) finds a cached page, the configuration is discarded — the cached page still holds the handler from the first view instance, whose onGalleryStateChange closure writes into that dead view's @State. Gallery messages then silently update nothing.

Today only the Live/Instagram root tabs use cacheKey, where the pop gesture is gated off by viewControllers.count > 1 anyway — but this becomes a real bug the moment cacheKey is used on a pushed view. (The same staleness already exists for the navigationDecider closures, so this PR replicates a latent pattern rather than introducing it.)

Suggested fix — cache the handler alongside the page and rebind its closure on every hit:

// WebPageCache
private var galleryHandlers: [String: GalleryStateMessageHandler] = [:]

func page(
    for key: String,
    configurationProvider: () -> WebPage.Configuration,
    navigationDecider: some WebPage.NavigationDeciding,
    galleryStateHandler: GalleryStateMessageHandler
) -> WebPage {
    if let existing = cache[key] { return existing }
    let page = WebPage(configuration: configurationProvider(),
                       navigationDecider: navigationDecider)
    cache[key] = page
    galleryHandlers[key] = galleryStateHandler
    return page
}

func galleryHandler(for key: String) -> GalleryStateMessageHandler? {
    galleryHandlers[key]
}

func removePage(for key: String) {
    cache.removeValue(forKey: key)
    galleryHandlers.removeValue(forKey: key)
}
// MMWebView.makePage()
if let cacheKey {
    page = WebPageCache.shared.page(
        for: cacheKey,
        configurationProvider: { configuration },
        navigationDecider: navigationDecider,
        galleryStateHandler: galleryStateHandler
    )
    // Rebind the registered handler to this view instance on cache hits.
    WebPageCache.shared.galleryHandler(for: cacheKey)?
        .onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen }
}


return configuration
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ public enum MMWebViewUserScripts {
)
}

@MainActor
static var galleryStateObserver: WKUserScript {
WKUserScript(
source: """
(function() {
if (window.__mmGalleryObserver) { return; }
var selectors = '.pswp--open, .fancybox-container, #fancybox-overlay, .mfp-wrap';
var lastState = null;
function check() {
var open = !!document.querySelector(selectors);
if (open !== lastState) {
lastState = open;
window.webkit.messageHandlers.mmGalleryState.postMessage(open);
}
}
var observer = new MutationObserver(check);
observer.observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['class', 'style']
});
window.__mmGalleryObserver = observer;
check();
})();
""",
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
}

@MainActor
public static var hideSiteHeader: WKUserScript {
WKUserScript(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ final class WebPageCache {
static let shared = WebPageCache()

private var cache: [String: WebPage] = [:]
private var galleryHandlers: [String: GalleryStateMessageHandler] = [:]

private init() {}

Expand All @@ -20,7 +21,8 @@ final class WebPageCache {
func page(
for key: String,
configurationProvider: () -> WebPage.Configuration,
navigationDecider: some WebPage.NavigationDeciding
navigationDecider: some WebPage.NavigationDeciding,
galleryStateHandler: GalleryStateMessageHandler
) -> WebPage {
if let existing = cache[key] {
return existing
Expand All @@ -30,14 +32,20 @@ final class WebPageCache {
navigationDecider: navigationDecider
)
cache[key] = page
galleryHandlers[key] = galleryStateHandler
return page
}

func galleryHandler(for key: String) -> GalleryStateMessageHandler? {
galleryHandlers[key]
}

func hasPage(for key: String) -> Bool {
cache[key] != nil
}

func removePage(for key: String) {
cache.removeValue(forKey: key)
galleryHandlers.removeValue(forKey: key)
}
}