summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/RootView.swift6
-rw-r--r--Hutch/Networking/ManPageService.swift216
-rw-r--r--Hutch/Views/Lookup/LookupView.swift4
-rw-r--r--Hutch/Views/More/ManPageBrowserView.swift33
-rw-r--r--Hutch/Views/More/ManPageDetailView.swift103
-rw-r--r--Hutch/Views/More/MoreView.swift5
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift55
7 files changed, 419 insertions, 3 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index a71b247..96c17a8 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -269,6 +269,8 @@ enum MoreRoute: Hashable {
case settings
case mailingList(InboxMailingListReference)
case thread(InboxThreadSummary)
+ case manPageBrowser
+ case manPage(URL)
}
private struct MoreNavigationRoot: View {
@@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View {
NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
}
)
+ case .manPageBrowser:
+ ManPageBrowserView()
+ case .manPage(let url):
+ ManPageDetailView(url: url)
}
}
}
diff --git a/Hutch/Networking/ManPageService.swift b/Hutch/Networking/ManPageService.swift
new file mode 100644
index 0000000..07d5c2d
--- /dev/null
+++ b/Hutch/Networking/ManPageService.swift
@@ -0,0 +1,216 @@
+import Foundation
+
+/// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required).
+struct ManPage: Sendable {
+ let url: URL
+ let title: String
+ let contentHTML: String
+}
+
+struct ManPageService {
+ static let baseURL = URL(string: "https://man.sr.ht")!
+ static let pagesBaseURL = URL(string: "https://srht.site/")!
+
+ /// Fetches a man.sr.ht page and extracts the article content.
+ /// Uses an unauthenticated URLSession because man.sr.ht pages are public.
+ static func fetch(url: URL) async throws -> ManPage {
+ guard isTrustedDocumentationURL(url) else {
+ throw URLError(.badURL)
+ }
+
+ let (data, response) = try await URLSession.shared.data(from: url)
+ if let http = response as? HTTPURLResponse,
+ !(200...299).contains(http.statusCode) {
+ throw URLError(.badServerResponse)
+ }
+ guard let html = String(data: data, encoding: .utf8) else {
+ throw URLError(.cannotDecodeContentData)
+ }
+
+ return ManPage(
+ url: url,
+ title: extractTitle(from: html, fallbackURL: url),
+ contentHTML: sanitizeContentHTML(extractContent(from: html))
+ )
+ }
+
+ static func isTrustedDocumentationURL(_ url: URL) -> Bool {
+ guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
+ let host = url.host?.lowercased() else {
+ return false
+ }
+
+ return host == "man.sr.ht"
+ || host.hasSuffix(".man.sr.ht")
+ || host == "srht.site"
+ }
+
+ private static func extractTitle(from html: String, fallbackURL: URL) -> String {
+ if let headerHTML = substring(
+ in: html,
+ startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"#
+ ),
+ let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) {
+ let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !title.isEmpty {
+ return title
+ }
+ }
+
+ if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) {
+ let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines)
+ let suffix = " - man.sr.ht"
+ let normalizedTitle: String
+ if rawTitle.hasSuffix(suffix) {
+ normalizedTitle = String(rawTitle.dropLast(suffix.count))
+ } else {
+ normalizedTitle = rawTitle
+ }
+
+ if !normalizedTitle.isEmpty {
+ return normalizedTitle
+ }
+ }
+
+ let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? ""
+ return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent
+ }
+
+ private static func extractContent(from html: String) -> String {
+ if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty {
+ return content
+ }
+
+ if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty {
+ return content
+ }
+
+ if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty {
+ return content
+ }
+
+ return ""
+ }
+
+ private static func sanitizeContentHTML(_ html: String) -> String {
+ html.replacingOccurrences(
+ of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###,
+ with: "",
+ options: [.regularExpression, .caseInsensitive]
+ )
+ }
+
+ private static func extractArticleBlock(from html: String, className: String) -> String? {
+ extractElementBlock(from: html, elementName: "article", className: className)
+ }
+
+ private static func extractDivBlock(from html: String, className: String) -> String? {
+ extractElementBlock(from: html, elementName: "div", className: className)
+ }
+
+ private static func extractElementBlock(
+ from html: String,
+ elementName: String,
+ className: String
+ ) -> String? {
+ guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else {
+ return nil
+ }
+
+ let characters = Array(html)
+ var index = html.distance(from: html.startIndex, to: startRange.lowerBound)
+ var depth = 0
+ var foundOpeningDiv = false
+
+ while index < characters.count {
+ guard characters[index] == "<" else {
+ index += 1
+ continue
+ }
+
+ if hasPrefix("</\(elementName)", at: index, in: characters) {
+ if foundOpeningDiv {
+ depth -= 1
+ if depth == 0 {
+ let closeEnd = endOfTag(startingAt: index, in: characters)
+ return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd])
+ }
+ }
+ index += 1
+ continue
+ }
+
+ if hasPrefix("<\(elementName)", at: index, in: characters) {
+ if !isSelfClosingTag(startingAt: index, in: characters) {
+ depth += 1
+ foundOpeningDiv = true
+ }
+ index += 1
+ continue
+ }
+
+ index += 1
+ }
+
+ return nil
+ }
+
+ private static func firstMatch(in text: String, pattern: String) -> String? {
+ guard let regex = try? NSRegularExpression(
+ pattern: pattern,
+ options: [.caseInsensitive, .dotMatchesLineSeparators]
+ ) else {
+ return nil
+ }
+
+ let range = NSRange(text.startIndex..., in: text)
+ guard let match = regex.firstMatch(in: text, range: range),
+ match.numberOfRanges > 1,
+ let captureRange = Range(match.range(at: 1), in: text) else {
+ return nil
+ }
+
+ return String(text[captureRange])
+ }
+
+ private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? {
+ guard let range = text.range(of: needle) else {
+ return nil
+ }
+
+ return String(text[range.lowerBound...])
+ }
+
+ private static func stripHTML(from text: String) -> String {
+ let noTags = text.replacingOccurrences(
+ of: #"<[^>]+>"#,
+ with: "",
+ options: .regularExpression
+ )
+
+ return decodeHTMLEntities(noTags)
+ }
+
+ private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool {
+ guard index + prefix.count <= characters.count else { return false }
+ return String(characters[index..<(index + prefix.count)]).lowercased() == prefix
+ }
+
+ private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool {
+ let tagEnd = endOfTag(startingAt: index, in: characters)
+ guard tagEnd > index else { return false }
+ let tagContents = String(characters[index..<tagEnd])
+ return tagContents.contains("/>")
+ }
+
+ private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int {
+ var current = index
+ while current < characters.count {
+ if characters[current] == ">" {
+ return current + 1
+ }
+ current += 1
+ }
+ return characters.count
+ }
+}
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index bee2453..eb585fb 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -449,6 +449,10 @@ struct LookupView: View {
NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
}
)
+ case .manPageBrowser:
+ ManPageBrowserView()
+ case .manPage(let url):
+ ManPageDetailView(url: url)
}
}
.environment(appState)
diff --git a/Hutch/Views/More/ManPageBrowserView.swift b/Hutch/Views/More/ManPageBrowserView.swift
new file mode 100644
index 0000000..f854c79
--- /dev/null
+++ b/Hutch/Views/More/ManPageBrowserView.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+/// Entry point for the man.sr.ht browser in the More tab.
+/// Shows a pre-populated list of official sr.ht man pages.
+struct ManPageBrowserView: View {
+ private let officialDocs: [(title: String, url: URL)] = [
+ ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!),
+ ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!),
+ ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!),
+ ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!),
+ ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!),
+ ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!),
+ ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!),
+ ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!),
+ ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!),
+ ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!),
+ ("srht.site", URL(string: "https://srht.site/")!)
+ ]
+
+ var body: some View {
+ List {
+ Section("Official Man Pages") {
+ ForEach(officialDocs, id: \.title) { doc in
+ NavigationLink(value: MoreRoute.manPage(doc.url)) {
+ Text(doc.title)
+ }
+ }
+ }
+ }
+ .navigationTitle("Man Pages")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Hutch/Views/More/ManPageDetailView.swift b/Hutch/Views/More/ManPageDetailView.swift
new file mode 100644
index 0000000..96a92fb
--- /dev/null
+++ b/Hutch/Views/More/ManPageDetailView.swift
@@ -0,0 +1,103 @@
+import SwiftUI
+
+/// Fetches and renders a single man.sr.ht page.
+/// Internal man.sr.ht links update the current URL in-place rather than
+/// opening the browser, so the user can follow wiki links without leaving
+/// the view.
+struct ManPageDetailView: View {
+ let initialURL: URL
+
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var currentURL: URL
+ @State private var page: ManPage?
+ @State private var isLoading = false
+ @State private var error: String?
+
+ init(url: URL) {
+ initialURL = url
+ _currentURL = State(initialValue: url)
+ }
+
+ var body: some View {
+ ScrollView {
+ if isLoading {
+ SRHTLoadingStateView(message: "Loading page…")
+ .padding(.top, 40)
+ } else if let error {
+ SRHTErrorStateView(
+ title: "Couldn't Load Page",
+ message: error,
+ retryAction: { await loadPage() }
+ )
+ .padding()
+ } else if let page {
+ HTMLWebView(
+ html: page.contentHTML,
+ colorScheme: colorScheme,
+ style: .readme,
+ baseURL: page.url,
+ onInterceptURL: { url in
+ guard let destinationURL = normalizedManPageURL(for: url) else {
+ return false
+ }
+ currentURL = destinationURL
+ return true
+ }
+ )
+ .padding()
+ }
+ }
+ .navigationTitle(page?.title ?? "Documentation")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if let page {
+ Link(destination: page.url) {
+ Image(systemName: "safari")
+ }
+ }
+ }
+ }
+ .task(id: currentURL) {
+ await loadPage()
+ }
+ }
+
+ private func loadPage() async {
+ isLoading = true
+ error = nil
+
+ do {
+ page = try await ManPageService.fetch(url: currentURL)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ private func normalizedManPageURL(for url: URL) -> URL? {
+ if ManPageService.isTrustedDocumentationURL(url) {
+ return url
+ }
+
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let scheme = components.scheme?.lowercased(),
+ scheme == "about" || scheme == "file" else {
+ return nil
+ }
+
+ let rawPath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard !rawPath.isEmpty else {
+ if currentURL.host?.lowercased() == "srht.site" {
+ return ManPageService.pagesBaseURL
+ }
+ return ManPageService.baseURL
+ }
+
+ if currentURL.host?.lowercased() == "srht.site" {
+ return URL(string: "https://srht.site/\(rawPath)") ?? ManPageService.pagesBaseURL
+ }
+ return URL(string: "https://man.sr.ht/\(rawPath)/") ?? ManPageService.baseURL
+ }
+}
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index 9c5cf92..ae7228d 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -5,7 +5,6 @@ struct MoreView: View {
private let unsupportedLinks: [(title: String, url: URL)] = [
("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
- ("man.sr.ht", URL(string: "https://man.sr.ht")!),
("srht.site", URL(string: "https://srht.site")!)
]
@@ -21,6 +20,10 @@ struct MoreView: View {
NavigationLink(value: MoreRoute.lists) {
Label("Mailing Lists", systemImage: "list.bullet.rectangle")
}
+
+ NavigationLink(value: MoreRoute.manPageBrowser) {
+ Label("Man Pages", systemImage: "book")
+ }
NavigationLink(value: MoreRoute.pastes) {
Label("Pastes", systemImage: "doc.on.clipboard")
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 93fee26..5742811 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -1522,6 +1522,8 @@ struct HTMLWebView: View {
let html: String
let colorScheme: ColorScheme
var style: HTMLWebViewStyle = .readme
+ var baseURL: URL? = nil
+ var onInterceptURL: ((URL) -> Bool)? = nil
@Environment(\.openURL) private var openURL
@State private var contentHeight: CGFloat = 1
@State private var loadError: String?
@@ -1545,6 +1547,8 @@ struct HTMLWebView: View {
html: html,
colorScheme: colorScheme,
style: style,
+ baseURL: baseURL,
+ onInterceptURL: onInterceptURL,
openURL: openURL,
dynamicHeight: $contentHeight,
loadError: $loadError,
@@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
let html: String
let colorScheme: ColorScheme
let style: HTMLWebViewStyle
+ let baseURL: URL?
+ let onInterceptURL: ((URL) -> Bool)?
let openURL: OpenURLAction
@Binding var dynamicHeight: CGFloat
@Binding var loadError: String?
@@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
}
func updateUIView(_ webView: WKWebView, context: Context) {
+ context.coordinator.parent = self
let textColor = colorScheme == .dark ? "#fff" : "#000"
let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
@@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
overflow-wrap: normal;
}
img { max-width: 100%; height: auto; }
+ svg {
+ max-width: 100%;
+ height: auto;
+ }
input[type="checkbox"] {
margin-right: 0.45rem;
vertical-align: middle;
@@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
color: rgba(128, 128, 128, 0.85);
font-size: 0.9em;
}
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ }
+ .icon {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: middle;
+ }
+ .icon svg {
+ width: 0.65em;
+ height: 0.65em;
+ display: block;
+ fill: currentColor;
+ }
.org-title { margin: 0 0 0.25em; }
.org-author, .org-date {
margin: 0;
@@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
self.loadError = nil
}
}
- webView.loadHTMLString(wrapped, baseURL: nil)
+ webView.loadHTMLString(wrapped, baseURL: baseURL)
}
}
@@ -1751,7 +1778,7 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
static let heightCache = NSCache<NSString, NSNumber>()
- let parent: HTMLWebViewRepresentable
+ var parent: HTMLWebViewRepresentable
var lastHTML: String?
var lastReloadToken = 0
@@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
}
if navigationAction.navigationType == .linkActivated {
+ if isSameDocumentFragmentNavigation(requestURL) {
+ decisionHandler(.allow)
+ return
+ }
+ if let intercept = parent.onInterceptURL, intercept(requestURL) {
+ decisionHandler(.cancel)
+ return
+ }
if isAllowedReadmeNavigationURL(requestURL) {
parent.openURL(requestURL)
}
@@ -1824,4 +1859,20 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
}
}
}
+
+ private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool {
+ guard url.fragment != nil,
+ let baseURL = parent.baseURL else {
+ return false
+ }
+
+ guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
+ return false
+ }
+
+ destination.fragment = nil
+ base.fragment = nil
+ return destination.url == base.url
+ }
}