diff --git a/README.md b/README.md index 1f52d75..aa2999a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Control windows, files, displays, power, and sound from one native macOS menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain. -The current download, v1.0.0, contains Sound. Source builds include additional utilities in development toward the next release. The [product status guide](guide/product-status.md) distinguishes integrated features from work still under review. +The current download, v1.0.0, contains Sound. Source builds include additional utilities in development toward the next release. The [product status guide](guide/product-status.md) distinguishes released, integrated, and staged features. [semper.systems](https://www.semper.systems/) @@ -60,6 +60,7 @@ not download a Semper DMG from an unofficial source. - **Independent Utilities**: Home provides module summaries, attention items, up to four pinned actions, search, and recent action outcomes for the current session. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window. - **Manual Window Layout**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout). +- **Local Image Copies**: File Shelf's staged Resize a Copy action saves one local JPEG or PNG at up to 1,024 or 2,048 pixels on its longest edge without enlargement or overwriting a file. It removes descriptive metadata and requires a destination that supports macOS file cloning. See the [image-copy guide](guide/shelf-image-copy.md) for format, size, recovery, and native acceptance limits. - **Local Awake Sessions**: Public IOKit power assertions prevent idle system sleep, optionally keep the display on, and keep timed user sessions separate from Scene requests. - **Authenticated Away Curtain**: One opaque panel covers each display, ordinary input is filtered, and local widgets can show time, battery, Away duration, and awake-request state. - **Swift 6 & Core Audio TCC Taps**: Built using modern Swift 6 strict concurrency (`@MainActor`, `Sendable`) and low-latency CoreAudio process taps. @@ -127,6 +128,7 @@ in your keychain so macOS can recognize later source updates as the same app. - [Module Shell](guide/module-shell.md) - [Direct Utilities: Workspace Restore, File Shelf, Safe Eject](guide/direct-utilities.md) - [Choose Files in File Shelf](guide/shelf-file-selection.md) +- [Resize a Copy in File Shelf](guide/shelf-image-copy.md) - [Presentation Controls](guide/presentation-controls.md) - [Window Layout](guide/window-layout.md) - [Awake Sessions](guide/awake-sessions.md) diff --git a/ROADMAP.md b/ROADMAP.md index 11dbbc8..0c07ddb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,13 +1,12 @@ # Semper roadmap -Semper's source in this change set contains ten utility modules: Sound, Awake, -Displays, Workspace Restore, Window Layout, File Shelf, Safe Eject, Scenes, -Away, and Presentation. The baseline has nine integrated modules. Window Layout -becomes the tenth integrated module when this change set merges into `main`. -The downloadable release is v1.0.0, which contains Sound only. This roadmap -orders the work to deliver the whole suite as -dependable signed releases without hiding experimental behavior. Per-module -state lives in the [product status guide](guide/product-status.md). +Semper has ten utility modules integrated on `main`: Sound, Awake, Displays, +Workspace Restore, Window Layout, File Shelf, Safe Eject, Scenes, Away, and +Presentation. Resize a Copy extends File Shelf in this change set and awaits +integration. The downloadable release is v1.0.0, which contains Sound only. +This roadmap orders the work to deliver the whole suite as dependable signed +releases without hiding experimental behavior. Per-module state lives in the +[product status guide](guide/product-status.md). ## Current priorities @@ -35,9 +34,9 @@ state lives in the [product status guide](guide/product-status.md). ### 3. Window Layout acceptance and compatibility -The cleared implementation from [PR #106](https://github.com/niharnm/Semper/pull/106) -is included in this change set. Integration requires its merge to `main`; -native acceptance remains open. See the [Window Layout guide](guide/window-layout.md). +The implementation from [PR #106](https://github.com/niharnm/Semper/pull/106) +is integrated on `main` through [PR #110](https://github.com/niharnm/Semper/pull/110). +Native acceptance remains open. See the [Window Layout guide](guide/window-layout.md). - Verify all five manual commands, optional shortcuts, Home/search/pinned actions, intended-window selection, and later manual changes on real apps. @@ -67,14 +66,17 @@ native acceptance remains open. See the [Window Layout guide](guide/window-layou restarts. - File Shelf and Safe Eject: behavior improvements from reproducible reports, keeping original files and volumes safe. -- Finish review of File Shelf's **Resize a Copy** for one selected - local JPEG or PNG. Offer 1024 or 2048 pixels on the longest edge without - enlargement, show output dimensions, and save a separate copy. Preserve - orientation, color and transparency, explain metadata handling, and support - cancellation. No batch processing, uploads, or original-file replacement. -- Preserve pending cleanup through lifecycle changes, bound expiry retries, - verify file ownership before removal, and report the actual saved path. - Image-copy integration waits for the corrected implementation and tests. +- Complete integration and native acceptance of File Shelf's **Resize a Copy**, + implemented in this change set. It resizes one local JPEG or PNG to a longest + edge of 1024 or 2048 pixels without enlargement or overwriting a file. + Format, displayed orientation, color profile and PNG transparency are kept; + descriptive metadata is removed. JPEG re-encoding can lose detail. +- Verify Save-dialog focus, keyboard access, cancellation, and refusal of + destinations without macOS file-cloning support. Exercise cleanup recovery, + the verified saved path retained until Done, and Finish Without Verification + for changed or deleted output. Private cleanup remains required and public + copies remain untouched. See the [image-copy guide](guide/shelf-image-copy.md) + for input limits and remaining checks. No batch processing or uploads. - Awake and Away: keep power assertions and the curtain testable and honest about what they do not block. - A new utility needs a clear local user job, no account requirement, the @@ -130,9 +132,8 @@ discussion and include a hardware test plan. unit tests, the static website, and signed releases through GitHub and Homebrew are current. - Integrated on `main` and in no download yet: Awake, Displays, Workspace - Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation. -- Implemented in this change set, awaiting integration: Window Layout. -- Under review, not integrated: File Shelf's Resize a Copy. + Restore, Window Layout, File Shelf, Safe Eject, Scenes, Away, and Presentation. +- Implemented in this change set, awaiting integration: File Shelf's Resize a Copy. - Hardware-dependent: process taps, device routing, DDC, Bluetooth call mode, media keys, Accessibility window operations, volume ejection, and permission behavior. diff --git a/Semper/Modules/UtilityRuntime.swift b/Semper/Modules/UtilityRuntime.swift index 18d515a..52c501f 100644 --- a/Semper/Modules/UtilityRuntime.swift +++ b/Semper/Modules/UtilityRuntime.swift @@ -949,12 +949,13 @@ final class UtilityRuntime { }, stop: { [weak self] reason in guard let self, let shelf = self.shelf else { return } - if reason == .pause { - await shelf.pause() - } else { - await shelf.shutdown() - self.shelf = nil + let result = reason == .pause ? await shelf.pause() : await shelf.shutdown() + if case .failure(let failure) = result { + throw UtilityCleanupDeferral( + reason: shelf.imageCopy.message ?? shelf.message ?? failure.localizedDescription, + retaining: [.shelf]) } + if reason != .pause { self.shelf = nil } })) try lifecycle.register( .storage, diff --git a/Semper/Modules/UtilityShellView.swift b/Semper/Modules/UtilityShellView.swift index 752f0fe..aacdf80 100644 --- a/Semper/Modules/UtilityShellView.swift +++ b/Semper/Modules/UtilityShellView.swift @@ -1,3 +1,4 @@ +import Foundation import KeyboardShortcuts import SwiftUI @@ -104,7 +105,7 @@ struct UtilityShellView: View { registry: runtime.registry, lifecycle: runtime.lifecycle, pause: runtime.pause, remove: runtime.remove, mutationDisabledReason: runtime.mutationDisabledReason) case .module(let id): - module(id).disabled(id != .away && runtime.mutationDisabledReason != nil) + module(id).disabled(moduleInteractionDisabled(for: id)) } } @@ -128,7 +129,10 @@ struct UtilityShellView: View { UtilityActionList(commands: runtime.commands, actions: runtime.registry.favoriteActions) } ForEach(runtime.registry.addedModules) { module in - if compact, module.id == .shelf, + if compact, module.id == .shelf, shelfStopRecoveryRoute != nil { + ShelfStopRecoveryView(runtime: runtime) + .disabled(moduleInteractionDisabled(for: .shelf)) + } else if compact, module.id == .shelf, !runtime.registry.pausedModuleIDs.contains(.shelf), !runtime.lifecycle.stopping.contains(.shelf), !runtime.lifecycle.isShuttingDown, let shelf = runtime.shelf, shelf.isRunning @@ -230,6 +234,15 @@ struct UtilityShellView: View { return .module(.scenes) } + var shelfStopRecoveryRoute: ShelfStopRecoveryRoute? { + ShelfStopRecoveryRoute.current(in: runtime) + } + + func moduleInteractionDisabled(for id: UtilityModuleID) -> Bool { + if id == .shelf, shelfStopRecoveryRoute != nil { return false } + return id != .away && runtime.mutationDisabledReason != nil + } + @ViewBuilder private func module(_ id: UtilityModuleID) -> some View { if id == .away, runtime.awayCleanupResult != nil, runtime.awayCleanupResult != .complete { @@ -241,6 +254,8 @@ struct UtilityShellView: View { runtime.sceneShortcuts == nil || runtime.lifecycle.isShuttingDown { SceneRecoveryView(manager: scenes) + } else if id == .shelf, shelfStopRecoveryRoute != nil { + ShelfStopRecoveryView(runtime: runtime) } else if runtime.registry.pausedModuleIDs.contains(id) { ContentUnavailableView { Label("Module paused", systemImage: "pause.circle") @@ -540,6 +555,117 @@ struct UtilitySettingsView: View { } } +nonisolated enum ShelfStopRecoveryRoute: Equatable, Sendable { + case pause, shutdown + + @MainActor + static func current(in runtime: UtilityRuntime) -> Self? { + guard runtime.shelf?.stopFailure != nil else { return nil } + return runtime.lifecycle.isShuttingDown ? .shutdown : .pause + } + + @MainActor + static func retry(in runtime: UtilityRuntime) async throws { + switch current(in: runtime) { + case .pause: try await runtime.pause(.shelf) + case .shutdown: await runtime.shutdown() + case nil: return + } + } + + @MainActor + static func acknowledgeRecoveredCopy(in runtime: UtilityRuntime, requestID: UUID) async throws { + guard current(in: runtime) != nil, + runtime.shelf?.acknowledgeImageCopyReceipt(requestID: requestID) == true + else { return } + try await retry(in: runtime) + } + + @MainActor + static func acknowledgeUnverifiedCopy(in runtime: UtilityRuntime, requestID: UUID) async throws { + guard current(in: runtime) != nil, let service = runtime.shelf, + service.imageCopy.request?.id == requestID, service.imageCopy.hasUnverifiedPublishedCopy + else { return } + try await service.acknowledgeUnverifiedImageCopy(requestID: requestID).get() + guard runtime.shelf === service, service.imageCopy.request == nil else { return } + try await retry(in: runtime) + } +} + +private struct ShelfStopRecoveryView: View { + @Bindable var runtime: UtilityRuntime + @State private var retrying = false + + var body: some View { + let acknowledgementRequest = + runtime.shelf?.imageCopy.needsReceiptAcknowledgement == true + ? runtime.shelf?.imageCopy.request : nil + VStack(alignment: .leading, spacing: 14) { + Label("File Shelf cleanup needs attention", systemImage: "exclamationmark.triangle") + .font(.headline) + Text( + runtime.shelf?.imageCopy.message ?? runtime.shelf?.stopFailure?.localizedDescription + ?? "Retry cleanup to finish stopping File Shelf." + ) + .foregroundStyle(.secondary) + if let session = runtime.shelf?.imageCopy { + if session.needsReceiptAcknowledgement, let receipt = session.receipt { + Label("Copy recovered", systemImage: "checkmark.circle").foregroundStyle(.green) + Text(receipt.url.path).font(.callout).textSelection(.enabled) + } else { + ForEach(session.recoveryLocations, id: \.self) { location in + Text(location.path).font(.callout).textSelection(.enabled) + } + } + } + Text("Shelf items are retained until cleanup finishes.").font(.callout) + Button(acknowledgementRequest == nil ? "Retry Cleanup" : "Done") { + guard !retrying else { return } + retrying = true + Task { + do { + if let acknowledgementRequest { + try await ShelfStopRecoveryRoute.acknowledgeRecoveredCopy( + in: runtime, requestID: acknowledgementRequest.id) + } else { + try await ShelfStopRecoveryRoute.retry(in: runtime) + } + } catch { + runtime.message = error.localizedDescription + } + retrying = false + } + } + .buttonStyle(.borderedProminent) + .disabled(retrying || runtime.shelf?.isStopping == true || runtime.lifecycle.stopping.contains(.shelf)) + if let session = runtime.shelf?.imageCopy, session.hasUnverifiedPublishedCopy, + let request = session.request + { + ShelfUnverifiedCopyExplanation() + Button("Finish Without Verification") { + guard !retrying else { return } + retrying = true + Task { + do { + try await ShelfStopRecoveryRoute.acknowledgeUnverifiedCopy( + in: runtime, requestID: request.id) + } catch { + runtime.message = error.localizedDescription + } + retrying = false + } + } + .buttonStyle(.borderless) + .disabled( + retrying || session.isWorking || runtime.shelf?.isStopping == true + || runtime.lifecycle.stopping.contains(.shelf)) + } + if retrying { ProgressView().controlSize(.small) } + } + .padding(24) + } +} + private struct AwayCleanupView: View { @Bindable var runtime: UtilityRuntime @State private var retrying = false diff --git a/Semper/Shelf/ShelfImageCopy.swift b/Semper/Shelf/ShelfImageCopy.swift new file mode 100644 index 0000000..1cf88e8 --- /dev/null +++ b/Semper/Shelf/ShelfImageCopy.swift @@ -0,0 +1,559 @@ +import CoreFoundation +import CoreGraphics +import Darwin +import Foundation +import ImageIO +import UniformTypeIdentifiers + +nonisolated enum ShelfImageCopySize: Int, CaseIterable, Identifiable, Sendable { + case pixels1024 = 1024 + case pixels2048 = 2048 + var id: Int { rawValue } +} + +nonisolated struct ShelfImageDimensions: Equatable, Sendable { + let width: Int + let height: Int +} + +nonisolated enum ShelfImageCopyFormat: Sendable { + case jpeg, png + var fileExtension: String { self == .jpeg ? "jpg" : "png" } + fileprivate var identifier: CFString { + (self == .jpeg ? UTType.jpeg.identifier : UTType.png.identifier) as CFString + } +} + +nonisolated struct ShelfImageCopyPlan: Sendable { + let sourceURL: URL + let format: ShelfImageCopyFormat + let dimensions: ShelfImageDimensions + fileprivate let encoded: Data + fileprivate let sourcePath: String + fileprivate let color: ShelfImageColorSignature + fileprivate let hasAlpha: Bool + fileprivate let smallDimensions: ShelfImageDimensions + fileprivate let largeDimensions: ShelfImageDimensions + + func outputDimensions(for size: ShelfImageCopySize) -> ShelfImageDimensions { + size == .pixels1024 ? smallDimensions : largeDimensions + } + + func suggestedFilename(for size: ShelfImageCopySize) -> String { + let stem = String(sourceURL.deletingPathExtension().lastPathComponent.prefix(160)) + return "\(stem.isEmpty ? "Image" : stem)-\(size.rawValue)px.\(format.fileExtension)" + } +} + +nonisolated struct ShelfImageCopyReceipt: Sendable { + let url: URL + let dimensions: ShelfImageDimensions +} + +nonisolated enum ShelfImageCopyFailure: Error, Equatable, LocalizedError, Sendable { + case unsupported, animated, invalidImage, tooLarge, changedSource, colorProfile, transparency + case destinationExists, invalidDestination, writeFailed, verificationFailed, invalidTemporaryCopy + case cleanupFailed(ShelfImageTemporaryCopy) + case cloningUnsupported, destinationChanged + case publicationUncertain(ShelfImagePublishedCopy) + + var errorDescription: String? { + switch self { + case .unsupported: "Resize a Copy supports local JPEG and PNG images." + case .animated: "Animated images cannot be resized with Resize a Copy." + case .invalidImage: "The image could not be read completely." + case .tooLarge: "Resize a Copy supports images up to 32 MB, 40 megapixels, and 16,384 pixels per side." + case .changedSource: "The source changed while it was being read. Choose the image again." + case .colorProfile: "This image's color profile cannot be preserved. No copy was saved." + case .transparency: "This image's transparency cannot be preserved. No copy was saved." + case .destinationExists: "An item already exists at that destination. Choose a different name." + case .invalidDestination: "Choose a new JPEG or PNG filename in a writable folder." + case .writeFailed: "The resized copy could not be written. Check folder access and available disk space." + case .verificationFailed: "The resized copy did not pass verification. No copy was saved." + case .invalidTemporaryCopy: "The temporary image path is not owned by Resize a Copy." + case .cleanupFailed: "Temporary image cleanup needs recovery. Retry recovery before resizing another image." + case .cloningUnsupported: + "This location does not support Resize a Copy. Choose another location." + case .destinationChanged: "The destination folder changed. Choose the save location again." + case .publicationUncertain: + "A copy was created, but its location or temporary cleanup needs recovery. Do not save again." + } + } +} + +nonisolated protocol ShelfImageCopying: Sendable { + func inspect(_ source: URL, access: any ShelfFileAccess) throws -> ShelfImageCopyPlan + func writeCopy(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, to destination: URL) throws + -> ShelfImageCopyReceipt + func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt + func acknowledgeUnverifiedCopy(_ published: ShelfImagePublishedCopy) throws +} + +nonisolated struct NativeShelfImageCopier: ShelfImageCopying { + let fileOperations: ShelfImageFileOperations + + init(fileOperations: ShelfImageFileOperations = .native) { self.fileOperations = fileOperations } + + func inspect(_ source: URL, access: any ShelfFileAccess) throws -> ShelfImageCopyPlan { + try Self.checkCancellation() + guard source.isFileURL else { throw ShelfImageCopyFailure.unsupported } + let scoped = access.begin(source) + defer { if scoped { access.end(source) } } + switch access.state(of: source) { + case .available(isDirectory: false): break + case .available: throw ShelfImageCopyFailure.unsupported + case .cloudOnly: throw ShelfFailure.cloudOnly + case .missing: throw ShelfFailure.missing + case .inaccessible: throw ShelfFailure.inaccessible + } + var pathInfo = stat() + let pathResult = source.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return stat(path, &pathInfo) + } + guard pathResult == 0 else { throw ShelfFailure.inaccessible } + guard pathInfo.st_flags & UInt32(SF_DATALESS) == 0 else { throw ShelfFailure.cloudOnly } + let input = try ShelfIO.regularFileHandle(source) + defer { try? input.close() } + var before = stat() + guard fstat(input.fileDescriptor, &before) == 0 else { throw ShelfFailure.inaccessible } + guard before.st_flags & UInt32(SF_DATALESS) == 0 else { throw ShelfFailure.cloudOnly } + guard before.st_size > 0, before.st_size <= ShelfLimits.importBytes else { + throw ShelfImageCopyFailure.tooLarge + } + let data = try Self.readBounded(input) + var after = stat() + var current = stat() + let currentResult = source.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return stat(path, ¤t) + } + guard fstat(input.fileDescriptor, &after) == 0, currentResult == 0, + Self.sameFile(before, after), Self.sameFile(before, current), before.st_size == data.count + else { throw ShelfImageCopyFailure.changedSource } + return try autoreleasepool { + if data.starts(with: [137, 80, 78, 71, 13, 10, 26, 10]) { + try Self.validatePNG(data) + } else if !data.starts(with: [0xFF, 0xD8]) { + throw ShelfImageCopyFailure.unsupported + } + let imageSource = try Self.imageSource(data) + let format = try Self.format(imageSource) + guard CGImageSourceGetCount(imageSource) == 1 else { throw ShelfImageCopyFailure.animated } + guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0 + else { throw ShelfImageCopyFailure.invalidImage } + guard width <= 16_384, height <= 16_384, width <= ShelfLimits.imagePixels / height else { + throw ShelfImageCopyFailure.tooLarge + } + let orientation = (properties[kCGImagePropertyOrientation] as? Int) ?? 1 + guard (1...8).contains(orientation) else { throw ShelfImageCopyFailure.invalidImage } + let dimensions = ShelfImageDimensions( + width: orientation >= 5 ? height : width, height: orientation >= 5 ? width : height) + let uncached = + [kCGImageSourceShouldCache: false, kCGImageSourceShouldCacheImmediately: false] as CFDictionary + guard let original = CGImageSourceCreateImageAtIndex(imageSource, 0, uncached), + original.width == width, original.height == height + else { throw ShelfImageCopyFailure.invalidImage } + let color = try ShelfImageColorSignature(original) + let alpha = Self.hasAlpha(original) + let small = try Self.thumbnailDimensions(imageSource, dimensions, .pixels1024, color, alpha) + let large = try Self.thumbnailDimensions(imageSource, dimensions, .pixels2048, color, alpha) + try Self.checkCancellation() + return ShelfImageCopyPlan( + sourceURL: source, format: format, dimensions: dimensions, encoded: data, + sourcePath: source.resolvingSymlinksInPath().standardizedFileURL.path, + color: color, hasAlpha: alpha, smallDimensions: small, largeDimensions: large) + } + } + + func writeCopy(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, to destination: URL) throws + -> ShelfImageCopyReceipt + { + try Self.checkCancellation() + guard destination.isFileURL, + destination.standardizedFileURL != plan.sourceURL.standardizedFileURL, + destination.resolvingSymlinksInPath().standardizedFileURL.path != plan.sourcePath, + [plan.format.fileExtension, plan.format == .jpeg ? "jpeg" : "png"].contains( + destination.pathExtension.lowercased()) + else { throw ShelfImageCopyFailure.invalidDestination } + let destinationName = destination.lastPathComponent + guard !destinationName.isEmpty, !destinationName.contains("\0"), destinationName != ".", destinationName != ".." + else { throw ShelfImageCopyFailure.invalidDestination } + let owner = try ShelfImageFileOwner(destination: destination, operations: fileOperations) + let descriptor = owner.stageDescriptor + do { + try owner.validateForWriting() + try autoreleasepool { + let source = try Self.imageSource(plan.encoded) + let image = try Self.thumbnail(source, plan.dimensions, size) + guard Self.dimensions(image) == plan.outputDimensions(for: size) else { + throw ShelfImageCopyFailure.verificationFailed + } + try Self.verifyColorAndAlpha(image, color: plan.color, alpha: plan.hasAlpha) + try Self.encode(image, format: plan.format, descriptor: descriptor) + } + try Self.checkCancellation() + guard fsync(descriptor) == 0, lseek(descriptor, 0, SEEK_SET) == 0 else { + throw ShelfImageCopyFailure.writeFailed + } + let output = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + // ImageIO adds EXIF dimension tags even when the input is a fresh CGImage. + let cleaned = try Self.withoutAncillaryMetadata(Self.readBounded(output), format: plan.format) + guard ftruncate(descriptor, 0) == 0, lseek(descriptor, 0, SEEK_SET) == 0 else { + throw ShelfImageCopyFailure.writeFailed + } + let sink = ShelfImageOutputSink(descriptor: descriptor) + try cleaned.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress, sink.write(base, buffer.count) == buffer.count else { + throw sink.error ?? ShelfImageCopyFailure.writeFailed + } + } + guard fsync(descriptor) == 0, lseek(descriptor, 0, SEEK_SET) == 0 else { + throw ShelfImageCopyFailure.writeFailed + } + let encoded = try Self.readBounded(output) + try autoreleasepool { try Self.verifyOutput(encoded, plan: plan, size: size) } + try Self.checkCancellation() + return try owner.publish(encoded: encoded, dimensions: plan.outputDimensions(for: size)) + } catch { + if owner.hasPublished { throw ShelfImageCopyFailure.publicationUncertain(owner.publishedCopy) } + try owner.cleanUp() + throw error + } + } + + func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { + guard let owner = temporary.owner else { throw ShelfImageCopyFailure.invalidTemporaryCopy } + try owner.cleanUp() + } + + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt { + guard let owner = published.owner else { throw ShelfImageCopyFailure.publicationUncertain(published) } + return try owner.recoverPublication() + } + + func acknowledgeUnverifiedCopy(_ published: ShelfImagePublishedCopy) throws { + guard let owner = published.owner else { throw ShelfImageCopyFailure.publicationUncertain(published) } + do { try owner.acknowledgeUnverifiedCopy() } catch { + throw ShelfImageCopyFailure.publicationUncertain(published) + } + } + + private static func readBounded(_ file: FileHandle) throws -> Data { + var data = Data() + while true { + try checkCancellation() + let chunk: Data + do { + chunk = + try file.read(upToCount: min(ShelfLimits.chunkBytes, ShelfLimits.importBytes - data.count + 1)) + ?? Data() + } catch { throw ShelfImageCopyFailure.invalidImage } + if chunk.isEmpty { return data } + guard chunk.count <= ShelfLimits.importBytes - data.count else { throw ShelfImageCopyFailure.tooLarge } + data.append(chunk) + } + } + + private static func sameFile(_ first: stat, _ second: stat) -> Bool { + first.st_dev == second.st_dev && first.st_ino == second.st_ino && first.st_size == second.st_size + && first.st_mtimespec.tv_sec == second.st_mtimespec.tv_sec + && first.st_mtimespec.tv_nsec == second.st_mtimespec.tv_nsec + && first.st_ctimespec.tv_sec == second.st_ctimespec.tv_sec + && first.st_ctimespec.tv_nsec == second.st_ctimespec.tv_nsec + } + + private static func imageSource(_ data: Data) throws -> CGImageSource { + try checkCancellation() + guard + let source = CGImageSourceCreateWithData( + data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + CGImageSourceGetStatus(source) == .statusComplete + else { throw ShelfImageCopyFailure.invalidImage } + return source + } + + private static func format(_ source: CGImageSource) throws -> ShelfImageCopyFormat { + switch CGImageSourceGetType(source) as String? { + case UTType.jpeg.identifier: return .jpeg + case UTType.png.identifier: return .png + default: throw ShelfImageCopyFailure.unsupported + } + } + + private static func withoutAncillaryMetadata(_ data: Data, format: ShelfImageCopyFormat) throws -> Data { + var result = Data() + if format == .png { + try validatePNG(data) + result.append(data.prefix(8)) + var cursor = 8 + let permitted = ["IHDR", "PLTE", "IDAT", "IEND", "tRNS", "cHRM", "gAMA", "iCCP", "sRGB", "cICP"] + while cursor < data.count { + try checkCancellation() + let length = (0..<4).reduce(0) { ($0 << 8) | Int(data[cursor + $1]) } + let end = cursor + length + 12 + let type = String(decoding: data[(cursor + 4)..<(cursor + 8)], as: UTF8.self) + if permitted.contains(type) { result.append(data[cursor..= 2, length <= data.count - cursor else { throw ShelfImageCopyFailure.verificationFailed } + let end = cursor + length + let ancillary = (0xE0...0xEF).contains(marker) || marker == 0xFE + if !ancillary || marker == 0xE0 || marker == 0xE2 || marker == 0xEE { + result.append(data[start.. 0, height > 0 else { throw ShelfImageCopyFailure.invalidImage } + guard width <= 16_384, height <= 16_384, width <= ShelfLimits.imagePixels / height else { + throw ShelfImageCopyFailure.tooLarge + } + } + if type == Array("acTL".utf8) || type == Array("fcTL".utf8) || type == Array("fdAT".utf8) { + throw ShelfImageCopyFailure.animated + } + cursor += length + 12 + if type == Array("IEND".utf8) { + guard length == 0, cursor == data.count else { throw ShelfImageCopyFailure.invalidImage } + return + } + } + throw ShelfImageCopyFailure.invalidImage + } + + private static func thumbnail( + _ source: CGImageSource, _ dimensions: ShelfImageDimensions, _ size: ShelfImageCopySize + ) + throws -> CGImage + { + try checkCancellation() + let maximum = min(size.rawValue, max(dimensions.width, dimensions.height)) + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximum, + kCGImageSourceShouldCacheImmediately: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + throw ShelfImageCopyFailure.invalidImage + } + try checkCancellation() + guard image.width > 0, image.height > 0, image.width <= maximum, image.height <= maximum, + image.width <= dimensions.width, image.height <= dimensions.height, + abs(image.width * dimensions.height - image.height * dimensions.width) + <= max(dimensions.width, dimensions.height) + else { throw ShelfImageCopyFailure.verificationFailed } + return image + } + + private static func thumbnailDimensions( + _ source: CGImageSource, _ dimensions: ShelfImageDimensions, _ size: ShelfImageCopySize, + _ color: ShelfImageColorSignature, _ alpha: Bool + ) throws -> ShelfImageDimensions { + try autoreleasepool { + let image = try thumbnail(source, dimensions, size) + try verifyColorAndAlpha(image, color: color, alpha: alpha) + return Self.dimensions(image) + } + } + + private static func dimensions(_ image: CGImage) -> ShelfImageDimensions { + ShelfImageDimensions(width: image.width, height: image.height) + } + + private static func hasAlpha(_ image: CGImage) -> Bool { + switch image.alphaInfo { + case .first, .last, .premultipliedFirst, .premultipliedLast, .alphaOnly: true + default: false + } + } + + private static func verifyColorAndAlpha(_ image: CGImage, color: ShelfImageColorSignature, alpha: Bool) throws { + guard try ShelfImageColorSignature(image) == color else { throw ShelfImageCopyFailure.colorProfile } + guard hasAlpha(image) == alpha else { throw ShelfImageCopyFailure.transparency } + } + + private static func encode(_ image: CGImage, format: ShelfImageCopyFormat, descriptor: Int32) throws { + let sink = ShelfImageOutputSink(descriptor: descriptor) + var callbacks = CGDataConsumerCallbacks( + putBytes: { pointer, buffer, count in + guard let pointer else { return 0 } + return Unmanaged.fromOpaque(pointer).takeUnretainedValue().write(buffer, count) + }, + releaseConsumer: { pointer in + if let pointer { Unmanaged.fromOpaque(pointer).release() } + }) + let retained = Unmanaged.passRetained(sink) + guard let consumer = CGDataConsumer(info: retained.toOpaque(), cbks: &callbacks) else { + retained.release() + throw ShelfImageCopyFailure.writeFailed + } + guard let destination = CGImageDestinationCreateWithDataConsumer(consumer, format.identifier, 1, nil) else { + throw ShelfImageCopyFailure.writeFailed + } + let properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: 0.9, + kCGImageDestinationEmbedThumbnail: false, + kCGImageDestinationOptimizeColorForSharing: false, + ] + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + let finalized = CGImageDestinationFinalize(destination) + if let error = sink.error { throw error } + guard finalized else { throw ShelfImageCopyFailure.writeFailed } + try checkCancellation() + } + + private static func verifyOutput(_ data: Data, plan: ShelfImageCopyPlan, size: ShelfImageCopySize) throws { + let source = try imageSource(data) + guard try format(source) == plan.format, CGImageSourceGetCount(source) == 1, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let image = CGImageSourceCreateImageAtIndex( + source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary), + dimensions(image) == plan.outputDimensions(for: size), + (properties[kCGImagePropertyOrientation] as? Int ?? 1) == 1 + else { throw ShelfImageCopyFailure.verificationFailed } + try verifyColorAndAlpha(image, color: plan.color, alpha: plan.hasAlpha) + let forbidden = [ + kCGImagePropertyExifDictionary, kCGImagePropertyExifAuxDictionary, kCGImagePropertyGPSDictionary, + kCGImagePropertyIPTCDictionary, + ] + let tags = CGImageSourceCopyMetadataAtIndex(source, 0, nil).flatMap { CGImageMetadataCopyTags($0) } + guard forbidden.allSatisfy({ properties[$0] == nil }), tags.map({ CFArrayGetCount($0) == 0 }) ?? true + else { throw ShelfImageCopyFailure.verificationFailed } + if plan.format == .png { + try validatePNG(data) + let png = properties[kCGImagePropertyPNGDictionary] as? [CFString: Any] ?? [:] + let permitted = [ + kCGImagePropertyPNGGamma, kCGImagePropertyPNGInterlaceType, kCGImagePropertyPNGsRGBIntent, + kCGImagePropertyPNGChromaticities, kCGImagePropertyPNGXPixelsPerMeter, + kCGImagePropertyPNGYPixelsPerMeter, + ] + guard png.keys.allSatisfy({ permitted.contains($0) }) else { + throw ShelfImageCopyFailure.verificationFailed + } + } + } + + private static func checkCancellation() throws { + if Task.isCancelled { throw ShelfFailure.cancelled } + } +} + +nonisolated private struct ShelfImageColorSignature: Equatable, Sendable { + let profile: Data? + let name: String? + let model: Int + + init(_ image: CGImage) throws { + guard var space = image.colorSpace else { throw ShelfImageCopyFailure.colorProfile } + if space.model == .indexed { + guard let base = space.baseColorSpace else { throw ShelfImageCopyFailure.colorProfile } + space = base + } + guard space.model == .rgb || space.model == .monochrome else { throw ShelfImageCopyFailure.colorProfile } + profile = space.copyICCData() as Data? + name = profile == nil ? space.name as String? : nil + model = Int(space.model.rawValue) + guard profile != nil || name != nil else { throw ShelfImageCopyFailure.colorProfile } + } +} + +nonisolated private final class ShelfImageOutputSink { + private let descriptor: Int32 + private let lock = NSLock() + private var byteCount = 0 + private var failure: (any Error)? + var error: (any Error)? { lock.withLock { failure } } + + init(descriptor: Int32) { self.descriptor = descriptor } + + func write(_ buffer: UnsafeRawPointer, _ count: Int) -> Int { + lock.withLock { + guard failure == nil else { return 0 } + guard count <= ShelfLimits.importBytes - byteCount else { + failure = ShelfImageCopyFailure.tooLarge + return 0 + } + var written = 0 + while written < count { + if Task.isCancelled { + failure = ShelfFailure.cancelled + return written + } + let result = Darwin.write( + descriptor, buffer.advanced(by: written), min(count - written, ShelfLimits.chunkBytes)) + if result < 0, errno == EINTR { continue } + guard result > 0 else { + failure = ShelfImageCopyFailure.writeFailed + return written + } + written += result + byteCount += result + } + return written + } + } +} diff --git a/Semper/Shelf/ShelfImageCopySession.swift b/Semper/Shelf/ShelfImageCopySession.swift new file mode 100644 index 0000000..7081ea1 --- /dev/null +++ b/Semper/Shelf/ShelfImageCopySession.swift @@ -0,0 +1,303 @@ +import AppKit +import Foundation +import Observation +import UniformTypeIdentifiers + +nonisolated struct ShelfImageCopyRequest: Identifiable, Sendable { + let id: UUID + let itemID: UUID + let name: String +} + +@MainActor +protocol ShelfImageDestinationChoosing: AnyObject { + func chooseDestination(for plan: ShelfImageCopyPlan, size: ShelfImageCopySize) async -> URL? + func cancel() +} + +@MainActor +final class NativeShelfImageDestinationChooser: ShelfImageDestinationChoosing { + private var panel: NSSavePanel? + + func chooseDestination(for plan: ShelfImageCopyPlan, size: ShelfImageCopySize) async -> URL? { + guard panel == nil, !Task.isCancelled else { return nil } + let panel = NSSavePanel() + panel.allowedContentTypes = [plan.format == .jpeg ? .jpeg : .png] + panel.allowsOtherFileTypes = false + panel.nameFieldStringValue = plan.suggestedFilename(for: size) + panel.prompt = "Save Copy" + panel.message = "Choose a new filename. Existing files and the original image will not be replaced." + self.panel = panel + return await withCheckedContinuation { continuation in + panel.begin { response in + Task { @MainActor in + if self.panel === panel { self.panel = nil } + continuation.resume(returning: response == .OK ? panel.url : nil) + } + } + } + } + + func cancel() { panel?.cancel(nil) } +} + +@Observable +@MainActor +final class ShelfImageCopySession { + private(set) var request: ShelfImageCopyRequest? + private(set) var plan: ShelfImageCopyPlan? + private(set) var isWorking = false + private(set) var message: String? + private(set) var receipt: ShelfImageCopyReceipt? + private(set) var needsReceiptAcknowledgement = false + + var isActive: Bool { request != nil || isWorking || needsCleanup } + var needsCleanup: Bool { pendingCleanup != nil } + var hasUnverifiedPublishedCopy: Bool { + if case .published = pendingCleanup?.copy { return true } + return false + } + var recoveryLocations: [URL] { + switch pendingCleanup?.copy { + case .temporary(let stage): stage.recoveryLocations + case .published(let copy): copy.recoveryLocations + case nil: [] + } + } + + private enum PendingCopy: Sendable { + case temporary(ShelfImageTemporaryCopy) + case published(ShelfImagePublishedCopy) + } + + private struct PendingCleanup { + let copy: PendingCopy + let destination: URL + let scoped: Bool + } + + private let access: any ShelfFileAccess + private let copier: any ShelfImageCopying + private let destinationChooser: any ShelfImageDestinationChoosing + private var pendingCleanup: PendingCleanup? + @ObservationIgnored private var activeID: UUID? + @ObservationIgnored private var work: Task? + @ObservationIgnored private var cancellation: Task, Never>? + @ObservationIgnored private var cancellationRequested = false + + init( + access: any ShelfFileAccess = NativeShelfFileAccess(), + copier: any ShelfImageCopying = NativeShelfImageCopier(), + destinationChooser: any ShelfImageDestinationChoosing = NativeShelfImageDestinationChooser() + ) { + self.access = access + self.copier = copier + self.destinationChooser = destinationChooser + } + + @discardableResult + func begin(itemID: UUID, name: String, source: URL) -> ShelfImageCopyRequest? { + guard !isActive, work == nil, cancellation == nil, activeID == nil else { return nil } + let request = ShelfImageCopyRequest(id: UUID(), itemID: itemID, name: name) + activeID = request.id + cancellationRequested = false + work = Task { await inspect(source, requestID: request.id) } + isWorking = true + plan = nil + receipt = nil + message = nil + self.request = request + return request + } + + @discardableResult + func save(size: ShelfImageCopySize) -> Bool { + guard let request, let plan, activeID == request.id, !isWorking, + work == nil, cancellation == nil, !cancellationRequested, !needsCleanup, receipt == nil + else { return false } + work = Task { await save(plan, size: size, requestID: request.id) } + isWorking = true + message = nil + return true + } + + func stop() { + guard activeID != nil || work != nil || cancellation != nil || needsCleanup else { return } + cancellationRequested = true + work?.cancel() + destinationChooser.cancel() + isWorking = true + } + + func cancel(requestID: UUID? = nil) async -> Result { + if let requestID, requestID != activeID { return .success(()) } + if let cancellation { return await cancellation.value } + guard activeID != nil || work != nil || needsCleanup else { return .success(()) } + cancellationRequested = true + let currentWork = work + let cancellation = Task { @MainActor in + await currentWork?.value + var result = await self.retryCleanup() + if case .success = result { + if self.needsReceiptAcknowledgement { + result = .failure(.recoveredCopyNeedsAcknowledgement) + } else { + self.finishRequest() + } + } + self.cancellation = nil + self.isWorking = false + return result + } + self.cancellation = cancellation + currentWork?.cancel() + destinationChooser.cancel() + isWorking = true + return await cancellation.value + } + + @discardableResult + func acknowledgeRecoveredReceipt(requestID: UUID) -> Bool { + guard activeID == requestID, needsReceiptAcknowledgement, receipt != nil, + work == nil, cancellation == nil, !isWorking, !needsCleanup + else { return false } + finishRequest() + return true + } + + func acknowledgeUnverifiedCopy(requestID: UUID) async -> Result { + guard activeID == requestID else { return .failure(.cancelled) } + if let cancellation { return await cancellation.value } + guard work == nil, !isWorking, let pendingCleanup, case .published(let published) = pendingCleanup.copy + else { return .failure(.cancelled) } + let copier = copier + let cancellation = Task { @MainActor in + let worker = Task.detached(priority: .utility) { try copier.acknowledgeUnverifiedCopy(published) } + let result: Result + switch await worker.result { + case .success: + if pendingCleanup.scoped { self.access.end(pendingCleanup.destination) } + self.pendingCleanup = nil + self.receipt = nil + self.finishRequest() + result = .success(()) + case .failure(let error): + self.message = error.localizedDescription + result = .failure(.storeWrite) + } + self.cancellation = nil + self.isWorking = false + return result + } + self.cancellation = cancellation + cancellationRequested = true + isWorking = true + return await cancellation.value + } + + private func finishRequest() { + needsReceiptAcknowledgement = false + activeID = nil + request = nil + plan = nil + message = nil + cancellationRequested = false + } + + private func inspect(_ source: URL, requestID: UUID) async { + defer { finishWork(requestID: requestID) } + guard !Task.isCancelled, !cancellationRequested else { return } + let copier = copier + let access = access + let worker = Task.detached(priority: .userInitiated) { try copier.inspect(source, access: access) } + let result = await withTaskCancellationHandler { + await worker.result + } onCancel: { + worker.cancel() + } + guard activeID == requestID, !Task.isCancelled, !cancellationRequested else { return } + switch result { + case .success(let plan): self.plan = plan + case .failure(let error): report(error) + } + } + + private func save(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, requestID: UUID) async { + defer { finishWork(requestID: requestID) } + guard !Task.isCancelled, !cancellationRequested, + let destination = await destinationChooser.chooseDestination(for: plan, size: size), + !Task.isCancelled, !cancellationRequested, activeID == requestID + else { return } + let scoped = access.begin(destination) + let copier = copier + let worker = Task.detached(priority: .userInitiated) { + try copier.writeCopy(plan, size: size, to: destination) + } + let result = await withTaskCancellationHandler { + await worker.result + } onCancel: { + worker.cancel() + } + switch result { + case .success(let receipt): + if scoped { access.end(destination) } + self.receipt = receipt + case .failure(let error): + if let failure = error as? ShelfImageCopyFailure { + let recovery: PendingCopy? + switch failure { + case .cleanupFailed(let stage): recovery = .temporary(stage) + case .publicationUncertain(let copy): recovery = .published(copy) + default: recovery = nil + } + if let recovery { + pendingCleanup = PendingCleanup(copy: recovery, destination: destination, scoped: scoped) + message = failure.localizedDescription + return + } + } + if scoped { access.end(destination) } + if !Task.isCancelled, !cancellationRequested { report(error) } + } + } + + private func finishWork(requestID: UUID) { + guard activeID == requestID else { return } + work = nil + if !cancellationRequested, cancellation == nil { isWorking = false } + } + + private func retryCleanup() async -> Result { + guard let pendingCleanup else { return .success(()) } + let copier = copier + let copy = pendingCleanup.copy + let worker = Task.detached(priority: .utility) { + switch copy { + case .temporary(let stage): + try copier.removeTemporaryCopy(stage) + return nil + case .published(let published): + return try copier.recoverPublishedCopy(published) + } + } + switch await worker.result { + case .success(let receipt): + if let receipt { + self.receipt = receipt + needsReceiptAcknowledgement = true + message = nil + } + if pendingCleanup.scoped { access.end(pendingCleanup.destination) } + self.pendingCleanup = nil + return .success(()) + case .failure(let error): + message = error.localizedDescription + return .failure(.storeWrite) + } + } + + private func report(_ error: any Error) { + if error is CancellationError || error as? ShelfFailure == .cancelled { return } + message = error.localizedDescription + } +} diff --git a/Semper/Shelf/ShelfImageCopyView.swift b/Semper/Shelf/ShelfImageCopyView.swift new file mode 100644 index 0000000..fcbafc3 --- /dev/null +++ b/Semper/Shelf/ShelfImageCopyView.swift @@ -0,0 +1,146 @@ +import SwiftUI + +struct ShelfUnverifiedCopyExplanation: View { + var body: some View { + Text( + "No saved location can be confirmed. Any existing copy stays unchanged. Private temporary files must still be cleaned up." + ) + .font(.callout).foregroundStyle(.secondary) + } +} + +struct ShelfImageCleanupView: View { + let service: ShelfService + private var session: ShelfImageCopySession { service.imageCopy } + + var body: some View { + if session.needsReceiptAcknowledgement, let receipt = session.receipt, let request = session.request { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Label("Copy recovered", systemImage: "checkmark.circle").foregroundStyle(.green) + Text(receipt.url.path).textSelection(.enabled) + } + Spacer() + Button("Done") { service.acknowledgeImageCopyReceipt(requestID: request.id) } + .disabled(session.isWorking) + }.font(.callout) + } else if session.needsCleanup { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + Label("An image operation needs recovery.", systemImage: "exclamationmark.circle") + .foregroundStyle(.orange) + Spacer() + Button("Retry Cleanup") { + guard session.needsCleanup, let requestID = session.request?.id else { return } + Task { await service.cancelImageCopy(requestID: requestID) } + } + .disabled(session.isWorking) + } + if session.hasUnverifiedPublishedCopy, let request = session.request { + ShelfUnverifiedCopyExplanation() + Button("Finish Without Verification") { + Task { await service.acknowledgeUnverifiedImageCopy(requestID: request.id) } + } + .buttonStyle(.borderless) + .disabled(session.isWorking) + } + }.font(.callout) + } + } +} + +struct ShelfImageCopyView: View { + let service: ShelfService + private var session: ShelfImageCopySession { service.imageCopy } + let request: ShelfImageCopyRequest + @State private var size: ShelfImageCopySize = .pixels1024 + + var closeAction: () async -> Void { + { [service, requestID = request.id, acknowledgeReceipt = session.needsReceiptAcknowledgement] in + if acknowledgeReceipt { + service.acknowledgeImageCopyReceipt(requestID: requestID) + } else { + await service.cancelImageCopy(requestID: requestID) + } + } + } + + var body: some View { + let close = closeAction + VStack(alignment: .leading, spacing: 16) { + Text("Resize a Copy").font(.title2.weight(.semibold)) + Text(request.name).font(.headline).lineLimit(2).textSelection(.enabled) + if let receipt = session.receipt { + Label("Copy saved", systemImage: "checkmark.circle").foregroundStyle(.green) + Text("\(receipt.dimensions.width) × \(receipt.dimensions.height) pixels") + Text(receipt.url.path).font(.callout).textSelection(.enabled) + Text("The original is unchanged.").foregroundStyle(.secondary) + } else if let plan = session.plan { + Text("Original: \(plan.dimensions.width) × \(plan.dimensions.height) pixels") + .font(.callout).foregroundStyle(.secondary) + Picker("Longest edge", selection: $size) { + ForEach(ShelfImageCopySize.allCases) { option in + let output = plan.outputDimensions(for: option) + Text("Up to \(option.rawValue) pixels: \(output.width) × \(output.height)") + .tag(option) + } + } + .pickerStyle(.radioGroup) + .disabled(session.isWorking || session.needsCleanup) + Text( + "Smaller images keep their dimensions. The copy keeps its format, orientation, color profile, and PNG transparency." + ) + .font(.callout).foregroundStyle(.secondary) + Text( + "Camera, location, and other descriptive metadata are removed. JPEG copies are re-encoded with some quality loss." + ) + .font(.callout).foregroundStyle(.secondary) + } + if session.isWorking { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(session.plan == nil ? "Reading image…" : "Saving copy…") + }.font(.callout) + } + if !session.needsReceiptAcknowledgement, !session.recoveryLocations.isEmpty { + Text("Locations to check").font(.caption).foregroundStyle(.secondary) + ForEach(session.recoveryLocations, id: \.self) { url in + Text(url.path).font(.caption).textSelection(.enabled) + } + } + if let message = session.message { + Label(message, systemImage: "exclamationmark.circle") + .font(.callout).foregroundStyle(.orange).textSelection(.enabled) + } + if session.hasUnverifiedPublishedCopy { + ShelfUnverifiedCopyExplanation() + Button("Finish Without Verification") { + Task { await service.acknowledgeUnverifiedImageCopy(requestID: request.id) } + } + .buttonStyle(.borderless) + .disabled(session.isWorking) + } + HStack { + Spacer() + Button( + session.needsReceiptAcknowledgement + ? "Done" + : session.needsCleanup ? "Retry Cleanup" : session.receipt == nil ? "Cancel" : "Done" + ) { + Task { await close() } + } + .keyboardShortcut(session.needsCleanup || session.needsReceiptAcknowledgement ? nil : .cancelAction) + if session.plan != nil && session.receipt == nil { + Button("Save Copy…") { + if session.request?.id == request.id { session.save(size: size) } + } + .keyboardShortcut(.defaultAction) + .disabled(session.isWorking || session.needsCleanup) + } + } + } + .padding(24) + .frame(width: 460) + .interactiveDismissDisabled(session.isWorking) + } +} diff --git a/Semper/Shelf/ShelfImageFileOwnership.swift b/Semper/Shelf/ShelfImageFileOwnership.swift new file mode 100644 index 0000000..e000eef --- /dev/null +++ b/Semper/Shelf/ShelfImageFileOwnership.swift @@ -0,0 +1,400 @@ +import Darwin +import Foundation +import Synchronization + +nonisolated struct ShelfImageTemporaryCopy: Equatable, Sendable { + let url: URL + let device: Int32? + let inode: UInt64? + let parentDevice: Int32 + let parentInode: UInt64 + var recoveryLocations: [URL] { owner?.recoveryLocations ?? [url] } + let owner: ShelfImageFileOwner? + + init( + url: URL, device: Int32?, inode: UInt64?, parentDevice: Int32, parentInode: UInt64, + owner: ShelfImageFileOwner? = nil + ) { + self.url = url + self.device = device + self.inode = inode + self.parentDevice = parentDevice + self.parentInode = parentInode + self.owner = owner + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.url == rhs.url && lhs.device == rhs.device && lhs.inode == rhs.inode + && lhs.parentDevice == rhs.parentDevice && lhs.parentInode == rhs.parentInode + && lhs.owner?.id == rhs.owner?.id + } +} + +nonisolated struct ShelfImagePublishedCopy: Equatable, Sendable { + let requestedURL: URL + let dimensions: ShelfImageDimensions + var lastKnownURL: URL? { owner?.lastKnownURL } + var recoveryLocations: [URL] { owner?.recoveryLocations ?? [] } + let owner: ShelfImageFileOwner? + + init(requestedURL: URL, dimensions: ShelfImageDimensions, owner: ShelfImageFileOwner? = nil) { + self.requestedURL = requestedURL + self.dimensions = dimensions + self.owner = owner + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.requestedURL == rhs.requestedURL && lhs.dimensions == rhs.dimensions && lhs.owner?.id == rhs.owner?.id + } +} + +nonisolated enum ShelfImageFileCheckpoint: Equatable, Sendable { + case beforeStageIdentity, beforePublication, afterPublication, beforeClaim, afterClaim, beforeRestore, beforeReceipt +} + +nonisolated struct ShelfImageFileContext: Sendable { + let stage: URL + let claim: URL + let destination: URL +} + +nonisolated struct ShelfImageFileOperations: Sendable { + var makePrivateDirectory: @Sendable (URL) throws -> URL + var clone: @Sendable (Int32, Int32, String) throws -> Void + var path: @Sendable (Int32) throws -> URL + var checkpoint: @Sendable (ShelfImageFileCheckpoint, ShelfImageFileContext) throws -> Void = { _, _ in } + + static let native = Self( + makePrivateDirectory: { + try FileManager.default.url( + for: .itemReplacementDirectory, in: .userDomainMask, + appropriateFor: $0, create: true) + }, + clone: { source, parent, name in + guard fclonefileat(source, parent, name, UInt32(CLONE_NOFOLLOW | CLONE_NOOWNERCOPY)) == 0 else { + switch errno { + case EEXIST: throw ShelfImageCopyFailure.destinationExists + case ENOTSUP, EXDEV: throw ShelfImageCopyFailure.cloningUnsupported + default: throw ShelfImageCopyFailure.writeFailed + } + } + }, + path: { descriptor in + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + guard fcntl(descriptor, F_GETPATH, &buffer) == 0 else { throw ShelfImageCopyFailure.destinationChanged } + return try buffer.withUnsafeBufferPointer { + guard let base = $0.baseAddress else { throw ShelfImageCopyFailure.destinationChanged } + return URL(fileURLWithFileSystemRepresentation: base, isDirectory: false, relativeTo: nil) + } + }) +} + +nonisolated final class ShelfImageFileOwner: Sendable { + let id = UUID() + let stageDescriptor: Int32 + private let destination: URL + private let privateDirectory: URL + private let parentID: ShelfImageFileID + private let privateID: ShelfImageFileID + private let stageIdentity: Result + private let operations: ShelfImageFileOperations + private let state: Mutex + private let stageName = "image.tmp" + private let claimName = "cleanup-claim.tmp" + + private struct State { + var destinationFD: Int32 + var privateFD: Int32 + var stageFD: Int32 + var publishedFD: Int32 = -1 + var claimExists = false + var expected: Data? + var dimensions = ShelfImageDimensions(width: 0, height: 0) + var published = false + var cleaned = false + var location: URL? + } + + init(destination: URL, operations: ShelfImageFileOperations) throws { + self.destination = destination.standardizedFileURL + self.operations = operations + let parent = destination.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL + let parentFD = open(parent.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard parentFD >= 0 else { throw ShelfImageCopyFailure.invalidDestination } + do { + parentID = try ShelfImageFileID(descriptor: parentFD, kind: S_IFDIR) + privateDirectory = try operations.makePrivateDirectory(destination).standardizedFileURL + let privateFD = open(privateDirectory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard privateFD >= 0 else { throw ShelfImageCopyFailure.writeFailed } + do { + guard fchmod(privateFD, 0o700) == 0 else { throw ShelfImageCopyFailure.writeFailed } + privateID = try ShelfImageFileID(descriptor: privateFD, kind: S_IFDIR) + guard privateID.device == parentID.device else { throw ShelfImageCopyFailure.cloningUnsupported } + let fileFD = openat(privateFD, "image.tmp", O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard fileFD >= 0 else { throw ShelfImageCopyFailure.writeFailed } + stageDescriptor = fileFD + do { + try operations.checkpoint( + .beforeStageIdentity, + ShelfImageFileContext( + stage: privateDirectory.appendingPathComponent("image.tmp"), + claim: privateDirectory.appendingPathComponent("cleanup-claim.tmp"), + destination: destination)) + stageIdentity = .success(try ShelfImageFileID(descriptor: fileFD, kind: S_IFREG)) + } catch { + stageIdentity = .failure(error) + } + state = Mutex(State(destinationFD: parentFD, privateFD: privateFD, stageFD: fileFD)) + } catch { + close(privateFD) + throw error + } + } catch { + close(parentFD) + throw error + } + } + + deinit { + state.withLock { state in + for descriptor in [state.stageFD, state.privateFD, state.destinationFD, state.publishedFD] + where descriptor >= 0 { + close(descriptor) + } + } + } + + var temporaryCopy: ShelfImageTemporaryCopy { + let identity: ShelfImageFileID? + switch stageIdentity { + case .success(let value): identity = value + case .failure: identity = nil + } + return ShelfImageTemporaryCopy( + url: privateDirectory.appendingPathComponent(stageName), device: identity?.device, + inode: identity?.inode, parentDevice: privateID.device, parentInode: privateID.inode, + owner: self) + } + + var publishedCopy: ShelfImagePublishedCopy { + state.withLock { ShelfImagePublishedCopy(requestedURL: destination, dimensions: $0.dimensions, owner: self) } + } + + func validateForWriting() throws { + _ = try stageIdentity.get() + } + + var hasPublished: Bool { state.withLock { $0.published } } + var lastKnownURL: URL? { state.withLock { $0.location } } + var recoveryLocations: [URL] { + state.withLock { + let directory = ((try? operations.path($0.privateFD)) ?? privateDirectory).standardizedFileURL + var locations = $0.location.map { [$0] } ?? [] + if !$0.cleaned { locations.append(directory.appendingPathComponent(stageName)) } + if $0.claimExists { locations.append(directory.appendingPathComponent(claimName)) } + if $0.stageFD >= 0, let current = try? operations.path($0.stageFD).standardizedFileURL, + !locations.contains(current) + { + locations.append(current) + } + return locations + } + } + + func publish(encoded: Data, dimensions: ShelfImageDimensions) throws -> ShelfImageCopyReceipt { + try state.withLock { state in + guard !state.published, !state.cleaned else { throw ShelfImageCopyFailure.writeFailed } + try operations.checkpoint(.beforePublication, context) + try checkCancellation() + guard + try ShelfImageFileID( + path: destination.deletingLastPathComponent().resolvingSymlinksInPath(), kind: S_IFDIR + ) == parentID + else { + throw ShelfImageCopyFailure.destinationChanged + } + try operations.clone(state.stageFD, state.destinationFD, destination.lastPathComponent) + state.published = true + state.expected = encoded + state.dimensions = dimensions + do { + try capturePublishedDescriptor(&state) + try operations.checkpoint(.afterPublication, context) + return try recover(&state) + } catch { + throw ShelfImageCopyFailure.publicationUncertain( + ShelfImagePublishedCopy(requestedURL: destination, dimensions: dimensions, owner: self)) + } + } + } + + func recoverPublication() throws -> ShelfImageCopyReceipt { + try state.withLock { state in + guard state.published else { throw ShelfImageCopyFailure.writeFailed } + do { + if state.publishedFD < 0 { try capturePublishedDescriptor(&state) } + return try recover(&state) + } catch { + throw ShelfImageCopyFailure.publicationUncertain( + ShelfImagePublishedCopy(requestedURL: destination, dimensions: state.dimensions, owner: self)) + } + } + } + + func acknowledgeUnverifiedCopy() throws { + try state.withLock { state in + guard state.published else { throw ShelfImageCopyFailure.writeFailed } + try cleanUp(&state) + } + } + + func cleanUp() throws { + try state.withLock { state in + do { try cleanUp(&state) } catch { throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) } + } + } + + private var context: ShelfImageFileContext { + ShelfImageFileContext( + stage: privateDirectory.appendingPathComponent(stageName), + claim: privateDirectory.appendingPathComponent(claimName), destination: destination) + } + + private func capturePublishedDescriptor(_ state: inout State) throws { + let descriptor = openat( + state.destinationFD, destination.lastPathComponent, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw ShelfImageCopyFailure.destinationChanged } + do { + _ = try ShelfImageFileID(descriptor: descriptor, kind: S_IFREG) + try verifyBytes(descriptor, expected: state.expected) + state.publishedFD = descriptor + } catch { + close(descriptor) + throw error + } + } + + private func recover(_ state: inout State) throws -> ShelfImageCopyReceipt { + let location = try operations.path(state.publishedFD).standardizedFileURL + state.location = location + try verifyBytes(state.publishedFD, expected: state.expected) + try operations.checkpoint(.beforeReceipt, context) + let identity = try ShelfImageFileID(descriptor: state.publishedFD, kind: S_IFREG) + guard try ShelfImageFileID(path: location, kind: S_IFREG) == identity else { + throw ShelfImageCopyFailure.destinationChanged + } + try cleanUp(&state) + let finalLocation = try operations.path(state.publishedFD).standardizedFileURL + guard try ShelfImageFileID(path: finalLocation, kind: S_IFREG) == identity else { + throw ShelfImageCopyFailure.destinationChanged + } + state.location = finalLocation + let receipt = ShelfImageCopyReceipt(url: finalLocation, dimensions: state.dimensions) + return receipt + } + + private func cleanUp(_ state: inout State) throws { + if state.cleaned { return } + guard try ShelfImageFileID(path: privateDirectory, kind: S_IFDIR) == privateID else { + throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) + } + let stageID: ShelfImageFileID + switch stageIdentity { + case .success(let identity): stageID = identity + case .failure: stageID = try ShelfImageFileID(descriptor: state.stageFD, kind: S_IFREG) + } + if !state.claimExists { + try operations.checkpoint(.beforeClaim, context) + if renameatx_np(state.privateFD, stageName, state.privateFD, claimName, UInt32(RENAME_EXCL)) == 0 { + state.claimExists = true + } else if errno != ENOENT { + throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) + } + } + if state.claimExists { + try operations.checkpoint(.afterClaim, context) + let claimed = try ShelfImageFileID(parent: state.privateFD, name: claimName) + if claimed != stageID { + try operations.checkpoint(.beforeRestore, context) + if renameatx_np(state.privateFD, claimName, state.privateFD, stageName, UInt32(RENAME_EXCL)) == 0 { + state.claimExists = false + } + throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) + } + // The pinned private namespace is exclusively managed by this operation. + var retained = stat() + guard fstat(state.stageFD, &retained) == 0, retained.st_nlink == 1, + unlinkat(state.privateFD, claimName, 0) == 0 + else { + throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) + } + state.claimExists = false + } + var stage = stat() + guard fstat(state.stageFD, &stage) == 0, stage.st_nlink == 0, + try FileManager.default.contentsOfDirectory(atPath: privateDirectory.path).isEmpty + else { throw ShelfImageCopyFailure.cleanupFailed(temporaryCopy) } + state.cleaned = true + close(state.stageFD) + state.stageFD = -1 + // Leave the empty system replacement directory; its parent name is not privately owned. + } + + private func verifyBytes(_ descriptor: Int32, expected: Data?) throws { + guard let expected, lseek(descriptor, 0, SEEK_SET) == 0 else { throw ShelfImageCopyFailure.verificationFailed } + var before = stat() + guard fstat(descriptor, &before) == 0, before.st_size == expected.count, + before.st_flags & UInt32(SF_DATALESS) == 0 + else { throw ShelfImageCopyFailure.verificationFailed } + let file = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + var offset = 0 + while offset < expected.count { + let data = try file.read(upToCount: min(ShelfLimits.chunkBytes, expected.count - offset)) ?? Data() + guard !data.isEmpty, data == expected[offset..<(offset + data.count)] else { + throw ShelfImageCopyFailure.verificationFailed + } + offset += data.count + } + var after = stat() + guard fstat(descriptor, &after) == 0, before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec + else { throw ShelfImageCopyFailure.verificationFailed } + } + + private func checkCancellation() throws { + if Task.isCancelled { throw ShelfFailure.cancelled } + } +} + +nonisolated private struct ShelfImageFileID: Equatable, Sendable { + let device: Int32 + let inode: UInt64 + + init(device: Int32, inode: UInt64) { + self.device = device + self.inode = inode + } + init(descriptor: Int32, kind: mode_t) throws { + var info = stat() + guard fstat(descriptor, &info) == 0, info.st_mode & S_IFMT == kind, info.st_ino != 0 else { + throw ShelfImageCopyFailure.writeFailed + } + self.init(device: info.st_dev, inode: info.st_ino) + } + init(path: URL, kind: mode_t) throws { + var info = stat() + guard lstat(path.path, &info) == 0, info.st_mode & S_IFMT == kind, info.st_ino != 0 else { + throw ShelfImageCopyFailure.destinationChanged + } + self.init(device: info.st_dev, inode: info.st_ino) + } + init(parent: Int32, name: String) throws { + var info = stat() + guard fstatat(parent, name, &info, AT_SYMLINK_NOFOLLOW) == 0, info.st_ino != 0 + else { throw ShelfImageCopyFailure.writeFailed } + self.init(device: info.st_dev, inode: info.st_ino) + } +} diff --git a/Semper/Shelf/ShelfModels.swift b/Semper/Shelf/ShelfModels.swift index 602f46e..212e17c 100644 --- a/Semper/Shelf/ShelfModels.swift +++ b/Semper/Shelf/ShelfModels.swift @@ -70,6 +70,7 @@ nonisolated enum ShelfFileState: Equatable, Sendable { nonisolated enum ShelfFailure: Error, Equatable, LocalizedError, Sendable { case stopped, full, tooLarge, unsupported, missing, cloudOnly, inaccessible case invalidStore, storeVersion, storeWrite, cancelled, changedDuringRead, invalidImage + case recoveredCopyNeedsAcknowledgement var errorDescription: String? { switch self { @@ -86,6 +87,7 @@ nonisolated enum ShelfFailure: Error, Equatable, LocalizedError, Sendable { case .cancelled: "Operation cancelled." case .changedDuringRead: "The file changed while its checksum was being calculated. Try again." case .invalidImage: "This image is invalid or exceeds the image size limit." + case .recoveredCopyNeedsAcknowledgement: "Review the recovered copy's saved location, then choose Done." } } } diff --git a/Semper/Shelf/ShelfService.swift b/Semper/Shelf/ShelfService.swift index af98d7f..f9c0f93 100644 --- a/Semper/Shelf/ShelfService.swift +++ b/Semper/Shelf/ShelfService.swift @@ -16,6 +16,7 @@ final class ShelfService { private(set) var checksums: [UUID: ShelfChecksumState] = [:] private(set) var isRunning = false private(set) var isStopping = false + private(set) var stopFailure: ShelfFailure? private(set) var isClearing = false private(set) var persistenceEnabled = false private(set) var defaultExpiry: ShelfExpiry = .quit @@ -27,16 +28,22 @@ final class ShelfService { private var importCleanupNeedsRetry = false private var importCancellationCount = 0 - var canClear: Bool { !items.isEmpty || !pendingImportCleanup.isEmpty || importCleanupNeedsRetry } + var canClear: Bool { + !items.isEmpty || !pendingImportCleanup.isEmpty || importCleanupNeedsRetry || imageCopy.needsCleanup + } var canChooseFiles: Bool { isRunning && !isStopping && !isClearing && !storeNeedsReset && !isChoosingFiles && importCount == 0 && importCancellationCount == 0 && removingIDs.isEmpty - && pendingImportCleanup.isEmpty && !importCleanupNeedsRetry && items.count < ShelfLimits.items + && pendingImportCleanup.isEmpty && !importCleanupNeedsRetry && !imageCopy.isActive + && items.count < ShelfLimits.items } let store: ShelfStore + let imageCopy: ShelfImageCopySession @ObservationIgnored private let access: any ShelfFileAccess @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private let waitForExpiry: @Sendable (TimeInterval) async throws -> Void + @ObservationIgnored private var expiryBlockedRequests: [UUID: UUID] = [:] @ObservationIgnored private var loaded = false @ObservationIgnored private var scopes: [UUID: URL] = [:] @ObservationIgnored private var expiryTask: Task? @@ -49,20 +56,26 @@ final class ShelfService { @ObservationIgnored private let fileChooser: any ShelfFileChoosing @ObservationIgnored private let importer: @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload - @ObservationIgnored private var stopTask: Task? + @ObservationIgnored private var stopTask: Task, Never>? @ObservationIgnored private var invalidReferenceIDs: Set = [] init( store: ShelfStore = .standard, access: any ShelfFileAccess = NativeShelfFileAccess(), now: @escaping @Sendable () -> Date = { Date() }, + waitForExpiry: @escaping @Sendable (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + }, fileChooser: any ShelfFileChoosing = NativeShelfFileChooser(), + imageCopy: ShelfImageCopySession? = nil, importer: @escaping @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload = ShelfDropImporter.load ) { self.store = store self.access = access self.now = now + self.waitForExpiry = waitForExpiry self.fileChooser = fileChooser + self.imageCopy = imageCopy ?? ShelfImageCopySession(access: access) self.importer = importer } @@ -97,38 +110,67 @@ final class ShelfService { scheduleExpiry() } - func pause() async { - if let stopTask { - await stopTask.value - return - } + @discardableResult + func pause() async -> Result { + if let stopTask { return await stopTask.value } + imageCopy.stop() isStopping = true isRunning = false generation += 1 expiryTask?.cancel() expiryTask = nil - let drain = Task { [weak self] in - guard let self else { return } - _ = await self.cancelWork() - if let clearTask = self.clearTask { _ = await clearTask.value } + let drain = Task, Never> { [weak self] in + guard let self else { return .failure(.cancelled) } + defer { + self.isStopping = false + self.stopTask = nil + } + let cleanup = await self.cancelWork() + let clearResult = await self.clearTask?.value + let failure: ShelfFailure? + if case .failure(let error) = cleanup { + failure = error + } else if case .failure(let error) = clearResult { + failure = error + } else { + failure = nil + } + if let failure { + self.stopFailure = failure + self.report(failure) + self.isRunning = true + self.scheduleExpiry() + return .failure(failure) + } for url in self.scopes.values { self.access.end(url) } self.scopes.removeAll() - self.isStopping = false - self.stopTask = nil + self.stopFailure = nil + return .success(()) } stopTask = drain - await drain.value + return await drain.value } - func shutdown() async { - await pause() + @discardableResult + func shutdown() async -> Result { + if case .failure(let failure) = await pause() { return .failure(failure) } let removed = persistenceEnabled ? items.filter { $0.expiry == .quit || $0.hasExpired(at: now()) } : items - for item in removed { removeOwnedContent(item) } + for item in removed { + if case .failure(let failure) = removeOwnedContent(item) { + stopFailure = failure + isRunning = true + refresh() + scheduleExpiry() + return .failure(failure) + } + } let removedIDs = Set(removed.map(\.id)) items.removeAll { removedIDs.contains($0.id) } fileStates = fileStates.filter { !removedIDs.contains($0.key) } checksums = checksums.filter { !removedIDs.contains($0.key) } + expiryBlockedRequests = expiryBlockedRequests.filter { !removedIDs.contains($0.key) } persist() + return .success(()) } func refresh() { @@ -239,8 +281,14 @@ final class ShelfService { private func remove(_ id: UUID, onlyIfExpired: Bool) async { guard !isClearing else { return } let removalClearGeneration = clearGeneration + let imageRequest = imageCopy.request?.itemID == id ? imageCopy.request : nil + if imageRequest != nil { imageCopy.stop() } removingIDs.insert(id) defer { removingIDs.remove(id) } + if let imageRequest, case .failure(let failure) = await cancelImageCopy(requestID: imageRequest.id) { + report(failure) + return + } let worker = hashTasks[id] worker?.cancel() if worker != nil { checksums[id] = .cancelled } @@ -252,6 +300,7 @@ final class ShelfService { if let url = scopes.removeValue(forKey: id) { access.end(url) } removeOwnedContent(item) items.removeAll { $0.id == id } + expiryBlockedRequests[id] = nil fileStates[id] = nil invalidReferenceIDs.remove(id) checksums[id] = nil @@ -264,6 +313,7 @@ final class ShelfService { if let clearTask { return await clearTask.value } + imageCopy.stop() generation += 1 clearGeneration += 1 isClearing = true @@ -301,12 +351,14 @@ final class ShelfService { for url in self.scopes.values { self.access.end(url) } self.scopes.removeAll() self.items.removeAll() + self.expiryBlockedRequests.removeAll() self.fileStates.removeAll() self.invalidReferenceIDs.removeAll() self.checksums.removeAll() if self.message == ShelfFailure.storeWrite.localizedDescription || self.message == "Clear Shelf to retry temporary image cleanup before adding another drop." || self.message == "Wait for cancelled imports to finish cleaning up before adding another drop." + || self.message == "Finish Resize a Copy or retry its cleanup before adding more items." { self.message = nil } @@ -318,7 +370,7 @@ final class ShelfService { func expireItems() async { guard !isClearing else { return } - let ids = items.filter { $0.hasExpired(at: now()) }.map(\.id) + let ids = items.filter { $0.hasExpired(at: now()) && expiryBlockedRequests[$0.id] == nil }.map(\.id) for id in ids { await remove(id, onlyIfExpired: true) } scheduleExpiry() } @@ -355,6 +407,65 @@ final class ShelfService { checksums[id] = .cancelled } + @discardableResult + func cancelImageCopy(requestID: UUID? = nil, retryCleanup: Bool = true) async -> Result { + if let requestID, requestID != imageCopy.request?.id { return .success(()) } + if !retryCleanup, imageCopy.needsReceiptAcknowledgement { return .failure(.recoveredCopyNeedsAcknowledgement) } + if !retryCleanup, imageCopy.needsCleanup { return .failure(.storeWrite) } + let request = imageCopy.request + let result = await imageCopy.cancel(requestID: requestID) + if let request { updateImageExpiryBlock(request, result: result) } + return result + } + + func acknowledgeUnverifiedImageCopy(requestID: UUID) async -> Result { + guard let request = imageCopy.request, request.id == requestID else { return .failure(.cancelled) } + let result = await imageCopy.acknowledgeUnverifiedCopy(requestID: requestID) + updateImageExpiryBlock(request, result: result) + return result + } + + @discardableResult + func acknowledgeImageCopyReceipt(requestID: UUID) -> Bool { + guard let request = imageCopy.request, request.id == requestID, + imageCopy.acknowledgeRecoveredReceipt(requestID: requestID) + else { return false } + if expiryBlockedRequests[request.itemID] == requestID { + expiryBlockedRequests[request.itemID] = nil + scheduleExpiry() + } + return true + } + + private func updateImageExpiryBlock(_ request: ShelfImageCopyRequest, result: Result) { + switch result { + case .success: + if expiryBlockedRequests[request.itemID] == request.id { + expiryBlockedRequests[request.itemID] = nil + scheduleExpiry() + } + case .failure: + if imageCopy.request?.id == request.id, + imageCopy.needsCleanup || imageCopy.needsReceiptAcknowledgement + { + expiryBlockedRequests[request.itemID] = request.id + } + } + } + + func canResizeImage(_ item: ShelfItem) -> Bool { + isRunning && !isStopping && !isClearing && !storeNeedsReset && !imageCopy.isActive + && importTasks.isEmpty && !isChoosingFiles && importCancellationCount == 0 + && pendingImportCleanup.isEmpty && !importCleanupNeedsRetry && removingIDs.isEmpty + && items.contains(where: { $0.id == item.id }) + && fileStates[item.id] == .available(isDirectory: false) + } + + func prepareImageCopy(_ item: ShelfItem) -> ShelfImageCopyRequest? { + guard canResizeImage(item), let url = prepareFileAction(item) else { return nil } + return imageCopy.begin(itemID: item.id, name: item.name, source: url) + } + @discardableResult func chooseFiles() -> Bool { guard removingIDs.isEmpty else { @@ -413,6 +524,10 @@ final class ShelfService { report(ShelfFailure.full) return false } + guard !imageCopy.isActive else { + message = "Finish Resize a Copy or retry its cleanup before adding more items." + return false + } guard importTasks.isEmpty, !isChoosingFiles else { message = "Finish or cancel the current selection or import before adding more items." return false @@ -579,7 +694,9 @@ final class ShelfService { } private func cancelWork() async -> Result { + imageCopy.stop() let importCleanup = await cancelImports() + let imageCleanup = await cancelImageCopy() let workers = hashTasks for (id, task) in workers { task.cancel() @@ -589,7 +706,8 @@ final class ShelfService { _ = await task.result hashTasks[id] = nil } - return importCleanup + if case .failure = importCleanup { return importCleanup } + return imageCleanup } private func reconcileImportCleanup() { @@ -638,10 +756,13 @@ final class ShelfService { private func scheduleExpiry() { expiryTask?.cancel() expiryTask = nil - guard isRunning, !isClearing, let deadline = items.compactMap(\.expiresAt).min() else { return } + guard isRunning, !isClearing, + let deadline = items.filter({ expiryBlockedRequests[$0.id] == nil }).compactMap(\.expiresAt).min() + else { return } let delay = min(86_400, max(0, deadline.timeIntervalSince(now()))) + let waitForExpiry = waitForExpiry expiryTask = Task { [weak self] in - do { try await Task.sleep(for: .seconds(delay)) } catch { return } + do { try await waitForExpiry(delay) } catch { return } guard !Task.isCancelled, let self, self.isRunning else { return } await self.expireItems() } diff --git a/Semper/Shelf/ShelfViews.swift b/Semper/Shelf/ShelfViews.swift index 8b9a60e..85beddd 100644 --- a/Semper/Shelf/ShelfViews.swift +++ b/Semper/Shelf/ShelfViews.swift @@ -43,6 +43,7 @@ struct ShelfCompactView: View { } if service.items.count > 3 { Button("View all \(service.items.count) items", action: openDetail) } } + ShelfImageCleanupView(service: service) if service.importCount > 0 { HStack { ProgressView().controlSize(.small) @@ -64,6 +65,7 @@ struct ShelfDetailView: View { let service: ShelfService @State private var targeted = false @State private var preview: ShelfItem? + @State private var resizeRequest: ShelfImageCopyRequest? @State private var confirmClear = false @State private var confirmReset = false @@ -122,6 +124,7 @@ struct ShelfDetailView: View { Button("Dismiss") { service.dismissMessage() } }.font(.callout).foregroundStyle(.orange) } + ShelfImageCleanupView(service: service) if service.importCount > 0 { HStack { ProgressView().controlSize(.small) @@ -179,6 +182,13 @@ struct ShelfDetailView: View { } }.padding().frame(minWidth: 540, minHeight: 380) } + .sheet(item: $resizeRequest) { request in + ShelfImageCopyView(service: service, request: request) + .onDisappear { Task { await service.cancelImageCopy(requestID: request.id, retryCleanup: false) } } + } + .onChange(of: service.imageCopy.request?.id) { _, id in + if resizeRequest?.id != id { resizeRequest = nil } + } .onChange(of: service.isRunning) { _, running in if !running { preview = nil } } .onChange(of: service.items) { _, items in if let preview, !items.contains(where: { $0.id == preview.id }) { self.preview = nil } @@ -226,7 +236,7 @@ struct ShelfDetailView: View { HStack(spacing: 12) { if service.fileURL(for: item) != nil { Button("Quick Look") { if service.prepareFileAction(item) != nil { preview = item } }.disabled( - service.fileStates[item.id]?.isAvailable != true) + service.fileStates[item.id]?.isAvailable != true || service.imageCopy.isActive) Button("Reveal in Finder") { service.reveal(item) } Button("Copy Path") { service.copyPath(item) } if service.fileStates[item.id] == .available(isDirectory: false) { @@ -240,6 +250,14 @@ struct ShelfDetailView: View { Button("Copy Link") { service.copyContents(item) } } }.font(.callout).disabled(!service.isRunning) + if service.fileStates[item.id] == .available(isDirectory: false) { + Button("Resize a Copy…", systemImage: "arrow.up.left.and.arrow.down.right") { + resizeRequest = service.prepareImageCopy(item) + } + .font(.callout) + .disabled(!service.canResizeImage(item)) + .help("Save a smaller JPEG or PNG without changing the original.") + } if let checksum = service.checksums[item.id] { switch checksum { case .calculating: diff --git a/SemperTests/DirectUtilityRuntimeTests.swift b/SemperTests/DirectUtilityRuntimeTests.swift index 51c5619..35bb55f 100644 --- a/SemperTests/DirectUtilityRuntimeTests.swift +++ b/SemperTests/DirectUtilityRuntimeTests.swift @@ -505,6 +505,227 @@ struct DirectUtilityRuntimeTests { } } + nonisolated enum ShelfStopAction: CaseIterable, Sendable { case pause, removal, shutdown, pausedShutdown } + + @Test( + "Shelf cleanup failure retains its runtime owner until a deliberate lifecycle retry", + .timeLimit(.minutes(1)), arguments: ShelfStopAction.allCases) + func shelfStopCleanupRetry(action: ShelfStopAction) async throws { + try await withRuntime { runtime, probe in + let entered = DirectShelfStopSignal() + let cancelled = DirectShelfStopSignal() + let release = DirectShelfStopSignal() + let finished = DirectShelfStopSignal() + let access = DirectShelfStopAccess() + probe.shelfAccess = access + let name = "shelf-item-\(UUID()).png" + probe.shelfImporter = { _, _ in + let waiting = Task.detached { await release.wait() } + entered.resolve() + return await withTaskCancellationHandler { + _ = await waiting.value + return .cachedFile(name) + } onCancel: { + cancelled.resolve() + } + } + try await runtime.start(.shelf) + let shelf = try #require(runtime.shelf) + let shell = UtilityShellView(runtime: runtime, connectsShellActions: false) + #expect(shell.shelfStopRecoveryRoute == nil) + let source = probe.directory.appendingPathComponent("retained-source.txt") + let original = Data("original reference".utf8) + try original.write(to: source) + try shelf.addFile(source) + let retained = shelf.items + try shelf.store.prepareCache() + let obstacle = shelf.store.cache.appendingPathComponent(name) + try FileManager.default.createSymbolicLink(at: obstacle, withDestinationURL: source) + defer { + release.resolve() + if FileManager.default.fileExists(atPath: obstacle.path) { + do { try FileManager.default.removeItem(at: obstacle) } catch { Issue.record(error) } + } + } + try #require(shelf.importDrops([NSItemProvider()])) + try #require(await entered.wait()) + let stopping = Task { @MainActor () -> UtilityCleanupDeferral? in + defer { finished.resolve() } + do { + switch action { + case .pause, .pausedShutdown: try await runtime.pause(.shelf) + case .removal: try await runtime.remove(.shelf) + case .shutdown: await runtime.shutdown() + } + return nil + } catch { + guard let deferral = error as? UtilityCleanupDeferral else { + Issue.record(error) + return nil + } + return deferral + } + } + try #require(await cancelled.wait()) + #expect(!finished.isResolved) + #expect(runtime.shelf === shelf && shelf.isStopping) + #expect(shelf.items == retained) + #expect(access.active == 1) + release.resolve() + let deferral = await stopping.value + if action != .shutdown { + let failure = try #require(deferral) + #expect(failure.retaining == [.shelf]) + #expect(runtime.registry.pausedModuleIDs.contains(.shelf)) + guard case .failed = runtime.registry.state(for: .shelf)?.runtime else { + Issue.record("Failed Shelf stop was not reported by the registry") + return + } + } + #expect(runtime.shelf === shelf) + #expect(shelf.isRunning && !shelf.isStopping) + #expect(shelf.items == retained && access.active == 1) + #expect(runtime.registry.state(for: .shelf)?.presence == .added) + #expect(runtime.lifecycle.failures[.shelf] != nil) + #expect(shelf.stopFailure == .storeWrite) + #expect(shell.shelfStopRecoveryRoute == (action == .shutdown ? .shutdown : .pause)) + #expect(probe.creations[.shelf] == 1) + #expect(try Data(contentsOf: source) == original) + + if action == .pausedShutdown { + await runtime.shutdown() + #expect(runtime.lifecycle.isShuttingDown) + #expect(runtime.registry.pausedModuleIDs.contains(.shelf)) + #expect(runtime.shelf === shelf && shelf.isRunning) + #expect(shell.shelfStopRecoveryRoute == .shutdown) + #expect(access.active == 1) + } + + try FileManager.default.removeItem(at: obstacle) + switch action { + case .pause: + try await ShelfStopRecoveryRoute.retry(in: runtime) + #expect(runtime.shelf === shelf && !shelf.isRunning) + #expect(runtime.registry.state(for: .shelf)?.runtime == .paused) + #expect(shelf.items == retained) + case .removal: + try await runtime.remove(.shelf) + #expect(runtime.shelf == nil) + #expect(runtime.registry.state(for: .shelf)?.presence == .available) + case .shutdown, .pausedShutdown: + let permit = try runtime.mutationAdmission.acquire(owner: .awayMode, mode: .exclusive) + defer { runtime.mutationAdmission.release(permit) } + #expect(runtime.mutationDisabledReason != nil) + #expect(!shell.moduleInteractionDisabled(for: .shelf)) + #expect(shell.moduleInteractionDisabled(for: .workspace)) + #expect(shell.moduleInteractionDisabled(for: .sound)) + await #expect(throws: UtilityLifecycleError.self) { try await runtime.start(.shelf) } + try await ShelfStopRecoveryRoute.retry(in: runtime) + #expect(runtime.shelf == nil) + #expect(shell.moduleInteractionDisabled(for: .shelf)) + } + #expect(shell.shelfStopRecoveryRoute == nil) + #expect(shelf.stopFailure == nil) + #expect(runtime.lifecycle.failures[.shelf] == nil) + #expect(access.active == 0) + #expect(probe.creations[.shelf] == 1) + #expect(try Data(contentsOf: source) == original) + } + } + + @Test( + "Publication recovery stays accessible until its acknowledgement finishes the pending stop", + .timeLimit(.minutes(1)), arguments: [false, true], [false, true]) + func shelfRecoveredLocationAcknowledgement(shutdown: Bool, verified: Bool) async throws { + try await withRuntime { runtime, probe in + let access = DirectShelfStopAccess() + let destination = probe.directory.appendingPathComponent("requested-copy.png") + let recovered = probe.directory.appendingPathComponent("relocated-copy.png") + let copier = DirectShelfRecoveredCopier(recovered: recovered) + let session = ShelfImageCopySession( + access: access, copier: copier, destinationChooser: DirectShelfImageChooser(destination: destination)) + probe.shelfAccess = access + probe.shelfImageCopy = session + let source = probe.directory.appendingPathComponent("source.png") + let bitmap = try #require( + NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0)) + bitmap.setColor(.red, atX: 0, y: 0) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: source) + try await runtime.start(.shelf) + let shelf = try #require(runtime.shelf) + try shelf.addFile(source) + let item = try #require(shelf.items.first) + let request = try #require(shelf.prepareImageCopy(item)) + while session.isWorking, !Task.isCancelled { await Task.yield() } + try #require(session.plan != nil) + try #require(session.save(size: .pixels1024)) + while session.isWorking, !Task.isCancelled { await Task.yield() } + try #require(session.needsCleanup && session.receipt == nil) + let edited = Data("user edited the recovered fixture".utf8) + if !verified { + try edited.write(to: recovered) + copier.failRecovery = true + } + + if shutdown { + await runtime.shutdown() + } else { + await #expect(throws: UtilityCleanupDeferral.self) { try await runtime.pause(.shelf) } + } + let shell = UtilityShellView(runtime: runtime, connectsShellActions: false) + #expect(runtime.shelf === shelf && shelf.items.map(\.id) == [item.id]) + #expect(shell.shelfStopRecoveryRoute == (shutdown ? .shutdown : .pause)) + #expect(shelf.prepareImageCopy(item) == nil) + #expect(copier.writes == 1 && copier.recoveries == 1) + if verified { + #expect(shelf.stopFailure == .recoveredCopyNeedsAcknowledgement) + #expect(session.needsReceiptAcknowledgement && !session.needsCleanup) + #expect(session.request?.id == request.id && session.receipt?.url == recovered) + #expect(access.active == 1) + _ = await shelf.cancelImageCopy(requestID: request.id, retryCleanup: false) + try await ShelfStopRecoveryRoute.acknowledgeRecoveredCopy(in: runtime, requestID: UUID()) + #expect(runtime.shelf === shelf && session.request?.id == request.id) + #expect(session.needsReceiptAcknowledgement && session.receipt?.url == recovered) + #expect(copier.writes == 1 && copier.recoveries == 1) + try await ShelfStopRecoveryRoute.acknowledgeRecoveredCopy(in: runtime, requestID: request.id) + #expect(session.receipt?.url == recovered) + #expect(try Data(contentsOf: recovered) == Data("recovered fixture copy".utf8)) + } else { + #expect(shelf.stopFailure == .storeWrite) + #expect(session.hasUnverifiedPublishedCopy && session.receipt == nil) + #expect(access.active == 2) + try await ShelfStopRecoveryRoute.acknowledgeUnverifiedCopy(in: runtime, requestID: UUID()) + #expect(copier.acknowledgements == 0) + copier.failPrivateCleanup = true + await #expect(throws: ShelfFailure.self) { + try await ShelfStopRecoveryRoute.acknowledgeUnverifiedCopy(in: runtime, requestID: request.id) + } + #expect(runtime.shelf === shelf && session.hasUnverifiedPublishedCopy) + #expect(session.request?.id == request.id && session.receipt == nil && access.active == 2) + _ = await shelf.cancelImageCopy(requestID: request.id, retryCleanup: false) + #expect(copier.acknowledgements == 1 && copier.recoveries == 1) + copier.failPrivateCleanup = false + try await ShelfStopRecoveryRoute.acknowledgeUnverifiedCopy(in: runtime, requestID: request.id) + #expect(session.receipt == nil && copier.acknowledgements == 2) + #expect(try Data(contentsOf: recovered) == edited) + } + #expect(shell.shelfStopRecoveryRoute == nil) + #expect(!session.needsReceiptAcknowledgement && session.request == nil) + #expect(shelf.stopFailure == nil && access.active == 0) + #expect(copier.writes == 1 && copier.recoveries == 1) + #expect(!FileManager.default.fileExists(atPath: destination.path)) + if shutdown { + #expect(runtime.shelf == nil) + } else { + #expect(runtime.shelf === shelf && !shelf.isRunning) + #expect(runtime.registry.state(for: .shelf)?.runtime == .paused) + } + } + } + @Test("Direct service changes update permission and limitation badges without restarting services") func liveStatus() async throws { try await withRuntime { runtime, probe in @@ -696,6 +917,10 @@ private final class DirectRuntimeProbe { var creations: [UtilityModuleID: Int] = [:] var workspaceCreationFails = false var workspaceBackendOverride: (any WorkspaceWindowBackend)? + var shelfAccess: any ShelfFileAccess = DirectRuntimeFileAccess() + var shelfImageCopy: ShelfImageCopySession? + var shelfImporter: @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload = ShelfDropImporter + .load init(directory: URL) { self.directory = directory @@ -732,7 +957,8 @@ private final class DirectRuntimeProbe { func makeShelf() -> ShelfService { creations[.shelf, default: 0] += 1 return ShelfService( - store: ShelfStore(root: directory.appendingPathComponent("Shelf")), access: DirectRuntimeFileAccess()) + store: ShelfStore(root: directory.appendingPathComponent("Shelf")), access: shelfAccess, + imageCopy: shelfImageCopy, importer: shelfImporter) } func makeStorage() -> SafeEjectService { @@ -803,6 +1029,111 @@ private struct DirectRuntimeFileAccess: ShelfFileAccess { func resolve(_ bookmark: Data) throws -> URL { throw ShelfFailure.unsupported } } +nonisolated private final class DirectShelfRecoveredCopier: ShelfImageCopying, @unchecked Sendable { + let recovered: URL + private let lock = NSLock() + private var writeCount = 0 + private var recoveryCount = 0 + private var acknowledgementCount = 0 + private var recoveryFailure = false + private var privateCleanupFailure = false + var writes: Int { lock.withLock { writeCount } } + var recoveries: Int { lock.withLock { recoveryCount } } + var acknowledgements: Int { lock.withLock { acknowledgementCount } } + var failRecovery: Bool { + get { lock.withLock { recoveryFailure } } + set { lock.withLock { recoveryFailure = newValue } } + } + var failPrivateCleanup: Bool { + get { lock.withLock { privateCleanupFailure } } + set { lock.withLock { privateCleanupFailure = newValue } } + } + + init(recovered: URL) { self.recovered = recovered } + + func inspect(_ source: URL, access: any ShelfFileAccess) throws -> ShelfImageCopyPlan { + try NativeShelfImageCopier().inspect(source, access: access) + } + + func writeCopy(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, to destination: URL) throws + -> ShelfImageCopyReceipt + { + lock.withLock { writeCount += 1 } + try Data("recovered fixture copy".utf8).write(to: recovered) + throw ShelfImageCopyFailure.publicationUncertain( + ShelfImagePublishedCopy(requestedURL: destination, dimensions: plan.outputDimensions(for: size))) + } + + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt { + lock.withLock { recoveryCount += 1 } + if failRecovery { throw ShelfImageCopyFailure.publicationUncertain(published) } + return ShelfImageCopyReceipt(url: recovered, dimensions: published.dimensions) + } + + func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { + throw ShelfImageCopyFailure.invalidTemporaryCopy + } + + func acknowledgeUnverifiedCopy(_ published: ShelfImagePublishedCopy) throws { + lock.withLock { acknowledgementCount += 1 } + if failPrivateCleanup { throw ShelfImageCopyFailure.publicationUncertain(published) } + } +} + +@MainActor +private final class DirectShelfImageChooser: ShelfImageDestinationChoosing { + let destination: URL + init(destination: URL) { self.destination = destination } + func chooseDestination(for plan: ShelfImageCopyPlan, size: ShelfImageCopySize) async -> URL? { destination } + func cancel() {} +} + +nonisolated private final class DirectShelfStopSignal: @unchecked Sendable { + private let lock = NSLock() + private var value: Bool? + private var continuation: CheckedContinuation? + var isResolved: Bool { lock.withLock { value != nil } } + + func resolve(_ value: Bool = true) { + let waiting = lock.withLock { () -> CheckedContinuation? in + guard self.value == nil else { return nil } + self.value = value + defer { continuation = nil } + return continuation + } + waiting?.resume(returning: value) + } + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let existing = lock.withLock { () -> Bool? in + if let value { return value } + self.continuation = continuation + return nil + } + if let existing { continuation.resume(returning: existing) } + } + } onCancel: { + self.resolve(false) + } + } +} + +nonisolated private final class DirectShelfStopAccess: ShelfFileAccess, @unchecked Sendable { + private let lock = NSLock() + private var scopes = 0 + var active: Int { lock.withLock { scopes } } + func begin(_ url: URL) -> Bool { + lock.withLock { scopes += 1 } + return true + } + func end(_ url: URL) { lock.withLock { scopes -= 1 } } + func state(of url: URL) -> ShelfFileState { .available(isDirectory: false) } + func bookmark(for url: URL) throws -> Data { throw ShelfFailure.unsupported } + func resolve(_ bookmark: Data) throws -> URL { throw ShelfFailure.unsupported } +} + private actor DirectRuntimeHeldWorkspaceBackend: WorkspaceWindowBackend { private(set) var waiting = false private var released = false diff --git a/SemperTests/ShelfImageCopySessionTests.swift b/SemperTests/ShelfImageCopySessionTests.swift new file mode 100644 index 0000000..0db175c --- /dev/null +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -0,0 +1,799 @@ +import AppKit +import CoreFoundation +import CoreGraphics +import Foundation +import ImageIO +import Observation +import Testing +import UniformTypeIdentifiers + +@testable import Semper + +nonisolated private final class ImageSessionSignal: @unchecked Sendable { + private let lock = NSLock() + private var value: Bool? + private var continuation: CheckedContinuation? + var isResolved: Bool { lock.withLock { value != nil } } + + func resolve(_ value: Bool = true) { + let waiting = lock.withLock { () -> CheckedContinuation? in + guard self.value == nil else { return nil } + self.value = value + defer { continuation = nil } + return continuation + } + waiting?.resume(returning: value) + } + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let value = lock.withLock { () -> Bool? in + if let value = self.value { return value } + self.continuation = continuation + return nil + } + if let value { continuation.resume(returning: value) } + } + } onCancel: { + self.resolve(false) + } + } +} + +nonisolated private final class ImageSessionGate: @unchecked Sendable { + let entered = ImageSessionSignal() + private let condition = NSCondition() + private var released: Bool + private var cancelled = false + var observedCancellation: Bool { condition.withLock { cancelled } } + + init(held: Bool) { released = !held } + func wait() { + entered.resolve() + condition.lock() + while !released { condition.wait() } + cancelled = Task.isCancelled + condition.unlock() + } + func release() { + condition.withLock { + released = true + condition.broadcast() + } + } +} + +nonisolated private final class ImageSessionAccess: ShelfFileAccess, @unchecked Sendable { + private let lock = NSLock() + private var scopes: [URL: Int] = [:] + var balanced: Bool { lock.withLock { scopes.values.allSatisfy { $0 == 0 } } } + func active(_ url: URL) -> Int { lock.withLock { scopes[url, default: 0] } } + func begin(_ url: URL) -> Bool { + lock.withLock { scopes[url, default: 0] += 1 } + return true + } + func end(_ url: URL) { lock.withLock { scopes[url, default: 0] -= 1 } } + func state(of url: URL) -> ShelfFileState { .available(isDirectory: false) } + func bookmark(for url: URL) throws -> Data { Data(url.path.utf8) } + func resolve(_ bookmark: Data) throws -> URL { URL(fileURLWithPath: String(decoding: bookmark, as: UTF8.self)) } +} + +nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecked Sendable { + let inspection: ImageSessionGate + let writing: ImageSessionGate + let plan: ShelfImageCopyPlan + let temporary: ShelfImageTemporaryCopy + private let lock = NSLock() + private var writeCount = 0 + private var removed: [ShelfImageTemporaryCopy] = [] + private var cleanupFailure = false + private var recoveryFailure = true + private var recoveryCount = 0 + private var acknowledgementCount = 0 + let failWrite: Bool + let uncertainPublication: Bool + let recoveredOutput: URL + var writes: Int { lock.withLock { writeCount } } + var recoveries: Int { lock.withLock { recoveryCount } } + var acknowledgements: Int { lock.withLock { acknowledgementCount } } + var cleanupTokens: [ShelfImageTemporaryCopy] { lock.withLock { removed } } + var failCleanup: Bool { + get { lock.withLock { cleanupFailure } } + set { lock.withLock { cleanupFailure = newValue } } + } + var failRecovery: Bool { + get { lock.withLock { recoveryFailure } } + set { lock.withLock { recoveryFailure = newValue } } + } + + init( + plan: ShelfImageCopyPlan, root: URL, holdInspect: Bool, holdWrite: Bool, failWrite: Bool, + uncertainPublication: Bool + ) { + self.plan = plan + inspection = ImageSessionGate(held: holdInspect) + writing = ImageSessionGate(held: holdWrite) + self.failWrite = failWrite + self.uncertainPublication = uncertainPublication + recoveredOutput = root.appendingPathComponent("relocated-copy.png") + temporary = ShelfImageTemporaryCopy( + url: root.appendingPathComponent(".semper-image-copy-\(UUID()).tmp"), device: 7, inode: 11, + parentDevice: 7, parentInode: 13) + } + + func inspect(_ source: URL, access: any ShelfFileAccess) throws -> ShelfImageCopyPlan { + let scoped = access.begin(source) + defer { if scoped { access.end(source) } } + inspection.wait() + return plan + } + + func writeCopy(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, to destination: URL) throws + -> ShelfImageCopyReceipt + { + lock.withLock { writeCount += 1 } + writing.wait() + if failWrite { + try Data("owned stage".utf8).write(to: temporary.url) + throw ShelfImageCopyFailure.cleanupFailed(temporary) + } + if uncertainPublication { + try Data("published copy".utf8).write(to: recoveredOutput) + throw ShelfImageCopyFailure.publicationUncertain( + ShelfImagePublishedCopy(requestedURL: destination, dimensions: plan.outputDimensions(for: size))) + } + try Data("published copy".utf8).write(to: destination) + return ShelfImageCopyReceipt(url: destination, dimensions: plan.outputDimensions(for: size)) + } + + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt { + lock.withLock { recoveryCount += 1 } + if failRecovery { throw ShelfImageCopyFailure.publicationUncertain(published) } + return ShelfImageCopyReceipt(url: recoveredOutput, dimensions: published.dimensions) + } + + func acknowledgeUnverifiedCopy(_ published: ShelfImagePublishedCopy) throws { + lock.withLock { acknowledgementCount += 1 } + if failCleanup { throw ShelfImageCopyFailure.publicationUncertain(published) } + } + + func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { + lock.withLock { removed.append(temporary) } + guard temporary == self.temporary else { throw ShelfImageCopyFailure.invalidTemporaryCopy } + if failCleanup { throw ShelfImageCopyFailure.cleanupFailed(temporary) } + try FileManager.default.removeItem(at: temporary.url) + } +} + +@MainActor +private final class ImageSessionChooser: ShelfImageDestinationChoosing { + private(set) var calls = 0 + private(set) var cancellations = 0 + var destination: URL? + var held = false + var entered = ImageSessionSignal() + var release = ImageSessionSignal() + + func chooseDestination(for plan: ShelfImageCopyPlan, size: ShelfImageCopySize) async -> URL? { + calls += 1 + entered.resolve() + if held { + let release = release + let waiting = Task.detached { await release.wait() } + _ = await waiting.value + } + return destination + } + func cancel() { cancellations += 1 } + func reset(destination: URL?, held: Bool) { + self.destination = destination + self.held = held + entered = ImageSessionSignal() + release = ImageSessionSignal() + } +} + +@MainActor +private final class ImageSessionIdle { + let session: ShelfImageCopySession + let finished = ImageSessionSignal() + private var observing = true + init(_ session: ShelfImageCopySession) { self.session = session } + func wait() async -> Bool { + observe() + let value = await finished.wait() + observing = false + return value + } + private func observe() { + guard observing else { return } + let idle = withObservationTracking { + !session.isWorking + } onChange: { [weak self] in + Task { @MainActor [weak self] in self?.observe() } + } + if idle { finished.resolve() } + } +} + +nonisolated private final class ImageSessionClock: @unchecked Sendable { + private let lock = NSLock() + private var value = Date(timeIntervalSince1970: 1_800_000_000) + func now() -> Date { lock.withLock { value } } + func advance(_ seconds: TimeInterval) { lock.withLock { value.addTimeInterval(seconds) } } +} + +@MainActor +private final class ImageExpiryScheduler { + private(set) var delays: [TimeInterval] = [] + private var releases: [ImageSessionSignal] = [] + private var observers: [(Int, ImageSessionSignal)] = [] + + func wait(_ delay: TimeInterval) async throws { + try Task.checkCancellation() + let release = ImageSessionSignal() + delays.append(delay) + releases.append(release) + for (count, signal) in observers where delays.count >= count { signal.resolve() } + guard await release.wait(), !Task.isCancelled else { throw CancellationError() } + } + + func scheduled(_ count: Int) async -> Bool { + if delays.count >= count { return true } + let signal = ImageSessionSignal() + observers.append((count, signal)) + return await signal.wait() + } + + func fire(_ index: Int) { releases[index].resolve() } +} + +@Suite("Shelf image copy session", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct ShelfImageCopySessionTests { + private struct Fixture { + let root: URL + let source: URL + let output: URL + let access: ImageSessionAccess + let copier: ImageSessionCopier + let chooser: ImageSessionChooser + let session: ShelfImageCopySession + let service: ShelfService + } + + private func withFixture( + holdInspect: Bool = false, holdWrite: Bool = false, failWrite: Bool = false, + uncertainPublication: Bool = false, + now: @escaping @Sendable () -> Date = { Date() }, + waitForExpiry: @escaping @Sendable (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + }, + body: (Fixture) async throws -> Void + ) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("shelf-image-session-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) } + } + let source = root.appendingPathComponent("source.png") + let output = root.appendingPathComponent("copy.png") + let colorSpace = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let context = try #require( + CGContext( + data: nil, width: 16, height: 12, bitsPerComponent: 8, bytesPerRow: 64, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)) + context.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: 16, height: 12)) + let destination = try #require( + CGImageDestinationCreateWithURL(source as CFURL, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try #require(context.makeImage()), nil) + try #require(CGImageDestinationFinalize(destination)) + let access = ImageSessionAccess() + let plan = try NativeShelfImageCopier().inspect(source, access: access) + let copier = ImageSessionCopier( + plan: plan, root: root, holdInspect: holdInspect, holdWrite: holdWrite, failWrite: failWrite, + uncertainPublication: uncertainPublication) + let chooser = ImageSessionChooser() + chooser.destination = output + let session = ShelfImageCopySession(access: access, copier: copier, destinationChooser: chooser) + let service = ShelfService( + store: ShelfStore(root: root.appendingPathComponent("store")), + access: access, now: now, waitForExpiry: waitForExpiry, imageCopy: session) + service.start() + let fixture = Fixture( + root: root, source: source, output: output, access: access, + copier: copier, chooser: chooser, session: session, service: service) + let original = try Data(contentsOf: source) + do { try await body(fixture) } catch { + await finish(fixture) + throw error + } + await finish(fixture) + #expect(access.balanced) + #expect(try Data(contentsOf: source) == original) + } + + private func finish(_ f: Fixture) async { + f.copier.inspection.release() + f.copier.writing.release() + f.copier.failCleanup = false + f.copier.failRecovery = false + f.chooser.release.resolve() + await f.service.shutdown() + if f.session.needsReceiptAcknowledgement, let requestID = f.session.request?.id { + #expect(f.service.acknowledgeImageCopyReceipt(requestID: requestID)) + await f.service.shutdown() + } + if case .failure(let error) = await f.session.cancel() { Issue.record(error) } + } + + private func begin(_ f: Fixture) async throws -> ShelfImageCopyRequest { + let request = try #require(f.session.begin(itemID: UUID(), name: "source.png", source: f.source)) + try #require(await ImageSessionIdle(f.session).wait()) + try #require(f.session.plan != nil) + return request + } + + @Test("A held inspection owns the request and drains before cancellation returns") + func heldInspection() async throws { + try await withFixture(holdInspect: true) { f in + let request = try #require(f.session.begin(itemID: UUID(), name: "source.png", source: f.source)) + try #require(await f.copier.inspection.entered.wait()) + #expect(f.session.isActive && f.session.isWorking) + #expect(f.session.begin(itemID: UUID(), name: "second", source: f.source) == nil) + #expect(!f.session.save(size: .pixels1024)) + let entered = ImageSessionSignal() + let finished = ImageSessionSignal() + let cancellation = Task { @MainActor in + entered.resolve() + defer { finished.resolve() } + return await f.session.cancel(requestID: request.id) + } + try #require(await entered.wait()) + #expect(!finished.isResolved) + #expect(f.access.active(f.source) == 1) + f.copier.inspection.release() + try await cancellation.value.get() + #expect(f.copier.inspection.observedCancellation) + #expect(!f.session.isActive) + #expect(f.session.plan == nil && f.session.request == nil) + #expect(f.access.balanced) + } + } + + @Test("A cancelled held dialog drains and ignores a late selected URL") + func heldDialog() async throws { + try await withFixture { f in + _ = try await begin(f) + f.chooser.reset(destination: f.output, held: true) + try #require(f.session.save(size: .pixels1024)) + try #require(await f.chooser.entered.wait()) + f.session.stop() + let finished = ImageSessionSignal() + let cancelling = Task { @MainActor in + defer { finished.resolve() } + return await f.session.cancel() + } + #expect(f.session.isWorking) + #expect(f.chooser.cancellations > 0) + #expect(!finished.isResolved) + #expect(!f.session.save(size: .pixels2048)) + f.chooser.release.resolve() + try await cancelling.value.get() + #expect(f.copier.writes == 0) + #expect(f.session.receipt == nil && !f.session.isActive) + #expect(f.access.active(f.output) == 0) + } + } + + @Test("Save dialog cancellation is silent and preserves options for another save") + func dialogRetry() async throws { + try await withFixture { f in + let request = try await begin(f) + f.chooser.destination = nil + try #require(f.session.save(size: .pixels1024)) + #expect(!f.session.save(size: .pixels2048)) + try #require(await ImageSessionIdle(f.session).wait()) + #expect(f.session.request?.id == request.id && f.session.plan != nil) + #expect(f.session.message == nil && f.session.receipt == nil) + f.chooser.destination = f.output + try #require(f.session.save(size: .pixels2048)) + try #require(await ImageSessionIdle(f.session).wait()) + #expect(f.session.receipt?.url == f.output) + #expect(!f.session.save(size: .pixels1024)) + await ShelfImageCopyView(service: f.service, request: request).closeAction() + #expect(f.session.request == nil) + #expect(try Data(contentsOf: f.output) == Data("published copy".utf8)) + #expect(f.copier.cleanupTokens.isEmpty) + } + } + + @Test("A late successful write keeps its receipt and published file after cancellation") + func latePublication() async throws { + try await withFixture(holdWrite: true) { f in + _ = try await begin(f) + try #require(f.session.save(size: .pixels1024)) + try #require(await f.copier.writing.entered.wait()) + f.session.stop() + let entered = ImageSessionSignal() + let finished = ImageSessionSignal() + let cancelling = Task { @MainActor in + entered.resolve() + defer { finished.resolve() } + return await f.session.cancel() + } + try #require(await entered.wait()) + #expect(!finished.isResolved && f.session.isWorking) + #expect(f.access.active(f.output) == 1) + f.copier.writing.release() + try await cancelling.value.get() + #expect(f.copier.writing.observedCancellation) + #expect(f.session.receipt?.url == f.output) + #expect(!f.session.isActive && f.access.balanced) + try await f.session.cancel().get() + #expect(try Data(contentsOf: f.output) == Data("published copy".utf8)) + #expect(f.copier.cleanupTokens.isEmpty) + } + } + + @Test("Cancelled write cleanup retains its exact token and scope until explicit retry succeeds") + func cleanupRetry() async throws { + try await withFixture(holdWrite: true, failWrite: true) { f in + let request = try await begin(f) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await f.copier.writing.entered.wait()) + f.session.stop() + let cancelling = Task { @MainActor in await f.session.cancel() } + f.copier.writing.release() + if case .failure(let failure) = await cancelling.value { + #expect(failure == .storeWrite) + } else { + Issue.record("Cancellation succeeded despite a retained stage") + } + #expect(f.session.needsCleanup && f.session.isActive && !f.session.isWorking) + #expect(f.session.request?.id == request.id && f.session.message != nil) + #expect(f.session.begin(itemID: UUID(), name: "blocked", source: f.source) == nil) + #expect(f.access.active(f.output) == 1) + #expect(f.copier.cleanupTokens == [f.copier.temporary]) + #expect(FileManager.default.fileExists(atPath: f.copier.temporary.url.path)) + f.copier.failCleanup = false + try await f.session.cancel(requestID: request.id).get() + #expect(f.copier.cleanupTokens == [f.copier.temporary, f.copier.temporary]) + #expect(!f.session.isActive && !f.session.needsCleanup && f.access.balanced) + #expect(!FileManager.default.fileExists(atPath: f.copier.temporary.url.path)) + #expect(!FileManager.default.fileExists(atPath: f.output.path)) + } + } + + @Test("A stale request cannot close a newer session or its save dialog") + func staleCancellation() async throws { + try await withFixture { f in + let first = try await begin(f) + try await f.session.cancel(requestID: first.id).get() + let second = try await begin(f) + f.chooser.reset(destination: nil, held: true) + try #require(f.session.save(size: .pixels1024)) + try #require(await f.chooser.entered.wait()) + let cancellations = f.chooser.cancellations + try await f.session.cancel(requestID: first.id).get() + #expect(f.session.request?.id == second.id && f.session.isWorking) + #expect(f.chooser.cancellations == cancellations) + f.chooser.release.resolve() + try #require(await ImageSessionIdle(f.session).wait()) + } + } + + @Test("Uncertain publication retains its scope and recovers the actual URL without publishing again") + func publicationRecovery() async throws { + try await withFixture(uncertainPublication: true) { f in + let request = try await begin(f) + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + #expect(f.session.needsCleanup && f.session.receipt == nil) + #expect(f.session.message?.contains("A copy was created") == true) + #expect(f.access.active(f.output) == 1) + #expect(!f.session.save(size: .pixels2048)) + if case .failure(let failure) = await f.session.cancel(requestID: request.id) { + #expect(failure == .storeWrite) + } else { + Issue.record("Unverified publication was treated as recovered") + } + #expect(f.session.request?.id == request.id && f.session.needsCleanup) + #expect(f.session.receipt == nil && f.access.active(f.output) == 1) + #expect(f.copier.writes == 1 && f.copier.recoveries == 1) + f.copier.failRecovery = false + if case .failure(let failure) = await f.session.cancel(requestID: request.id) { + #expect(failure == .recoveredCopyNeedsAcknowledgement) + } else { + Issue.record("Recovery dismissed its saved location before acknowledgement") + } + #expect(f.session.receipt?.url == f.copier.recoveredOutput) + #expect(f.session.isActive && !f.session.needsCleanup && f.access.balanced) + #expect(f.session.request?.id == request.id && f.session.needsReceiptAcknowledgement) + #expect(!f.session.save(size: .pixels2048)) + #expect(f.session.begin(itemID: UUID(), name: "next", source: f.source) == nil) + #expect(!f.service.acknowledgeImageCopyReceipt(requestID: UUID())) + _ = await f.service.cancelImageCopy(requestID: request.id, retryCleanup: false) + #expect(f.session.request?.id == request.id && f.session.receipt?.url == f.copier.recoveredOutput) + #expect(f.copier.writes == 1 && f.copier.recoveries == 2) + #expect(f.service.acknowledgeImageCopyReceipt(requestID: request.id)) + #expect(!f.session.isActive && !f.session.needsReceiptAcknowledgement) + #expect(f.session.receipt?.url == f.copier.recoveredOutput) + #expect(f.copier.cleanupTokens.isEmpty) + #expect(!FileManager.default.fileExists(atPath: f.output.path)) + #expect(try Data(contentsOf: f.copier.recoveredOutput) == Data("published copy".utf8)) + } + } + + @Test("Previously rendered Cancel and Retry Cleanup actions cannot acknowledge a recovered receipt") + func renderedCloseActionsRetainRecoveredReceipt() async throws { + try await withFixture(uncertainPublication: true) { f in + let request = try await begin(f) + let view = ShelfImageCopyView(service: f.service, request: request) + let cancel = view.closeAction + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + try #require(f.session.needsCleanup && f.session.receipt == nil) + let retryCleanup = view.closeAction + f.copier.failRecovery = false + _ = await f.service.cancelImageCopy(requestID: request.id) + try #require(f.session.needsReceiptAcknowledgement) + for action in [cancel, retryCleanup] { + await action() + #expect(f.session.request?.id == request.id && f.session.needsReceiptAcknowledgement) + #expect(f.session.receipt?.url == f.copier.recoveredOutput) + #expect(f.copier.writes == 1 && f.copier.recoveries == 1 && f.copier.acknowledgements == 0) + } + let done = view.closeAction + await done() + #expect(f.session.request == nil && !f.session.needsReceiptAcknowledgement) + #expect(f.session.receipt?.url == f.copier.recoveredOutput) + } + } + + @Test("Unverified acknowledgement requires private cleanup and creates no saved receipt", arguments: [false, true]) + func unverifiedPublicationAcknowledgement(deleted: Bool) async throws { + try await withFixture(uncertainPublication: true) { f in + let request = try await begin(f) + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + let changed = Data("user edited the fixture copy".utf8) + if deleted { + try FileManager.default.removeItem(at: f.copier.recoveredOutput) + } else { + try changed.write(to: f.copier.recoveredOutput) + } + _ = await f.session.cancel(requestID: request.id) + #expect(f.session.hasUnverifiedPublishedCopy && f.session.receipt == nil) + f.copier.failCleanup = true + if case .success = await f.service.acknowledgeUnverifiedImageCopy(requestID: request.id) { + Issue.record("Unverified acknowledgement discarded failed private cleanup") + } + #expect(f.session.request?.id == request.id && f.session.hasUnverifiedPublishedCopy) + #expect(f.session.receipt == nil && f.access.active(f.output) == 1) + _ = await f.service.cancelImageCopy(requestID: request.id, retryCleanup: false) + #expect(f.copier.acknowledgements == 1 && f.copier.recoveries == 1) + f.copier.failCleanup = false + try await f.service.acknowledgeUnverifiedImageCopy(requestID: request.id).get() + #expect(!f.session.isActive && !f.session.needsReceiptAcknowledgement) + #expect(f.session.receipt == nil && f.access.balanced) + #expect(f.copier.writes == 1 && f.copier.acknowledgements == 2 && f.copier.recoveries == 1) + if deleted { + #expect(!FileManager.default.fileExists(atPath: f.copier.recoveredOutput.path)) + } else { + #expect(try Data(contentsOf: f.copier.recoveredOutput) == changed) + } + } + } + + nonisolated enum StopAction: CaseIterable, Sendable { case clear, pause, remove, shutdown } + nonisolated enum RetainedStopAction: CaseIterable, Sendable { case pause, shutdown } + + @Test( + "Failed image cleanup retains Shelf state until a stop retry succeeds", arguments: RetainedStopAction.allCases) + func serviceRetainsFailedStop(action: RetainedStopAction) async throws { + try await withFixture(failWrite: true) { f in + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + let request = try #require(f.service.prepareImageCopy(item)) + try #require(await ImageSessionIdle(f.session).wait()) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + let result = action == .pause ? await f.service.pause() : await f.service.shutdown() + if case .failure(let failure) = result { + #expect(failure == .storeWrite) + } else { + Issue.record("Stop succeeded despite retained image cleanup") + } + #expect(f.service.stopFailure == .storeWrite) + #expect(f.service.isRunning && !f.service.isStopping) + #expect(f.service.items.map(\.id) == [item.id]) + #expect(f.access.active(f.source) == 1 && f.access.active(f.output) == 1) + #expect(f.session.needsCleanup && f.copier.cleanupTokens.count == 1) + if case .success = await f.service.cancelImageCopy(requestID: request.id, retryCleanup: false) { + Issue.record("Sheet dismissal cleared failed stop recovery") + } + #expect(f.session.needsCleanup && f.copier.cleanupTokens.count == 1) + f.copier.failCleanup = false + let retry = action == .pause ? await f.service.pause() : await f.service.shutdown() + try retry.get() + #expect(f.service.stopFailure == nil && !f.service.isRunning) + #expect(f.access.balanced && !f.session.needsCleanup) + #expect(f.service.items.count == (action == .pause ? 1 : 0)) + #expect(f.copier.cleanupTokens.count == 2) + } + } + + @Test("Shelf lifecycle waits for held inspection before releasing the source", arguments: StopAction.allCases) + func serviceDrain(action: StopAction) async throws { + try await withFixture(holdInspect: true) { f in + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + try #require(f.service.canResizeImage(item)) + try #require(f.service.prepareImageCopy(item) != nil) + try #require(await f.copier.inspection.entered.wait()) + #expect(!f.service.canChooseFiles && !f.service.chooseFiles()) + #expect(!f.service.importDrops([NSItemProvider()])) + let entered = ImageSessionSignal() + let finished = ImageSessionSignal() + let stopping = Task { @MainActor in + entered.resolve() + defer { finished.resolve() } + switch action { + case .clear: return await f.service.clear() + case .pause: await f.service.pause() + case .remove: await f.service.remove(item.id) + case .shutdown: await f.service.shutdown() + } + return Result.success(()) + } + try #require(await entered.wait()) + #expect(!finished.isResolved) + #expect(f.access.active(f.source) == 2) + f.copier.inspection.release() + try await stopping.value.get() + #expect(f.copier.inspection.observedCancellation) + #expect(f.session.plan == nil && !f.session.isActive) + #expect(f.access.active(f.source) == 0) + } + } + + @Test("Shelf clear reports pending image cleanup and retries before removing source references") + func serviceCleanupRetry() async throws { + try await withFixture(failWrite: true) { f in + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + try #require(f.service.prepareImageCopy(item) != nil) + try #require(await ImageSessionIdle(f.session).wait()) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await f.copier.writing.entered.wait()) + if case .failure(let failure) = await f.service.clear() { + #expect(failure == .storeWrite) + } else { + Issue.record("Clear succeeded despite a retained stage") + } + #expect(f.service.items.map(\.id) == [item.id]) + #expect(f.service.canClear && f.session.needsCleanup) + #expect(f.access.active(f.source) == 1) + f.copier.failCleanup = false + try await f.service.clear().get() + #expect(f.service.items.isEmpty && !f.session.needsCleanup) + #expect(f.access.balanced) + } + } + + @Test("Shelf clear retains a successfully published user copy") + func serviceKeepsPublishedCopy() async throws { + try await withFixture { f in + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + try #require(f.service.prepareImageCopy(item) != nil) + try #require(await ImageSessionIdle(f.session).wait()) + try #require(f.session.save(size: .pixels2048)) + try #require(await ImageSessionIdle(f.session).wait()) + #expect(f.session.receipt?.url == f.output) + try await f.service.clear().get() + #expect(f.service.items.isEmpty && !f.session.isActive) + #expect(f.session.receipt?.url == f.output) + #expect(try Data(contentsOf: f.output) == Data("published copy".utf8)) + #expect(f.copier.cleanupTokens.isEmpty) + } + } + + @Test("Failed image cleanup waits for explicit retry while unrelated items continue to expire") + func failedCleanupSuspendsOnlyItsExpiry() async throws { + let clock = ImageSessionClock() + let scheduler = ImageExpiryScheduler() + try await withFixture(failWrite: true, now: clock.now, waitForExpiry: scheduler.wait) { + f in + f.service.setDefaultExpiry(.fifteenMinutes) + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + f.service.setDefaultExpiry(.oneHour) + try f.service.addText("unrelated expiring item") + try #require(await scheduler.scheduled(1)) + #expect(scheduler.delays == [900]) + let request = try #require(f.service.prepareImageCopy(item)) + try #require(await ImageSessionIdle(f.session).wait()) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + clock.advance(900) + scheduler.fire(0) + try #require(await scheduler.scheduled(2)) + #expect(f.copier.cleanupTokens == [f.copier.temporary]) + #expect(scheduler.delays == [900, 2700]) + #expect(f.session.needsCleanup && f.service.items.count == 2) + clock.advance(2700) + scheduler.fire(1) + while f.service.items.count == 2, !Task.isCancelled { await Task.yield() } + try #require(f.service.items.map(\.id) == [item.id]) + await f.service.expireItems() + #expect(f.copier.cleanupTokens.count == 1) + #expect(scheduler.delays.count == 2) + f.copier.failCleanup = false + try await f.service.cancelImageCopy(requestID: request.id).get() + try #require(await scheduler.scheduled(3)) + #expect(scheduler.delays == [900, 2700, 0]) + scheduler.fire(2) + while !f.service.items.isEmpty, !Task.isCancelled { await Task.yield() } + #expect(f.service.items.isEmpty) + #expect(f.copier.cleanupTokens.count == 2) + #expect(!f.session.needsCleanup && f.access.balanced) + } + } + + @Test("A stale cancellation cannot release a newer image request's expiry block") + func staleCancellationPreservesExpiryBlock() async throws { + let clock = ImageSessionClock() + let scheduler = ImageExpiryScheduler() + try await withFixture(failWrite: true, now: clock.now, waitForExpiry: scheduler.wait) { + f in + f.service.setDefaultExpiry(.fifteenMinutes) + try f.service.addFile(f.source) + let item = try #require(f.service.items.first) + try #require(await scheduler.scheduled(1)) + let first = try #require(f.service.prepareImageCopy(item)) + try #require(await ImageSessionIdle(f.session).wait()) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + clock.advance(900) + await f.service.expireItems() + #expect(f.copier.cleanupTokens.count == 1) + f.copier.failCleanup = false + try await f.service.cancelImageCopy(requestID: first.id).get() + try #require(await scheduler.scheduled(2)) + + let second = try #require(f.service.prepareImageCopy(item)) + try #require(await ImageSessionIdle(f.session).wait()) + f.copier.failCleanup = true + try #require(f.session.save(size: .pixels1024)) + try #require(await ImageSessionIdle(f.session).wait()) + if case .success = await f.service.cancelImageCopy(requestID: second.id) { + Issue.record("New request cleanup succeeded despite the fixture refusal") + } + #expect(f.copier.cleanupTokens.count == 3) + try await f.service.cancelImageCopy(requestID: first.id).get() + await f.service.expireItems() + #expect(f.session.request?.id == second.id && f.session.needsCleanup) + #expect(f.copier.cleanupTokens.count == 3) + #expect(f.service.items.map(\.id) == [item.id]) + #expect(scheduler.delays == [900, 0]) + f.copier.failCleanup = false + try await f.service.cancelImageCopy(requestID: second.id).get() + try #require(await scheduler.scheduled(3)) + scheduler.fire(2) + while !f.service.items.isEmpty, !Task.isCancelled { await Task.yield() } + #expect(f.service.items.isEmpty && f.access.balanced) + #expect(f.copier.cleanupTokens.count == 4) + } + } + +} diff --git a/SemperTests/ShelfImageCopyTests.swift b/SemperTests/ShelfImageCopyTests.swift new file mode 100644 index 0000000..604da9d --- /dev/null +++ b/SemperTests/ShelfImageCopyTests.swift @@ -0,0 +1,915 @@ +import CoreFoundation +import CoreGraphics +import CryptoKit +import Darwin +import Foundation +import ImageIO +import Synchronization +import Testing +import UniformTypeIdentifiers + +@testable import Semper + +nonisolated private final class ShelfImageAccessSpy: ShelfFileAccess, @unchecked Sendable { + private let lock = NSLock() + private var opened = 0 + private var closed = 0 + let fileState: ShelfFileState + let cancelOnState: Bool + var balanced: Bool { lock.withLock { opened == closed } } + var beginCount: Int { lock.withLock { opened } } + + init(state: ShelfFileState = .available(isDirectory: false), cancelOnState: Bool = false) { + fileState = state + self.cancelOnState = cancelOnState + } + func begin(_ url: URL) -> Bool { + lock.withLock { opened += 1 } + return true + } + func end(_ url: URL) { lock.withLock { closed += 1 } } + func state(of url: URL) -> ShelfFileState { + if cancelOnState { withUnsafeCurrentTask { $0?.cancel() } } + return fileState + } + func bookmark(for url: URL) throws -> Data { throw ShelfFailure.unsupported } + func resolve(_ bookmark: Data) throws -> URL { throw ShelfFailure.unsupported } +} + +@Suite("Shelf image copies", .serialized, .timeLimit(.minutes(1))) +struct ShelfImageCopyTests { + private struct Fixture { + let root: URL + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("shelf-image-copy-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + func url(_ name: String) -> URL { root.appendingPathComponent(name) } + func remove() { + do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) } + } + } + + private func image( + width: Int, height: Int, colorSpace: CFString = CGColorSpace.sRGB, alpha: Bool = false + ) throws -> CGImage { + let colors: [[UInt8]] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]] + let opacity: [UInt8] = alpha ? [255, 128, 0, 192] : [255, 255, 255, 255] + var bytes = [UInt8](repeating: 0, count: width * height * 4) + for y in 0..= height / 2 ? 2 : 0) + (x >= width / 2 ? 1 : 0) + let offset = (y * width + x) * 4 + bytes[offset] = colors[quadrant][0] + bytes[offset + 1] = colors[quadrant][1] + bytes[offset + 2] = colors[quadrant][2] + bytes[offset + 3] = opacity[quadrant] + } + } + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let space = try #require(CGColorSpace(name: colorSpace)) + return try #require( + CGImage( + width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: width * 4, + space: space, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)) + } + + private func encode( + _ image: CGImage, to url: URL, type: UTType, orientation: Int = 1, privateMetadata: Bool = false + ) throws { + let destination = try #require( + CGImageDestinationCreateWithURL(url as CFURL, type.identifier as CFString, 1, nil)) + var properties: [CFString: Any] = [ + kCGImagePropertyOrientation: orientation, + kCGImageDestinationLossyCompressionQuality: 1.0, + ] + if privateMetadata { + properties[kCGImagePropertyExifDictionary] = [ + kCGImagePropertyExifDateTimeOriginal: "2001:02:03 04:05:06", + kCGImagePropertyExifUserComment: "PRIVATE-EXIF-MARKER", + kCGImagePropertyExifMakerNote: Data("PRIVATE-MAKER-MARKER".utf8), + ] + properties[kCGImagePropertyGPSDictionary] = [ + kCGImagePropertyGPSLatitude: 37.25, kCGImagePropertyGPSLatitudeRef: "N", + kCGImagePropertyGPSLongitude: 122.5, kCGImagePropertyGPSLongitudeRef: "W", + ] + properties[kCGImagePropertyIPTCDictionary] = [kCGImagePropertyIPTCCaptionAbstract: "PRIVATE-IPTC-MARKER"] + properties[kCGImagePropertyTIFFDictionary] = [kCGImagePropertyTIFFArtist: "PRIVATE-ARTIST-MARKER"] + if type == .png { + properties[kCGImagePropertyPNGDictionary] = [ + kCGImagePropertyPNGAuthor: "PRIVATE-PNG-MARKER", + kCGImagePropertyPNGTitle: "PRIVATE-TITLE-MARKER", + kCGImagePropertyPNGDescription: "PRIVATE-TEXT-MARKER", + ] + } + } + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + try #require(CGImageDestinationFinalize(destination)) + } + + private func source(_ url: URL) throws -> CGImageSource { + try #require(CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary)) + } + private func decoded(_ url: URL) throws -> CGImage { + try #require(CGImageSourceCreateImageAtIndex(try source(url), 0, nil)) + } + private func properties(_ url: URL) throws -> [CFString: Any] { + try #require(CGImageSourceCopyPropertiesAtIndex(try source(url), 0, nil) as? [CFString: Any]) + } + private func digest(_ url: URL) throws -> Data { Data(SHA256.hash(data: try Data(contentsOf: url))) } + + private func pixel(_ image: CGImage, x: Double, y: Double) throws -> [UInt8] { + let crop = try #require( + image.cropping( + to: CGRect( + x: Int(Double(image.width) * x), y: Int(Double(image.height) * y), width: 1, height: 1))) + var bytes = [UInt8](repeating: 0, count: 4) + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + try bytes.withUnsafeMutableBytes { buffer in + let context = try #require( + CGContext( + data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)) + context.setBlendMode(.copy) + context.draw(crop, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + } + return bytes + } + + private func corners(_ image: CGImage) throws -> [[UInt8]] { + try [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8)].map { try pixel(image, x: $0.0, y: $0.1) } + } + + private func bigEndian(_ number: UInt32) -> Data { + Data([ + UInt8(truncatingIfNeeded: number >> 24), UInt8(truncatingIfNeeded: number >> 16), + UInt8(truncatingIfNeeded: number >> 8), UInt8(truncatingIfNeeded: number), + ]) + } + + private func pngChunk(_ name: String, payload: Data) -> Data { + let body = Data(name.utf8) + payload + var crc: UInt32 = 0xFFFF_FFFF + for byte in body { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = crc & 1 == 0 ? crc >> 1 : (crc >> 1) ^ 0xEDB8_8320 } + } + return bigEndian(UInt32(payload.count)) + body + bigEndian(crc ^ 0xFFFF_FFFF) + } + + @Test( + "Both formats honor both longest-edge limits, odd aspect ratios, and no enlargement", + arguments: [ShelfImageCopyFormat.jpeg, .png], [ShelfImageCopySize.pixels1024, .pixels2048]) + func dimensions(format: ShelfImageCopyFormat, size: ShelfImageCopySize) throws { + let f = try Fixture() + defer { f.remove() } + let copier = NativeShelfImageCopier() + let access = ShelfImageAccessSpy() + for (width, height) in [(2303, 1301), (317, 113)] { + let input = f.url("source-\(width).dat") + try encode(try image(width: width, height: height), to: input, type: format == .jpeg ? .jpeg : .png) + let original = try digest(input) + let plan = try copier.inspect(input, access: access) + #expect(plan.sourceURL == input) + #expect(plan.format == format) + #expect(plan.dimensions == ShelfImageDimensions(width: width, height: height)) + let expected = plan.outputDimensions(for: size) + let edge = min(width, size.rawValue) + #expect(expected.width == edge) + #expect(abs(Double(expected.height) - Double(height) * Double(edge) / Double(width)) <= 1) + let suggested = plan.suggestedFilename(for: size) + #expect((suggested as NSString).pathExtension == format.fileExtension) + #expect(!suggested.contains("/")) + let output = f.url(suggested) + let receipt = try copier.writeCopy(plan, size: size, to: output) + #expect(receipt.url == output) + #expect(receipt.dimensions == expected) + let actual = try decoded(output) + #expect(actual.width == expected.width && actual.height == expected.height) + #expect( + CGImageSourceGetType(try source(output)) as String? == (format == .jpeg ? UTType.jpeg : .png).identifier + ) + #expect(try digest(input) == original) + } + #expect(access.balanced) + } + + @Test("Every EXIF orientation preserves displayed corner positions", arguments: Array(1...8)) + func orientations(orientation: Int) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("oriented.jpg") + try encode(try image(width: 80, height: 48), to: input, type: .jpeg, orientation: orientation) + #expect(try properties(input)[kCGImagePropertyOrientation] as? Int == orientation) + let original = try digest(input) + let rawCorners = try corners(decoded(input)) + let mappings = [ + [0, 1, 2, 3], [1, 0, 3, 2], [3, 2, 1, 0], [2, 3, 0, 1], + [0, 2, 1, 3], [2, 0, 3, 1], [3, 1, 2, 0], [1, 3, 0, 2], + ] + let copier = NativeShelfImageCopier() + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let expected = orientation >= 5 ? ShelfImageDimensions(width: 48, height: 80) : .init(width: 80, height: 48) + #expect(plan.dimensions == expected) + let output = f.url("copy.jpg") + let receipt = try copier.writeCopy(plan, size: .pixels1024, to: output) + #expect(receipt.dimensions == expected) + let actual = try decoded(output) + #expect(actual.width == expected.width && actual.height == expected.height) + let actualCorners = try corners(actual) + for index in 0..<4 { + let expectedPixel = rawCorners[mappings[orientation - 1][index]] + for channel in 0..<3 { + #expect(abs(Int(actualCorners[index][channel]) - Int(expectedPixel[channel])) <= 20) + } + } + let outputOrientation = try properties(output)[kCGImagePropertyOrientation] as? Int + #expect(outputOrientation == nil || outputOrientation == 1) + #expect(try digest(input) == original) + } + + @Test( + "Tagged color profiles and PNG transparency survive fresh encoding", + arguments: [ShelfImageCopyFormat.jpeg, .png], ["sRGB", "Display P3"]) + func profilesAndAlpha(format: ShelfImageCopyFormat, profile: String) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("tagged.\(format.fileExtension)") + try encode( + try image( + width: 80, height: 48, colorSpace: profile == "sRGB" ? CGColorSpace.sRGB : CGColorSpace.displayP3, + alpha: format == .png), to: input, type: format == .jpeg ? .jpeg : .png) + let sourceSpace = try #require(try decoded(input).colorSpace) + let sourceICC = try #require(sourceSpace.copyICCData()) as Data + let copier = NativeShelfImageCopier() + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.\(format.fileExtension)") + _ = try copier.writeCopy(plan, size: .pixels2048, to: output) + let result = try decoded(output) + let outputSpace = try #require(result.colorSpace) + #expect(try #require(outputSpace.copyICCData()) as Data == sourceICC) + if format == .png { + let sourcePixels = try corners(decoded(input)) + let outputPixels = try corners(result) + #expect(sourcePixels.map { $0[3] } == [255, 128, 0, 192]) + #expect(outputPixels.map { $0[3] } == sourcePixels.map { $0[3] }) + } + } + + @Test("Fresh output omits private metadata and XMP", arguments: [ShelfImageCopyFormat.jpeg, .png]) + func metadata(format: ShelfImageCopyFormat) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("private.\(format.fileExtension)") + try encode( + try image(width: 80, height: 48), to: input, type: format == .jpeg ? .jpeg : .png, privateMetadata: true) + let packet = Data( + ("" + + "").utf8) + var encoded = try Data(contentsOf: input) + if format == .jpeg { + let payload = Data("http://ns.adobe.com/xap/1.0/\0".utf8) + packet + let length = payload.count + 2 + encoded.insert(contentsOf: Data([0xFF, 0xE1, UInt8(length >> 8), UInt8(length & 255)]) + payload, at: 2) + } else { + let payload = Data("XML:com.adobe.xmp\0\0\0\0\0".utf8) + packet + encoded.insert(contentsOf: pngChunk("iTXt", payload: payload), at: 33) + } + try encoded.write(to: input) + let inputProperties = try properties(input) + try #require(String(describing: inputProperties).contains("PRIVATE-")) + if format == .jpeg { + try #require(inputProperties[kCGImagePropertyGPSDictionary] != nil) + try #require(inputProperties[kCGImagePropertyIPTCDictionary] != nil) + let inputExif = try #require(inputProperties[kCGImagePropertyExifDictionary] as? [CFString: Any]) + try #require(inputExif[kCGImagePropertyExifDateTimeOriginal] != nil) + try #require(inputExif[kCGImagePropertyExifUserComment] != nil) + } else { + let inputPNG = try #require(inputProperties[kCGImagePropertyPNGDictionary] as? [CFString: Any]) + try #require(inputPNG[kCGImagePropertyPNGAuthor] != nil) + } + try #require(encoded.range(of: Data("PRIVATE-XMP-MARKER".utf8)) != nil) + let original = try digest(input) + let copier = NativeShelfImageCopier() + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.\(format.fileExtension)") + _ = try copier.writeCopy(plan, size: .pixels1024, to: output) + let values = try properties(output) + #expect(!String(describing: values).contains("PRIVATE-")) + #expect(try Data(contentsOf: output).range(of: Data("PRIVATE-".utf8)) == nil) + #expect(values[kCGImagePropertyGPSDictionary] == nil) + #expect(values[kCGImagePropertyIPTCDictionary] == nil) + #expect(values[kCGImagePropertyExifDictionary] == nil) + let tags = CGImageSourceCopyMetadataAtIndex(try source(output), 0, nil).flatMap { CGImageMetadataCopyTags($0) } + #expect(tags.map { CFArrayGetCount($0) } ?? 0 == 0) + let exif = values[kCGImagePropertyExifDictionary] as? [CFString: Any] + for key in [ + kCGImagePropertyExifDateTimeOriginal, kCGImagePropertyExifUserComment, kCGImagePropertyExifMakerNote, + ] { + #expect(exif?[key] == nil) + } + #expect(try digest(input) == original) + } + + @Test("Existing destinations, the source, and source aliases are never replaced") + func destinationRefusal() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 32, height: 24), to: input, type: .png) + let original = try digest(input) + let existing = f.url("existing.png") + let marker = Data("existing destination".utf8) + try marker.write(to: existing) + let symlink = f.url("alias.png") + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: input) + let hardlink = f.url("hardlink.png") + try FileManager.default.linkItem(at: input, to: hardlink) + let copier = NativeShelfImageCopier() + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let before = try FileManager.default.contentsOfDirectory(atPath: f.root.path).sorted() + for destination in [existing, input, symlink, hardlink] { + #expect(throws: ShelfImageCopyFailure.self) { + try copier.writeCopy(plan, size: .pixels1024, to: destination) + } + #expect(try digest(input) == original) + #expect(try Data(contentsOf: existing) == marker) + #expect(try FileManager.default.contentsOfDirectory(atPath: f.root.path).sorted() == before) + } + #expect(throws: ShelfImageCopyFailure.invalidDestination) { + try copier.writeCopy(plan, size: .pixels1024, to: f.url("invalid\0.png")) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: f.root.path).sorted() == before) + } + + @Test("Unsupported, corrupt, animated PNG, and excessive encoded input are rejected") + func rejectedInputs() throws { + let f = try Fixture() + defer { f.remove() } + let copier = NativeShelfImageCopier() + let access = ShelfImageAccessSpy() + let gif = f.url("static.gif") + try encode(try image(width: 16, height: 12), to: gif, type: .gif) + let corrupt = f.url("broken.png") + try Data("not an image".utf8).write(to: corrupt) + let png = f.url("static.png") + try encode(try image(width: 16, height: 12), to: png, type: .png) + let apng = f.url("single-frame.png") + var animated = try Data(contentsOf: png) + let control = + bigEndian(0) + bigEndian(16) + bigEndian(12) + bigEndian(0) + bigEndian(0) + + Data([0, 1, 0, 10, 0, 0]) + animated.insert( + contentsOf: + pngChunk("acTL", payload: bigEndian(1) + bigEndian(0)) + pngChunk("fcTL", payload: control), at: 33) + try animated.write(to: apng) + #expect(CGImageSourceGetCount(try source(apng)) == 1) + let oversized = f.url("large.png") + try Data(contentsOf: png).write(to: oversized) + let handle = try FileHandle(forWritingTo: oversized) + try handle.truncate(atOffset: UInt64(ShelfLimits.importBytes + 1)) + try handle.close() + for input in [gif, corrupt, apng, oversized] { + #expect(throws: ShelfImageCopyFailure.self) { try copier.inspect(input, access: access) } + } + #expect(access.balanced) + } + + @Test("Oversized dimensions are rejected from small encoded headers", arguments: [(16385, 1), (8192, 8192)]) + func oversizedDimensions(dimensions: (Int, Int)) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("dimensions.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + var data = try Data(contentsOf: input) + var header = Data(data[16..<29]) + header.replaceSubrange(0..<8, with: bigEndian(UInt32(dimensions.0)) + bigEndian(UInt32(dimensions.1))) + data.replaceSubrange(8..<33, with: pngChunk("IHDR", payload: header)) + try data.write(to: input) + #expect(data[16..<20] == bigEndian(UInt32(dimensions.0))) + #expect(data[20..<24] == bigEndian(UInt32(dimensions.1))) + #expect(data.count < 4096) + #expect(throws: ShelfImageCopyFailure.tooLarge) { + try NativeShelfImageCopier().inspect(input, access: ShelfImageAccessSpy()) + } + } + + @Test( + "Unavailable files are refused without retaining access", + arguments: [ + ShelfFileState.inaccessible, .missing, .cloudOnly, .available(isDirectory: true), + ]) + func unavailable(state: ShelfFileState) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let access = ShelfImageAccessSpy(state: state) + switch state { + case .inaccessible: + #expect(throws: ShelfFailure.inaccessible) { try NativeShelfImageCopier().inspect(input, access: access) } + case .missing: + #expect(throws: ShelfFailure.missing) { try NativeShelfImageCopier().inspect(input, access: access) } + case .cloudOnly: + #expect(throws: ShelfFailure.cloudOnly) { try NativeShelfImageCopier().inspect(input, access: access) } + case .available: + #expect(throws: ShelfImageCopyFailure.unsupported) { + try NativeShelfImageCopier().inspect(input, access: access) + } + } + #expect(access.balanced) + } + + @MainActor + @Test("Cancellation before inspection or writing leaves files and access untouched") + func cancellationBeforeWork() async throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let original = try digest(input) + let access = ShelfImageAccessSpy() + let copier = NativeShelfImageCopier() + let inspection = Task { @MainActor in try copier.inspect(input, access: access) } + inspection.cancel() + switch await inspection.result { + case .success: Issue.record("Cancelled inspection returned a plan") + case .failure(let error): #expect(error as? ShelfFailure == .cancelled) + } + #expect(access.beginCount == 0) + let plan = try copier.inspect(input, access: access) + let output = f.url("copy.png") + let before = try FileManager.default.contentsOfDirectory(atPath: f.root.path).sorted() + let writing = Task { @MainActor in try copier.writeCopy(plan, size: .pixels1024, to: output) } + writing.cancel() + switch await writing.result { + case .success: Issue.record("Cancelled write returned a receipt") + case .failure(let error): #expect(error as? ShelfFailure == .cancelled) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + #expect(try FileManager.default.contentsOfDirectory(atPath: f.root.path).sorted() == before) + #expect(try digest(input) == original) + #expect(access.balanced) + } + + @Test("Cancellation after scope acquisition closes access and preserves the original") + func cancellationAfterScopeAcquisition() async throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let original = try digest(input) + let access = ShelfImageAccessSpy(cancelOnState: true) + let operation = Task.detached { try NativeShelfImageCopier().inspect(input, access: access) } + switch await operation.result { + case .success: Issue.record("Cancelled inspection returned a plan") + case .failure(let error): #expect(error as? ShelfFailure == .cancelled) + } + #expect(access.beginCount == 1) + #expect(access.balanced) + #expect(try digest(input) == original) + } + + private func fileOperations(_ root: URL) -> ShelfImageFileOperations { + var operations = ShelfImageFileOperations.native + operations.makePrivateDirectory = { _ in + let directory = root.appendingPathComponent("private-\(UUID())") + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + return directory + } + return operations + } + + private func ownedFile(_ root: URL, operations: ShelfImageFileOperations? = nil) throws -> ShelfImageFileOwner { + let owner = try ShelfImageFileOwner( + destination: root.appendingPathComponent("copy.png"), + operations: operations ?? fileOperations(root)) + let file = FileHandle(fileDescriptor: owner.stageDescriptor, closeOnDealloc: false) + try file.write(contentsOf: Data("owned temporary image".utf8)) + return owner + } + + @Test("Cleanup retains failures and refuses a replacement at the recorded temporary path") + func cleanupRetainsIdentity() throws { + let f = try Fixture() + defer { f.remove() } + let owner = try ownedFile(f.root) + let token = owner.temporaryCopy + let temporary = token.url + let retained = temporary.deletingLastPathComponent().appendingPathComponent("retained.tmp") + let original = try Data(contentsOf: temporary) + let copier = NativeShelfImageCopier() + try FileManager.default.moveItem(at: temporary, to: retained) + let replacement = Data("replacement file".utf8) + try replacement.write(to: temporary) + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + #expect(try Data(contentsOf: temporary) == replacement) + #expect(try Data(contentsOf: retained) == original) + try FileManager.default.removeItem(at: temporary) + try FileManager.default.moveItem(at: retained, to: temporary) + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: temporary.path) + defer { + if FileManager.default.fileExists(atPath: temporary.path) { + do { try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: temporary.path) } catch { + Issue.record(error) + } + } + } + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + #expect(try Data(contentsOf: temporary) == original) + try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: temporary.path) + try copier.removeTemporaryCopy(token) + #expect(!FileManager.default.fileExists(atPath: temporary.path)) + try copier.removeTemporaryCopy(token) + } + + @Test("Cleanup retains a renamed or replaced private parent until it returns", arguments: [false, true]) + func cleanupRetainsUnavailableParent(replaced: Bool) throws { + let f = try Fixture() + defer { f.remove() } + let owner = try ownedFile(f.root) + let token = owner.temporaryCopy + let parent = token.url.deletingLastPathComponent() + let moved = f.url("moved-private-directory") + let original = try Data(contentsOf: token.url) + let copier = NativeShelfImageCopier() + try FileManager.default.moveItem(at: parent, to: moved) + if replaced { try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) } + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + #expect(try Data(contentsOf: moved.appendingPathComponent(token.url.lastPathComponent)) == original) + if replaced { + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + try FileManager.default.removeItem(at: parent) + } + try FileManager.default.moveItem(at: moved, to: parent) + try copier.removeTemporaryCopy(token) + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + } + + @Test("Cleanup accepts a missing child in its available original private parent") + func cleanupAcceptsMissingChild() throws { + let f = try Fixture() + defer { f.remove() } + let owner = try ownedFile(f.root) + let token = owner.temporaryCopy + try FileManager.default.removeItem(at: token.url) + try NativeShelfImageCopier().removeTemporaryCopy(token) + #expect(try FileManager.default.contentsOfDirectory(atPath: token.url.deletingLastPathComponent().path).isEmpty) + } + + @Test("A blocked mismatch restore retains one claim and never deletes replacement bytes") + func blockedClaimRestore() throws { + let f = try Fixture() + defer { f.remove() } + let injected = Mutex(false) + var operations = fileOperations(f.root) + operations.checkpoint = { checkpoint, context in + if checkpoint == .beforeRestore, + injected.withLock({ value in + if value { return false } + value = true + return true + }) + { + try Data("occupied restore".utf8).write(to: context.stage) + } + } + let owner = try ownedFile(f.root, operations: operations) + let token = owner.temporaryCopy + let stage = token.url + let directory = stage.deletingLastPathComponent() + let retained = directory.appendingPathComponent("retained.tmp") + try FileManager.default.moveItem(at: stage, to: retained) + let replacement = Data("replacement bytes".utf8) + try replacement.write(to: stage) + let copier = NativeShelfImageCopier() + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + let claim = directory.appendingPathComponent("cleanup-claim.tmp") + #expect(try Data(contentsOf: claim) == replacement) + #expect(try Data(contentsOf: stage) == Data("occupied restore".utf8)) + let names = try FileManager.default.contentsOfDirectory(atPath: directory.path).sorted() + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).sorted() == names) + #expect(token.recoveryLocations.contains(claim.resolvingSymlinksInPath())) + try FileManager.default.removeItem(at: stage) + #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } + #expect(try Data(contentsOf: stage) == replacement) + try FileManager.default.removeItem(at: stage) + try FileManager.default.moveItem(at: retained, to: stage) + try copier.removeTemporaryCopy(token) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + + @Test("Descriptor publication preserves the verified image when its staging name changes") + func descriptorPublicationIgnoresStageReplacement() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let calls = Mutex(0) + let context = Mutex(nil) + var operations = fileOperations(f.root) + operations.clone = { source, directory, name in + calls.withLock { $0 += 1 } + try ShelfImageFileOperations.native.clone(source, directory, name) + } + operations.checkpoint = { checkpoint, value in + if checkpoint == .beforePublication { + context.withLock { $0 = value } + try FileManager.default.moveItem( + at: value.stage, + to: value.stage.deletingLastPathComponent().appendingPathComponent("retained.tmp")) + try Data("replacement bytes".utf8).write(to: value.stage) + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + var recovery: ShelfImagePublishedCopy? + do { + _ = try copier.writeCopy(plan, size: .pixels1024, to: output) + Issue.record("Expected retained cleanup") + } catch ShelfImageCopyFailure.publicationUncertain(let token) { recovery = token } + let token = try #require(recovery) + #expect(try decoded(output).width == 16) + let paths = try #require(context.withLock { $0 }) + #expect(try Data(contentsOf: paths.stage) == Data("replacement bytes".utf8)) + #expect( + token.recoveryLocations.contains( + paths.stage.deletingLastPathComponent().appendingPathComponent("retained.tmp").standardizedFileURL)) + try FileManager.default.removeItem(at: paths.stage) + try FileManager.default.moveItem( + at: paths.stage.deletingLastPathComponent().appendingPathComponent("retained.tmp"), + to: paths.stage) + let receipt = try copier.recoverPublishedCopy(token) + #expect(receipt.url == output) + #expect(calls.withLock { $0 } == 1) + } + + @Test("Unsupported cloning creates no destination and cleans the private image") + func cloneRefusal() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let context = Mutex(nil) + var operations = fileOperations(f.root) + operations.clone = { _, _, _ in throw ShelfImageCopyFailure.cloningUnsupported } + operations.checkpoint = { checkpoint, value in + if checkpoint == .beforePublication { context.withLock { $0 = value } } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + #expect(throws: ShelfImageCopyFailure.cloningUnsupported) { + try copier.writeCopy(plan, size: .pixels1024, to: output) + } + #expect(!FileManager.default.fileExists(atPath: output.path)) + let paths = try #require(context.withLock { $0 }) + #expect( + try FileManager.default.contentsOfDirectory(atPath: paths.stage.deletingLastPathComponent().path).isEmpty) + } + + @Test("A changed destination parent is refused before publication") + func changedParentBeforePublication() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let parent = f.url("destination") + let moved = f.url("moved") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) + var operations = fileOperations(f.root) + operations.checkpoint = { checkpoint, _ in + if checkpoint == .beforePublication { + try FileManager.default.moveItem(at: parent, to: moved) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + #expect(throws: ShelfImageCopyFailure.destinationChanged) { + try copier.writeCopy(plan, size: .pixels1024, to: parent.appendingPathComponent("copy.png")) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: moved.path).isEmpty) + } + + @Test("A moved destination produces its verified actual URL and preserves the replacement folder") + func movedParentReceipt() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let parent = f.url("destination") + let moved = f.url("moved") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) + var operations = fileOperations(f.root) + operations.checkpoint = { checkpoint, _ in + if checkpoint == .afterPublication { + try FileManager.default.moveItem(at: parent, to: moved) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) + try Data("replacement destination".utf8).write(to: parent.appendingPathComponent("copy.png")) + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let receipt = try copier.writeCopy(plan, size: .pixels1024, to: parent.appendingPathComponent("copy.png")) + #expect(receipt.url == moved.appendingPathComponent("copy.png")) + #expect(try decoded(receipt.url).width == 16) + #expect(try Data(contentsOf: parent.appendingPathComponent("copy.png")) == Data("replacement destination".utf8)) + } + + @Test("Unknown published location recovers without cloning or deleting the published copy") + func uncertainPublicationRecovers() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let failPath = Mutex(true) + let clones = Mutex(0) + var operations = fileOperations(f.root) + operations.path = { descriptor in + if failPath.withLock({ $0 }) { throw ShelfImageCopyFailure.destinationChanged } + return try ShelfImageFileOperations.native.path(descriptor) + } + operations.clone = { source, parent, name in + clones.withLock { $0 += 1 } + try ShelfImageFileOperations.native.clone(source, parent, name) + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + var recovery: ShelfImagePublishedCopy? + do { + _ = try copier.writeCopy(plan, size: .pixels1024, to: output) + Issue.record("Expected location recovery") + } catch ShelfImageCopyFailure.publicationUncertain(let token) { recovery = token } + let token = try #require(recovery) + let original = try digest(output) + #expect(throws: ShelfImageCopyFailure.publicationUncertain(token)) { try copier.recoverPublishedCopy(token) } + #expect(try digest(output) == original) + failPath.withLock { $0 = false } + #expect(try copier.recoverPublishedCopy(token).url == output) + #expect(clones.withLock { $0 } == 1) + #expect(try digest(output) == original) + let moved = f.url("moved-copy.png") + try FileManager.default.moveItem(at: output, to: moved) + #expect(try copier.recoverPublishedCopy(token).url == moved) + #expect(clones.withLock { $0 } == 1) + #expect(try digest(moved) == original) + } + + @Test("Cancellation at the publication boundary cleans staging without creating a copy") + func cancellationAtPublication() async throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let original = try digest(input) + let context = Mutex(nil) + let clones = Mutex(0) + var operations = fileOperations(f.root) + operations.clone = { _, _, _ in clones.withLock { $0 += 1 } } + operations.checkpoint = { checkpoint, value in + if checkpoint == .beforePublication { + context.withLock { $0 = value } + withUnsafeCurrentTask { $0?.cancel() } + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + let task = Task.detached { try copier.writeCopy(plan, size: .pixels1024, to: output) } + do { + _ = try await task.value + Issue.record("Expected cancelled publication") + } catch ShelfFailure.cancelled {} + #expect(clones.withLock { $0 } == 0) + #expect(!FileManager.default.fileExists(atPath: output.path)) + #expect(try digest(input) == original) + let paths = try #require(context.withLock { $0 }) + #expect( + try FileManager.default.contentsOfDirectory(atPath: paths.stage.deletingLastPathComponent().path).isEmpty) + } + @Test("An initial identity failure stops writing and cleans only through the retained descriptor") + func initialIdentityFailure() throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let context = Mutex(nil) + var operations = fileOperations(f.root) + operations.checkpoint = { checkpoint, value in + if checkpoint == .beforeStageIdentity { + context.withLock { $0 = value } + throw ShelfImageCopyFailure.writeFailed + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + #expect(throws: ShelfImageCopyFailure.writeFailed) { try copier.writeCopy(plan, size: .pixels1024, to: output) } + #expect(!FileManager.default.fileExists(atPath: output.path)) + let paths = try #require(context.withLock { $0 }) + #expect( + try FileManager.default.contentsOfDirectory(atPath: paths.stage.deletingLastPathComponent().path).isEmpty) + } + + @Test( + "Edited or deleted published copies require private cleanup before acknowledgement", + arguments: [false, true], [false, true]) + func acknowledgeChangedPublication(deleted: Bool, blockedCleanup: Bool) throws { + let f = try Fixture() + defer { f.remove() } + let input = f.url("source.png") + try encode(try image(width: 16, height: 12), to: input, type: .png) + let original = try digest(input) + let context = Mutex(nil) + let clones = Mutex(0) + let pathReads = Mutex(0) + var operations = fileOperations(f.root) + operations.clone = { source, parent, name in + clones.withLock { $0 += 1 } + try ShelfImageFileOperations.native.clone(source, parent, name) + } + operations.path = { descriptor in + pathReads.withLock { $0 += 1 } + return try ShelfImageFileOperations.native.path(descriptor) + } + operations.checkpoint = { checkpoint, value in + if checkpoint == .afterPublication { + context.withLock { $0 = value } + throw ShelfImageCopyFailure.destinationChanged + } + } + let copier = NativeShelfImageCopier(fileOperations: operations) + let plan = try copier.inspect(input, access: ShelfImageAccessSpy()) + let output = f.url("copy.png") + var recovery: ShelfImagePublishedCopy? + do { + _ = try copier.writeCopy(plan, size: .pixels1024, to: output) + Issue.record("Expected uncertain publication") + } catch ShelfImageCopyFailure.publicationUncertain(let token) { recovery = token } + let token = try #require(recovery) + let paths = try #require(context.withLock { $0 }) + let privateDirectory = paths.stage.deletingLastPathComponent() + let privateBytes = try Data(contentsOf: paths.stage) + let edited = Data("user edited this published copy".utf8) + if deleted { try FileManager.default.removeItem(at: output) } else { try edited.write(to: output) } + #expect(throws: ShelfImageCopyFailure.publicationUncertain(token)) { try copier.recoverPublishedCopy(token) } + let readsBeforeAcknowledgement = pathReads.withLock { $0 } + if blockedCleanup { + let moved = f.url("retained-private-directory") + try FileManager.default.moveItem(at: privateDirectory, to: moved) + #expect(throws: ShelfImageCopyFailure.publicationUncertain(token)) { + try copier.acknowledgeUnverifiedCopy(token) + } + #expect(try Data(contentsOf: moved.appendingPathComponent(paths.stage.lastPathComponent)) == privateBytes) + #expect(pathReads.withLock { $0 } == readsBeforeAcknowledgement) + #expect(clones.withLock { $0 } == 1) + if deleted { + #expect(!FileManager.default.fileExists(atPath: output.path)) + } else { + #expect(try Data(contentsOf: output) == edited) + } + try FileManager.default.moveItem(at: moved, to: privateDirectory) + } + try copier.acknowledgeUnverifiedCopy(token) + #expect(try FileManager.default.contentsOfDirectory(atPath: privateDirectory.path).isEmpty) + try copier.acknowledgeUnverifiedCopy(token) + #expect(pathReads.withLock { $0 } == readsBeforeAcknowledgement) + #expect(clones.withLock { $0 } == 1) + #expect(try digest(input) == original) + if deleted { + #expect(!FileManager.default.fileExists(atPath: output.path)) + } else { + #expect(try Data(contentsOf: output) == edited) + } + } + + @Test("Acknowledgement refuses an owner that has not published a copy") + func acknowledgementRequiresPublication() throws { + let f = try Fixture() + defer { f.remove() } + let owner = try ownedFile(f.root) + let temporary = owner.temporaryCopy + let bytes = try Data(contentsOf: temporary.url) + let published = ShelfImagePublishedCopy( + requestedURL: f.url("copy.png"), + dimensions: ShelfImageDimensions(width: 16, height: 12), owner: owner) + let copier = NativeShelfImageCopier() + #expect(throws: ShelfImageCopyFailure.publicationUncertain(published)) { + try copier.acknowledgeUnverifiedCopy(published) + } + #expect(try Data(contentsOf: temporary.url) == bytes) + try copier.removeTemporaryCopy(temporary) + } + +} diff --git a/SemperTests/ShelfTests.swift b/SemperTests/ShelfTests.swift index 3fe5a5f..5895458 100644 --- a/SemperTests/ShelfTests.swift +++ b/SemperTests/ShelfTests.swift @@ -507,9 +507,11 @@ struct ShelfTests { try service.addFile(fixture.file) let item = try #require(service.items.first) service.checksum(item.id) - async let first: Void = service.pause() - async let second: Void = service.shutdown() - _ = await (first, second) + async let first = service.pause() + async let second = service.shutdown() + let results = await (first, second) + try results.0.get() + try results.1.get() #expect(!service.isRunning) #expect(!service.isStopping) #expect(service.items.isEmpty) diff --git a/guide/product-status.md b/guide/product-status.md index a0d8932..200e2c1 100644 --- a/guide/product-status.md +++ b/guide/product-status.md @@ -1,14 +1,14 @@ # Semper product status -Semper's source in this change set contains ten utility modules. This page +Semper has ten utility modules integrated on `main`. This page records what each module does, where it stands, and what remains before release. It changes in the same commit as the work that changes a status. -Snapshot: baseline `main` at `6afe10d`, 2026-09-09, including the interaction -fixes from [PR #108](https://github.com/niharnm/Semper/pull/108). Cleared Window -Layout source `0f25f65` is staged with that baseline at `e621a11`; its integration -requires this full change set to merge into `main`. Latest downloadable release: -v1.0.0, published 2026-08-26, containing Sound only. +Snapshot: baseline `main` at `f3e278d`, 2026-09-09, including Window Layout and +website alignment from [PR #110](https://github.com/niharnm/Semper/pull/110). +Cleared Resize a Copy source `2db9a4a` is staged with that baseline at `a575a87`; +its integration requires this full change set to merge into `main`. Latest +downloadable release: v1.0.0, published 2026-08-26, containing Sound only. ## States @@ -29,7 +29,7 @@ v1.0.0, published 2026-08-26, containing Sound only. | Awake | Keep the Mac awake for a chosen duration, with app and battery stop conditions | Integrated | Shared gates, plus assertion, expiry, and stop-condition checks on hardware | [Source](../Semper/Awake), [guide](awake-sessions.md) | | Displays | Read and set supported external display brightness, contrast, volume, and input | Integrated | Shared gates, plus DDC checks on real displays | [Source](../Semper/Displays), [guide](module-shell.md#displays) | | Workspace Restore | Return selected app windows to a saved arrangement | Integrated | Shared gates, plus Accessibility permission flows, multi-display, and Spaces checks | [Source](../Semper/Workspace), [guide](direct-utilities.md) | -| Window Layout | Place one eligible window or restore its preceding placement | Implemented in this change set | Source merge and shared gates; full-height/auto-hide limits, focus, shortcuts, constrained windows and recovery need native verification | [Source](../Semper/WindowLayout), [guide](window-layout.md) | +| Window Layout | Place one eligible window or restore its preceding placement | Integrated | Shared gates; full-height/auto-hide limits, focus, shortcuts, constrained windows and recovery need native verification | [Source](../Semper/WindowLayout), [guide](window-layout.md) | | File Shelf | Hold temporary files, links, images, and text between apps | Integrated | Shared gates, plus drop-source, missing-file, and persistence checks | [Source](../Semper/Shelf), [guide](direct-utilities.md) | | Safe Eject | Review removable volumes to eject and check each observed result | Integrated | Shared gates, plus disposable-drive single and batch eject checks | [Source](../Semper/Storage), [guide](direct-utilities.md) | | Scenes | Save and apply settings across utilities together, with a restore point | Integrated | Shared gates, plus capture, apply, and recovery checks on hardware | [Source](../Semper/Scenes), [guide](module-shell.md) | @@ -48,20 +48,19 @@ the shared release gates remain separate. ## Next increments -This proposed feature is outside both the staged source and baseline `main` -snapshots above and is not released. +This feature extends File Shelf in the staged source snapshot. It is outside +the baseline `main` snapshot and is not released. It adds no new module. -| Increment | State | Acceptance before integration | +| Increment | State | Remaining acceptance | | --- | --- | --- | -| File Shelf Resize a Copy | In review, [PR #109](https://github.com/niharnm/Semper/pull/109) | Correct pending lifecycle/expiry, cleanup ownership and saved-path findings; verify the final implementation before integration and native acceptance | +| File Shelf Resize a Copy | Implemented in this change set, [PR #109](https://github.com/niharnm/Semper/pull/109) | Combined verification and source merge; native Save, keyboard/VoiceOver, cancellation, recovery and destination compatibility remain separate release gates | ## Window Layout -Cleared source `0f25f65` adds left half, right half, maximize, center and +Source `0f25f65` adds left half, right half, maximize, center and previous-placement restore with optional shortcuts and Home/search/pinned -actions. It is included in the staged source snapshot and becomes the tenth -integrated module when this change set merges into `main`. The baseline main -snapshot contains nine modules. +actions. It is integrated through PR #110 at `f3e278d`; both the baseline main +and staged source snapshots contain ten modules. Full-height current windows and targets are refused even for ordinary windowed apps. Halves and maximize can therefore be unavailable when both the Dock and @@ -77,6 +76,34 @@ Passing source tests does not establish native focus, keyboard, VoiceOver, permission, real-window or hardware acceptance. See the [Window Layout guide](window-layout.md). +## File Shelf Resize a Copy + +Cleared source `2db9a4a` is included in this change set. Select one fully +downloaded local JPEG or PNG, review dimensions for a longest edge of 1,024 or +2,048 pixels, then save a separate copy. Images are not enlarged. The source +format, displayed orientation, color profile and PNG transparency are kept; +JPEG re-encoding can lose detail. Descriptive metadata, including camera and +location data, is removed. Information visible in the pixels remains. + +Inputs are limited to 32 MiB, 40 million pixels and 16,384 pixels per side. +Animated, unsupported, corrupt, unavailable or larger images are refused. +The original is untouched and existing destinations are never overwritten. +Saving requires a destination filesystem that supports macOS file cloning; +unsupported locations are refused. There are no uploads, cloud downloads or +batch operations. + +Failed cleanup retains the affected items and file access for explicit retry. +Recovery keeps the verified saved path visible until Done; pending Pause or +Quit waits for that acknowledgement. If edited or deleted output cannot be +verified, Finish Without Verification ends tracking only after private cleanup. +It does not delete or republish the public copy. Native image calls may finish +before cancellation, and no fixed peak memory bound is claimed. + +Source clearance does not establish native Save-dialog focus, keyboard, +VoiceOver, cancellation, recovery, provider/volume or signed-build acceptance. +See the [image-copy guide](shelf-image-copy.md) for cleanup ownership limits and +the remaining checks. + ## Shared release gates Integrated modules pass automated tests that use injected fixtures. Those tests diff --git a/guide/shelf-image-copy.md b/guide/shelf-image-copy.md new file mode 100644 index 0000000..20f5c76 --- /dev/null +++ b/guide/shelf-image-copy.md @@ -0,0 +1,19 @@ +# Resize a Copy + +In File Shelf's full view, use **Resize a Copy…** beneath a local file. JPEG and PNG content is checked before options appear. Choose a longest edge of 1,024 or 2,048 pixels, review the exact output dimensions, then select **Save Copy…** and choose a new filename in the macOS Save dialog. + +Images are never enlarged. The copy keeps its source format, displayed orientation, color profile, and PNG transparency. JPEG output is re-encoded at a fixed quality setting and can lose detail even when its dimensions do not change. The original file is never modified. An existing destination is refused, including the source itself; select a different name to retry. + +Camera, location, and descriptive metadata are removed, including source EXIF, GPS, IPTC, XMP, camera notes, and PNG text. Format information needed to render the new image, including its color profile, remains. This does not remove information visible in the pixels. + +Resize a Copy accepts one fully downloaded local JPEG or PNG of at most 32 MiB, 40 million pixels, and 16,384 pixels per side. Animated, unsupported, corrupt, unavailable, or larger inputs are refused. It does not download cloud files, watch folders, process batches, upload images, or save a resize history. + +Cancel in the Save dialog returns to the size options. Cancel in the size options, Clear Shelf, Pause, source removal or expiry, and Quit cancel and drain owned work. A native image operation may need to finish before cancellation can complete. A successfully published copy belongs to you and is never removed by clearing the shelf. + +If cleanup fails, Shelf retains the affected items and file access. Use **Retry Cleanup** in the image options or the File Shelf recovery view. Recovery remains available after a failed Pause or Quit. An affected source waits for that retry before expiring; other shelf items can still expire. If a copy was created but its location or cleanup cannot be verified, Shelf says so and blocks another save. Retry recovers the existing copy without creating another one. Any paths shown during recovery are locations to check, not a confirmed saved-file receipt. A successfully recovered copy's verified path stays visible until you select **Done**. Pause or Quit remains pending until that acknowledgement. + +If you edited or deleted the created copy and verification cannot succeed, **Finish Without Verification** ends tracking without a saved-file confirmation. This action leaves the public copy as it is and still requires cleanup of verified private temporary files. If that cleanup fails, Shelf retains recovery and file access so you can retry. + +Saving requires a destination filesystem that supports macOS file cloning. Unsupported locations are refused. Temporary image data lives in a private directory on that volume. Cleanup retains objects whose ownership cannot be verified. An empty system-managed replacement directory may remain after cleanup; it contains no image data. Cleanup assumes this operation exclusively manages entries in its private directory. Another process running as the same user can still change those entries; the directory is not a security boundary against that process. + +The implementation uses a bounded source snapshot and bounded output, verifies the encoded image before publishing it, and refuses to overwrite an existing path. ImageIO does not promise a fixed peak memory limit or interruption inside every native call. Native Save-dialog focus, cancellation, and signed-build behavior require the quality task's separate acceptance pass. diff --git a/website/about.html b/website/about.html index e7b097a..2cfe214 100644 --- a/website/about.html +++ b/website/about.html @@ -102,7 +102,7 @@

Semper for Mac.