From e484ce56c213bb19a3af289eec733160b3e72b10 Mon Sep 17 00:00:00 2001 From: infinityf4p <186150610+infinityf4p@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:06:44 +0800 Subject: [PATCH] Harden local storage and navigation stability --- .../Core/UI/InteractiveNavigationPop.swift | 145 +------ .../Domain/Models/ReadingPreferences.swift | 49 ++- TiebaPure/Domain/Models/SavedThread.swift | 405 +++++++++++++++--- .../Domain/Models/SavedThreadMedia.swift | 130 +++++- .../Features/Forum/ForumThreadsView.swift | 16 +- .../Settings/ReadingSettingsView.swift | 5 +- .../Features/Settings/SavedThreadsView.swift | 105 ++++- .../Features/Thread/ThreadDetailView.swift | 6 +- .../NavigationSourceLifecycleTests.swift | 22 +- TiebaPureTests/SavedThreadTests.swift | 165 +++++++ TiebaPureTests/TiebaPureSmokeTests.swift | 78 ---- 11 files changed, 776 insertions(+), 350 deletions(-) diff --git a/TiebaPure/Core/UI/InteractiveNavigationPop.swift b/TiebaPure/Core/UI/InteractiveNavigationPop.swift index 4e7dba6..1483d96 100644 --- a/TiebaPure/Core/UI/InteractiveNavigationPop.swift +++ b/TiebaPure/Core/UI/InteractiveNavigationPop.swift @@ -1,48 +1,8 @@ import SwiftUI import UIKit -/// Documents the system navigation gesture used by each supported OS family. -/// -/// iOS 26 adds `UINavigationController.interactiveContentPopGestureRecognizer`, -/// which recognizes an interactive pop across the navigation controller's -/// content. Earlier systems only provide the leading-edge -/// `interactivePopGestureRecognizer`. -enum NavigationBackGesturePolicy { - enum Mode: Equatable { - case content - case edge - } - - static func mode(systemMajorVersion: Int) -> Mode { - systemMajorVersion >= 26 ? .content : .edge - } - - static var currentMode: Mode { - mode(systemMajorVersion: ProcessInfo.processInfo.operatingSystemVersion.majorVersion) - } -} - -enum NativeEdgePopGestureActivationPolicy { - static func shouldEnable( - requestedEnabled: Bool, - mode: NavigationBackGesturePolicy.Mode, - isVisible: Bool, - isAttachedToWindow: Bool, - stackDepth: Int, - hasActiveTransition: Bool - ) -> Bool { - requestedEnabled - && mode == .edge - && isVisible - && isAttachedToWindow - && stackDepth > 1 - && hasActiveTransition == false - } -} - enum NavigationPopGestureControlHostingPolicy { - static func requiresController(systemMajorVersion: Int, isEnabled: Bool) -> Bool { - _ = systemMajorVersion + static func requiresController(isEnabled: Bool) -> Bool { // A normal NavigationStack must keep complete ownership of its native // recognizers. Hosting a controller merely to force-enable the edge // recognizer raced SwiftUI's push transition on iOS 16-18, especially @@ -72,7 +32,6 @@ extension View { /// afterwards. func fullScreenInteractiveNavigationPop(isEnabled: Bool = true) -> some View { let hostsController = NavigationPopGestureControlHostingPolicy.requiresController( - systemMajorVersion: ProcessInfo.processInfo.operatingSystemVersion.majorVersion, isEnabled: isEnabled ) let exposesDiagnostics = NavigationPopGestureDiagnostics.isEnabled @@ -122,9 +81,6 @@ private struct NativeNavigationPopGestureControl: UIViewControllerRepresentable @MainActor final class Controller: UIViewController { private var requestedEnabled = true - private var isVisible = false - private var scheduledUpdateGeneration = 0 - private var transitionRetryGeneration: Int? private weak var controlledNavigationController: UINavigationController? private var previousEdgeGestureState: Bool? private var previousContentGestureState: Bool? @@ -140,22 +96,15 @@ private struct NativeNavigationPopGestureControl: UIViewControllerRepresentable func setPopGesturesEnabled(_ isEnabled: Bool) { requestedEnabled = isEnabled - scheduledUpdateGeneration &+= 1 applyRequestedState() - scheduleRequestedStateUpdateIfNeeded() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - isVisible = true applyRequestedState() - scheduleRequestedStateUpdateIfNeeded() } override func viewWillDisappear(_ animated: Bool) { - isVisible = false - scheduledUpdateGeneration &+= 1 - transitionRetryGeneration = nil restorePopGesturesIfNeeded() super.viewWillDisappear(animated) } @@ -197,41 +146,6 @@ private struct NativeNavigationPopGestureControl: UIViewControllerRepresentable updateDiagnostics(using: navigationController) } - private func enableNativeEdgePopIfNeeded() { - guard let navigationController else { - updateDiagnostics(using: nil) - return - } - guard requestedEnabled, - isVisible, - viewIfLoaded?.window != nil, - NavigationBackGesturePolicy.currentMode == .edge, - navigationController.viewControllers.count > 1 else { - updateDiagnostics(using: navigationController) - return - } - if let transitionCoordinator = navigationController.transitionCoordinator { - updateDiagnostics(using: navigationController) - scheduleEdgeActivationAfterTransition( - transitionCoordinator, - navigationController: navigationController - ) - return - } - guard NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: requestedEnabled, - mode: NavigationBackGesturePolicy.currentMode, - isVisible: isVisible, - isAttachedToWindow: viewIfLoaded?.window != nil, - stackDepth: navigationController.viewControllers.count, - hasActiveTransition: false - ) else { - return - } - navigationController.interactivePopGestureRecognizer?.isEnabled = true - updateDiagnostics(using: navigationController) - } - private func updateDiagnostics(using navigationController: UINavigationController?) { #if DEBUG guard NavigationPopGestureDiagnostics.isEnabled else { return } @@ -243,66 +157,11 @@ private struct NativeNavigationPopGestureControl: UIViewControllerRepresentable "enabled=\(edgeGesture?.isEnabled ?? false)", "shouldBegin=\(delegateAllowsBegin ?? false)", "depth=\(navigationController?.viewControllers.count ?? 0)", - "visible=\(isVisible)", + "visible=\(viewIfLoaded?.window != nil)", "attached=\(viewIfLoaded?.window != nil)" ].joined(separator: ",") #endif } - - private func scheduleEdgeActivationAfterTransition( - _ transitionCoordinator: UIViewControllerTransitionCoordinator, - navigationController: UINavigationController - ) { - let generation = scheduledUpdateGeneration - guard transitionRetryGeneration != generation else { return } - transitionRetryGeneration = generation - let accepted = transitionCoordinator.animate(alongsideTransition: nil) { [weak self, weak navigationController] _ in - DispatchQueue.main.async { - guard let self else { return } - if self.transitionRetryGeneration == generation { - self.transitionRetryGeneration = nil - } - guard let navigationController, - self.requestedEnabled, - self.isVisible, - self.scheduledUpdateGeneration == generation, - self.viewIfLoaded?.window != nil, - self.navigationController === navigationController else { - return - } - guard NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: NavigationBackGesturePolicy.currentMode, - isVisible: self.isVisible, - isAttachedToWindow: self.viewIfLoaded?.window != nil, - stackDepth: navigationController.viewControllers.count, - hasActiveTransition: navigationController.transitionCoordinator != nil - ) else { - return - } - navigationController.interactivePopGestureRecognizer?.isEnabled = true - self.updateDiagnostics(using: navigationController) - } - } - if accepted == false { - transitionRetryGeneration = nil - } - } - - private func scheduleRequestedStateUpdateIfNeeded() { - guard requestedEnabled, isVisible else { return } - let generation = scheduledUpdateGeneration - DispatchQueue.main.async { [weak self] in - guard let self, - self.requestedEnabled, - self.isVisible, - self.scheduledUpdateGeneration == generation, - self.viewIfLoaded?.window != nil else { - return - } - self.applyRequestedState() - } - } } } diff --git a/TiebaPure/Domain/Models/ReadingPreferences.swift b/TiebaPure/Domain/Models/ReadingPreferences.swift index 8d88dbf..e0346d9 100644 --- a/TiebaPure/Domain/Models/ReadingPreferences.swift +++ b/TiebaPure/Domain/Models/ReadingPreferences.swift @@ -721,6 +721,7 @@ final class ReaderFontStore: ObservableObject { let fileName = "\(prepared.sha256).\(prepared.fileExtension)" let destinationURL = directoryURL.appendingPathComponent(fileName, isDirectory: false) let destinationExisted = fileManager.fileExists(atPath: destinationURL.path) + let fileManager = self.fileManager let displayName: String do { displayName = try await Task.detached(priority: .userInitiated) { @@ -731,7 +732,7 @@ final class ReaderFontStore: ObservableObject { throw ReaderFontStoreError.invalidFont } try prepared.data.write(to: destinationURL, options: [.atomic]) - try FileManager.default.setAttributes( + try fileManager.setAttributes( Self.protectedFileAttributes, ofItemAtPath: destinationURL.path ) @@ -743,6 +744,7 @@ final class ReaderFontStore: ObservableObject { } guard registerFont(at: destinationURL, postScriptName: prepared.postScriptName), UIFont(name: prepared.postScriptName, size: 17) != nil else { + CTFontManagerUnregisterFontsForURL(destinationURL as CFURL, .process, nil) try? fileManager.removeItem(at: destinationURL) throw ReaderFontStoreError.registrationFailed } @@ -763,6 +765,7 @@ final class ReaderFontStore: ObservableObject { persistenceError = nil return imported } catch { + CTFontManagerUnregisterFontsForURL(destinationURL as CFURL, .process, nil) if destinationExisted == false { try? fileManager.removeItem(at: destinationURL) } persistenceError = error.localizedDescription throw error @@ -795,12 +798,48 @@ final class ReaderFontStore: ObservableObject { let validated = await Task.detached(priority: .utility) { candidates.filter { Self.isStoredFontValid($0, directoryURL: directoryURL) } }.value - entries = validated.filter { font in + entries = validated.compactMap { font in let fileURL = directoryURL.appendingPathComponent(font.fileName, isDirectory: false) - return registerFont(at: fileURL, postScriptName: font.postScriptName) - && UIFont(name: font.postScriptName, size: 17) != nil + guard registerFont(at: fileURL, postScriptName: font.postScriptName), + UIFont(name: font.postScriptName, size: 17) != nil else { + CTFontManagerUnregisterFontsForURL(fileURL as CFURL, .process, nil) + return nil + } + return font + } + let loadedEntries = entries + let catalogFile = self.catalogFile + let fileManager = self.fileManager + do { + try await Task.detached(priority: .utility) { + if loadedEntries != candidates { + try catalogFile?.replace(loadedEntries) + } + let referenced = Set(loadedEntries.map(\.fileName)) + let children = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey + ], + options: [] + ) + for child in children { + let fileExtension = child.pathExtension.lowercased() + guard ["ttf", "otf"].contains(fileExtension), + referenced.contains(child.lastPathComponent) == false else { continue } + let values = try child.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey + ]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { continue } + try fileManager.removeItem(at: child) + } + }.value + } catch { + persistenceError = error.localizedDescription } - if entries.count != candidates.count { + if loadedEntries.count != candidates.count, persistenceError == nil { persistenceError = ReaderFontStoreError.registrationFailed.localizedDescription } isReady = true diff --git a/TiebaPure/Domain/Models/SavedThread.swift b/TiebaPure/Domain/Models/SavedThread.swift index d5f02a4..da1806e 100644 --- a/TiebaPure/Domain/Models/SavedThread.swift +++ b/TiebaPure/Domain/Models/SavedThread.swift @@ -41,16 +41,29 @@ struct SavedThreadSnapshot: Identifiable, Equatable, Codable, Sendable { guard (1...Self.currentFormatVersion).contains(formatVersion) else { throw SavedThreadError.unsupportedFormat } + let mainPosts = posts.filter { $0.post.floor == 1 } guard thread.id > 0, forum.id > 0, - let mainPost, + mainPosts.count == 1, + let mainPost = mainPosts.first, mainPost.post.id > 0, mainPost.post.threadID == thread.id, - posts.allSatisfy({ $0.post.id > 0 && $0.post.threadID == thread.id }) else { + posts.allSatisfy({ savedPost in + savedPost.post.id > 0 + && savedPost.post.threadID == thread.id + && savedPost.post.floor > 0 + && savedPost.subposts.allSatisfy { $0.id > 0 && $0.floor > 0 } + && Set(savedPost.subposts.map(\.id)).count == savedPost.subposts.count + }), + (latestReplyCount ?? thread.replyCount) >= 0 else { throw SavedThreadError.incompleteThread } let postIDs = posts.map(\.post.id) - guard Set(postIDs).count == postIDs.count else { + let postFloors = posts.map(\.post.floor) + let subpostIDs = posts.flatMap { $0.subposts.map(\.id) } + guard Set(postIDs).count == postIDs.count, + Set(postFloors).count == postFloors.count, + Set(subpostIDs).count == subpostIDs.count else { throw SavedThreadError.inconsistentPagination } guard effectiveMediaAssets.allSatisfy(\.isValid) else { @@ -79,8 +92,13 @@ struct SavedThreadSnapshot: Identifiable, Equatable, Codable, Sendable { }) else { throw SavedThreadError.invalidMediaManifest } - let uniqueMediaBytes = groupedFiles.values.compactMap(\.first).reduce(0) { - $0 + $1.byteCount + var uniqueMediaBytes = 0 + for asset in groupedFiles.values.compactMap(\.first) { + let (next, overflow) = uniqueMediaBytes.addingReportingOverflow(asset.byteCount) + guard overflow == false else { + throw SavedThreadError.invalidMediaManifest + } + uniqueMediaBytes = next } guard uniqueMediaBytes <= SavedThreadPolicy.maximumMediaBytesPerThread, effectiveMediaMode != .textOnly || effectiveMediaAssets.isEmpty else { @@ -142,16 +160,163 @@ enum SavedThreadPolicy { // FileDocument materializes package contents while handing them to the // document picker. Keep a hard ceiling that remains safe on older devices. static let maximumBackupBytes = 128 * 1_024 * 1_024 + static let maximumBackupEntries = 5_000 } @MainActor final class SavedThreadStore: ObservableObject { static let shared = SavedThreadStore() + private struct MutationResult: Sendable { + var entries: [SavedThreadSnapshot] + var revision: Int + } + + private final class MutationCoordinator: @unchecked Sendable { + private let lock = NSLock() + private var revision = 0 + + func perform( + _ body: () throws -> [SavedThreadSnapshot] + ) rethrows -> MutationResult { + lock.lock() + defer { lock.unlock() } + let entries = try body() + revision &+= 1 + return MutationResult(entries: entries, revision: revision) + } + } + + private actor MutationActor { + private let file: SecureCodableFile<[SavedThreadSnapshot]> + private let mediaStore: SavedThreadMediaStore + private let coordinator: MutationCoordinator + + init( + file: SecureCodableFile<[SavedThreadSnapshot]>, + mediaStore: SavedThreadMediaStore, + coordinator: MutationCoordinator + ) { + self.file = file + self.mediaStore = mediaStore + self.coordinator = coordinator + } + + func save( + _ snapshot: SavedThreadSnapshot, + preparedMedia: PreparedSavedThreadMedia? + ) throws -> MutationResult { + try coordinator.perform { + do { + try Task.checkCancellation() + let validated = try snapshot.validated() + try preparedMedia?.commit() + let updated = try file.update(default: []) { values in + values.removeAll { $0.id == validated.id } + values.insert(validated, at: 0) + values = Array( + try SavedThreadStore.normalized(values) + .prefix(SavedThreadPolicy.maximumSavedThreads) + ) + } + preparedMedia?.finish() + if validated.effectiveMediaMode == .textOnly { + try? mediaStore.remove(threadID: validated.id) + } + mediaStore.removeOrphans(keeping: Set(updated.map(\.id))) + return updated + } catch { + preparedMedia?.rollback() + throw error + } + } + } + + func recordUpdateChecks( + _ latestReplyCounts: [Int64: Int], + checkedAt: Date + ) throws -> MutationResult { + try coordinator.perform { + try Task.checkCancellation() + return try file.update(default: []) { values in + for index in values.indices { + guard let latest = latestReplyCounts[values[index].id] else { continue } + values[index].latestCheckedAt = checkedAt + values[index].latestReplyCount = max(latest, values[index].thread.replyCount) + } + values = try SavedThreadStore.normalized(values) + } + } + } + + func remove(threadID: Int64) throws -> MutationResult { + try coordinator.perform { + try Task.checkCancellation() + let updated = try file.update(default: []) { values in + values.removeAll { $0.id == threadID } + values = try SavedThreadStore.normalized(values) + } + try? mediaStore.remove(threadID: threadID) + mediaStore.removeOrphans(keeping: Set(updated.map(\.id))) + return updated + } + } + + func clear() throws -> MutationResult { + try coordinator.perform { + try Task.checkCancellation() + try file.replace([]) + try? mediaStore.clear() + return [] + } + } + + func importBackup( + imported: [SavedThreadSnapshot], + prepared: [PreparedSavedThreadMedia], + replacingExisting: Bool + ) throws -> MutationResult { + try coordinator.perform { + try Task.checkCancellation() + guard imported.count == prepared.count else { throw SavedThreadError.invalidBackup } + let existing = try SavedThreadStore.normalized(file.load() ?? []) + let plan = try SavedThreadStore.backupImportPlan( + imported: imported, + existing: existing, + replacingExisting: replacingExisting + ) + let preparedBySnapshot = Array(zip(imported, prepared)) + let selected = preparedBySnapshot.compactMap { snapshot, media in + plan.importedThreadIDs.contains(snapshot.id) ? media : nil + } + preparedBySnapshot + .filter { plan.importedThreadIDs.contains($0.0.id) == false } + .forEach { $0.1.rollback() } + + do { + try selected.forEach { try $0.commit() } + try file.replace(plan.entries) + selected.forEach { $0.finish() } + plan.entries + .filter { $0.effectiveMediaMode == .textOnly } + .forEach { try? mediaStore.remove(threadID: $0.id) } + mediaStore.removeOrphans(keeping: Set(plan.entries.map(\.id))) + return plan.entries + } catch { + selected.reversed().forEach { $0.rollback() } + throw error + } + } + } + } + @Published private(set) var entries: [SavedThreadSnapshot] = [] @Published private(set) var persistenceError: String? private var file: SecureCodableFile<[SavedThreadSnapshot]>? + private var mutationCoordinator: MutationCoordinator? + private var mutationActor: MutationActor? + private var appliedMutationRevision = 0 let mediaStore: SavedThreadMediaStore private struct BackupImportPlan { @@ -179,9 +344,23 @@ final class SavedThreadStore: ObservableObject { maximumByteCount: SavedThreadPolicy.maximumStorageByteCount ) self.file = file + let coordinator = MutationCoordinator() + mutationCoordinator = coordinator + mutationActor = MutationActor( + file: file, + mediaStore: mediaStore, + coordinator: coordinator + ) entries = try Self.normalized(file.load() ?? []) + do { + try mediaStore.repairStorage(snapshots: entries) + } catch { + persistenceError = error.localizedDescription + } } catch { file = nil + mutationCoordinator = nil + mutationActor = nil persistenceError = error.localizedDescription } } @@ -202,25 +381,50 @@ final class SavedThreadStore: ObservableObject { _ snapshot: SavedThreadSnapshot, preparedMedia: PreparedSavedThreadMedia? ) throws { - guard let file else { throw SavedThreadError.persistenceUnavailable } - let validated = try snapshot.validated() - try preparedMedia?.commit() - do { - entries = try file.update(default: []) { values in - values.removeAll { $0.id == validated.id } - values.insert(validated, at: 0) - values = Array(try Self.normalized(values).prefix(SavedThreadPolicy.maximumSavedThreads)) - } - preparedMedia?.finish() - if validated.effectiveMediaMode == .textOnly { - try? mediaStore.remove(threadID: validated.id) + guard let file, let mutationCoordinator else { + preparedMedia?.rollback() + throw SavedThreadError.persistenceUnavailable + } + let result = try mutationCoordinator.perform { + do { + let validated = try snapshot.validated() + try preparedMedia?.commit() + let updated = try file.update(default: []) { values in + values.removeAll { $0.id == validated.id } + values.insert(validated, at: 0) + values = Array( + try Self.normalized(values) + .prefix(SavedThreadPolicy.maximumSavedThreads) + ) + } + preparedMedia?.finish() + if validated.effectiveMediaMode == .textOnly { + try? mediaStore.remove(threadID: validated.id) + } + mediaStore.removeOrphans(keeping: Set(updated.map(\.id))) + return updated + } catch { + preparedMedia?.rollback() + throw error } - } catch { + } + apply(result) + } + + func saveWithoutBlocking( + _ snapshot: SavedThreadSnapshot, + preparedMedia: PreparedSavedThreadMedia? + ) async throws { + guard let mutationActor else { preparedMedia?.rollback() + throw SavedThreadError.persistenceUnavailable + } + do { + apply(try await mutationActor.save(snapshot, preparedMedia: preparedMedia)) + } catch { + persistenceError = error.localizedDescription throw error } - mediaStore.removeOrphans(keeping: Set(entries.map(\.id))) - persistenceError = nil } func recordUpdateCheck( @@ -228,14 +432,35 @@ final class SavedThreadStore: ObservableObject { latestReplyCount: Int, checkedAt: Date = Date() ) throws { - guard let file else { throw SavedThreadError.persistenceUnavailable } - entries = try file.update(default: []) { values in - guard let index = values.firstIndex(where: { $0.id == threadID }) else { return } - values[index].latestCheckedAt = checkedAt - values[index].latestReplyCount = max(latestReplyCount, values[index].thread.replyCount) - values = try Self.normalized(values) + guard let file, let mutationCoordinator else { + throw SavedThreadError.persistenceUnavailable + } + let result = try mutationCoordinator.perform { + try file.update(default: []) { values in + guard let index = values.firstIndex(where: { $0.id == threadID }) else { return } + values[index].latestCheckedAt = checkedAt + values[index].latestReplyCount = max(latestReplyCount, values[index].thread.replyCount) + values = try Self.normalized(values) + } + } + apply(result) + } + + func recordUpdateChecksWithoutBlocking( + _ latestReplyCounts: [Int64: Int], + checkedAt: Date = Date() + ) async throws { + guard latestReplyCounts.isEmpty == false else { return } + guard let mutationActor else { throw SavedThreadError.persistenceUnavailable } + do { + apply(try await mutationActor.recordUpdateChecks( + latestReplyCounts, + checkedAt: checkedAt + )) + } catch { + persistenceError = error.localizedDescription + throw error } - persistenceError = nil } func importBackup( @@ -292,33 +517,74 @@ final class SavedThreadStore: ObservableObject { throw error } }.value + guard let mutationActor else { + prepared.reversed().forEach { $0.rollback() } + throw SavedThreadError.persistenceUnavailable + } do { - try commitBackupImport( + apply(try await mutationActor.importBackup( imported: imported, prepared: prepared, replacingExisting: replacingExisting - ) + )) } catch { prepared.reversed().forEach { $0.rollback() } + persistenceError = error.localizedDescription throw error } } func remove(threadID: Int64) throws { - guard let file else { throw SavedThreadError.persistenceUnavailable } - entries = try file.update(default: []) { values in - values.removeAll { $0.id == threadID } - values = try Self.normalized(values) + guard let file, let mutationCoordinator else { + throw SavedThreadError.persistenceUnavailable + } + let result = try mutationCoordinator.perform { + let updated = try file.update(default: []) { values in + values.removeAll { $0.id == threadID } + values = try Self.normalized(values) + } + try mediaStore.remove(threadID: threadID) + return updated + } + apply(result) + } + + func removeWithoutBlocking(threadID: Int64) async throws { + guard let mutationActor else { throw SavedThreadError.persistenceUnavailable } + do { + apply(try await mutationActor.remove(threadID: threadID)) + } catch { + persistenceError = error.localizedDescription + throw error } - try mediaStore.remove(threadID: threadID) - persistenceError = nil } func clear() throws { - guard let file else { throw SavedThreadError.persistenceUnavailable } - try file.replace([]) - try mediaStore.clear() - entries = [] + guard let file, let mutationCoordinator else { + throw SavedThreadError.persistenceUnavailable + } + let result = try mutationCoordinator.perform { + try file.replace([]) + try? mediaStore.clear() + return [] + } + apply(result) + } + + func clearWithoutBlocking() async throws { + guard let mutationActor else { throw SavedThreadError.persistenceUnavailable } + do { + apply(try await mutationActor.clear()) + } catch { + persistenceError = error.localizedDescription + throw error + } + } + + private func apply(_ result: MutationResult) { + guard result.revision >= appliedMutationRevision else { return } + appliedMutationRevision = result.revision + entries = result.entries persistenceError = nil } @@ -327,33 +593,44 @@ final class SavedThreadStore: ObservableObject { prepared: [PreparedSavedThreadMedia], replacingExisting: Bool ) throws { - guard let file else { throw SavedThreadError.persistenceUnavailable } + guard let file, let mutationCoordinator else { + throw SavedThreadError.persistenceUnavailable + } guard imported.count == prepared.count else { throw SavedThreadError.invalidBackup } + let result = try mutationCoordinator.perform { + // Preparation can run off the main actor. Recompute the winner + // against the latest committed store state. + let plan = try Self.backupImportPlan( + imported: imported, + existing: try Self.normalized(file.load() ?? []), + replacingExisting: replacingExisting + ) + let preparedBySnapshot = Array(zip(imported, prepared)) + let selected = preparedBySnapshot.compactMap { snapshot, media in + plan.importedThreadIDs.contains(snapshot.id) ? media : nil + } + preparedBySnapshot + .filter { plan.importedThreadIDs.contains($0.0.id) == false } + .forEach { $0.1.rollback() } - // Preparation can run off the main actor. Recompute the winner against - // the latest store state so an older backup cannot replace newer media. - let plan = try Self.backupImportPlan( - imported: imported, - existing: entries, - replacingExisting: replacingExisting - ) - let preparedBySnapshot = Array(zip(imported, prepared)) - let selected = preparedBySnapshot.compactMap { snapshot, media in - plan.importedThreadIDs.contains(snapshot.id) ? media : nil - } - preparedBySnapshot - .filter { plan.importedThreadIDs.contains($0.0.id) == false } - .forEach { $0.1.rollback() } - - try selected.forEach { try $0.commit() } - try file.replace(plan.entries) - entries = plan.entries - selected.forEach { $0.finish() } - mediaStore.removeOrphans(keeping: Set(entries.map(\.id))) - persistenceError = nil + do { + try selected.forEach { try $0.commit() } + try file.replace(plan.entries) + selected.forEach { $0.finish() } + plan.entries + .filter { $0.effectiveMediaMode == .textOnly } + .forEach { try? mediaStore.remove(threadID: $0.id) } + mediaStore.removeOrphans(keeping: Set(plan.entries.map(\.id))) + return plan.entries + } catch { + selected.reversed().forEach { $0.rollback() } + throw error + } + } + apply(result) } - private static func backupImportPlan( + nonisolated private static func backupImportPlan( imported: [SavedThreadSnapshot], existing: [SavedThreadSnapshot], replacingExisting: Bool @@ -384,7 +661,9 @@ final class SavedThreadStore: ObservableObject { return BackupImportPlan(entries: limited, importedThreadIDs: importedThreadIDs) } - private static func normalized(_ values: [SavedThreadSnapshot]) throws -> [SavedThreadSnapshot] { + nonisolated private static func normalized( + _ values: [SavedThreadSnapshot] + ) throws -> [SavedThreadSnapshot] { var knownIDs = Set() return try values .map { try $0.validated() } diff --git a/TiebaPure/Domain/Models/SavedThreadMedia.swift b/TiebaPure/Domain/Models/SavedThreadMedia.swift index 4623b88..1945cd3 100644 --- a/TiebaPure/Domain/Models/SavedThreadMedia.swift +++ b/TiebaPure/Domain/Models/SavedThreadMedia.swift @@ -43,15 +43,20 @@ struct SavedThreadMediaAsset: Identifiable, Equatable, Codable, Sendable { var id: String { "\(kind.rawValue):\(fileName):\(sourceIdentities.joined(separator: "|"))" } var isValid: Bool { - let normalizedSources = sourceIdentities.filter { source in + let sourcesAreValid = sourceIdentities.allSatisfy { source in source.isEmpty == false && source.utf8.count <= 8_192 } - return normalizedSources.isEmpty == false - && Set(normalizedSources).count == normalizedSources.count + return sourceIdentities.isEmpty == false + && sourcesAreValid + && Set(sourceIdentities).count == sourceIdentities.count && fileName.isEmpty == false + && fileName.utf8.count <= 100 + && fileName != "." + && fileName != ".." && fileName == URL(fileURLWithPath: fileName).lastPathComponent && fileName.contains("/") == false && fileName.contains("\\") == false + && fileName.utf8.allSatisfy(Self.isAllowedFileNameByte) && byteCount > 0 && byteCount <= TiebaVideoDownloadClient.maximumVideoBytes && sha256.utf8.count == 64 @@ -61,6 +66,15 @@ struct SavedThreadMediaAsset: Identifiable, Equatable, Codable, Sendable { private static func isLowercaseHexDigit(_ byte: UInt8) -> Bool { (48...57).contains(byte) || (97...102).contains(byte) } + + private static func isAllowedFileNameByte(_ byte: UInt8) -> Bool { + (48...57).contains(byte) + || (65...90).contains(byte) + || (97...122).contains(byte) + || byte == 45 + || byte == 46 + || byte == 95 + } } final class SavedThreadMediaAuthorization: @unchecked Sendable { @@ -139,6 +153,11 @@ final class PreparedSavedThreadMedia: @unchecked Sendable { hadPreviousDirectory = fileManager.fileExists(atPath: finalURL.path) if hadPreviousDirectory { try fileManager.moveItem(at: finalURL, to: rollbackURL) + } else { + try fileManager.createDirectory( + at: rollbackURL, + withIntermediateDirectories: false + ) } do { try fileManager.moveItem(at: stagingURL, to: finalURL) @@ -147,6 +166,8 @@ final class PreparedSavedThreadMedia: @unchecked Sendable { if hadPreviousDirectory, fileManager.fileExists(atPath: rollbackURL.path) { try? fileManager.moveItem(at: rollbackURL, to: finalURL) + } else if fileManager.fileExists(atPath: rollbackURL.path) { + try? fileManager.removeItem(at: rollbackURL) } throw error } @@ -170,6 +191,14 @@ final class PreparedSavedThreadMedia: @unchecked Sendable { if fileManager.fileExists(atPath: stagingURL.path) { try? fileManager.removeItem(at: stagingURL) } + if hadPreviousDirectory, + fileManager.fileExists(atPath: finalURL.path) == false, + fileManager.fileExists(atPath: rollbackURL.path) { + try? fileManager.moveItem(at: rollbackURL, to: finalURL) + } else if hadPreviousDirectory == false, + fileManager.fileExists(atPath: rollbackURL.path) { + try? fileManager.removeItem(at: rollbackURL) + } case .committed: if fileManager.fileExists(atPath: finalURL.path) { try? fileManager.removeItem(at: finalURL) @@ -177,6 +206,8 @@ final class PreparedSavedThreadMedia: @unchecked Sendable { if hadPreviousDirectory, fileManager.fileExists(atPath: rollbackURL.path) { try? fileManager.moveItem(at: rollbackURL, to: finalURL) + } else if fileManager.fileExists(atPath: rollbackURL.path) { + try? fileManager.removeItem(at: rollbackURL) } case .finished, .rolledBack: break @@ -221,7 +252,6 @@ final class SavedThreadMediaStore: @unchecked Sendable { isDirectory: true ) try Self.prepareProtectedDirectory(root, fileManager: fileManager) - Self.cleanupAbandonedTransactions(in: root, fileManager: fileManager) self.fileManager = fileManager rootURL = root SavedThreadMediaAuthorization.shared.register(rootURL: root) @@ -414,19 +444,93 @@ final class SavedThreadMediaStore: @unchecked Sendable { } } - private static func cleanupAbandonedTransactions( - in rootURL: URL, - fileManager: FileManager - ) { - guard let children = try? fileManager.contentsOfDirectory( + func repairStorage(snapshots: [SavedThreadSnapshot]) throws { + guard let rootURL else { return } + let validated = try snapshots.map { try $0.validated() } + let snapshotsByID = Dictionary(uniqueKeysWithValues: validated.map { ($0.id, $0) }) + let validThreadIDs = Set(snapshotsByID.keys) + let children = try fileManager.contentsOfDirectory( at: rootURL, - includingPropertiesForKeys: nil, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [] - ) else { return } - for url in children where url.lastPathComponent.hasPrefix(".staging-") - || url.lastPathComponent.hasPrefix(".rollback-") { + ) + + for url in children where url.lastPathComponent.hasPrefix(".staging-") { try? fileManager.removeItem(at: url) } + + let rollbackGroups = Dictionary(grouping: children.filter { + $0.lastPathComponent.hasPrefix(".rollback-") + }) { Self.transactionThreadID(from: $0.lastPathComponent) } + + for (threadID, candidates) in rollbackGroups { + guard let threadID, validThreadIDs.contains(threadID) else { + candidates.forEach { try? fileManager.removeItem(at: $0) } + continue + } + guard let snapshot = snapshotsByID[threadID] else { continue } + let finalURL = directoryURL(threadID: threadID, rootURL: rootURL) + if try Self.directory(finalURL, matches: snapshot, fileManager: fileManager) { + candidates.forEach { try? fileManager.removeItem(at: $0) } + continue + } + guard let restorable = try candidates.first(where: { + try Self.directory($0, matches: snapshot, fileManager: fileManager) + }) else { + // Preserve unmatched rollback data. It may be the only copy left + // after an interrupted filesystem operation. + continue + } + if fileManager.fileExists(atPath: finalURL.path) { + try fileManager.removeItem(at: finalURL) + } + try fileManager.moveItem(at: restorable, to: finalURL) + candidates.filter { $0 != restorable }.forEach { + try? fileManager.removeItem(at: $0) + } + } + + for snapshot in validated where snapshot.effectiveMediaMode == .textOnly { + try? remove(threadID: snapshot.id) + } + removeOrphans(keeping: validThreadIDs) + } + + private static func transactionThreadID(from name: String) -> Int64? { + guard name.hasPrefix(".rollback-") else { return nil } + return Int64(String(name.dropFirst(".rollback-".count).prefix { $0 != "-" })) + } + + private static func directory( + _ directoryURL: URL, + matches snapshot: SavedThreadSnapshot, + fileManager: FileManager + ) throws -> Bool { + guard fileManager.fileExists(atPath: directoryURL.path) else { return false } + let values = try directoryURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { return false } + let children = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey], + options: [] + ) + let expected = Set(snapshot.effectiveMediaAssets.map(\.fileName)) + guard Set(children.map(\.lastPathComponent)) == expected else { return false } + let assetsByName = Dictionary(grouping: snapshot.effectiveMediaAssets, by: \.fileName) + .compactMapValues(\.first) + return try children.allSatisfy { fileURL in + guard let asset = assetsByName[fileURL.lastPathComponent] else { return false } + let fileValues = try fileURL.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey + ]) + let digest = try Self.sha256(fileURL: fileURL) + return fileValues.isRegularFile == true + && fileValues.isSymbolicLink != true + && fileValues.fileSize == asset.byteCount + && digest == asset.sha256 + } } private func capture( diff --git a/TiebaPure/Features/Forum/ForumThreadsView.swift b/TiebaPure/Features/Forum/ForumThreadsView.swift index 0c25d6a..f88924a 100644 --- a/TiebaPure/Features/Forum/ForumThreadsView.swift +++ b/TiebaPure/Features/Forum/ForumThreadsView.swift @@ -109,7 +109,7 @@ struct ForumThreadsView: View { account: account, scope: activeSearch.scope, initialKeyword: activeSearch.keyword, - onClose: { isStandaloneSearchPresented = false } + onClose: dismissStandaloneSearch ) } } @@ -698,16 +698,24 @@ struct ForumThreadsView: View { } openSearchInParent(route) } else { - activeSearch = route - if NestedSearchOpenRoutingPolicy.destination( + let destination = NestedSearchOpenRoutingPolicy.destination( hasParentHandler: false, systemMajorVersion: ProcessInfo.processInfo.operatingSystemVersion.majorVersion - ) == .standaloneSearch { + ) + if destination == .standaloneSearch { isStandaloneSearchPresented = true + } else { + isStandaloneSearchPresented = false } + activeSearch = route } } + private func dismissStandaloneSearch() { + activeSearch = nil + isStandaloneSearchPresented = false + } + private func openNewThreadComposer() { guard contentSubmissionSettingsStore.newThreadsEnabled else { forumActionError = "请先在设置中开启“允许发帖”。" diff --git a/TiebaPure/Features/Settings/ReadingSettingsView.swift b/TiebaPure/Features/Settings/ReadingSettingsView.swift index 57c231a..3c39fbc 100644 --- a/TiebaPure/Features/Settings/ReadingSettingsView.swift +++ b/TiebaPure/Features/Settings/ReadingSettingsView.swift @@ -203,10 +203,7 @@ struct ReadingSettingsView: View { isImportingFont = true Task { do { - let prepared = try await Task.detached(priority: .userInitiated) { - try ReaderFontStore.prepareImport(from: url) - }.value - let imported = try await fontStore.importFont(prepared) + let imported = try await fontStore.importFont(from: url) guard let family = imported.family else { throw ReaderFontStoreError.invalidFont } diff --git a/TiebaPure/Features/Settings/SavedThreadsView.swift b/TiebaPure/Features/Settings/SavedThreadsView.swift index 0f245c8..cd54066 100644 --- a/TiebaPure/Features/Settings/SavedThreadsView.swift +++ b/TiebaPure/Features/Settings/SavedThreadsView.swift @@ -51,8 +51,9 @@ struct SavedThreadsView: View { Section { ForEach(visibleEntries) { snapshot in NavigationLink { - SavedThreadDetailView( - snapshot: store.mediaStore.resolvedSnapshot(snapshot) + SavedThreadDetailDestination( + snapshot: snapshot, + mediaStore: store.mediaStore ) } label: { SavedThreadRow(snapshot: snapshot) @@ -186,11 +187,13 @@ struct SavedThreadsView: View { } private func remove(_ threadID: Int64) { - do { - try store.remove(threadID: threadID) - refreshStorageUsage() - } catch { - errorMessage = error.localizedDescription + Task { + do { + try await store.removeWithoutBlocking(threadID: threadID) + refreshStorageUsage() + } catch { + errorMessage = error.localizedDescription + } } } @@ -211,13 +214,12 @@ struct SavedThreadsView: View { let service = SavedThreadUpdateService(api: environment.api) var updatedCount = 0 var failedCount = 0 - for snapshot in store.entries { + var latestReplyCounts: [Int64: Int] = [:] + let snapshots = store.entries + for snapshot in snapshots { do { let latest = try await service.latestReplyCount(snapshot: snapshot, account: account) - try store.recordUpdateCheck( - threadID: snapshot.id, - latestReplyCount: latest - ) + latestReplyCounts[snapshot.id] = latest if latest > snapshot.thread.replyCount { updatedCount += 1 } } catch is CancellationError { return @@ -225,6 +227,16 @@ struct SavedThreadsView: View { failedCount += 1 } } + do { + try await store.recordUpdateChecksWithoutBlocking(latestReplyCounts) + } catch is CancellationError { + return + } catch { + if showsResult { + errorMessage = "检查已完成,但保存检查结果失败:\(error.localizedDescription)" + } + return + } if showsResult { resultMessage = failedCount == 0 ? "检查完成,\(updatedCount)个帖子有新回复。" @@ -296,11 +308,13 @@ struct SavedThreadsView: View { } private func clearAll() { - do { - try store.clear() - refreshStorageUsage() - } catch { - errorMessage = error.localizedDescription + Task { + do { + try await store.clearWithoutBlocking() + refreshStorageUsage() + } catch { + errorMessage = error.localizedDescription + } } } @@ -318,6 +332,31 @@ struct SavedThreadsView: View { } } +private struct SavedThreadDetailDestination: View { + let snapshot: SavedThreadSnapshot + let mediaStore: SavedThreadMediaStore + @State private var resolvedSnapshot: SavedThreadSnapshot? + + var body: some View { + Group { + if let resolvedSnapshot { + SavedThreadDetailView(snapshot: resolvedSnapshot) + } else { + ProgressView("正在读取本地帖子") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TiebaPureTheme.ColorToken.readerGroupedBackground) + } + } + .task { + let value = await Task.detached(priority: .userInitiated) { + mediaStore.resolvedSnapshot(snapshot) + }.value + guard Task.isCancelled == false else { return } + resolvedSnapshot = value + } + } +} + private struct SavedThreadRow: View { let snapshot: SavedThreadSnapshot @@ -493,11 +532,16 @@ struct SavedThreadBackupDocument: FileDocument { remainingBytes -= threadFiles.values.reduce(0) { $0 + $1.count } files[snapshot.id] = threadFiles } + guard 2 + self.snapshots.count + files.values.reduce(0, { $0 + $1.count }) + <= SavedThreadPolicy.maximumBackupEntries else { + throw SavedThreadError.invalidBackup + } mediaFiles = files } init(fileWrapper: FileWrapper) throws { - guard fileWrapper.isDirectory, + guard Self.hasAllowedEntryCount(fileWrapper), + fileWrapper.isDirectory, let root = fileWrapper.fileWrappers, Set(root.keys) == ["manifest.json", "Media"], let manifestWrapper = root["manifest.json"], @@ -578,7 +622,12 @@ struct SavedThreadBackupDocument: FileDocument { throw SavedThreadError.invalidBackup } var totalBytes = 0 + var entryCount = 0 for case let child as URL in enumerator { + entryCount += 1 + guard entryCount <= SavedThreadPolicy.maximumBackupEntries else { + throw SavedThreadError.invalidBackup + } let values = try child.resourceValues(forKeys: [ .isDirectoryKey, .isRegularFileKey, @@ -609,7 +658,10 @@ struct SavedThreadBackupDocument: FileDocument { let mediaByteCount = mediaFiles.values.reduce(0) { partial, files in partial + files.values.reduce(0) { $0 + $1.count } } - guard manifestData.count + mediaByteCount <= SavedThreadPolicy.maximumBackupBytes else { + let entryCount = 2 + snapshots.count + + mediaFiles.values.reduce(0) { $0 + $1.count } + guard entryCount <= SavedThreadPolicy.maximumBackupEntries, + manifestData.count + mediaByteCount <= SavedThreadPolicy.maximumBackupBytes else { throw SavedThreadError.backupStorageLimitExceeded } let manifestWrapper = FileWrapper(regularFileWithContents: manifestData) @@ -651,6 +703,21 @@ struct SavedThreadBackupDocument: FileDocument { encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] return try encoder.encode(manifest) } + + private static func hasAllowedEntryCount(_ root: FileWrapper) -> Bool { + var stack = [root] + var count = 0 + while let current = stack.popLast() { + guard current.isDirectory else { continue } + guard let children = current.fileWrappers?.values else { return false } + for child in children { + count += 1 + guard count <= SavedThreadPolicy.maximumBackupEntries else { return false } + stack.append(child) + } + } + return true + } } private struct SavedSubpostsView: View { diff --git a/TiebaPure/Features/Thread/ThreadDetailView.swift b/TiebaPure/Features/Thread/ThreadDetailView.swift index 06e447e..8b04818 100644 --- a/TiebaPure/Features/Thread/ThreadDetailView.swift +++ b/TiebaPure/Features/Thread/ThreadDetailView.swift @@ -887,12 +887,16 @@ struct ThreadDetailView: View { snapshot: snapshot, mode: mode ) + defer { preparedMedia.rollback() } try Task.checkCancellation() snapshot.mediaMode = mode snapshot.mediaAssets = preparedMedia.assets snapshot.latestCheckedAt = nil snapshot.latestReplyCount = nil - try savedThreadStore.save(snapshot, preparedMedia: preparedMedia) + try await savedThreadStore.saveWithoutBlocking( + snapshot, + preparedMedia: preparedMedia + ) let mediaBytes = Dictionary(grouping: preparedMedia.assets, by: \.fileName) .values .compactMap(\.first) diff --git a/TiebaPureTests/NavigationSourceLifecycleTests.swift b/TiebaPureTests/NavigationSourceLifecycleTests.swift index bb8f6b2..36ea67c 100644 --- a/TiebaPureTests/NavigationSourceLifecycleTests.swift +++ b/TiebaPureTests/NavigationSourceLifecycleTests.swift @@ -25,28 +25,10 @@ final class NavigationSourceLifecycleTests: XCTestCase { func testNavigationGestureControllerOnlyHostsForExplicitDisable() { XCTAssertFalse( - NavigationPopGestureControlHostingPolicy.requiresController( - systemMajorVersion: 16, - isEnabled: true - ) - ) - XCTAssertFalse( - NavigationPopGestureControlHostingPolicy.requiresController( - systemMajorVersion: 18, - isEnabled: true - ) - ) - XCTAssertFalse( - NavigationPopGestureControlHostingPolicy.requiresController( - systemMajorVersion: 26, - isEnabled: true - ) + NavigationPopGestureControlHostingPolicy.requiresController(isEnabled: true) ) XCTAssertTrue( - NavigationPopGestureControlHostingPolicy.requiresController( - systemMajorVersion: 26, - isEnabled: false - ) + NavigationPopGestureControlHostingPolicy.requiresController(isEnabled: false) ) } diff --git a/TiebaPureTests/SavedThreadTests.swift b/TiebaPureTests/SavedThreadTests.swift index 0b196b8..c7cf7b7 100644 --- a/TiebaPureTests/SavedThreadTests.swift +++ b/TiebaPureTests/SavedThreadTests.swift @@ -123,6 +123,163 @@ final class SavedThreadTests: XCTestCase { ) } + func testStoreRepairsInterruptedMediaReplacementFromPersistedSnapshot() async throws { + let directory = try makeTemporaryDirectory() + let store = SavedThreadStore(baseDirectoryURL: directory) + let originalData = Data("persisted offline payload".utf8) + var original = try await capturedSnapshot() + let asset = mediaAsset( + data: originalData, + source: "voice:" + String(repeating: "1", count: 32) + ) + original.mediaMode = .complete + original.mediaAssets = [asset] + let prepared = try store.mediaStore.prepareImport( + snapshot: original, + files: [asset.fileName: originalData] + ) + try store.save(original, preparedMedia: prepared) + + let mediaRoot = savedThreadMediaRoot(in: directory) + let final = mediaRoot.appendingPathComponent(String(original.id), isDirectory: true) + let rollback = mediaRoot.appendingPathComponent( + ".rollback-\(original.id)-interrupted", + isDirectory: true + ) + let abandonedStaging = mediaRoot.appendingPathComponent( + ".staging-\(original.id)-interrupted", + isDirectory: true + ) + try FileManager.default.moveItem(at: final, to: rollback) + try FileManager.default.createDirectory(at: final, withIntermediateDirectories: false) + try Data("uncommitted replacement".utf8).write( + to: final.appendingPathComponent(asset.fileName) + ) + try FileManager.default.createDirectory( + at: abandonedStaging, + withIntermediateDirectories: false + ) + + let reopened = SavedThreadStore(baseDirectoryURL: directory) + + XCTAssertEqual( + try reopened.mediaStore.backupFiles(for: XCTUnwrap(reopened.entries.first))[asset.fileName], + originalData + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: rollback.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: abandonedStaging.path)) + } + + func testStoreRepairsInterruptedFirstMediaCommitForTextOnlySnapshot() async throws { + let directory = try makeTemporaryDirectory() + let store = SavedThreadStore(baseDirectoryURL: directory) + let snapshot = try await capturedSnapshot() + try store.save(snapshot) + + let mediaRoot = savedThreadMediaRoot(in: directory) + let final = mediaRoot.appendingPathComponent(String(snapshot.id), isDirectory: true) + let rollback = mediaRoot.appendingPathComponent( + ".rollback-\(snapshot.id)-interrupted", + isDirectory: true + ) + try FileManager.default.createDirectory(at: rollback, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: final, withIntermediateDirectories: false) + try Data("uncommitted media".utf8).write( + to: final.appendingPathComponent("uncommitted.audio") + ) + + _ = SavedThreadStore(baseDirectoryURL: directory) + + XCTAssertFalse(FileManager.default.fileExists(atPath: final.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: rollback.path)) + } + + func testValidationRejectsDuplicateFloorsAndCrossPostSubposts() async throws { + let snapshot = try await capturedSnapshot() + + var duplicateFloor = snapshot + var extraPost = try XCTUnwrap(duplicateFloor.posts.last) + extraPost.post.id += 1_000 + duplicateFloor.posts.append(extraPost) + XCTAssertThrowsError(try duplicateFloor.validated()) { error in + XCTAssertEqual(error as? SavedThreadError, .inconsistentPagination) + } + + var duplicateSubpost = snapshot + let nested = try XCTUnwrap(duplicateSubpost.posts.last?.subposts.first) + duplicateSubpost.posts[0].subposts.append(nested) + XCTAssertThrowsError(try duplicateSubpost.validated()) { error in + XCTAssertEqual(error as? SavedThreadError, .inconsistentPagination) + } + } + + func testMediaManifestRejectsPartiallyInvalidSourceIdentities() async throws { + var snapshot = try await capturedSnapshot() + let data = Data("payload".utf8) + var asset = mediaAsset(data: data, source: "https://example.invalid/valid") + asset.sourceIdentities.append("") + snapshot.mediaMode = .complete + snapshot.mediaAssets = [asset] + + XCTAssertThrowsError(try snapshot.validated()) { error in + XCTAssertEqual(error as? SavedThreadError, .invalidMediaManifest) + } + } + + func testImportingTextOnlySnapshotRemovesPreviousMediaDirectory() async throws { + let directory = try makeTemporaryDirectory() + let store = SavedThreadStore(baseDirectoryURL: directory) + let data = Data("offline payload".utf8) + var snapshot = try await capturedSnapshot() + let asset = mediaAsset( + data: data, + source: "voice:" + String(repeating: "2", count: 32) + ) + snapshot.mediaMode = .complete + snapshot.mediaAssets = [asset] + let prepared = try store.mediaStore.prepareImport( + snapshot: snapshot, + files: [asset.fileName: data] + ) + try store.save(snapshot, preparedMedia: prepared) + + var textOnly = snapshot + textOnly.savedAt = snapshot.savedAt.addingTimeInterval(1) + textOnly.mediaMode = .textOnly + textOnly.mediaAssets = [] + try await store.importBackupWithoutBlocking( + snapshots: [textOnly], + mediaFiles: [textOnly.id: [:]], + replacingExisting: true + ) + + let final = savedThreadMediaRoot(in: directory) + .appendingPathComponent(String(snapshot.id), isDirectory: true) + XCTAssertFalse(FileManager.default.fileExists(atPath: final.path)) + } + + func testRejectedAsyncSaveDiscardsPreparedStagingDirectory() async throws { + let directory = try makeTemporaryDirectory() + let store = SavedThreadStore(baseDirectoryURL: directory) + var snapshot = try await capturedSnapshot() + let prepared = try store.mediaStore.prepareImport(snapshot: snapshot, files: [:]) + snapshot.thread.id = 0 + + do { + try await store.saveWithoutBlocking(snapshot, preparedMedia: prepared) + XCTFail("无效快照不应提交") + } catch { + XCTAssertEqual(error as? SavedThreadError, .incompleteThread) + } + + let mediaRoot = savedThreadMediaRoot(in: directory) + let children = try FileManager.default.contentsOfDirectory( + at: mediaRoot, + includingPropertiesForKeys: nil + ) + XCTAssertFalse(children.contains { $0.lastPathComponent.hasPrefix(".staging-") }) + } + func testBackupImportAndUpdateCountRoundTrip() async throws { let sourceDirectory = try makeTemporaryDirectory() let destinationDirectory = try makeTemporaryDirectory() @@ -340,6 +497,14 @@ final class SavedThreadTests: XCTestCase { return result } + private func savedThreadMediaRoot(in baseDirectory: URL) -> URL { + baseDirectory + .appendingPathComponent("TiebaPure", isDirectory: true) + .appendingPathComponent("Persistence", isDirectory: true) + .appendingPathComponent(SecurePersistenceLocation.currentDirectoryName, isDirectory: true) + .appendingPathComponent("saved-thread-media", isDirectory: true) + } + private func makeTemporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/TiebaPureTests/TiebaPureSmokeTests.swift b/TiebaPureTests/TiebaPureSmokeTests.swift index 25bf16a..089acd8 100644 --- a/TiebaPureTests/TiebaPureSmokeTests.swift +++ b/TiebaPureTests/TiebaPureSmokeTests.swift @@ -3506,84 +3506,6 @@ final class TiebaPureSmokeTests: XCTestCase { ) } - func testNavigationBackGesturePolicyUsesNativeContentPopOnlyOnIOS26() { - XCTAssertEqual( - NavigationBackGesturePolicy.mode(systemMajorVersion: 18), - .edge - ) - XCTAssertEqual( - NavigationBackGesturePolicy.mode(systemMajorVersion: 25), - .edge - ) - XCTAssertEqual( - NavigationBackGesturePolicy.mode(systemMajorVersion: 26), - .content - ) - XCTAssertEqual( - NavigationBackGesturePolicy.mode(systemMajorVersion: 27), - .content - ) - } - - func testNativeEdgePopActivationOnlyEnablesEligibleLegacyNavigationStacks() { - XCTAssertTrue(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .edge, - isVisible: true, - isAttachedToWindow: true, - stackDepth: 2, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: false, - mode: .edge, - isVisible: true, - isAttachedToWindow: true, - stackDepth: 2, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .content, - isVisible: true, - isAttachedToWindow: true, - stackDepth: 2, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .edge, - isVisible: false, - isAttachedToWindow: true, - stackDepth: 2, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .edge, - isVisible: true, - isAttachedToWindow: false, - stackDepth: 2, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .edge, - isVisible: true, - isAttachedToWindow: true, - stackDepth: 1, - hasActiveTransition: false - )) - XCTAssertFalse(NativeEdgePopGestureActivationPolicy.shouldEnable( - requestedEnabled: true, - mode: .edge, - isVisible: true, - isAttachedToWindow: true, - stackDepth: 2, - hasActiveTransition: true - )) - } - func testInlineContentTextUsesOneLiveLayoutStackWithoutClippingAfterReuse() { let textView = InlineContentTextView() if #available(iOS 17.0, *) {