Commit db74be7ec4
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | |||
| 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | CODE_SIGN_STYLE = Automatic; | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 23; | 542 | CURRENT_PROJECT_VERSION = 24; |
| 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | ENABLE_PREVIEWS = YES; | 544 | ENABLE_PREVIEWS = YES; |
| 545 | GENERATE_INFOPLIST_FILE = YES; | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | |||
| 556 | "$(inherited)", | 556 | "$(inherited)", |
| 557 | "@executable_path/Frameworks", | 557 | "@executable_path/Frameworks", |
| 558 | ); | 558 | ); |
| 559 | MARKETING_VERSION = 2.9.1; | 559 | MARKETING_VERSION = 2.10.0; |
| 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | PRODUCT_NAME = "$(TARGET_NAME)"; | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | |||
| 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | CODE_SIGN_STYLE = Automatic; | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 23; | 579 | CURRENT_PROJECT_VERSION = 24; |
| 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | ENABLE_PREVIEWS = YES; | 581 | ENABLE_PREVIEWS = YES; |
| 582 | GENERATE_INFOPLIST_FILE = YES; | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | |||
| 593 | "$(inherited)", | 593 | "$(inherited)", |
| 594 | "@executable_path/Frameworks", | 594 | "@executable_path/Frameworks", |
| 595 | ); | 595 | ); |
| 596 | MARKETING_VERSION = 2.9.1; | 596 | MARKETING_VERSION = 2.10.0; |
| 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | PRODUCT_NAME = "$(TARGET_NAME)"; | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | |||
| 656 | APPLICATION_EXTENSION_API_ONLY = YES; | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | CODE_SIGN_STYLE = Automatic; | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 23; | 659 | CURRENT_PROJECT_VERSION = 24; |
| 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | GENERATE_INFOPLIST_FILE = NO; | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | |||
| 666 | "@executable_path/Frameworks", | 666 | "@executable_path/Frameworks", |
| 667 | "@executable_path/../../Frameworks", | 667 | "@executable_path/../../Frameworks", |
| 668 | ); | 668 | ); |
| 669 | MARKETING_VERSION = 2.9.1; | 669 | MARKETING_VERSION = 2.10.0; |
| 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | PRODUCT_NAME = "$(TARGET_NAME)"; | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | SKIP_INSTALL = YES; | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | |||
| 685 | APPLICATION_EXTENSION_API_ONLY = YES; | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | CODE_SIGN_STYLE = Automatic; | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 23; | 688 | CURRENT_PROJECT_VERSION = 24; |
| 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | GENERATE_INFOPLIST_FILE = NO; | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | |||
| 695 | "@executable_path/Frameworks", | 695 | "@executable_path/Frameworks", |
| 696 | "@executable_path/../../Frameworks", | 696 | "@executable_path/../../Frameworks", |
| 697 | ); | 697 | ); |
| 698 | MARKETING_VERSION = 2.9.1; | 698 | MARKETING_VERSION = 2.10.0; |
| 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | PRODUCT_NAME = "$(TARGET_NAME)"; | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | SKIP_INSTALL = YES; | 701 | SKIP_INSTALL = YES; |
Hutch/App/RootView.swift +6
| @@ -269,6 +269,8 @@ enum MoreRoute: Hashable { | |||
| 269 | case settings | 269 | case settings |
| 270 | case mailingList(InboxMailingListReference) | 270 | case mailingList(InboxMailingListReference) |
| 271 | case thread(InboxThreadSummary) | 271 | case thread(InboxThreadSummary) |
| 272 | case manPageBrowser | ||
| 273 | case manPage(URL) | ||
| 272 | } | 274 | } |
| 273 | 275 | ||
| 274 | private struct MoreNavigationRoot: View { | 276 | private struct MoreNavigationRoot: View { |
| @@ -302,6 +304,10 @@ private struct MoreNavigationRoot: View { | |||
| 302 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 304 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) |
| 303 | } | 305 | } |
| 304 | ) | 306 | ) |
| 307 | case .manPageBrowser: | ||
| 308 | ManPageBrowserView() | ||
| 309 | case .manPage(let url): | ||
| 310 | ManPageDetailView(url: url) | ||
| 305 | } | 311 | } |
| 306 | } | 312 | } |
| 307 | } | 313 | } |
Hutch/Networking/ManPageService.swift added +216
| @@ -0,0 +1,216 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Fetches and parses man.sr.ht wiki pages over plain HTTP (no auth required). | ||
| 4 | struct ManPage: Sendable { | ||
| 5 | let url: URL | ||
| 6 | let title: String | ||
| 7 | let contentHTML: String | ||
| 8 | } | ||
| 9 | |||
| 10 | struct ManPageService { | ||
| 11 | static let baseURL = URL(string: "https://man.sr.ht")! | ||
| 12 | static let pagesBaseURL = URL(string: "https://srht.site/")! | ||
| 13 | |||
| 14 | /// Fetches a man.sr.ht page and extracts the article content. | ||
| 15 | /// Uses an unauthenticated URLSession because man.sr.ht pages are public. | ||
| 16 | static func fetch(url: URL) async throws -> ManPage { | ||
| 17 | guard isTrustedDocumentationURL(url) else { | ||
| 18 | throw URLError(.badURL) | ||
| 19 | } | ||
| 20 | |||
| 21 | let (data, response) = try await URLSession.shared.data(from: url) | ||
| 22 | if let http = response as? HTTPURLResponse, | ||
| 23 | !(200...299).contains(http.statusCode) { | ||
| 24 | throw URLError(.badServerResponse) | ||
| 25 | } | ||
| 26 | guard let html = String(data: data, encoding: .utf8) else { | ||
| 27 | throw URLError(.cannotDecodeContentData) | ||
| 28 | } | ||
| 29 | |||
| 30 | return ManPage( | ||
| 31 | url: url, | ||
| 32 | title: extractTitle(from: html, fallbackURL: url), | ||
| 33 | contentHTML: sanitizeContentHTML(extractContent(from: html)) | ||
| 34 | ) | ||
| 35 | } | ||
| 36 | |||
| 37 | static func isTrustedDocumentationURL(_ url: URL) -> Bool { | ||
| 38 | guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, | ||
| 39 | let host = url.host?.lowercased() else { | ||
| 40 | return false | ||
| 41 | } | ||
| 42 | |||
| 43 | return host == "man.sr.ht" | ||
| 44 | || host.hasSuffix(".man.sr.ht") | ||
| 45 | || host == "srht.site" | ||
| 46 | } | ||
| 47 | |||
| 48 | private static func extractTitle(from html: String, fallbackURL: URL) -> String { | ||
| 49 | if let headerHTML = substring( | ||
| 50 | in: html, | ||
| 51 | startingAtFirstOccurrenceOf: #"<div class="header-tabbed">"# | ||
| 52 | ), | ||
| 53 | let h2Contents = firstMatch(in: headerHTML, pattern: #"<h2\b[^>]*>(.*?)</h2>"#) { | ||
| 54 | let title = stripHTML(from: h2Contents).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 55 | if !title.isEmpty { | ||
| 56 | return title | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | if let titleContents = firstMatch(in: html, pattern: #"<title\b[^>]*>(.*?)</title>"#) { | ||
| 61 | let rawTitle = stripHTML(from: titleContents).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 62 | let suffix = " - man.sr.ht" | ||
| 63 | let normalizedTitle: String | ||
| 64 | if rawTitle.hasSuffix(suffix) { | ||
| 65 | normalizedTitle = String(rawTitle.dropLast(suffix.count)) | ||
| 66 | } else { | ||
| 67 | normalizedTitle = rawTitle | ||
| 68 | } | ||
| 69 | |||
| 70 | if !normalizedTitle.isEmpty { | ||
| 71 | return normalizedTitle | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | let lastComponent = fallbackURL.pathComponents.last { $0 != "/" } ?? "" | ||
| 76 | return lastComponent.isEmpty ? fallbackURL.absoluteString : lastComponent | ||
| 77 | } | ||
| 78 | |||
| 79 | private static func extractContent(from html: String) -> String { | ||
| 80 | if let content = extractDivBlock(from: html, className: "markdown"), !content.isEmpty { | ||
| 81 | return content | ||
| 82 | } | ||
| 83 | |||
| 84 | if let content = extractArticleBlock(from: html, className: "content"), !content.isEmpty { | ||
| 85 | return content | ||
| 86 | } | ||
| 87 | |||
| 88 | if let content = extractDivBlock(from: html, className: "content"), !content.isEmpty { | ||
| 89 | return content | ||
| 90 | } | ||
| 91 | |||
| 92 | return "" | ||
| 93 | } | ||
| 94 | |||
| 95 | private static func sanitizeContentHTML(_ html: String) -> String { | ||
| 96 | html.replacingOccurrences( | ||
| 97 | of: ###"<a\b[^>]*aria-hidden="true"[^>]*href="#[^"]*"[^>]*>\s*#\s*</a>"###, | ||
| 98 | with: "", | ||
| 99 | options: [.regularExpression, .caseInsensitive] | ||
| 100 | ) | ||
| 101 | } | ||
| 102 | |||
| 103 | private static func extractArticleBlock(from html: String, className: String) -> String? { | ||
| 104 | extractElementBlock(from: html, elementName: "article", className: className) | ||
| 105 | } | ||
| 106 | |||
| 107 | private static func extractDivBlock(from html: String, className: String) -> String? { | ||
| 108 | extractElementBlock(from: html, elementName: "div", className: className) | ||
| 109 | } | ||
| 110 | |||
| 111 | private static func extractElementBlock( | ||
| 112 | from html: String, | ||
| 113 | elementName: String, | ||
| 114 | className: String | ||
| 115 | ) -> String? { | ||
| 116 | guard let startRange = html.range(of: #"<\#(elementName) class="\#(className)""#) else { | ||
| 117 | return nil | ||
| 118 | } | ||
| 119 | |||
| 120 | let characters = Array(html) | ||
| 121 | var index = html.distance(from: html.startIndex, to: startRange.lowerBound) | ||
| 122 | var depth = 0 | ||
| 123 | var foundOpeningDiv = false | ||
| 124 | |||
| 125 | while index < characters.count { | ||
| 126 | guard characters[index] == "<" else { | ||
| 127 | index += 1 | ||
| 128 | continue | ||
| 129 | } | ||
| 130 | |||
| 131 | if hasPrefix("</\(elementName)", at: index, in: characters) { | ||
| 132 | if foundOpeningDiv { | ||
| 133 | depth -= 1 | ||
| 134 | if depth == 0 { | ||
| 135 | let closeEnd = endOfTag(startingAt: index, in: characters) | ||
| 136 | return String(characters[html.distance(from: html.startIndex, to: startRange.lowerBound)..<closeEnd]) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | index += 1 | ||
| 140 | continue | ||
| 141 | } | ||
| 142 | |||
| 143 | if hasPrefix("<\(elementName)", at: index, in: characters) { | ||
| 144 | if !isSelfClosingTag(startingAt: index, in: characters) { | ||
| 145 | depth += 1 | ||
| 146 | foundOpeningDiv = true | ||
| 147 | } | ||
| 148 | index += 1 | ||
| 149 | continue | ||
| 150 | } | ||
| 151 | |||
| 152 | index += 1 | ||
| 153 | } | ||
| 154 | |||
| 155 | return nil | ||
| 156 | } | ||
| 157 | |||
| 158 | private static func firstMatch(in text: String, pattern: String) -> String? { | ||
| 159 | guard let regex = try? NSRegularExpression( | ||
| 160 | pattern: pattern, | ||
| 161 | options: [.caseInsensitive, .dotMatchesLineSeparators] | ||
| 162 | ) else { | ||
| 163 | return nil | ||
| 164 | } | ||
| 165 | |||
| 166 | let range = NSRange(text.startIndex..., in: text) | ||
| 167 | guard let match = regex.firstMatch(in: text, range: range), | ||
| 168 | match.numberOfRanges > 1, | ||
| 169 | let captureRange = Range(match.range(at: 1), in: text) else { | ||
| 170 | return nil | ||
| 171 | } | ||
| 172 | |||
| 173 | return String(text[captureRange]) | ||
| 174 | } | ||
| 175 | |||
| 176 | private static func substring(in text: String, startingAtFirstOccurrenceOf needle: String) -> String? { | ||
| 177 | guard let range = text.range(of: needle) else { | ||
| 178 | return nil | ||
| 179 | } | ||
| 180 | |||
| 181 | return String(text[range.lowerBound...]) | ||
| 182 | } | ||
| 183 | |||
| 184 | private static func stripHTML(from text: String) -> String { | ||
| 185 | let noTags = text.replacingOccurrences( | ||
| 186 | of: #"<[^>]+>"#, | ||
| 187 | with: "", | ||
| 188 | options: .regularExpression | ||
| 189 | ) | ||
| 190 | |||
| 191 | return decodeHTMLEntities(noTags) | ||
| 192 | } | ||
| 193 | |||
| 194 | private static func hasPrefix(_ prefix: String, at index: Int, in characters: [Character]) -> Bool { | ||
| 195 | guard index + prefix.count <= characters.count else { return false } | ||
| 196 | return String(characters[index..<(index + prefix.count)]).lowercased() == prefix | ||
| 197 | } | ||
| 198 | |||
| 199 | private static func isSelfClosingTag(startingAt index: Int, in characters: [Character]) -> Bool { | ||
| 200 | let tagEnd = endOfTag(startingAt: index, in: characters) | ||
| 201 | guard tagEnd > index else { return false } | ||
| 202 | let tagContents = String(characters[index..<tagEnd]) | ||
| 203 | return tagContents.contains("/>") | ||
| 204 | } | ||
| 205 | |||
| 206 | private static func endOfTag(startingAt index: Int, in characters: [Character]) -> Int { | ||
| 207 | var current = index | ||
| 208 | while current < characters.count { | ||
| 209 | if characters[current] == ">" { | ||
| 210 | return current + 1 | ||
| 211 | } | ||
| 212 | current += 1 | ||
| 213 | } | ||
| 214 | return characters.count | ||
| 215 | } | ||
| 216 | } | ||
Hutch/Views/Lookup/LookupView.swift +4
| @@ -449,6 +449,10 @@ struct LookupView: View { | |||
| 449 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | 449 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) |
| 450 | } | 450 | } |
| 451 | ) | 451 | ) |
| 452 | case .manPageBrowser: | ||
| 453 | ManPageBrowserView() | ||
| 454 | case .manPage(let url): | ||
| 455 | ManPageDetailView(url: url) | ||
| 452 | } | 456 | } |
| 453 | } | 457 | } |
| 454 | .environment(appState) | 458 | .environment(appState) |
Hutch/Views/More/ManPageBrowserView.swift added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Entry point for the man.sr.ht browser in the More tab. | ||
| 4 | /// Shows a pre-populated list of official sr.ht man pages. | ||
| 5 | struct ManPageBrowserView: View { | ||
| 6 | private let officialDocs: [(title: String, url: URL)] = [ | ||
| 7 | ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!), | ||
| 8 | ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!), | ||
| 9 | ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!), | ||
| 10 | ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!), | ||
| 11 | ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!), | ||
| 12 | ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!), | ||
| 13 | ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!), | ||
| 14 | ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!), | ||
| 15 | ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!), | ||
| 16 | ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!), | ||
| 17 | ("srht.site", URL(string: "https://srht.site/")!) | ||
| 18 | ] | ||
| 19 | |||
| 20 | var body: some View { | ||
| 21 | List { | ||
| 22 | Section("Official Man Pages") { | ||
| 23 | ForEach(officialDocs, id: \.title) { doc in | ||
| 24 | NavigationLink(value: MoreRoute.manPage(doc.url)) { | ||
| 25 | Text(doc.title) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | } | ||
| 30 | .navigationTitle("Man Pages") | ||
| 31 | .navigationBarTitleDisplayMode(.inline) | ||
| 32 | } | ||
| 33 | } | ||
Hutch/Views/More/ManPageDetailView.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Fetches and renders a single man.sr.ht page. | ||
| 4 | /// Internal man.sr.ht links update the current URL in-place rather than | ||
| 5 | /// opening the browser, so the user can follow wiki links without leaving | ||
| 6 | /// the view. | ||
| 7 | struct ManPageDetailView: View { | ||
| 8 | let initialURL: URL | ||
| 9 | |||
| 10 | @Environment(\.colorScheme) private var colorScheme | ||
| 11 | @State private var currentURL: URL | ||
| 12 | @State private var page: ManPage? | ||
| 13 | @State private var isLoading = false | ||
| 14 | @State private var error: String? | ||
| 15 | |||
| 16 | init(url: URL) { | ||
| 17 | initialURL = url | ||
| 18 | _currentURL = State(initialValue: url) | ||
| 19 | } | ||
| 20 | |||
| 21 | var body: some View { | ||
| 22 | ScrollView { | ||
| 23 | if isLoading { | ||
| 24 | SRHTLoadingStateView(message: "Loading page…") | ||
| 25 | .padding(.top, 40) | ||
| 26 | } else if let error { | ||
| 27 | SRHTErrorStateView( | ||
| 28 | title: "Couldn't Load Page", | ||
| 29 | message: error, | ||
| 30 | retryAction: { await loadPage() } | ||
| 31 | ) | ||
| 32 | .padding() | ||
| 33 | } else if let page { | ||
| 34 | HTMLWebView( | ||
| 35 | html: page.contentHTML, | ||
| 36 | colorScheme: colorScheme, | ||
| 37 | style: .readme, | ||
| 38 | baseURL: page.url, | ||
| 39 | onInterceptURL: { url in | ||
| 40 | guard let destinationURL = normalizedManPageURL(for: url) else { | ||
| 41 | return false | ||
| 42 | } | ||
| 43 | currentURL = destinationURL | ||
| 44 | return true | ||
| 45 | } | ||
| 46 | ) | ||
| 47 | .padding() | ||
| 48 | } | ||
| 49 | } | ||
| 50 | .navigationTitle(page?.title ?? "Documentation") | ||
| 51 | .navigationBarTitleDisplayMode(.inline) | ||
| 52 | .toolbar { | ||
| 53 | ToolbarItem(placement: .topBarTrailing) { | ||
| 54 | if let page { | ||
| 55 | Link(destination: page.url) { | ||
| 56 | Image(systemName: "safari") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .task(id: currentURL) { | ||
| 62 | await loadPage() | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | private func loadPage() async { | ||
| 67 | isLoading = true | ||
| 68 | error = nil | ||
| 69 | |||
| 70 | do { | ||
| 71 | page = try await ManPageService.fetch(url: currentURL) | ||
| 72 | } catch { | ||
| 73 | self.error = error.localizedDescription | ||
| 74 | } | ||
| 75 | |||
| 76 | isLoading = false | ||
| 77 | } | ||
| 78 | |||
| 79 | private func normalizedManPageURL(for url: URL) -> URL? { | ||
| 80 | if ManPageService.isTrustedDocumentationURL(url) { | ||
| 81 | return url | ||
| 82 | } | ||
| 83 | |||
| 84 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), | ||
| 85 | let scheme = components.scheme?.lowercased(), | ||
| 86 | scheme == "about" || scheme == "file" else { | ||
| 87 | return nil | ||
| 88 | } | ||
| 89 | |||
| 90 | let rawPath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 91 | guard !rawPath.isEmpty else { | ||
| 92 | if currentURL.host?.lowercased() == "srht.site" { | ||
| 93 | return ManPageService.pagesBaseURL | ||
| 94 | } | ||
| 95 | return ManPageService.baseURL | ||
| 96 | } | ||
| 97 | |||
| 98 | if currentURL.host?.lowercased() == "srht.site" { | ||
| 99 | return URL(string: "https://srht.site/\(rawPath)") ?? ManPageService.pagesBaseURL | ||
| 100 | } | ||
| 101 | return URL(string: "https://man.sr.ht/\(rawPath)/") ?? ManPageService.baseURL | ||
| 102 | } | ||
| 103 | } | ||
Hutch/Views/More/MoreView.swift +4 −1
| @@ -5,7 +5,6 @@ struct MoreView: View { | |||
| 5 | 5 | ||
| 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ | 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ |
| 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), | 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), |
| 8 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), | ||
| 9 | ("srht.site", URL(string: "https://srht.site")!) | 8 | ("srht.site", URL(string: "https://srht.site")!) |
| 10 | ] | 9 | ] |
| 11 | 10 | ||
| @@ -21,6 +20,10 @@ struct MoreView: View { | |||
| 21 | NavigationLink(value: MoreRoute.lists) { | 20 | NavigationLink(value: MoreRoute.lists) { |
| 22 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") | 21 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") |
| 23 | } | 22 | } |
| 23 | |||
| 24 | NavigationLink(value: MoreRoute.manPageBrowser) { | ||
| 25 | Label("Man Pages", systemImage: "book") | ||
| 26 | } | ||
| 24 | 27 | ||
| 25 | NavigationLink(value: MoreRoute.pastes) { | 28 | NavigationLink(value: MoreRoute.pastes) { |
| 26 | Label("Pastes", systemImage: "doc.on.clipboard") | 29 | Label("Pastes", systemImage: "doc.on.clipboard") |
Hutch/Views/Repositories/ReadmeView.swift +53 −2
| @@ -1522,6 +1522,8 @@ struct HTMLWebView: View { | |||
| 1522 | let html: String | 1522 | let html: String |
| 1523 | let colorScheme: ColorScheme | 1523 | let colorScheme: ColorScheme |
| 1524 | var style: HTMLWebViewStyle = .readme | 1524 | var style: HTMLWebViewStyle = .readme |
| 1525 | var baseURL: URL? = nil | ||
| 1526 | var onInterceptURL: ((URL) -> Bool)? = nil | ||
| 1525 | @Environment(\.openURL) private var openURL | 1527 | @Environment(\.openURL) private var openURL |
| 1526 | @State private var contentHeight: CGFloat = 1 | 1528 | @State private var contentHeight: CGFloat = 1 |
| 1527 | @State private var loadError: String? | 1529 | @State private var loadError: String? |
| @@ -1545,6 +1547,8 @@ struct HTMLWebView: View { | |||
| 1545 | html: html, | 1547 | html: html, |
| 1546 | colorScheme: colorScheme, | 1548 | colorScheme: colorScheme, |
| 1547 | style: style, | 1549 | style: style, |
| 1550 | baseURL: baseURL, | ||
| 1551 | onInterceptURL: onInterceptURL, | ||
| 1548 | openURL: openURL, | 1552 | openURL: openURL, |
| 1549 | dynamicHeight: $contentHeight, | 1553 | dynamicHeight: $contentHeight, |
| 1550 | loadError: $loadError, | 1554 | loadError: $loadError, |
| @@ -1581,6 +1585,8 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | |||
| 1581 | let html: String | 1585 | let html: String |
| 1582 | let colorScheme: ColorScheme | 1586 | let colorScheme: ColorScheme |
| 1583 | let style: HTMLWebViewStyle | 1587 | let style: HTMLWebViewStyle |
| 1588 | let baseURL: URL? | ||
| 1589 | let onInterceptURL: ((URL) -> Bool)? | ||
| 1584 | let openURL: OpenURLAction | 1590 | let openURL: OpenURLAction |
| 1585 | @Binding var dynamicHeight: CGFloat | 1591 | @Binding var dynamicHeight: CGFloat |
| 1586 | @Binding var loadError: String? | 1592 | @Binding var loadError: String? |
| @@ -1607,6 +1613,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | |||
| 1607 | } | 1613 | } |
| 1608 | 1614 | ||
| 1609 | func updateUIView(_ webView: WKWebView, context: Context) { | 1615 | func updateUIView(_ webView: WKWebView, context: Context) { |
| 1616 | context.coordinator.parent = self | ||
| 1610 | let textColor = colorScheme == .dark ? "#fff" : "#000" | 1617 | let textColor = colorScheme == .dark ? "#fff" : "#000" |
| 1611 | let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" | 1618 | let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" |
| 1612 | 1619 | ||
| @@ -1648,6 +1655,10 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | |||
| 1648 | overflow-wrap: normal; | 1655 | overflow-wrap: normal; |
| 1649 | } | 1656 | } |
| 1650 | img { max-width: 100%; height: auto; } | 1657 | img { max-width: 100%; height: auto; } |
| 1658 | svg { | ||
| 1659 | max-width: 100%; | ||
| 1660 | height: auto; | ||
| 1661 | } | ||
| 1651 | input[type="checkbox"] { | 1662 | input[type="checkbox"] { |
| 1652 | margin-right: 0.45rem; | 1663 | margin-right: 0.45rem; |
| 1653 | vertical-align: middle; | 1664 | vertical-align: middle; |
| @@ -1711,6 +1722,22 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | |||
| 1711 | color: rgba(128, 128, 128, 0.85); | 1722 | color: rgba(128, 128, 128, 0.85); |
| 1712 | font-size: 0.9em; | 1723 | font-size: 0.9em; |
| 1713 | } | 1724 | } |
| 1725 | .btn { | ||
| 1726 | display: inline-flex; | ||
| 1727 | align-items: center; | ||
| 1728 | gap: 0.4em; | ||
| 1729 | } | ||
| 1730 | .icon { | ||
| 1731 | display: inline-flex; | ||
| 1732 | align-items: center; | ||
| 1733 | vertical-align: middle; | ||
| 1734 | } | ||
| 1735 | .icon svg { | ||
| 1736 | width: 0.65em; | ||
| 1737 | height: 0.65em; | ||
| 1738 | display: block; | ||
| 1739 | fill: currentColor; | ||
| 1740 | } | ||
| 1714 | .org-title { margin: 0 0 0.25em; } | 1741 | .org-title { margin: 0 0 0.25em; } |
| 1715 | .org-author, .org-date { | 1742 | .org-author, .org-date { |
| 1716 | margin: 0; | 1743 | margin: 0; |
| @@ -1743,7 +1770,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { | |||
| 1743 | self.loadError = nil | 1770 | self.loadError = nil |
| 1744 | } | 1771 | } |
| 1745 | } | 1772 | } |
| 1746 | webView.loadHTMLString(wrapped, baseURL: nil) | 1773 | webView.loadHTMLString(wrapped, baseURL: baseURL) |
| 1747 | } | 1774 | } |
| 1748 | } | 1775 | } |
| 1749 | 1776 | ||
| @@ -1751,7 +1778,7 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc | |||
| 1751 | static let websiteDataStore = WKWebsiteDataStore.nonPersistent() | 1778 | static let websiteDataStore = WKWebsiteDataStore.nonPersistent() |
| 1752 | static let heightCache = NSCache<NSString, NSNumber>() | 1779 | static let heightCache = NSCache<NSString, NSNumber>() |
| 1753 | 1780 | ||
| 1754 | let parent: HTMLWebViewRepresentable | 1781 | var parent: HTMLWebViewRepresentable |
| 1755 | var lastHTML: String? | 1782 | var lastHTML: String? |
| 1756 | var lastReloadToken = 0 | 1783 | var lastReloadToken = 0 |
| 1757 | 1784 | ||
| @@ -1785,6 +1812,14 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc | |||
| 1785 | } | 1812 | } |
| 1786 | 1813 | ||
| 1787 | if navigationAction.navigationType == .linkActivated { | 1814 | if navigationAction.navigationType == .linkActivated { |
| 1815 | if isSameDocumentFragmentNavigation(requestURL) { | ||
| 1816 | decisionHandler(.allow) | ||
| 1817 | return | ||
| 1818 | } | ||
| 1819 | if let intercept = parent.onInterceptURL, intercept(requestURL) { | ||
| 1820 | decisionHandler(.cancel) | ||
| 1821 | return | ||
| 1822 | } | ||
| 1788 | if isAllowedReadmeNavigationURL(requestURL) { | 1823 | if isAllowedReadmeNavigationURL(requestURL) { |
| 1789 | parent.openURL(requestURL) | 1824 | parent.openURL(requestURL) |
| 1790 | } | 1825 | } |
| @@ -1824,4 +1859,20 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc | |||
| 1824 | } | 1859 | } |
| 1825 | } | 1860 | } |
| 1826 | } | 1861 | } |
| 1862 | |||
| 1863 | private func isSameDocumentFragmentNavigation(_ url: URL) -> Bool { | ||
| 1864 | guard url.fragment != nil, | ||
| 1865 | let baseURL = parent.baseURL else { | ||
| 1866 | return false | ||
| 1867 | } | ||
| 1868 | |||
| 1869 | guard var destination = URLComponents(url: url, resolvingAgainstBaseURL: false), | ||
| 1870 | var base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { | ||
| 1871 | return false | ||
| 1872 | } | ||
| 1873 | |||
| 1874 | destination.fragment = nil | ||
| 1875 | base.fragment = nil | ||
| 1876 | return destination.url == base.url | ||
| 1877 | } | ||
| 1827 | } | 1878 | } |