From 1854916a447274c40e3de19995cc308cb4ccefff Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:22:24 -0800 Subject: [PATCH] Open custom article URI schemes on iOS Custom-scheme URLs are now opened with UIApplication.open so iOS can prompt and switch apps. http, https, mailto, tel, javascript, data, file, about, blob, and vbscript stay on the existing web and system handlers, and internal article image schemes remain excluded. Navigation for other named schemes is cancelled. Fixes #4866 --- Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift | 13 ++++++++ .../URLPreparedForOpeningTests.swift | 33 +++++++++++++++++++ iOS/Article/WebViewController.swift | 15 +++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift b/Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift index 51e1b07ac4..c4a226c94e 100755 --- a/Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift +++ b/Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift @@ -17,6 +17,7 @@ private struct URLConstants { static let schemeNetNewsWire = "netnewswire" static let schemeFeed = "feed" static let schemeFeeds = "feeds" + static let refusedSchemesForExternalApplications: Set = [schemeHTTP, schemeHTTPS, schemeMailto, "tel", "javascript", "data", "file", "about", "blob", "vbscript"] #if os(macOS) static let schemeMagnet = "magnet" static let schemeNetNewsWireFeed = "x-netnewswire-feed" @@ -68,6 +69,18 @@ public extension URL { return components.url } + // Written with the help of an LLM. + func preparedForOpeningInExternalApplication(excluding excludedSchemes: Set = []) -> URL? { + guard let scheme = scheme?.lowercased(with: localeForLowercasing), + !scheme.isEmpty, + !URLConstants.refusedSchemesForExternalApplications.contains(scheme), + !excludedSchemes.contains(where: { $0.lowercased(with: localeForLowercasing) == scheme }) else { + return nil + } + + return self + } + func preparedForOpeningInBrowser() -> URL? { guard let scheme = scheme?.lowercased(with: localeForLowercasing), URLConstants.allowedSchemesForOpening.contains(scheme) else { diff --git a/Modules/RSWeb/Tests/RSWebTests/URLPreparedForOpeningTests.swift b/Modules/RSWeb/Tests/RSWebTests/URLPreparedForOpeningTests.swift index 6c6b3806a0..c0623d7967 100644 --- a/Modules/RSWeb/Tests/RSWebTests/URLPreparedForOpeningTests.swift +++ b/Modules/RSWeb/Tests/RSWebTests/URLPreparedForOpeningTests.swift @@ -47,6 +47,39 @@ struct URLPreparedForOpeningTests { #expect(prepared.absoluteString == "http://example.com/path?q=1") } + @Test func externalApplicationURLsPassThroughUnchanged() throws { + for string in [ + "octal://item?id=12345678", + "OCTAL://item?id=1", + "netnewswire://theme/add?url=https://example.com/t.nnwtheme", + "netnewswire://theme/add", + "feed:https://example.com/rss.xml", + "feeds:https://example.com/rss.xml" + ] { + let prepared = try #require(url(string).preparedForOpeningInExternalApplication()) + #expect(prepared.absoluteString == string) + } + } + + @Test func blocksNonExternalApplicationURLs() throws { + for string in [ + "http://example.com/", "https://example.com/", "mailto:me@example.com", "tel:+15555555555", + "javascript:alert(1)", "JAVASCRIPT:alert(1)", "data:text/html,x", "file:///etc/passwd", + "about:blank", "blob:https://example.com/id", "vbscript:msgbox(1)", "relative/path" + ] { + #expect(try url(string).preparedForOpeningInExternalApplication() == nil) + } + } + + @Test func excludesInternalSchemesCaseInsensitively() throws { + let excludedSchemes: Set = ["nnwImageIcon", "nnwArticleImage"] + for string in ["nnwImageIcon:abc", "nnwArticleImage:abc", "NNWIMAGEICON:abc", "NNWARTICLEIMAGE:abc"] { + let original = try url(string) + #expect(original.preparedForOpeningInExternalApplication(excluding: excludedSchemes) == nil) + #expect(original.preparedForOpeningInExternalApplication() != nil) + } + } + private func url(_ string: String) throws -> URL { try #require(URL(string: string)) } diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index dcd73e3e01..fabab9ad19 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -511,8 +511,11 @@ extension WebViewController: WKNavigationDelegate { UIApplication.shared.open(url, options: [.universalLinksOnly: false], completionHandler: nil) } + } else if let externalURL = url.preparedForOpeningInExternalApplication(excluding: [ArticleRenderer.imageIconScheme, ArticleRenderer.articleImageScheme]) { + decisionHandler(.cancel, preferences) + UIApplication.shared.open(externalURL, options: [:], completionHandler: nil) } else { - decisionHandler(.allow, preferences) + decisionHandler(url.scheme == nil ? .allow : .cancel, preferences) } } else { decisionHandler(.allow, preferences) @@ -960,8 +963,16 @@ private extension WebViewController { } } - // If the resource cannot be opened with an installed app, present the web view. + // Web URLs fall back to the web view when no installed app opens them. func openURL(_ url: URL) { + if let externalURL = url.preparedForOpeningInExternalApplication(excluding: [ArticleRenderer.imageIconScheme, ArticleRenderer.articleImageScheme]) { + UIApplication.shared.open(externalURL, options: [:], completionHandler: nil) + return + } + guard url.isHTTPOrHTTPSURL() || url.scheme?.lowercased() == "mailto" || url.scheme?.lowercased() == "tel" else { + return + } + UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in assert(Thread.isMainThread) guard didOpen == false else {