From 7fba8922e1540741240555560cc9504342bdb091 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 18 Mar 2026 19:16:02 -0500 Subject: Guard authenticated text fetches to sr.ht hosts and add regression test --- Hutch.xcodeproj/project.pbxproj | 130 +++++++++++++++++++++ .../xcshareddata/xcschemes/Hutch.xcscheme | 96 +++++++++++++++ .../xcshareddata/xcschemes/HutchTests.xcscheme | 69 +++++++++++ 3 files changed, 295 insertions(+) create mode 100644 Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme create mode 100644 Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme (limited to 'Hutch.xcodeproj') diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8aab8bb..3cd0646 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -13,12 +13,25 @@ 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B4B28D02F6704280045FA19; + remoteInfo = Hutch; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = ""; }; 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = ""; }; 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = ""; }; + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -40,6 +53,11 @@ path = Hutch; sourceTree = ""; }; + 8B8182B82F6B73F3000AE049 /* HutchTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HutchTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,17 +68,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B42F6B73F3000AE049 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 8B4B28C82F6704280045FA19 = { isa = PBXGroup; children = ( + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, + 8B8182C22F6B742B000AE049 /* HutchTests */, 8B2F89632F69DEB900FC0253 /* LICENSE */, 8B2F89642F69DEB900FC0253 /* README.md */, 8B2F89652F69DEB900FC0253 /* SECURITY.md */, 8B2F89612F69DEA900FC0253 /* TODO.md */, 8B4B28D32F6704280045FA19 /* Hutch */, + 8B8182B82F6B73F3000AE049 /* HutchTests */, 8B4B28D22F6704280045FA19 /* Products */, ); sourceTree = ""; @@ -69,6 +97,7 @@ isa = PBXGroup; children = ( 8B4B28D12F6704280045FA19 /* Hutch.app */, + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, ); name = Products; sourceTree = ""; @@ -98,6 +127,29 @@ productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; productType = "com.apple.product-type.application"; }; + 8B8182B62F6B73F3000AE049 /* HutchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */; + buildPhases = ( + 8B8182B32F6B73F3000AE049 /* Sources */, + 8B8182B42F6B73F3000AE049 /* Frameworks */, + 8B8182B52F6B73F3000AE049 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B8182B82F6B73F3000AE049 /* HutchTests */, + ); + name = HutchTests; + packageProductDependencies = ( + ); + productName = HutchTests; + productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -111,6 +163,10 @@ 8B4B28D02F6704280045FA19 = { CreatedOnToolsVersion = 26.3; }; + 8B8182B62F6B73F3000AE049 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = 8B4B28D02F6704280045FA19; + }; }; }; buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; @@ -128,6 +184,7 @@ projectRoot = ""; targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, + 8B8182B62F6B73F3000AE049 /* HutchTests */, ); }; /* End PBXProject section */ @@ -144,6 +201,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B52F6B73F3000AE049 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -154,8 +218,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B32F6B73F3000AE049 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B4B28D02F6704280045FA19 /* Hutch */; + targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8B4B28DA2F6704290045FA19 /* Debug */ = { isa = XCBuildConfiguration; @@ -350,6 +429,48 @@ }; name = Release; }; + 8B8182BD2F6B73F3000AE049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Debug; + }; + 8B8182BE2F6B73F3000AE049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -371,6 +492,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B8182BD2F6B73F3000AE049 /* Debug */, + 8B8182BE2F6B73F3000AE049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B4B28C92F6704280045FA19 /* Project object */; diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme new file mode 100644 index 0000000..4bb83be --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme new file mode 100644 index 0000000..909ebd2 --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- cgit v1.2.3 From e07b4a28d6ac6086e5b652f72b9089e4d06f7e80 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 18 Mar 2026 19:23:29 -0500 Subject: harden README web rendering and sanitize untrusted links --- .../xcshareddata/xcschemes/HutchTests.xcscheme | 14 +- Hutch/Views/Repositories/ReadmeView.swift | 141 ++++++++++++++++----- HutchTests/ReadmeViewTests.swift | 29 +++++ 3 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 HutchTests/ReadmeViewTests.swift (limited to 'Hutch.xcodeproj') diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme index 909ebd2..2cbf63c 100644 --- a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme @@ -42,7 +42,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - + - + + + + + diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b53885e..576adfc 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St let alt = nsText.substring(with: match.range(at: 1)) let source = nsText.substring(with: match.range(at: 2)) let resolvedSource = imageURLResolver?(source) ?? source - return #"\#(escapeHTMLAttribute(alt))"# + guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { + return escapeHTML(alt) + } + return #"\#(escapeHTMLAttribute(alt))"# } // Links: [text](url) - result = result.replacingOccurrences( - of: #"\[([^\]]+)\]\(([^)]+)\)"#, - with: #"$1"#, - options: .regularExpression - ) + result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in + let label = nsText.substring(with: match.range(at: 1)) + let rawURL = nsText.substring(with: match.range(at: 2)) + guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { + return label + } + return #"\#(label)"# + } // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"\#(label)"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return label + } + return #"\#(label)"# } result = protectMatches( in: result, @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"\#(url)"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return url + } + return #"\#(url)"# } result = protectMatches( in: result, @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } +nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https", "mailto"], + allowsFragmentOnly: true + ) +} + +nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https"], + allowsFragmentOnly: false + ) +} + +nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "about" || scheme == "data" { + return true + } + guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { + return false + } + return sanitizedURL == escapeHTMLAttribute(url.absoluteString) +} + +nonisolated private func sanitizeReadmeURLString( + _ rawURL: String, + allowedSchemes: Set, + allowsFragmentOnly: Bool +) -> String? { + let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return nil } + + if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { + return escapeHTMLAttribute(trimmedURL) + } + + guard let components = URLComponents(string: trimmedURL), + let scheme = components.scheme?.lowercased(), + allowedSchemes.contains(scheme), + let sanitizedURL = components.url?.absoluteString else { + return nil + } + + return escapeHTMLAttribute(sanitizedURL) +} + nonisolated private func isOrgTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } @@ -899,6 +962,7 @@ struct HTMLWebView: View { let html: String let colorScheme: ColorScheme var style: HTMLWebViewStyle = .readme + @Environment(\.openURL) private var openURL @State private var contentHeight: CGFloat = 1 @State private var loadError: String? @State private var reloadToken = 0 @@ -921,6 +985,7 @@ struct HTMLWebView: View { html: html, colorScheme: colorScheme, style: style, + openURL: openURL, dynamicHeight: $contentHeight, loadError: $loadError, reloadToken: reloadToken @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { let html: String let colorScheme: ColorScheme let style: HTMLWebViewStyle + let openURL: OpenURLAction @Binding var dynamicHeight: CGFloat @Binding var loadError: String? let reloadToken: Int @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() - config.defaultWebpagePreferences.allowsContentJavaScript = true + config.defaultWebpagePreferences.allowsContentJavaScript = false config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore let webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false webView.backgroundColor = .clear webView.clipsToBounds = false + webView.allowsLinkPreview = false webView.scrollView.isScrollEnabled = false webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.clipsToBounds = false @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc handleLoadFailure(error) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void + ) { + guard let requestURL = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + if isAllowedReadmeNavigationURL(requestURL) { + parent.openURL(requestURL) + } + decisionHandler(.cancel) + return + } + + if isAllowedReadmeNavigationURL(requestURL) { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + private func handleLoadFailure(_ error: Error) { let nsError = error as NSError guard nsError.code != NSURLErrorCancelled else { return } @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } private func updateHeight(for webView: WKWebView) { - let script = """ - Math.max( - document.body.scrollHeight, - document.body.offsetHeight, - document.documentElement.scrollHeight, - document.documentElement.offsetHeight, - Math.ceil(document.body.getBoundingClientRect().height), - Math.ceil(document.documentElement.getBoundingClientRect().height) - ) - """ - - webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let value = result as? Double, value > 0 else { return } - let height = ceil(value) + 4 - DispatchQueue.main.async { - guard let self else { return } - if let html = self.lastHTML { - Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) - } - if abs(self.parent.dynamicHeight - height) > 0.5 { - self.parent.dynamicHeight = height - } + webView.layoutIfNeeded() + let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 + guard height > 0 else { return } + DispatchQueue.main.async { + if let html = self.lastHTML { + Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) + } + if abs(self.parent.dynamicHeight - height) > 0.5 { + self.parent.dynamicHeight = height } } } diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift new file mode 100644 index 0000000..9c3c019 --- /dev/null +++ b/HutchTests/ReadmeViewTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import Hutch + +struct ReadmeViewTests { + + @Test + func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() { + #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil) + #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil) + #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil) + } + + @Test + func processInlineDropsUnsafeMarkdownLinks() { + let rendered = processInline("[click me](javascript:alert)") + + #expect(rendered == "click me") + #expect(!rendered.contains("href=")) + #expect(!rendered.contains("javascript:")) + } + + @Test + func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() { + #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1") + #expect(sanitizedReadmeLinkURLString("mailto:test@example.com") == "mailto:test@example.com") + #expect(sanitizedReadmeLinkURLString("#readme") == "#readme") + } +} -- cgit v1.2.3 From 01ce5ee73feb8bda35749a186c0efcd3d6d7a060 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 18 Mar 2026 19:52:01 -0500 Subject: use native markdown rendering for settings bio --- Hutch.xcodeproj/project.pbxproj | 8 ++++---- Hutch/Views/Settings/SettingsView.swift | 33 ++++++++++++++++++++++++++------- HutchTests/SettingsViewTests.swift | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 HutchTests/SettingsViewTests.swift (limited to 'Hutch.xcodeproj') diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 3cd0646..6368778 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -363,7 +363,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -380,7 +380,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -399,7 +399,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -416,7 +416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index 802e12d..9b7d41f 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -1,6 +1,10 @@ import PhotosUI import SwiftUI +private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace +) + struct SettingsView: View { @Environment(AppState.self) private var appState @Environment(\.colorScheme) private var colorScheme @@ -163,13 +167,7 @@ struct SettingsView: View { Text("Bio") .font(.caption) .foregroundStyle(.secondary) - RenderedMarkupContentView( - content: .markdown(bio), - readmePath: nil, - colorScheme: colorScheme, - ownerCanonicalName: "", - repositoryName: "" - ) + SettingsBioView(markdown: bio) } } @@ -424,6 +422,27 @@ struct SettingsView: View { } } +private struct SettingsBioView: View { + let markdown: String + + var body: some View { + Text(settingsBioAttributedString(markdown)) + .frame(maxWidth: .infinity, alignment: .leading) + .tint(.accentColor) + .textSelection(.enabled) + } +} + +func settingsBioAttributedString(_ markdown: String) -> AttributedString { + guard let attributed = try? AttributedString( + markdown: markdown, + options: settingsBioMarkdownOptions + ) else { + return AttributedString(markdown) + } + return attributed +} + // MARK: - Edit Profile Sheet private struct EditProfileSheet: View { diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift new file mode 100644 index 0000000..950c3dc --- /dev/null +++ b/HutchTests/SettingsViewTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import Hutch + +struct SettingsViewTests { + + @Test + @MainActor + func settingsBioAttributedStringPreservesInlineMarkdown() { + let attributed = settingsBioAttributedString("Hello **world** and [link](https://example.com)") + + #expect(String(attributed.characters).contains("Hello world and link")) + } + + @Test + @MainActor + func settingsBioAttributedStringFallsBackForInvalidMarkdown() { + let attributed = settingsBioAttributedString("[broken") + + #expect(String(attributed.characters) == "[broken") + } +} -- cgit v1.2.3