summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Sora/Views/Post/Details/PostDetailsImageView.swift38
-rw-r--r--Sora/Views/Post/Details/PostDetailsView.swift8
-rw-r--r--SoraTests/ViewDerivedDataTests.swift88
3 files changed, 111 insertions, 23 deletions
diff --git a/Sora/Views/Post/Details/PostDetailsImageView.swift b/Sora/Views/Post/Details/PostDetailsImageView.swift
index dda1356..1e0da72 100644
--- a/Sora/Views/Post/Details/PostDetailsImageView.swift
+++ b/Sora/Views/Post/Details/PostDetailsImageView.swift
@@ -34,14 +34,17 @@ struct PostDetailsImageView<Placeholder: View>: View {
#if os(iOS)
if settings.enableShareShortcut {
Button {
+ guard let shareURL = url else { return }
+
keyWindow?.rootViewController?.present(
UIActivityViewController(
- activityItems: [url ?? URL(string: "")!], applicationActivities: nil
+ activityItems: [shareURL], applicationActivities: nil
), animated: true
)
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
+ .disabled(url == nil)
}
#endif
@@ -96,14 +99,19 @@ struct PostDetailsImageView<Placeholder: View>: View {
.keyboardShortcut("c", modifiers: [.command])
Button {
- openURL(postURL(for: post?.id ?? ""))
+ guard let postURL = postURL(for: post?.id) else { return }
+
+ openURL(postURL)
} label: {
Label("Open Post in Safari", systemImage: "safari")
}
+ .disabled(postURL(for: post?.id) == nil)
- if let source = post?.source {
+ if let source = post?.source,
+ let sourceURL = URL(string: source.trimmingCharacters(in: .whitespacesAndNewlines))
+ {
Button {
- openURL(URL(string: source)!)
+ openURL(sourceURL)
} label: {
Label("Open Source Link in Safari", systemImage: "safari")
}
@@ -170,17 +178,31 @@ struct PostDetailsImageView<Placeholder: View>: View {
self.post = post
}
- private func postURL(for id: String) -> URL {
+ private func postURL(for id: String?) -> URL? {
+ guard let id, !id.isEmpty else { return nil }
+
+ var components = URLComponents()
+
+ components.scheme = "https"
+ components.host = manager.domain
+
switch manager.flavor {
case .moebooru:
- return URL(string: "https://\(manager.domain)/post/show/\(id)")!
+ components.path = "/post/show/\(id)"
case .gelbooru:
- return URL(string: "https://\(manager.domain)/index.php?page=post&s=view&id=\(id)")!
+ components.path = "/index.php"
+ components.queryItems = [
+ URLQueryItem(name: "page", value: "post"),
+ URLQueryItem(name: "s", value: "view"),
+ URLQueryItem(name: "id", value: id),
+ ]
case .danbooru:
- return URL(string: "https://\(manager.domain)/posts/\(id)")!
+ components.path = "/posts/\(id)"
}
+
+ return components.url
}
#if os(macOS)
diff --git a/Sora/Views/Post/Details/PostDetailsView.swift b/Sora/Views/Post/Details/PostDetailsView.swift
index 8dc95d4..9c55798 100644
--- a/Sora/Views/Post/Details/PostDetailsView.swift
+++ b/Sora/Views/Post/Details/PostDetailsView.swift
@@ -129,9 +129,11 @@ struct PostDetailsView: View {
#if os(macOS)
if settings.enableShareShortcut {
- ToolbarItem {
- ShareLink(item: imageURL!) {
- Label("Share", systemImage: "square.and.arrow.up")
+ if let imageURL {
+ ToolbarItem {
+ ShareLink(item: imageURL) {
+ Label("Share", systemImage: "square.and.arrow.up")
+ }
}
}
}
diff --git a/SoraTests/ViewDerivedDataTests.swift b/SoraTests/ViewDerivedDataTests.swift
index 9152591..c20e9d1 100644
--- a/SoraTests/ViewDerivedDataTests.swift
+++ b/SoraTests/ViewDerivedDataTests.swift
@@ -193,6 +193,58 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
)
}
+ func testPostDetailsImageViewAvoidsForceUnwrappedRuntimeURLs() throws {
+ let source = try loadSource(at: "Sora/Views/Post/Details/PostDetailsImageView.swift")
+ let normalizedSource = strippingCommentsAndStrings(from: source)
+ let forcedShareFallbackURLCount = tokenCount(
+ matching: #"\burl\s*\?\?\s*URL\s*\(\s*string:\s*\)\s*!"#,
+ in: normalizedSource
+ )
+ let forcedSourceURLCount = tokenCount(
+ matching: #"\bURL\s*\(\s*string:\s*source\s*\)\s*!"#,
+ in: normalizedSource
+ )
+ let forcedPostURLBuilderCount = tokenCount(
+ matching: #"\breturn\s+URL\s*\(\s*string:\s*[^)]+\)\s*!"#,
+ in: normalizedSource
+ )
+
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ forcedShareFallbackURLCount,
+ 0,
+ "Post details share actions should not force unwrap fallback URLs."
+ )
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ forcedSourceURLCount,
+ 0,
+ "Post details source links should be validated before opening."
+ )
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ forcedPostURLBuilderCount,
+ 0,
+ "Post details post-url helpers should return optional URLs instead of force-unwrapping."
+ )
+ }
+
+ func testPostDetailsViewAvoidsForceUnwrappedShareURL() throws {
+ let source = try loadSource(at: "Sora/Views/Post/Details/PostDetailsView.swift")
+ let normalizedSource = strippingCommentsAndStrings(from: source)
+ let forcedShareItemCount = tokenCount(
+ matching: #"\bShareLink\s*\(\s*item:\s*imageURL\s*!"#,
+ in: normalizedSource
+ )
+
+ // swiftlint:disable:next prefer_nimble
+ XCTAssertEqual(
+ forcedShareItemCount,
+ 0,
+ "Post details share actions should not force unwrap image URLs."
+ )
+ }
+
func testListViewsAvoidComparatorRandomShuffleSorting() throws {
let listViewSource = try loadSource(at: "Sora/Views/Generic/GenericListView.swift")
let favoritesViewSource = try loadSource(at: "Sora/Views/FavoritesView.swift")
@@ -304,9 +356,11 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
named: "func url(forPosts page: Int, limit: Int, tags: [String]) -> URL?",
from: source
)
- let danbooruCaseStart = try XCTUnwrap(urlBuilderSection.range(of: "case .danbooru:")?.lowerBound)
+ let danbooruCaseStart = try XCTUnwrap(
+ urlBuilderSection.range(of: "case .danbooru:")?.lowerBound)
let danbooruCaseEnd = try XCTUnwrap(
- urlBuilderSection.range(of: "case .moebooru:", range: danbooruCaseStart..<urlBuilderSection.endIndex)?
+ urlBuilderSection.range(
+ of: "case .moebooru:", range: danbooruCaseStart..<urlBuilderSection.endIndex)?
.lowerBound
)
let danbooruSection = String(urlBuilderSection[danbooruCaseStart..<danbooruCaseEnd])
@@ -329,9 +383,11 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
named: "func url(forPosts page: Int, limit: Int, tags: [String]) -> URL?",
from: source
)
- let moebooruCaseStart = try XCTUnwrap(urlBuilderSection.range(of: "case .moebooru:")?.lowerBound)
+ let moebooruCaseStart = try XCTUnwrap(
+ urlBuilderSection.range(of: "case .moebooru:")?.lowerBound)
let moebooruCaseEnd = try XCTUnwrap(
- urlBuilderSection.range(of: "case .gelbooru:", range: moebooruCaseStart..<urlBuilderSection.endIndex)?
+ urlBuilderSection.range(
+ of: "case .gelbooru:", range: moebooruCaseStart..<urlBuilderSection.endIndex)?
.lowerBound
)
let moebooruSection = String(urlBuilderSection[moebooruCaseStart..<moebooruCaseEnd])
@@ -373,7 +429,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
in: moebooruTagHelperSection
)
let explicitHoldsBypassCount = tokenCount(
- matching: #"if\s+hasExplicitHoldsFilter\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#,
+ matching:
+ #"if\s+hasExplicitHoldsFilter\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#,
in: moebooruTagHelperSection
)
@@ -394,7 +451,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
func testSettingsManagerPersistsShowHeldMoebooruPostsFlag() throws {
let source = try loadSource(at: "Sora/Data/Settings/SettingsManager.swift")
let showHeldSettingCount = tokenCount(
- matching: #"\@AppStorage\("showHeldMoebooruPosts"\)\s*var\s+showHeldMoebooruPosts\s*=\s*false"#,
+ matching:
+ #"\@AppStorage\("showHeldMoebooruPosts"\)\s*var\s+showHeldMoebooruPosts\s*=\s*false"#,
in: source
)
@@ -442,7 +500,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
from: source
)
let showHeldBypassCount = tokenCount(
- matching: #"guard\s*!\s*showHeldMoebooruPosts\s*else\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#,
+ matching:
+ #"guard\s*!\s*showHeldMoebooruPosts\s*else\s*\{\s*return\s+tags\.joined\(separator:\s*"\+"\)\s*\}"#,
in: moebooruTagHelperSection
)
@@ -466,9 +525,11 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
named: "func url(forPosts page: Int, limit: Int, tags: [String]) -> URL?",
from: source
)
- let danbooruCaseStart = try XCTUnwrap(urlBuilderSection.range(of: "case .danbooru:")?.lowerBound)
+ let danbooruCaseStart = try XCTUnwrap(
+ urlBuilderSection.range(of: "case .danbooru:")?.lowerBound)
let danbooruCaseEnd = try XCTUnwrap(
- urlBuilderSection.range(of: "case .moebooru:", range: danbooruCaseStart..<urlBuilderSection.endIndex)?
+ urlBuilderSection.range(
+ of: "case .moebooru:", range: danbooruCaseStart..<urlBuilderSection.endIndex)?
.lowerBound
)
let danbooruSection = String(urlBuilderSection[danbooruCaseStart..<danbooruCaseEnd])
@@ -477,7 +538,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
from: source
)
let danbooruCursorPageQueryCount = tokenCount(
- matching: #"URLQueryItem\(name:\s*"page",\s*value:\s*danbooruPageToken\(for:\s*page,\s*tags:\s*tags\)\)"#,
+ matching:
+ #"URLQueryItem\(name:\s*"page",\s*value:\s*danbooruPageToken\(for:\s*page,\s*tags:\s*tags\)\)"#,
in: danbooruSection
)
let beforeCursorCount = tokenCount(
@@ -506,7 +568,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
from: source
)
let explicitSortGuardCount = tokenCount(
- matching: #"guard\s*!\s*hasExplicitSortTag\(in:\s*tags\)\s*else\s*\{\s*return\s*String\(page\)\s*\}"#,
+ matching:
+ #"guard\s*!\s*hasExplicitSortTag\(in:\s*tags\)\s*else\s*\{\s*return\s*String\(page\)\s*\}"#,
in: pageTokenFunctionSection
)
let sortTagHelperSection = try extractFunction(
@@ -655,7 +718,8 @@ final class ViewDerivedDataTests: XCTestCase { // swiftlint:disable:this type_b
func testDanbooruPostModelUsesOptionalVisibilityDependentFields() throws {
let source = try loadSource(at: "Sora/Data/Danbooru/DanbooruPost.swift")
let optionalFileURLCount = tokenCount(matching: #"let\s+fileURL:\s+String\?"#, in: source)
- let optionalLargeFileURLCount = tokenCount(matching: #"let\s+largeFileURL:\s+String\?"#, in: source)
+ let optionalLargeFileURLCount = tokenCount(
+ matching: #"let\s+largeFileURL:\s+String\?"#, in: source)
let optionalPreviewURLCount = tokenCount(
matching: #"let\s+previewFileURL:\s+String\?"#,
in: source