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
13 changes: 13 additions & 0 deletions Modules/RSWeb/Sources/RSWeb/URL+RSWeb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ private struct URLConstants {
static let schemeNetNewsWire = "netnewswire"
static let schemeFeed = "feed"
static let schemeFeeds = "feeds"
static let refusedSchemesForExternalApplications: Set<String> = [schemeHTTP, schemeHTTPS, schemeMailto, "tel", "javascript", "data", "file", "about", "blob", "vbscript"]
#if os(macOS)
static let schemeMagnet = "magnet"
static let schemeNetNewsWireFeed = "x-netnewswire-feed"
Expand Down Expand Up @@ -68,6 +69,18 @@ public extension URL {
return components.url
}

// Written with the help of an LLM.
func preparedForOpeningInExternalApplication(excluding excludedSchemes: Set<String> = []) -> 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 {
Expand Down
33 changes: 33 additions & 0 deletions Modules/RSWeb/Tests/RSWebTests/URLPreparedForOpeningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["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))
}
Expand Down
15 changes: 13 additions & 2 deletions iOS/Article/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down