Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions Sources/AerialDrop/AerialDropApp.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import AppKit
import SwiftUI

@main
struct AerialDropApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = AppModel()

var body: some Scene {
WindowGroup("AerialDrop") {
ContentView()
.environment(model)
.frame(minWidth: 760, minHeight: 520)
.onAppear { appDelegate.model = model }
}
.defaultSize(width: 1040, height: 700)
.windowResizability(.contentMinSize)
Expand All @@ -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
}
}
109 changes: 98 additions & 11 deletions Sources/AerialDrop/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,18 @@ final class AppModel {
var activeAerialAssetIDs: Set<String> = []
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<Void, Never>?
private var importGeneration = 0
private var encodeStartedAt: Date?

private let paths: WallpaperPaths
private let manifestStore: ManifestStore
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +205,7 @@ final class AppModel {
try manifestStore.prepareDirectories()

stage = .processingVideo
encodeStartedAt = Date()
let encodedSize = try await videoProcessor.makeNativeMOV(
from: source,
destination: videoDestination,
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -290,6 +332,7 @@ final class AppModel {
}

func reload() async {
sweepOrphanedTempSegments()
catalogueState = .loading
do {
try manifestStore.requireManifest()
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -358,6 +416,7 @@ final class AppModel {
wallpaper: wallpaper,
activationResult: activationResult
)
pendingLibraryHighlightID = wallpaper.id
return activationResult
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down
Loading