diff --git a/Sources/Library/QuickOpen/QuickOpenResult.swift b/Sources/Library/QuickOpen/QuickOpenResult.swift index 5e17e1c..6976b5f 100644 --- a/Sources/Library/QuickOpen/QuickOpenResult.swift +++ b/Sources/Library/QuickOpen/QuickOpenResult.swift @@ -88,10 +88,24 @@ struct QuickOpenResult: Identifiable { return URL(string: text).flatMap { $0.host == nil || !WebFetch.isWeb($0) ? nil : $0 } } guard let url = URL(string: "https://\(text)"), let host = url.host, - let last = host.split(separator: ".").last, host.contains("."), - last.count >= 2, last.allSatisfy(\.isLetter) else { return nil } + isWebHost(host) else { return nil } return url } + + /// Whether a host is one a person would type on purpose: a dotted name whose last part is letters, such as + /// `example.com`, or a dotted-quad address or `localhost`, which is how a router or a local server is written. + /// A single word, or too few dots to be an address, is not one. The fetch-safety rules that refuse local and + /// private addresses apply to background requests, not to a link the user typed. + static func isWebHost(_ host: String) -> Bool { + if host.lowercased() == "localhost" { return true } + let parts = host.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + if parts.count == 4, parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }), + parts.allSatisfy({ (0...255).contains(Int($0) ?? -1) }) { + return true + } + guard host.contains("."), let last = parts.last else { return false } + return last.count >= 2 && last.allSatisfy(\.isLetter) + } } /// The library's openable items, prepared for Quick Open. Names are lowercased and the text each item is matched diff --git a/Sources/Library/System/Spotlight.swift b/Sources/Library/System/Spotlight.swift index 3433146..c54de52 100644 --- a/Sources/Library/System/Spotlight.swift +++ b/Sources/Library/System/Spotlight.swift @@ -31,7 +31,9 @@ enum Spotlight { /// The host of a bookmark's address, such as "jira.example.com". The address may have no scheme, since a bookmark can /// be saved as "jira.example.com/browse". Nil when no host can be read. static func host(of address: String) -> String? { - if let host = QuickOpenResult.link(from: address)?.host, !host.isEmpty { return host } + // A dotted host, as before: a bare `localhost` is a link a person can type, but there is no domain to show + // for it in Spotlight. + if let host = QuickOpenResult.link(from: address)?.host, host.contains(".") { return host } // A template's braces are not valid in a URL, so the host is read from the text by hand. var text = Substring(address.trimmingCharacters(in: .whitespaces)) if let range = text.range(of: "://") { text = text[range.upperBound...] } diff --git a/Tests/Checks/main.swift b/Tests/Checks/main.swift index 4d813f8..3cb2e7e 100644 --- a/Tests/Checks/main.swift +++ b/Tests/Checks/main.swift @@ -210,6 +210,24 @@ let pt = [LibraryItem(name: "Jira", url: "https://x.atlassian.net"), check(QuickOpenResult.search("", in: pt, counts: [:]).compactMap(\.node).map(\.name) == ["Jira", "Jenkins", "Terminal"], "empty search order") check(QuickOpenResult.search("j", in: pt, counts: [:]).compactMap(\.node).map(\.name) == ["Jira", "Jenkins"], "prefix search") check(QuickOpenResult.link(from: "example.com/a")?.absoluteString == "https://example.com/a" && QuickOpenResult.link(from: "hello world") == nil, "link detection") +// A router or a local server is typed without a scheme, so those are links too. +do { + for text in ["192.168.1.1", "192.168.1.1:8080", "localhost:3000", "http://192.168.1.1/admin", "https://example.com:8443/x"] { + check(QuickOpenResult.link(from: text) != nil, "a typed link '\(text)' is offered") + } + check(QuickOpenResult.link(from: "192.168.1.1")?.absoluteString == "https://192.168.1.1" + && QuickOpenResult.link(from: "192.168.1.1:8080")?.absoluteString == "https://192.168.1.1:8080" + && QuickOpenResult.link(from: "localhost:3000")?.absoluteString == "https://localhost:3000", + "a typed address gets the https default, with its port kept") + for text in ["1.2.3", "word", "hello world", "999.1.1.1", "192.168.1", "1.2.3.4.5", "", "a b c"] { + check(QuickOpenResult.link(from: text) == nil, "not a typed link: '\(text)'") + } + check(QuickOpenResult.link(from: " 192.168.1.1 ")?.host == "192.168.1.1", "a typed address is trimmed") + check(QuickOpenResult.link(from: "192.168.1.1/x?y=z")?.host == "192.168.1.1", "a path and query on a typed address are kept") + check(QuickOpenResult.isWebHost("192.168.1.1") && QuickOpenResult.isWebHost("localhost") && QuickOpenResult.isWebHost("example.com") + && !QuickOpenResult.isWebHost("1.2.3") && !QuickOpenResult.isWebHost("word") && !QuickOpenResult.isWebHost("256.1.1.1"), + "a host is a typed link when it is a dotted name, a dotted-quad address, or localhost") +} /// The Quick Open search as it was before the index: walks and lowercases the whole tree on every call. The index /// must return exactly what this returns.