diff --git a/README.md b/README.md index 796bf66..d7386f9 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ open -n dist/AerialDrop.app - Open Aerial Storage Folder - Validate Current Catalogue +- Restore Latest Backup (replaces the catalogue with the newest AerialDrop backup; refused if Apple's catalogue changed since the backup) - Remove All AerialDrop Wallpapers ## How it works diff --git a/Sources/AerialDrop/AerialDropApp.swift b/Sources/AerialDrop/AerialDropApp.swift index 850a68c..90a2ba5 100644 --- a/Sources/AerialDrop/AerialDropApp.swift +++ b/Sources/AerialDrop/AerialDropApp.swift @@ -1,7 +1,9 @@ +import AppKit import SwiftUI @main struct AerialDropApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var model = AppModel() var body: some Scene { @@ -9,6 +11,7 @@ struct AerialDropApp: App { ContentView() .environment(model) .frame(minWidth: 760, minHeight: 520) + .onAppear { appDelegate.model = model } } .defaultSize(width: 1040, height: 700) .windowResizability(.contentMinSize) @@ -27,3 +30,28 @@ struct AerialDropApp: App { } } } + +/// Intercepts quit while an import is running so a long encode is not +/// discarded silently (any partial files are cleaned on the next launch). +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + weak var model: AppModel? + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let model, + model.isWorking, + model.stage != .idle, + model.stage != .finished else { + return .terminateNow + } + + let alert = NSAlert() + alert.messageText = "Import in Progress" + alert.informativeText = "AerialDrop is still importing. Quitting now will discard the encode. You can cancel the import safely from the toolbar instead." + alert.addButton(withTitle: "Keep Importing") + alert.addButton(withTitle: "Quit Anyway") + alert.alertStyle = .warning + let response = alert.runModal() + return response == .alertSecondButtonReturn ? .terminateNow : .terminateCancel + } +} diff --git a/Sources/AerialDrop/AppModel.swift b/Sources/AerialDrop/AppModel.swift index 17713d2..b8b04c2 100644 --- a/Sources/AerialDrop/AppModel.swift +++ b/Sources/AerialDrop/AppModel.swift @@ -25,10 +25,18 @@ final class AppModel { var activeAerialAssetIDs: Set = [] var activationFailure: ManagedWallpaper? var activationFailureMessage: String? + /// Human-readable label of the Library operation currently in progress + /// (activation, removal, remove-all, restore), shown as busy feedback. + /// Nil while idle or during an import, which has its own progress UI. + private(set) var operationLabel: String? + /// ID of the most recently completed import; the Library selects and + /// scrolls to this wallpaper when it appears. Cleared once applied. + var pendingLibraryHighlightID: String? private var selectionVersion = 0 private var importTask: Task? private var importGeneration = 0 + private var encodeStartedAt: Date? private let paths: WallpaperPaths private let manifestStore: ManifestStore @@ -72,21 +80,42 @@ final class AppModel { } /// Maps the real encode fraction into the progress band occupied by the - /// video-processing stage; other stages use their fixed milestones. + /// video-processing stage; other stages use their fixed milestones. The + /// encode band starts at the preparing-folders milestone (0.3) and ends + /// below the thumbnail milestone (0.7), so the bar never moves backward + /// across stage transitions. var displayProgress: Double { - if stage == .processingVideo && importProgress > 0 { - return 0.15 + importProgress * 0.6 + if stage == .processingVideo { + return 0.3 + min(importProgress, 0.95) * 0.4 } return stage.progress } + /// The encode ETA extrapolates from the throttled 1% progress steps, so a + /// stalled encoder would otherwise present an absurd, ever-growing + /// countdown. No credible encode of an 80-second segment exceeds this. + static let maxEncodeETA: TimeInterval = 1800 + + /// Estimated seconds remaining in the encode stage, derived from the + /// progress rate observed since encoding started. Nil outside the encode + /// stage or while the estimate is not yet meaningful. + var encodeETA: TimeInterval? { + guard stage == .processingVideo, + let start = encodeStartedAt, + importProgress > 0.05 else { return nil } + let elapsed = Date().timeIntervalSince(start) + guard elapsed > 3 else { return nil } + let fraction = min(max(importProgress, 0.01), 0.95) + let eta = elapsed * (1 - fraction) / fraction + return min(eta, Self.maxEncodeETA) + } + func chooseVideo(_ url: URL) { selectionVersion += 1 let version = selectionVersion - let previousTitle = selectedVideo.map { $0.deletingPathExtension().lastPathComponent } - if title.isEmpty || title == previousTitle { - title = url.deletingPathExtension().lastPathComponent - } + // Always follow the chosen file: a name left over from a previously + // selected source is confusing when the source is replaced. + title = url.deletingPathExtension().lastPathComponent selectedVideo = url importOutcome = nil cropOffset = 0.5 @@ -176,6 +205,7 @@ final class AppModel { try manifestStore.prepareDirectories() stage = .processingVideo + encodeStartedAt = Date() let encodedSize = try await videoProcessor.makeNativeMOV( from: source, destination: videoDestination, @@ -252,12 +282,19 @@ final class AppModel { func removeWallpaper(_ wallpaper: ManagedWallpaper) async { isWorking = true - defer { isWorking = false } + operationLabel = "Removing “\(wallpaper.title)”…" + defer { + isWorking = false + operationLabel = nil + } do { refreshActiveSelectionForRemoval() guard !activeAerialAssetIDs.contains(wallpaper.id) else { throw AerialDropError.activeWallpaperCannotBeRemoved } + if pendingLibraryHighlightID == wallpaper.id { + pendingLibraryHighlightID = nil + } try manifestStore.removeWallpaper(id: wallpaper.id) await systemService.refresh() await reload() @@ -274,9 +311,14 @@ final class AppModel { func removeAllWallpapers() async { isWorking = true - defer { isWorking = false } + operationLabel = "Removing all AerialDrop wallpapers…" + defer { + isWorking = false + operationLabel = nil + } do { refreshActiveSelectionForRemoval() + pendingLibraryHighlightID = nil let managedIDs = Set(try manifestStore.importedWallpapers().map(\.id)) guard activeAerialAssetIDs.isDisjoint(with: managedIDs) else { throw AerialDropError.activeWallpaperCannotBeRemoved @@ -290,6 +332,7 @@ final class AppModel { } func reload() async { + sweepOrphanedTempSegments() catalogueState = .loading do { try manifestStore.requireManifest() @@ -302,6 +345,17 @@ final class AppModel { try? refreshActiveSelection() } + /// Removes leftover AerialDrop encode temp files (e.g. after the app was + /// quit mid-import). Only touches files matching AerialDrop's own temp + /// naming, never other apps' catalogue files. + private func sweepOrphanedTempSegments() { + guard !isWorking else { return } + guard let files = try? FileManager.default.contentsOfDirectory(atPath: paths.videos.path) else { return } + for name in files where name.hasPrefix(".AerialDrop-") && name.hasSuffix(".mov") { + try? FileManager.default.removeItem(at: paths.videos.appending(path: name)) + } + } + func setWallpaper(_ wallpaper: ManagedWallpaper) { Task { await activateWallpaper(wallpaper) @@ -312,7 +366,11 @@ final class AppModel { /// model tests while UI callers retain the non-blocking action method. func activateWallpaper(_ wallpaper: ManagedWallpaper) async { isWorking = true - defer { isWorking = false } + operationLabel = "Applying “\(wallpaper.title)”…" + defer { + isWorking = false + operationLabel = nil + } do { try await systemService.activateAerial(assetID: wallpaper.id) dismissActivationFailure() @@ -358,6 +416,7 @@ final class AppModel { wallpaper: wallpaper, activationResult: activationResult ) + pendingLibraryHighlightID = wallpaper.id return activationResult } @@ -385,7 +444,7 @@ final class AppModel { func validateCatalogue() { do { try manifestStore.validateCurrentManifest() - alertMessage = "The current Aerial catalogue passed AerialDrop’s structural and preservation checks." + alertMessage = "The current Aerial catalogue is valid and ready to use." } catch { alertMessage = error.localizedDescription } @@ -395,6 +454,34 @@ final class AppModel { systemService.openWallpaperSettings() } + /// The newest AerialDrop catalogue backup, for the restore confirmation. + func latestBackupInfo() -> ManifestStore.BackupInfo? { + manifestStore.latestBackup() + } + + /// Replaces the current catalogue with the newest AerialDrop backup. The + /// restore is refused (with nothing changed) if foreign catalogue data + /// changed since the backup. + func restoreLatestBackup() async { + isWorking = true + operationLabel = "Restoring catalogue backup…" + defer { + isWorking = false + operationLabel = nil + } + do { + guard let info = manifestStore.latestBackup() else { + alertMessage = "No AerialDrop backups were found." + return + } + try manifestStore.restoreBackup(info) + await reload() + alertMessage = "Restored the Aerial catalogue backup from \(info.date.formatted(date: .abbreviated, time: .shortened)) (\(info.operation))." + } catch { + alertMessage = error.localizedDescription + } + } + func openStorageFolder() { systemService.openFolder(paths.base) } diff --git a/Sources/AerialDrop/ContentView.swift b/Sources/AerialDrop/ContentView.swift index 673a27a..81ada97 100644 --- a/Sources/AerialDrop/ContentView.swift +++ b/Sources/AerialDrop/ContentView.swift @@ -6,7 +6,7 @@ struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @State private var destination: AppDestination? = .library @State private var preImportDestination: AppDestination? - @State private var removeAllConfirmation = false + @State private var confirmation: ConfirmationKind? @State private var alertPresented = false @State private var alertMessage: String? @@ -41,7 +41,7 @@ struct ContentView: View { } .disabled(model.isWorking) - Button("Wallpaper Settings", systemImage: "gearshape") { + Button("Wallpaper Settings", systemImage: "photo") { model.openWallpaperSettings() } @@ -89,14 +89,17 @@ struct ContentView: View { destination = .importVideo } .confirmationDialog( - "Remove every AerialDrop wallpaper?", - isPresented: $removeAllConfirmation, + confirmationTitle, + isPresented: Binding( + get: { confirmation != nil }, + set: { if !$0 { confirmation = nil } } + ), titleVisibility: .visible ) { - Button("Remove All", role: .destructive) { model.removeAll() } - Button("Cancel", role: .cancel) { } + Button(confirmationConfirmTitle, role: confirmationRole) { performConfirmation() } + Button("Cancel", role: .cancel) { confirmation = nil } } message: { - Text("This removes AerialDrop entries and their copied video and thumbnail files. A manifest backup is created first.") + Text(confirmationMessage) } .fileImporter( isPresented: $model.showingFileImporter, @@ -126,8 +129,14 @@ struct ContentView: View { private var destinationView: some View { switch destination ?? .library { case .library: - LibraryPane(onImport: beginImport) - .navigationTitle("Library") + LibraryPane( + onImport: beginImport, + onDropVideo: { url in + model.chooseVideo(url) + destination = .importVideo + } + ) + .navigationTitle("Library") case .importVideo: ImportPane(onViewLibrary: { destination = .library }) .navigationTitle("Import Wallpaper") @@ -238,8 +247,18 @@ struct ContentView: View { Button("Open Aerial Storage Folder") { model.openStorageFolder() } Button("Validate Current Catalogue") { model.validateCatalogue() } Divider() + Button("Restore Latest Backup…") { + if let info = model.latestBackupInfo() { + confirmation = .restore(info) + } else { + model.alertMessage = "No AerialDrop backups were found." + } + } + .disabled(model.isWorking) + .help("Replace the current catalogue with the newest AerialDrop backup") + Divider() Button("Remove All AerialDrop Wallpapers", role: .destructive) { - removeAllConfirmation = true + confirmation = .removeAll } .disabled( model.wallpapers.isEmpty @@ -258,6 +277,69 @@ struct ContentView: View { } return "Remove every AerialDrop wallpaper" } + + private var confirmationTitle: String { + switch confirmation { + case .removeAll: + "Remove every AerialDrop wallpaper?" + case .restore(let info): + "Restore the backup from \(info.date.formatted(date: .abbreviated, time: .shortened))?" + case nil: + "" + } + } + + private var confirmationConfirmTitle: String { + switch confirmation { + case .removeAll: "Remove All" + case .restore: "Restore Backup" + case nil: "" + } + } + + private var confirmationRole: ButtonRole? { + switch confirmation { + case .removeAll: .destructive + case .restore: nil + case nil: nil + } + } + + private var confirmationMessage: String { + switch confirmation { + case .removeAll: + "This removes every AerialDrop entry and its copied video and thumbnail files. Your original source videos are untouched — import them again to restore them. A catalogue backup is created first." + case .restore(let info): + "This replaces the current Aerial catalogue with the backup from \(info.date.formatted(date: .abbreviated, time: .shortened)) (\(info.operation)). The current catalogue is backed up first. Restoring is refused if the catalogue changed since the backup. Wallpapers whose video files were deleted since the backup will appear as “Video missing” and can be removed." + case nil: + "" + } + } + + private func performConfirmation() { + switch confirmation { + case .removeAll: + model.removeAll() + case .restore: + Task { await model.restoreLatestBackup() } + case nil: + break + } + confirmation = nil + } +} + +/// The single confirmation dialog covers the maintenance flows that need one. +private enum ConfirmationKind: Identifiable { + case removeAll + case restore(ManifestStore.BackupInfo) + + var id: String { + switch self { + case .removeAll: "removeAll" + case .restore: "restore" + } + } } private enum AppDestination: String, CaseIterable, Identifiable { diff --git a/Sources/AerialDrop/ManifestStore.swift b/Sources/AerialDrop/ManifestStore.swift index 80bf97f..7d512ce 100644 --- a/Sources/AerialDrop/ManifestStore.swift +++ b/Sources/AerialDrop/ManifestStore.swift @@ -68,6 +68,80 @@ struct ManifestStore { try validateCandidate(root, preservingForeignEntriesFrom: root) } + /// A restorable catalogue backup: the backup file, its creation date, and + /// the operation that produced it. + struct BackupInfo: Equatable { + let url: URL + let date: Date + let operation: String + } + + /// The newest AerialDrop manifest backup, or nil when none exists. + /// Backups written within the same millisecond share a timestamp, so they + /// are ordered by the file's modification date (the actual write order); + /// the full backup name breaks any remaining ties so that directory + /// enumeration order never decides the winner. + func latestBackup() -> BackupInfo? { + guard let names = try? fileManager.contentsOfDirectory(atPath: paths.backups.path) else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" + + let candidates: [(name: String, info: BackupInfo)] = names.compactMap { name in + guard name.hasPrefix("entries-"), name.hasSuffix(".json") else { return nil } + let core = String(name.dropFirst("entries-".count).dropLast(".json".count)) + guard core.count > 20 else { return nil } + let timestamp = String(core.prefix(19)) + let operation = String(core.dropFirst(20)) + guard let date = formatter.date(from: timestamp) else { return nil } + return (name, BackupInfo( + url: paths.backups.appending(path: name), + date: date, + operation: operation + )) + } + + return candidates + .sorted { lhs, rhs in + guard lhs.info.date == rhs.info.date else { return lhs.info.date > rhs.info.date } + let lhsModified = (try? lhs.info.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? lhs.info.date + let rhsModified = (try? rhs.info.url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? rhs.info.date + guard lhsModified == rhsModified else { return lhsModified > rhsModified } + return lhs.name > rhs.name + } + .first?.info + } + + /// Replaces the current manifest with the backup's content, after backing + /// up the current manifest and refusing when foreign (non-AerialDrop) + /// catalogue data changed since the backup. Managed assets whose installed + /// files are missing are tolerated — they surface as "Video missing" in + /// the Library and can be removed there. + func restoreBackup(_ info: BackupInfo) throws { + do { + let backupData = try Data(contentsOf: info.url) + let backupRoot = try loadRoot(from: backupData) + try mutateManifest(operation: "restore", requireManagedFiles: false) { root in + root = backupRoot + } + } catch let error as AerialDropError { + throw AerialDropError.backupRestoreRejected(reason(for: error)) + } catch { + throw AerialDropError.backupRestoreRejected(error.localizedDescription) + } + } + + private func reason(for error: AerialDropError) -> String { + switch error { + case .foreignManifestDataChanged: + return "The catalogue has changed since this backup was created, and restoring it would remove newer changes." + case .manifestChangedDuringOperation: + return "The catalogue changed while the restore was being prepared. Try again." + default: + return error.localizedDescription + } + } + func addWallpaper(id: String, title: String, width: Int = 0, height: Int = 0) throws { try requireManifest() try prepareDirectories() @@ -291,6 +365,7 @@ struct ManifestStore { private func mutateManifest( operation: String, + requireManagedFiles: Bool = true, mutation: (inout [String: Any]) throws -> Void ) throws { let originalData = try Data(contentsOf: paths.manifest) @@ -299,7 +374,11 @@ struct ManifestStore { var candidateRoot = originalRoot try mutation(&candidateRoot) - try validateCandidate(candidateRoot, preservingForeignEntriesFrom: originalRoot) + try validateCandidate( + candidateRoot, + preservingForeignEntriesFrom: originalRoot, + requireManagedFiles: requireManagedFiles + ) guard JSONSerialization.isValidJSONObject(candidateRoot) else { throw AerialDropError.malformedManifest("generated JSON is invalid") @@ -324,7 +403,11 @@ struct ManifestStore { throw AerialDropError.manifestChangedDuringOperation } let writtenRoot = try loadRoot(from: writtenData) - try validateCandidate(writtenRoot, preservingForeignEntriesFrom: originalRoot) + try validateCandidate( + writtenRoot, + preservingForeignEntriesFrom: originalRoot, + requireManagedFiles: requireManagedFiles + ) } private func loadRoot(from data: Data) throws -> [String: Any] { @@ -357,7 +440,8 @@ struct ManifestStore { private func validateCandidate( _ candidate: [String: Any], - preservingForeignEntriesFrom original: [String: Any] + preservingForeignEntriesFrom original: [String: Any], + requireManagedFiles: Bool = true ) throws { try validateBaseManifest(candidate) @@ -420,7 +504,7 @@ struct ManifestStore { } for asset in managedAssets { - try validateManagedAsset(asset) + try validateManagedAsset(asset, requireFiles: requireManagedFiles) } guard let category = candidateCategories.first(where: { ($0["id"] as? String) == Self.categoryID }) else { @@ -429,7 +513,7 @@ struct ManifestStore { try validateManagedCategory(category, validAssetIDs: Set(managedAssets.compactMap { $0["id"] as? String })) } - private func validateManagedAsset(_ asset: [String: Any]) throws { + private func validateManagedAsset(_ asset: [String: Any], requireFiles: Bool = true) throws { let requiredStrings = [ "id", "shotID", "localizedNameKey", "accessibilityLabel", "previewImage", "url-4K-SDR-240FPS" @@ -439,11 +523,16 @@ struct ManifestStore { } guard let id = asset["id"] as? String, (asset["previewImage"] as? String) == paths.thumbnailURL(for: id).absoluteString, - (asset["url-4K-SDR-240FPS"] as? String) == paths.videoURL(for: id).absoluteString, - fileManager.fileExists(atPath: paths.thumbnailURL(for: id).path), - fileManager.fileExists(atPath: paths.videoURL(for: id).path) + (asset["url-4K-SDR-240FPS"] as? String) == paths.videoURL(for: id).absoluteString else { - throw AerialDropError.malformedManifest("AerialDrop asset paths or installed files are invalid") + throw AerialDropError.malformedManifest("AerialDrop asset paths are invalid") + } + if requireFiles { + guard fileManager.fileExists(atPath: paths.thumbnailURL(for: id).path), + fileManager.fileExists(atPath: paths.videoURL(for: id).path) + else { + throw AerialDropError.malformedManifest("AerialDrop asset paths or installed files are invalid") + } } guard (asset["categories"] as? [String])?.contains(Self.categoryID) == true else { throw AerialDropError.malformedManifest("AerialDrop asset has the wrong category") diff --git a/Sources/AerialDrop/Models.swift b/Sources/AerialDrop/Models.swift index f10fa45..f021b63 100644 --- a/Sources/AerialDrop/Models.swift +++ b/Sources/AerialDrop/Models.swift @@ -122,6 +122,7 @@ enum AerialDropError: LocalizedError { case foreignWallpaperSelectionDataChanged(String) case wallpaperSelectionVerificationFailed(String) case activeWallpaperCannotBeRemoved + case backupRestoreRejected(String) var errorDescription: String? { switch self { @@ -138,7 +139,10 @@ enum AerialDropError: LocalizedError { case .exportSessionUnavailable: return "macOS could not create a video export session for this file." case .exportFailed(let reason): - return "Video conversion failed: \(reason)" + let hint = reason.contains("OSStatus") + ? " Try freeing up disk space and using a lower quality or resolution." + : "" + return "Video conversion failed: \(reason)\(hint)" case .thumbnailFailed: return "A thumbnail could not be generated from the video." case .invalidTitle: @@ -162,9 +166,9 @@ enum AerialDropError: LocalizedError { case .nativeVideoWrongFrameRate(let frameRate): return "The native Aerial export is \(frameRate.formatted(.number.precision(.fractionLength(3)))) fps instead of 30 fps." case .nativeVideoNotMain10(let bits): - return "The native Aerial export is only \(bits)-bit. Tahoe custom Aerial playback requires the 10-bit HEVC Main10 media class used by the working Wallper asset." + return "The native Aerial export is only \(bits)-bit. Tahoe custom Aerial playback requires the 10-bit HEVC Main10 media format." case .nativeVideoNotFullRange: - return "The native Aerial export is limited-range video. The working Tahoe custom Aerial asset uses full-range 10-bit HEVC." + return "The native Aerial export is limited-range video. Tahoe custom Aerial playback requires full-range 10-bit HEVC." case .main10EncodingUnavailable: return "This Mac could not initialize the HEVC Main10 encoder required for reliable Tahoe Aerial playback." case .manifestChangedDuringOperation: @@ -183,6 +187,8 @@ enum AerialDropError: LocalizedError { return "AerialDrop wrote the wallpaper selection, but macOS did not confirm the expected Aerial (\(expectedID)). Your backup was kept and no automatic restore was attempted." case .activeWallpaperCannotBeRemoved: return "Choose another wallpaper before removing the AerialDrop wallpaper that is currently active." + case .backupRestoreRejected(let reason): + return "The backup could not be restored. \(reason) Nothing was changed." } } } diff --git a/Sources/AerialDrop/VideoProcessor.swift b/Sources/AerialDrop/VideoProcessor.swift index d2364cb..94258c2 100644 --- a/Sources/AerialDrop/VideoProcessor.swift +++ b/Sources/AerialDrop/VideoProcessor.swift @@ -475,6 +475,14 @@ struct VideoProcessor: Sendable { cursor = CMTimeAdd(cursor, insertionDuration) } + // Export to a marked temp file and move it into place: an interrupted + // passthrough must never leave an unmarked partial file at the final + // destination, which the orphan sweep cannot identify as AerialDrop's. + let repeatURL = destination.deletingLastPathComponent().appendingPathComponent( + ".AerialDrop-\(UUID().uuidString)-repeat.mov" + ) + defer { try? FileManager.default.removeItem(at: repeatURL) } + guard let exporter = AVAssetExportSession( asset: repeated, presetName: AVAssetExportPresetPassthrough @@ -485,15 +493,13 @@ struct VideoProcessor: Sendable { throw AerialDropError.passthroughUnavailable } - try? FileManager.default.removeItem(at: destination) - exporter.outputURL = destination exporter.outputFileType = .mov exporter.shouldOptimizeForNetworkUse = true exporter.timeRange = CMTimeRange(start: .zero, duration: nativeTargetDuration) try Task.checkCancellation() do { - try await exporter.export(to: destination, as: .mov) + try await exporter.export(to: repeatURL, as: .mov) } catch { throw AerialDropError.exportFailed( error.localizedDescription @@ -501,6 +507,8 @@ struct VideoProcessor: Sendable { ) } try Task.checkCancellation() + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: repeatURL, to: destination) } private func firstRenderableSampleTime(asset: AVAsset, track: AVAssetTrack) throws -> CMTime { diff --git a/Sources/AerialDrop/Views/ImportPane.swift b/Sources/AerialDrop/Views/ImportPane.swift index f99a184..6565ada 100644 --- a/Sources/AerialDrop/Views/ImportPane.swift +++ b/Sources/AerialDrop/Views/ImportPane.swift @@ -49,7 +49,8 @@ struct ImportPane: View { ImportSuccessView( outcome: outcome, onViewLibrary: onViewLibrary, - onImportAnother: beginAnotherImport + onImportAnother: beginAnotherImport, + onOpenWallpaperSettings: model.openWallpaperSettings ) .accessibilityFocused($accessibilityStatus, equals: .completion) } else { @@ -57,6 +58,7 @@ struct ImportPane: View { ImportProgressView( stage: model.stage, progress: model.displayProgress, + eta: model.encodeETA, canCancel: model.isImportCancellable, onCancel: { model.cancelImport() } ) @@ -83,7 +85,9 @@ struct ImportPane: View { quality: $model.conversionQuality, outputHeightCap: $model.outputHeightCap, cropOffset: $model.cropOffset, - sourceResolution: model.sourceResolution + sourceResolution: model.sourceResolution, + outputSummary: encodedOutputSummary, + duplicateTitle: duplicateTitle ) .disabled(model.isWorking) @@ -105,6 +109,27 @@ struct ImportPane: View { model.importOutcome?.activationResult == .activationFailed } + /// The encoded frame size and an estimated output size for the 80-second + /// loop, derived from the same pure functions the pipeline uses, so the + /// user sees the consequences of the quality/resolution choices in advance. + /// The trimmed wallpaper name when an existing wallpaper already uses it. + private var duplicateTitle: String? { + let clean = model.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { return nil } + guard model.wallpapers.contains(where: { + $0.title.localizedCaseInsensitiveCompare(clean) == .orderedSame + }) else { return nil } + return clean + } + + private var encodedOutputSummary: String? { + guard let source = model.sourceResolution else { return nil } + let size = encodedOutputSize(sourceSize: source, outputHeightCap: model.outputHeightCap) + let bitrate = bitrateBps(quality: model.conversionQuality, renderHeight: Int(size.height)) + let megabytes = Int(Double(bitrate) * 80.0 / 8.0 / 1_000_000) + return "\(Int(size.width)) × \(Int(size.height)) · est. \(megabytes) MB" + } + private func beginAnotherImport() { model.importOutcome = nil model.showingFileImporter = true @@ -258,6 +283,8 @@ private struct ImportSettingsView: View { @Binding var cropOffset: Double let sourceResolution: CGSize? + let outputSummary: String? + let duplicateTitle: String? @FocusState private var nameIsFocused: Bool @@ -266,7 +293,7 @@ private struct ImportSettingsView: View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { settingLabel("Name") - TextField("Wallpaper name", text: $title) + TextField("Enter a wallpaper name", text: $title) .focused($nameIsFocused) .onSubmit { nameIsFocused = false } } @@ -294,6 +321,13 @@ private struct ImportSettingsView: View { .frame(maxWidth: 240, alignment: .leading) } + if let outputSummary { + GridRow { + settingLabel("Output") + Text(outputSummary) + } + } + if isUltrawideSource { GridRow(alignment: .top) { settingLabel("Crop") @@ -314,6 +348,17 @@ private struct ImportSettingsView: View { } } .padding(8) + + if let duplicateTitle { + Label( + "A wallpaper named “\(duplicateTitle)” already exists. Importing will create a duplicate.", + systemImage: "exclamationmark.triangle" + ) + .font(.footnote) + .foregroundStyle(.orange) + .padding(.horizontal, 8) + .padding(.bottom, 8) + } } } @@ -324,7 +369,11 @@ private struct ImportSettingsView: View { private var cropPreset: Binding { Binding( - get: { nearestCropPreset(cropOffset) }, + get: { + let nearest = nearestCropPreset(cropOffset) + if abs(cropOffset - nearest) <= 0.05 { return nearest } + return -1 // between presets: no segment highlighted + }, set: { cropOffset = $0 } ) } @@ -347,6 +396,7 @@ private struct ImportSettingsView: View { private struct ImportProgressView: View { let stage: ImportStage let progress: Double + let eta: TimeInterval? let canCancel: Bool let onCancel: () -> Void @@ -363,6 +413,13 @@ private struct ImportProgressView: View { .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() + + if let etaText { + Text(etaText) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } } ProgressView(value: progress) @@ -386,6 +443,14 @@ private struct ImportProgressView: View { .accessibilityLabel("Import progress") } + private var etaText: String? { + guard let eta, eta > 5 else { return nil } + if eta >= 60 { + return "≈ \(Int(eta / 60)) min left" + } + return "≈ \(Int(eta)) s left" + } + private var cancellableMessage: String { "The source video stays unchanged. You can cancel before installation begins." } @@ -399,6 +464,7 @@ private struct ImportSuccessView: View { let outcome: ImportOutcome let onViewLibrary: () -> Void let onImportAnother: () -> Void + let onOpenWallpaperSettings: () -> Void var body: some View { GroupBox { @@ -423,6 +489,7 @@ private struct ImportSuccessView: View { HStack { Spacer() + Button("Open Wallpaper Settings", systemImage: "photo", action: onOpenWallpaperSettings) Button("Import Another", systemImage: "plus", action: onImportAnother) Button("View in Library", systemImage: "photo.on.rectangle.angled", action: onViewLibrary) .buttonStyle(.borderedProminent) @@ -471,7 +538,7 @@ private struct ImportDetailsView: View { VStack(alignment: .leading, spacing: 8) { Label("Builds an 80-second, 30 fps HEVC Main10 stream", systemImage: "film") Label("Creates a Tahoe-compatible HEIF preview", systemImage: "photo") - Label("Backs up entries.json and preserves foreign entries", systemImage: "doc.badge.gearshape") + Label("Creates a backup before every catalogue change", systemImage: "doc.badge.gearshape") Label("Adds the result to the native Aerial catalogue", systemImage: "rectangle.stack") } .font(.callout) diff --git a/Sources/AerialDrop/Views/LibraryPane.swift b/Sources/AerialDrop/Views/LibraryPane.swift index 520e2ea..dc215a2 100644 --- a/Sources/AerialDrop/Views/LibraryPane.swift +++ b/Sources/AerialDrop/Views/LibraryPane.swift @@ -2,6 +2,7 @@ import SwiftUI struct LibraryPane: View { let onImport: () -> Void + let onDropVideo: (URL) -> Void @Environment(AppModel.self) private var model @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -13,6 +14,8 @@ struct LibraryPane: View { @State private var renameTarget: ManagedWallpaper? @State private var renameText = "" @State private var showingRenameAlert = false + @State private var highlightTarget: String? + @State private var dropTargeted = false private let wallpaperColumns = [ GridItem(.adaptive(minimum: 220, maximum: 320), spacing: 20) @@ -26,6 +29,41 @@ struct LibraryPane: View { var body: some View { libraryState .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + if dropTargeted { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.accentColor, style: StrokeStyle(lineWidth: 2, dash: [6])) + .padding(12) + .overlay { + Label("Drop to import as wallpaper", systemImage: "plus.circle") + .font(.headline) + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + .dropDestination(for: URL.self) { urls, _ in + guard model.catalogueState == .ready, !model.isWorking, let first = urls.first else { return false } + // Mirror VideoProcessor.validate: only movie files enter the import flow. + let ext = first.pathExtension.lowercased() + guard ext == "mp4" || ext == "mov" else { return false } + onDropVideo(first) + return true + } isTargeted: { targeted in + guard model.catalogueState == .ready, !model.isWorking else { return } + dropTargeted = targeted + } + .onChange(of: model.operationLabel) { _, label in + if let label { + AccessibilityNotification.Announcement(label).post() + } + } + .onAppear { + applyPendingHighlight() + } + .onChange(of: model.pendingLibraryHighlightID) { _, _ in + applyPendingHighlight() + } .confirmationDialog( removeDialogTitle, isPresented: $showingRemoveConfirmation, @@ -34,7 +72,7 @@ struct LibraryPane: View { Button("Remove", role: .destructive) { confirmRemoval() } Button("Cancel", role: .cancel) { } } message: { - Text("This removes the wallpaper and its copied video and thumbnail files. A manifest backup is created first.") + Text("This removes the wallpaper and its copied video and thumbnail files. Your original source video is untouched — import it again to restore it. A catalogue backup is created first.") } .alert("Rename Wallpaper", isPresented: $showingRenameAlert) { TextField("Wallpaper name", text: $renameText) @@ -51,6 +89,7 @@ struct LibraryPane: View { wallpaper: wallpaper, isActive: model.activeAerialAssetIDs.contains(wallpaper.id), isWorking: model.isWorking, + operationLabel: model.operationLabel, onSetWallpaper: { model.setWallpaper(wallpaper) }, onRename: { previewWallpaper = nil @@ -87,47 +126,91 @@ struct LibraryPane: View { @ViewBuilder private var readyLibrary: some View { - if model.wallpapers.isEmpty { - emptyLibrary - } else { - Group { - if filteredWallpapers.isEmpty { - ContentUnavailableView.search(text: searchText) - } else { - wallpaperGrid + VStack(spacing: 12) { + if let label = model.operationLabel { + operationBanner(label) + } + + if model.wallpapers.isEmpty { + emptyLibrary + } else { + Group { + if filteredWallpapers.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + wallpaperGrid + } } + .searchable(text: $searchText, placement: .toolbar, prompt: "Search wallpapers") } - .searchable(text: $searchText, placement: .toolbar, prompt: "Search wallpapers") } } + private func operationBanner(_ label: String) -> some View { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(label) + .font(.callout) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .padding(.horizontal, 24) + .accessibilityElement(children: .combine) + } + private var wallpaperGrid: some View { - ScrollView { - LazyVGrid(columns: wallpaperColumns, spacing: 20) { - ForEach(filteredWallpapers) { wallpaper in - WallpaperCard( - wallpaper: wallpaper, - isSelected: selectedID == wallpaper.id, - isActive: model.activeAerialAssetIDs.contains(wallpaper.id), - isWorking: model.isWorking, - onSelect: { - guard !model.isWorking else { return } - selectedID = selectedID == wallpaper.id ? nil : wallpaper.id - }, - onDoubleClick: { openPreview(wallpaper) }, - onPreview: { openPreview(wallpaper) }, - onSetWallpaper: { model.setWallpaper(wallpaper) }, - onRename: { beginRename(wallpaper) }, - onReveal: { model.revealInFinder(wallpaper) }, - onRemove: { requestRemoval(wallpaper) } - ) + ScrollViewReader { proxy in + ScrollView { + LazyVGrid(columns: wallpaperColumns, spacing: 20) { + ForEach(filteredWallpapers) { wallpaper in + WallpaperCard( + wallpaper: wallpaper, + isSelected: selectedID == wallpaper.id, + isActive: model.activeAerialAssetIDs.contains(wallpaper.id), + isWorking: model.isWorking, + onSelect: { + guard !model.isWorking else { return } + selectedID = selectedID == wallpaper.id ? nil : wallpaper.id + }, + onDoubleClick: { openPreview(wallpaper) }, + onPreview: { openPreview(wallpaper) }, + onSetWallpaper: { model.setWallpaper(wallpaper) }, + onRename: { beginRename(wallpaper) }, + onReveal: { model.revealInFinder(wallpaper) }, + onRemove: { requestRemoval(wallpaper) } + ) + .id(wallpaper.id) + } + } + .padding(24) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.wallpapers) + } + .onChange(of: highlightTarget) { _, target in + guard let target else { return } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25)) { + proxy.scrollTo(target, anchor: .center) } + highlightTarget = nil } - .padding(24) - .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.wallpapers) } } + /// Selects and scrolls to the wallpaper whose import just completed, + /// clearing the request so it is applied only once. + private func applyPendingHighlight() { + guard let id = model.pendingLibraryHighlightID, + model.wallpapers.contains(where: { $0.id == id }) else { return } + if !searchText.isEmpty { + searchText = "" + } + selectedID = id + highlightTarget = id + model.pendingLibraryHighlightID = nil + } + private var emptyLibrary: some View { ContentUnavailableView { Label("No AerialDrop Wallpapers", systemImage: "rectangle.stack.badge.plus") diff --git a/Sources/AerialDrop/Views/VideoPreview.swift b/Sources/AerialDrop/Views/VideoPreview.swift index 2d1e5e1..57bf6bb 100644 --- a/Sources/AerialDrop/Views/VideoPreview.swift +++ b/Sources/AerialDrop/Views/VideoPreview.swift @@ -71,11 +71,57 @@ struct VideoPreview: View { let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true generator.maximumSize = CGSize(width: 1280, height: 1280) - let target = min(max(duration ?? 0.5, 0.05), 0.5) - let time = CMTime(seconds: target, preferredTimescale: 600) - if let result = try? await generator.image(at: time) { - frame = NSImage(cgImage: result.image, size: .zero) + + // Sample a few early timestamps and prefer the first frame that is not + // (nearly) black, so fade-in sources don't preview as a black box. + let durationSeconds = duration ?? 1 + let candidates = [0.5, 2.0, 5.0].filter { $0 < durationSeconds } + let times = candidates.isEmpty + ? [min(max(durationSeconds, 0.05), 0.5)] + : candidates + var fallback: NSImage? + for seconds in times { + let time = CMTime(seconds: seconds, preferredTimescale: 600) + guard let result = try? await generator.image(at: time) else { continue } + let image = NSImage(cgImage: result.image, size: .zero) + if Self.isMeaningfullyVisible(result.image) { + frame = image + return + } + if fallback == nil { + fallback = image + } + } + frame = fallback + } + + /// True when a frame has enough non-dark pixels to represent the video + /// (a fade-in-from-black or first-frame-black source fails this). + static func isMeaningfullyVisible(_ image: CGImage) -> Bool { + let bitmap = NSBitmapImageRep(cgImage: image) + let stepX = max(1, bitmap.pixelsWide / 10) + let stepY = max(1, bitmap.pixelsHigh / 10) + var bright = 0 + var sampled = 0 + var x = 0 + while x < bitmap.pixelsWide { + var y = 0 + while y < bitmap.pixelsHigh { + if let color = bitmap.colorAt(x: x, y: y) { + let luminance = + 0.299 * color.redComponent + + 0.587 * color.greenComponent + + 0.114 * color.blueComponent + if luminance > 0.12 { + bright += 1 + } + } + sampled += 1 + y += stepY + } + x += stepX } + return sampled > 0 && Double(bright) / Double(sampled) > 0.1 } private func timeString(_ seconds: Double) -> String { diff --git a/Sources/AerialDrop/Views/WallpaperPreviewView.swift b/Sources/AerialDrop/Views/WallpaperPreviewView.swift index 4952e5e..07efcc2 100644 --- a/Sources/AerialDrop/Views/WallpaperPreviewView.swift +++ b/Sources/AerialDrop/Views/WallpaperPreviewView.swift @@ -4,6 +4,7 @@ struct WallpaperPreviewView: View { let wallpaper: ManagedWallpaper let isActive: Bool let isWorking: Bool + let operationLabel: String? let onSetWallpaper: () -> Void let onRename: () -> Void let onRemove: () -> Void @@ -107,6 +108,17 @@ struct WallpaperPreviewView: View { private var actionRow: some View { HStack(spacing: 10) { + if let operationLabel { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(operationLabel) + .font(.callout) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + } + Button("Reveal in Finder", systemImage: "folder", action: onReveal) Button("Rename…", systemImage: "pencil", action: onRename) .disabled(!actionAvailability.canRename) diff --git a/TESTING.md b/TESTING.md index 3c7dfb3..f46325e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,6 +44,28 @@ version before evaluating native playback. and the activation scope, progress, and completion actions have understandable labels. +## Maintenance and recovery + +1. With a managed wallpaper present, open Maintenance -> Restore Latest Backup. Confirm the confirmation shows the backup's date and operation, and that restoring replaces the catalogue (success alert) while the foreign Apple entries stay intact. +2. Confirm a restore is refused with nothing changed when the catalogue contains foreign changes newer than the backup (e.g. an Apple Aerial was added in System Settings after the backup). +3. After removing a wallpaper, restoring the latest backup brings its entry back marked Video missing (its video file was deleted by the removal) and removing it again is permitted. +4. During an import, press Command-Q and confirm a quit confirmation appears. Keep Importing resumes; Quit Anyway quits, and the next launch removes leftover .AerialDrop- temp files from the videos folder. +5. Confirm the Import pane shows the encoded resolution and an estimated file size before importing, and that quality/resolution changes update the estimate live. +6. Confirm the Import progress shows an estimated time remaining during the encode stage. +7. Confirm a warning appears when the wallpaper name matches an existing wallpaper. +8. After an import completes, View in Library selects and scrolls to the new wallpaper. +9. During Set as Wallpaper, Remove, Remove All, and Restore Latest Backup, confirm a progress banner with a label stays visible until the operation finishes (both in the Library grid and the preview sheet). + +## Import and Library polish + +1. Confirm the import progress bar never moves backward across stages (the encode start does not drop below the previous stage, and the thumbnail stage continues from a higher value). +2. With a fade-in-from-black source, confirm the Import preview shows a later, visible frame instead of a black box. +3. Confirm the completion card offers Open Wallpaper Settings alongside Import Another and View in Library. +4. Confirm the toolbar's Wallpaper Settings button shows a wallpaper icon (not a gear) and still opens System Settings -> Wallpaper. +5. Drag an MP4/MOV onto the Library pane and confirm it opens in the Import flow with a drop highlight; non-video drops are ignored. +6. Choose a video, rename it, then choose a different video; confirm the name field follows the new file. +7. For an ultrawide source, drag the crop slider between presets and confirm no crop segment is highlighted while the position is between presets, and the preview mask matches the slider. + ## Native activation and playback 1. Leave **Set as wallpaper after importing** enabled (the default), import a diff --git a/Tests/AerialDropTests/AppModelWallpaperTests.swift b/Tests/AerialDropTests/AppModelWallpaperTests.swift index d90b485..3290bf6 100644 --- a/Tests/AerialDropTests/AppModelWallpaperTests.swift +++ b/Tests/AerialDropTests/AppModelWallpaperTests.swift @@ -63,6 +63,44 @@ final class AppModelWallpaperTests: XCTestCase { XCTAssertTrue(model.wallpapers.isEmpty) } + func testChoosingANewSourceReplacesTheNameWithTheNewFileStem() { + let model = makeModel(service: FakeWallpaperService()) + model.title = "Custom Name" + let url = URL(fileURLWithPath: "/tmp/beach.mp4") + + model.chooseVideo(url) + + XCTAssertEqual(model.title, "beach") + XCTAssertEqual(model.selectedVideo, url) + } + + func testDisplayProgressIsMonotonicAcrossEveryStageTransition() { + let model = makeModel(service: FakeWallpaperService()) + + var previous = -1.0 + for stage in [ + ImportStage.validating, + .preparingFolders, + .processingVideo, + .generatingThumbnail, + .updatingManifest, + .refreshingSystem, + .finished + ] { + model.stage = stage + for fraction in [0.0, 0.01, 0.5, 0.95] { + model.importProgress = fraction + let current = model.displayProgress + XCTAssertGreaterThanOrEqual( + current, previous, + "Progress regressed at \(stage) with importProgress \(fraction): \(previous) -> \(current)" + ) + previous = current + } + } + XCTAssertEqual(model.displayProgress, 1) + } + func testImportCancellationAvailabilityStopsAtCatalogueCommitBoundary() { let model = makeModel(service: FakeWallpaperService()) model.isWorking = true @@ -100,6 +138,30 @@ final class AppModelWallpaperTests: XCTestCase { XCTAssertNil(model.alertMessage) } + func testActivationExposesOperationLabelWhileWorking() async throws { + let service = FakeWallpaperService() + let (enteredStream, enteredContinuation) = AsyncStream.makeStream() + let (releaseStream, releaseContinuation) = AsyncStream.makeStream() + service.enteredContinuation = enteredContinuation + service.releaseStream = releaseStream + let model = makeModel(service: service) + let wallpaper = makeWallpaper(id: "C0D3X-0012") + + let task = Task { await model.activateWallpaper(wallpaper) } + + var iterator = enteredStream.makeAsyncIterator() + await iterator.next() + XCTAssertEqual(model.operationLabel, "Applying “Test Aerial”…") + XCTAssertTrue(model.isWorking) + + releaseContinuation.finish() + await task.value + + XCTAssertNil(model.operationLabel) + XCTAssertFalse(model.isWorking) + XCTAssertEqual(model.activeAerialAssetIDs, Set([wallpaper.id])) + } + func testActivationFailureKeepsExistingActiveStateAndOffersRecovery() async { let service = FakeWallpaperService(activeIDs: ["CURRENT-AERIAL"]) service.activationError = TestError.activationFailed @@ -147,6 +209,51 @@ final class AppModelWallpaperTests: XCTestCase { XCTAssertEqual(model.activeAerialAssetIDs, Set([wallpaper.id])) } + func testCompletedImportSetsPendingLibraryHighlight() async { + let model = makeModel(service: FakeWallpaperService()) + let wallpaper = makeWallpaper(id: "C0D3X-0014") + + _ = await model.applyPostImportWallpaperSetting(to: wallpaper) + + XCTAssertEqual(model.pendingLibraryHighlightID, wallpaper.id) + } + + func testRestoreLatestBackupBringsBackARemovedWallpaper() async { + let wallpaper = makeWallpaper(id: "C0D3X-0016") + let service = FakeWallpaperService() + let home = makeTemporaryHome() + try! installManagedWallpaper(wallpaper, in: home) + let model = makeModel(service: service, home: home) + await model.reload() + XCTAssertEqual(model.wallpapers.count, 1) + + await model.removeWallpaper(wallpaper) + XCTAssertTrue(model.wallpapers.isEmpty) + + await model.restoreLatestBackup() + + XCTAssertEqual(model.wallpapers.count, 1) + XCTAssertEqual(model.wallpapers.first?.id, wallpaper.id) + // The video file was deleted by the removal, so the entry is degraded. + XCTAssertEqual(model.wallpapers.first?.videoExists, false) + XCTAssertTrue(model.alertMessage?.contains("Restored") == true) + XCTAssertFalse(model.isWorking) + } + + func testRemovingTheHighlightedWallpaperClearsThePendingHighlight() async { + let wallpaper = makeWallpaper(id: "C0D3X-0015") + let service = FakeWallpaperService() + let home = makeTemporaryHome() + try! installManagedWallpaper(wallpaper, in: home) + let model = makeModel(service: service, home: home) + model.pendingLibraryHighlightID = wallpaper.id + + await model.removeWallpaper(wallpaper) + + XCTAssertNil(model.pendingLibraryHighlightID) + XCTAssertTrue(model.wallpapers.isEmpty) + } + func testDisabledPostImportSettingRefreshesWithoutChangingWallpaper() async { let service = FakeWallpaperService(activeIDs: ["CURRENT-AERIAL"]) let model = makeModel(service: service, automaticActivationEnabled: { false }) @@ -322,6 +429,11 @@ private final class FakeWallpaperService: WallpaperServicing { var selectionReadError: Error? + /// Optional test hooks: resumes a continuation as soon as activation starts, + /// then blocks until the release stream finishes (see the operation-label test). + var enteredContinuation: AsyncStream.Continuation? + var releaseStream: AsyncStream? + init(activeIDs: Set = []) { self.activeIDs = activeIDs } @@ -335,6 +447,10 @@ private final class FakeWallpaperService: WallpaperServicing { func activateAerial(assetID: String) async throws { activatedAssetIDs.append(assetID) + enteredContinuation?.yield() + if let releaseStream { + for await _ in releaseStream { break } + } if let activationError { throw activationError } diff --git a/Tests/AerialDropTests/ManifestStoreTests.swift b/Tests/AerialDropTests/ManifestStoreTests.swift index f292dae..ce3e938 100644 --- a/Tests/AerialDropTests/ManifestStoreTests.swift +++ b/Tests/AerialDropTests/ManifestStoreTests.swift @@ -127,6 +127,161 @@ final class ManifestStoreTests: XCTestCase { XCTAssertNil(legacyWallpapers.first { $0.id == legacyID }?.resolution) } + func testLatestBackupSelectsNewestBackupAndParsesMetadata() throws { + let older = paths.backups.appending(path: "entries-20260801-100000-000-import.json") + let newer = paths.backups.appending(path: "entries-20260809-181419-745-remove.json") + try Data("{}".utf8).write(to: older) + try Data("{}".utf8).write(to: newer) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertEqual(info.url, newer) + XCTAssertEqual(info.operation, "remove") + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" + XCTAssertEqual(info.date, formatter.date(from: "20260809-181419-745")) + } + + func testLatestBackupParsesSuffixedOperationNames() throws { + let backup = paths.backups.appending(path: "entries-20260809-181419-745-import-2.json") + try Data("{}".utf8).write(to: backup) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertEqual(info.operation, "import-2") + } + + func testLatestBackupOrdersSameMillisecondBackupsByWriteOrder() throws { + let timestamp = "20260809-181419-745" + let importBackup = paths.backups.appending(path: "entries-\(timestamp)-import.json") + let renameBackup = paths.backups.appending(path: "entries-\(timestamp)-rename.json") + try Data("{}".utf8).write(to: importBackup) + try Data("{}".utf8).write(to: renameBackup) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertEqual(info.url, renameBackup) + } + + func testLatestBackupFallsBackToNameOrderWhenModificationTimesMatch() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let importBackup = paths.backups.appending(path: "entries-20260809-181419-745-import.json") + let renameBackup = paths.backups.appending(path: "entries-20260809-181419-745-rename.json") + try Data("{}".utf8).write(to: importBackup) + try Data("{}".utf8).write(to: renameBackup) + try FileManager.default.setAttributes( + [.modificationDate: date], + ofItemAtPath: importBackup.path + ) + try FileManager.default.setAttributes( + [.modificationDate: date], + ofItemAtPath: renameBackup.path + ) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertEqual(info.url, renameBackup) + } + + func testRestoreBackupWrapsMissingManifestAsRejected() throws { + let backup = paths.backups.appending(path: "entries-20260809-181419-745-import.json") + try fixtureData().write(to: backup) + try FileManager.default.removeItem(at: paths.manifest) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertThrowsError(try store.restoreBackup(info)) { error in + guard case AerialDropError.backupRestoreRejected = error else { + return XCTFail("Expected backupRestoreRejected, got \(error)") + } + } + } + + func testRestoreBackupReturnsManagedStateAndPreservesForeignData() throws { + let firstID = "12121212-3434-4567-8AAA-999999999991" + try Data("video".utf8).write(to: paths.videoURL(for: firstID)) + try Data("png".utf8).write(to: paths.thumbnailURL(for: firstID)) + try store.addWallpaper(id: firstID, title: "First") + let secondID = "12121212-3434-4567-8AAA-999999999992" + try Data("video".utf8).write(to: paths.videoURL(for: secondID)) + try Data("png".utf8).write(to: paths.thumbnailURL(for: secondID)) + try store.addWallpaper(id: secondID, title: "Second") + try store.renameWallpaper(id: secondID, title: "Renamed") + + // Simulate an entry lost to a bad edit: drop the second wallpaper. + var current = try json(at: paths.manifest) + var assets = try XCTUnwrap(current["assets"] as? [[String: Any]]) + assets.removeAll { + (($0["categories"] as? [String]) ?? []).contains(ManifestStore.categoryID) + && ($0["id"] as? String) == secondID + } + current["assets"] = assets + current["initialAssetCount"] = assets.count + try JSONSerialization.data(withJSONObject: current, options: [.prettyPrinted]) + .write(to: paths.manifest, options: .atomic) + + let info = try XCTUnwrap(store.latestBackup()) + try store.restoreBackup(info) + + let wallpapers = try store.importedWallpapers() + XCTAssertEqual(Set(wallpapers.map(\.id)), Set([firstID, secondID])) + XCTAssertEqual(wallpapers.first { $0.id == secondID }?.title, "Second") + let restored = try json(at: paths.manifest) + let foreignAssets = try XCTUnwrap(restored["assets"] as? [[String: Any]]).filter { + !(($0["categories"] as? [String]) ?? []).contains(ManifestStore.categoryID) + } + XCTAssertEqual(foreignAssets.count, 1) + XCTAssertEqual(restored["initialAssetCount"] as? Int, 3) + } + + func testRestoreBackupToleratesMissingManagedVideoFiles() throws { + let id = "12121212-3434-4567-8AAA-999999999993" + try Data("video".utf8).write(to: paths.videoURL(for: id)) + try Data("png".utf8).write(to: paths.thumbnailURL(for: id)) + try store.addWallpaper(id: id, title: "Missing Video") + try store.renameWallpaper(id: id, title: "Renamed") + try FileManager.default.removeItem(at: paths.videoURL(for: id)) + + // Drop the entry as if a bad edit removed it. + var current = try json(at: paths.manifest) + var assets = try XCTUnwrap(current["assets"] as? [[String: Any]]) + assets.removeAll { + (($0["categories"] as? [String]) ?? []).contains(ManifestStore.categoryID) + } + current["assets"] = assets + current["initialAssetCount"] = assets.count + try JSONSerialization.data(withJSONObject: current, options: [.prettyPrinted]) + .write(to: paths.manifest, options: .atomic) + + let info = try XCTUnwrap(store.latestBackup()) + try store.restoreBackup(info) + + let wallpapers = try store.importedWallpapers() + let restored = try XCTUnwrap(wallpapers.first { $0.id == id }) + XCTAssertFalse(restored.videoExists) + } + + func testRestoreBackupRefusesWhenForeignDataChangedSinceBackup() throws { + let id = "12121212-3434-4567-8AAA-999999999994" + try Data("video".utf8).write(to: paths.videoURL(for: id)) + try Data("png".utf8).write(to: paths.thumbnailURL(for: id)) + try store.addWallpaper(id: id, title: "First") + + // Foreign change after the backup: retitle the foreign asset directly. + var current = try json(at: paths.manifest) + var assets = try XCTUnwrap(current["assets"] as? [[String: Any]]) + assets[0]["accessibilityLabel"] = "Changed By Another Tool" + current["assets"] = assets + try JSONSerialization.data(withJSONObject: current, options: [.prettyPrinted]) + .write(to: paths.manifest, options: .atomic) + + let info = try XCTUnwrap(store.latestBackup()) + XCTAssertThrowsError(try store.restoreBackup(info)) { error in + guard case AerialDropError.backupRestoreRejected = error else { + return XCTFail("Expected backupRestoreRejected, got \(error)") + } + } + let after = try json(at: paths.manifest) + let afterAssets = try XCTUnwrap(after["assets"] as? [[String: Any]]) + XCTAssertEqual(afterAssets[0]["accessibilityLabel"] as? String, "Changed By Another Tool") + } + private func fixtureData() throws -> Data { let fixture: [String: Any] = [ "version": 1, diff --git a/Tests/AerialDropTests/VideoPreviewTests.swift b/Tests/AerialDropTests/VideoPreviewTests.swift new file mode 100644 index 0000000..0ee9ae4 --- /dev/null +++ b/Tests/AerialDropTests/VideoPreviewTests.swift @@ -0,0 +1,84 @@ +import AppKit +import XCTest +@testable import AerialDrop + +final class VideoPreviewTests: XCTestCase { + private func makeImage(color: NSColor, size: Int = 64) -> CGImage { + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + color.setFill() + NSRect(x: 0, y: 0, width: size, height: size).fill() + NSGraphicsContext.restoreGraphicsState() + return rep.cgImage! + } + + func testNearlyBlackFrameIsNotMeaningfullyVisible() { + let image = makeImage(color: NSColor(calibratedWhite: 0.05, alpha: 1)) + XCTAssertFalse(VideoPreview.isMeaningfullyVisible(image)) + } + + func testBrightFrameIsMeaningfullyVisible() { + let image = makeImage(color: .white) + XCTAssertTrue(VideoPreview.isMeaningfullyVisible(image)) + } + + func testMixedFrameWithEnoughBrightContentIsMeaningfullyVisible() { + // Left half dark, right half bright: ~50% bright pixels. + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 64, + pixelsHigh: 64, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSColor.black.setFill() + NSRect(x: 0, y: 0, width: 64, height: 64).fill() + NSColor.white.setFill() + NSRect(x: 32, y: 0, width: 32, height: 64).fill() + NSGraphicsContext.restoreGraphicsState() + XCTAssertTrue(VideoPreview.isMeaningfullyVisible(rep.cgImage!)) + } + + func testMostlyDarkFrameIsNotMeaningfullyVisible() { + // Tiny bright patch on black: ~1.5% bright pixels. + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 64, + pixelsHigh: 64, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSColor.black.setFill() + NSRect(x: 0, y: 0, width: 64, height: 64).fill() + NSColor.white.setFill() + NSRect(x: 30, y: 30, width: 4, height: 4).fill() + NSGraphicsContext.restoreGraphicsState() + XCTAssertFalse(VideoPreview.isMeaningfullyVisible(rep.cgImage!)) + } +}