From 9e9212f3fabee96eafb8d7a893da057370b3f8e9 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:32:35 -0700 Subject: [PATCH 1/5] Add Resize a Copy to File Shelf --- Semper/Shelf/ShelfImageCopy.swift | 610 +++++++++++++++++++ Semper/Shelf/ShelfImageCopySession.swift | 217 +++++++ Semper/Shelf/ShelfImageCopyView.swift | 86 +++ Semper/Shelf/ShelfService.swift | 41 +- Semper/Shelf/ShelfViews.swift | 20 +- SemperTests/ShelfImageCopySessionTests.swift | 491 +++++++++++++++ SemperTests/ShelfImageCopyTests.swift | 507 +++++++++++++++ guide/shelf-image-copy.md | 13 + 8 files changed, 1981 insertions(+), 4 deletions(-) create mode 100644 Semper/Shelf/ShelfImageCopy.swift create mode 100644 Semper/Shelf/ShelfImageCopySession.swift create mode 100644 Semper/Shelf/ShelfImageCopyView.swift create mode 100644 SemperTests/ShelfImageCopySessionTests.swift create mode 100644 SemperTests/ShelfImageCopyTests.swift create mode 100644 guide/shelf-image-copy.md diff --git a/Semper/Shelf/ShelfImageCopy.swift b/Semper/Shelf/ShelfImageCopy.swift new file mode 100644 index 0000000..6c96377 --- /dev/null +++ b/Semper/Shelf/ShelfImageCopy.swift @@ -0,0 +1,610 @@ +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 struct ShelfImageTemporaryCopy: Equatable, Sendable { + let url: URL + let device: Int32 + let inode: UInt64 +} + +nonisolated enum ShelfImageCopyFailure: Error, Equatable, LocalizedError, Sendable { + case unsupported, animated, invalidImage, tooLarge, changedSource, colorProfile, transparency + case destinationExists, invalidDestination, writeFailed, verificationFailed, invalidTemporaryCopy + case cleanupFailed(ShelfImageTemporaryCopy) + + 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: "A temporary image could not be removed. Retry cleanup before resizing another image." + } + } +} + +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 +} + +nonisolated struct NativeShelfImageCopier: ShelfImageCopying { + private static let temporaryPrefix = ".semper-image-copy-" + private static let temporarySuffix = ".tmp" + + 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 directory = destination.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL + let destinationName = destination.lastPathComponent + guard !destinationName.isEmpty, !destinationName.contains("\0"), destinationName != ".", destinationName != ".." + else { throw ShelfImageCopyFailure.invalidDestination } + let directoryFD = directory.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + } + guard directoryFD >= 0 else { throw ShelfImageCopyFailure.invalidDestination } + defer { close(directoryFD) } + var existing = stat() + let exists = fstatat(directoryFD, destinationName, &existing, AT_SYMLINK_NOFOLLOW) + guard exists != 0 else { throw ShelfImageCopyFailure.destinationExists } + guard errno == ENOENT else { throw ShelfImageCopyFailure.invalidDestination } + let name = Self.temporaryPrefix + UUID().uuidString + Self.temporarySuffix + let temporary = directory.appendingPathComponent(name) + let descriptor = openat(directoryFD, name, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode_t(0o600)) + guard descriptor >= 0 else { throw ShelfImageCopyFailure.writeFailed } + defer { close(descriptor) } + var created = stat() + guard fstat(descriptor, &created) == 0 else { + throw ShelfImageCopyFailure.cleanupFailed(.init(url: temporary, device: 0, inode: 0)) + } + let owned = ShelfImageTemporaryCopy(url: temporary, device: created.st_dev, inode: created.st_ino) + do { + 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() + guard try Self.verifyTemporaryIdentity(owned, directoryFD: directoryFD, name: name) else { + throw ShelfImageCopyFailure.writeFailed + } + guard renameatx_np(directoryFD, name, directoryFD, destinationName, UInt32(RENAME_EXCL)) == 0 else { + throw errno == EEXIST ? ShelfImageCopyFailure.destinationExists : ShelfImageCopyFailure.writeFailed + } + return ShelfImageCopyReceipt(url: destination, dimensions: plan.outputDimensions(for: size)) + } catch { + do { + if try Self.verifyTemporaryIdentity(owned, directoryFD: directoryFD, name: name), + unlinkat(directoryFD, name, 0) != 0, errno != ENOENT + { + throw ShelfImageCopyFailure.cleanupFailed(owned) + } + } catch { + throw ShelfImageCopyFailure.cleanupFailed(owned) + } + throw error + } + } + + func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { + let url = temporary.url + let name = url.lastPathComponent + guard url.isFileURL, name.hasPrefix(Self.temporaryPrefix), name.hasSuffix(Self.temporarySuffix), + UUID(uuidString: String(name.dropFirst(Self.temporaryPrefix.count).dropLast(Self.temporarySuffix.count))) + != nil + else { throw ShelfImageCopyFailure.invalidTemporaryCopy } + var info = stat() + let result = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return lstat(path, &info) + } + if result != 0 { + if errno == ENOENT { return } + throw ShelfImageCopyFailure.cleanupFailed(temporary) + } + guard temporary.inode != 0, info.st_mode & S_IFMT == S_IFREG, info.st_nlink == 1, + info.st_dev == temporary.device, info.st_ino == temporary.inode + else { + throw ShelfImageCopyFailure.cleanupFailed(temporary) + } + guard url.withUnsafeFileSystemRepresentation({ path in path.map { unlink($0) } ?? -1 }) == 0 else { + throw ShelfImageCopyFailure.cleanupFailed(temporary) + } + } + + @discardableResult + private static func verifyTemporaryIdentity(_ temporary: ShelfImageTemporaryCopy, directoryFD: Int32, name: String) + throws -> Bool + { + var current = stat() + guard fstatat(directoryFD, name, ¤t, AT_SYMLINK_NOFOLLOW) == 0 else { + if errno == ENOENT { return false } + throw ShelfImageCopyFailure.cleanupFailed(temporary) + } + guard temporary.inode != 0, current.st_mode & S_IFMT == S_IFREG, current.st_nlink == 1, + current.st_dev == temporary.device, current.st_ino == temporary.inode + else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } + return true + } + + 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..b9dd78c --- /dev/null +++ b/Semper/Shelf/ShelfImageCopySession.swift @@ -0,0 +1,217 @@ +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? + + var isActive: Bool { request != nil || isWorking || needsCleanup } + var needsCleanup: Bool { pendingCleanup != nil } + + private struct PendingCleanup { + let stage: ShelfImageTemporaryCopy + 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 + let result = await self.retryCleanup() + if case .success = result { + self.activeID = nil + self.request = nil + self.plan = nil + self.message = nil + self.cancellationRequested = false + } + self.cancellation = nil + self.isWorking = false + return result + } + self.cancellation = cancellation + currentWork?.cancel() + destinationChooser.cancel() + isWorking = true + return await cancellation.value + } + + 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, case .cleanupFailed(let stage) = failure { + pendingCleanup = PendingCleanup(stage: stage, 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 stage = pendingCleanup.stage + let worker = Task.detached(priority: .utility) { try copier.removeTemporaryCopy(stage) } + switch await worker.result { + case .success: + if pendingCleanup.scoped { access.end(pendingCleanup.destination) } + self.pendingCleanup = nil + return .success(()) + case .failure: + message = ShelfImageCopyFailure.cleanupFailed(stage).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..b70f79f --- /dev/null +++ b/Semper/Shelf/ShelfImageCopyView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct ShelfImageCleanupView: View { + let session: ShelfImageCopySession + + var body: some View { + if session.needsCleanup { + HStack(alignment: .top) { + Label("A temporary resized image still needs cleanup.", systemImage: "exclamationmark.circle") + .foregroundStyle(.orange) + Spacer() + Button("Retry Cleanup") { + guard session.needsCleanup, let requestID = session.request?.id else { return } + Task { await session.cancel(requestID: requestID) } + } + .disabled(session.isWorking) + }.font(.callout) + } + } +} + +struct ShelfImageCopyView: View { + let session: ShelfImageCopySession + let request: ShelfImageCopyRequest + @State private var size: ShelfImageCopySize = .pixels1024 + + var body: some View { + 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) + 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 let message = session.message { + Label(message, systemImage: "exclamationmark.circle") + .font(.callout).foregroundStyle(.orange).textSelection(.enabled) + } + HStack { + Spacer() + Button(session.receipt == nil ? "Cancel" : "Done") { + Task { await session.cancel(requestID: request.id) } + } + .keyboardShortcut(.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/ShelfService.swift b/Semper/Shelf/ShelfService.swift index af98d7f..8f13162 100644 --- a/Semper/Shelf/ShelfService.swift +++ b/Semper/Shelf/ShelfService.swift @@ -27,14 +27,18 @@ 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 var loaded = false @@ -56,6 +60,7 @@ final class ShelfService { store: ShelfStore = .standard, access: any ShelfFileAccess = NativeShelfFileAccess(), now: @escaping @Sendable () -> Date = { Date() }, fileChooser: any ShelfFileChoosing = NativeShelfFileChooser(), + imageCopy: ShelfImageCopySession? = nil, importer: @escaping @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload = ShelfDropImporter.load ) { @@ -63,6 +68,7 @@ final class ShelfService { self.access = access self.now = now self.fileChooser = fileChooser + self.imageCopy = imageCopy ?? ShelfImageCopySession(access: access) self.importer = importer } @@ -102,6 +108,7 @@ final class ShelfService { await stopTask.value return } + imageCopy.stop() isStopping = true isRunning = false generation += 1 @@ -239,8 +246,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 imageCopy.cancel(requestID: imageRequest.id) { + report(failure) + return + } let worker = hashTasks[id] worker?.cancel() if worker != nil { checksums[id] = .cancelled } @@ -264,6 +277,7 @@ final class ShelfService { if let clearTask { return await clearTask.value } + imageCopy.stop() generation += 1 clearGeneration += 1 isClearing = true @@ -307,6 +321,7 @@ final class ShelfService { 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 } @@ -355,6 +370,19 @@ final class ShelfService { checksums[id] = .cancelled } + 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 +441,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 +611,9 @@ final class ShelfService { } private func cancelWork() async -> Result { + imageCopy.stop() let importCleanup = await cancelImports() + let imageCleanup = await imageCopy.cancel() let workers = hashTasks for (id, task) in workers { task.cancel() @@ -589,7 +623,8 @@ final class ShelfService { _ = await task.result hashTasks[id] = nil } - return importCleanup + if case .failure = importCleanup { return importCleanup } + return imageCleanup } private func reconcileImportCleanup() { diff --git a/Semper/Shelf/ShelfViews.swift b/Semper/Shelf/ShelfViews.swift index 8b9a60e..777e355 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(session: service.imageCopy) 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(session: service.imageCopy) 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(session: service.imageCopy, request: request) + .onDisappear { Task { await service.imageCopy.cancel(requestID: request.id) } } + } + .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/ShelfImageCopySessionTests.swift b/SemperTests/ShelfImageCopySessionTests.swift new file mode 100644 index 0000000..c734e22 --- /dev/null +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -0,0 +1,491 @@ +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 { 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 + let failWrite: Bool + var writes: Int { lock.withLock { writeCount } } + var cleanupTokens: [ShelfImageTemporaryCopy] { lock.withLock { removed } } + var failCleanup: Bool { + get { lock.withLock { cleanupFailure } } + set { lock.withLock { cleanupFailure = newValue } } + } + + init(plan: ShelfImageCopyPlan, root: URL, holdInspect: Bool, holdWrite: Bool, failWrite: Bool) { + self.plan = plan + inspection = ImageSessionGate(held: holdInspect) + writing = ImageSessionGate(held: holdWrite) + self.failWrite = failWrite + temporary = ShelfImageTemporaryCopy( + url: root.appendingPathComponent(".semper-image-copy-\(UUID()).tmp"), device: 7, inode: 11) + } + + 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) + } + try Data("published copy".utf8).write(to: destination) + return ShelfImageCopyReceipt(url: destination, dimensions: plan.outputDimensions(for: size)) + } + + 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() } + } +} + +@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, + 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) + 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, 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.chooser.release.resolve() + 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)) + try await f.session.cancel().get() + #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()) + } + } + + nonisolated enum StopAction: CaseIterable, Sendable { case clear, pause, remove, shutdown } + + @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) + } + } +} diff --git a/SemperTests/ShelfImageCopyTests.swift b/SemperTests/ShelfImageCopyTests.swift new file mode 100644 index 0000000..dfc4f6d --- /dev/null +++ b/SemperTests/ShelfImageCopyTests.swift @@ -0,0 +1,507 @@ +import CoreFoundation +import CoreGraphics +import CryptoKit +import Darwin +import Foundation +import ImageIO +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) + } + } + + @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) + } + + @Test("Cleanup retains failures and refuses a replacement at the recorded temporary path") + func cleanupRetainsIdentity() throws { + let f = try Fixture() + defer { f.remove() } + let temporary = f.url(".semper-image-copy-\(UUID()).tmp") + let retained = f.url("retained-owned-copy.tmp") + let original = Data("owned temporary image".utf8) + try original.write(to: temporary) + var info = stat() + try #require(lstat(temporary.path, &info) == 0) + let token = ShelfImageTemporaryCopy(url: temporary, device: info.st_dev, inode: info.st_ino) + 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) + } +} diff --git a/guide/shelf-image-copy.md b/guide/shelf-image-copy.md new file mode 100644 index 0000000..98a34a6 --- /dev/null +++ b/guide/shelf-image-copy.md @@ -0,0 +1,13 @@ +# 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. Failed temporary cleanup remains visible and must be retried before another resize. + +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. From 1ba777bb09323616bcb17be3dfe6ff3d59407aff Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:39:10 -0700 Subject: [PATCH 2/5] Retain image cleanup when destination parents change --- Semper/Shelf/ShelfImageCopy.swift | 36 +++++++------ SemperTests/ShelfImageCopySessionTests.swift | 3 +- SemperTests/ShelfImageCopyTests.swift | 57 +++++++++++++++++++- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/Semper/Shelf/ShelfImageCopy.swift b/Semper/Shelf/ShelfImageCopy.swift index 6c96377..6abf4d9 100644 --- a/Semper/Shelf/ShelfImageCopy.swift +++ b/Semper/Shelf/ShelfImageCopy.swift @@ -54,6 +54,8 @@ nonisolated struct ShelfImageTemporaryCopy: Equatable, Sendable { let url: URL let device: Int32 let inode: UInt64 + let parentDevice: Int32 + let parentInode: UInt64 } nonisolated enum ShelfImageCopyFailure: Error, Equatable, LocalizedError, Sendable { @@ -186,6 +188,10 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { } guard directoryFD >= 0 else { throw ShelfImageCopyFailure.invalidDestination } defer { close(directoryFD) } + var parent = stat() + guard fstat(directoryFD, &parent) == 0, parent.st_mode & S_IFMT == S_IFDIR else { + throw ShelfImageCopyFailure.invalidDestination + } var existing = stat() let exists = fstatat(directoryFD, destinationName, &existing, AT_SYMLINK_NOFOLLOW) guard exists != 0 else { throw ShelfImageCopyFailure.destinationExists } @@ -197,9 +203,12 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { defer { close(descriptor) } var created = stat() guard fstat(descriptor, &created) == 0 else { - throw ShelfImageCopyFailure.cleanupFailed(.init(url: temporary, device: 0, inode: 0)) + throw ShelfImageCopyFailure.cleanupFailed( + .init(url: temporary, device: 0, inode: 0, parentDevice: parent.st_dev, parentInode: parent.st_ino)) } - let owned = ShelfImageTemporaryCopy(url: temporary, device: created.st_dev, inode: created.st_ino) + let owned = ShelfImageTemporaryCopy( + url: temporary, device: created.st_dev, inode: created.st_ino, + parentDevice: parent.st_dev, parentInode: parent.st_ino) do { try autoreleasepool { let source = try Self.imageSource(plan.encoded) @@ -260,21 +269,14 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { UUID(uuidString: String(name.dropFirst(Self.temporaryPrefix.count).dropLast(Self.temporarySuffix.count))) != nil else { throw ShelfImageCopyFailure.invalidTemporaryCopy } - var info = stat() - let result = url.withUnsafeFileSystemRepresentation { path in + let directoryFD = url.deletingLastPathComponent().withUnsafeFileSystemRepresentation { path in guard let path else { return Int32(-1) } - return lstat(path, &info) - } - if result != 0 { - if errno == ENOENT { return } - throw ShelfImageCopyFailure.cleanupFailed(temporary) + return open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) } - guard temporary.inode != 0, info.st_mode & S_IFMT == S_IFREG, info.st_nlink == 1, - info.st_dev == temporary.device, info.st_ino == temporary.inode - else { - throw ShelfImageCopyFailure.cleanupFailed(temporary) - } - guard url.withUnsafeFileSystemRepresentation({ path in path.map { unlink($0) } ?? -1 }) == 0 else { + guard directoryFD >= 0 else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } + defer { close(directoryFD) } + guard try Self.verifyTemporaryIdentity(temporary, directoryFD: directoryFD, name: name) else { return } + guard unlinkat(directoryFD, name, 0) == 0 else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } } @@ -283,6 +285,10 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { private static func verifyTemporaryIdentity(_ temporary: ShelfImageTemporaryCopy, directoryFD: Int32, name: String) throws -> Bool { + var parent = stat() + guard fstat(directoryFD, &parent) == 0, parent.st_mode & S_IFMT == S_IFDIR, + temporary.parentInode != 0, parent.st_dev == temporary.parentDevice, parent.st_ino == temporary.parentInode + else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } var current = stat() guard fstatat(directoryFD, name, ¤t, AT_SYMLINK_NOFOLLOW) == 0 else { if errno == ENOENT { return false } diff --git a/SemperTests/ShelfImageCopySessionTests.swift b/SemperTests/ShelfImageCopySessionTests.swift index c734e22..1ee7c05 100644 --- a/SemperTests/ShelfImageCopySessionTests.swift +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -102,7 +102,8 @@ nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecke writing = ImageSessionGate(held: holdWrite) self.failWrite = failWrite temporary = ShelfImageTemporaryCopy( - url: root.appendingPathComponent(".semper-image-copy-\(UUID()).tmp"), device: 7, inode: 11) + 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 { diff --git a/SemperTests/ShelfImageCopyTests.swift b/SemperTests/ShelfImageCopyTests.swift index dfc4f6d..95cc85a 100644 --- a/SemperTests/ShelfImageCopyTests.swift +++ b/SemperTests/ShelfImageCopyTests.swift @@ -479,7 +479,11 @@ struct ShelfImageCopyTests { try original.write(to: temporary) var info = stat() try #require(lstat(temporary.path, &info) == 0) - let token = ShelfImageTemporaryCopy(url: temporary, device: info.st_dev, inode: info.st_ino) + var parentInfo = stat() + try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) + let token = ShelfImageTemporaryCopy( + url: temporary, device: info.st_dev, inode: info.st_ino, + parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) let copier = NativeShelfImageCopier() try FileManager.default.moveItem(at: temporary, to: retained) let replacement = Data("replacement file".utf8) @@ -504,4 +508,55 @@ struct ShelfImageCopyTests { #expect(!FileManager.default.fileExists(atPath: temporary.path)) try copier.removeTemporaryCopy(token) } + + @Test( + "Cleanup retains a renamed or replaced parent until its original directory returns", arguments: [false, true]) + func cleanupRetainsUnavailableParent(replaced: Bool) throws { + let f = try Fixture() + defer { f.remove() } + let parent = f.url("destination") + let moved = f.url("moved-destination") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) + let temporary = parent.appendingPathComponent(".semper-image-copy-\(UUID()).tmp") + let original = Data("owned temporary image".utf8) + try original.write(to: temporary) + var info = stat() + try #require(lstat(temporary.path, &info) == 0) + var parentInfo = stat() + try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) + let token = ShelfImageTemporaryCopy( + url: temporary, device: info.st_dev, inode: info.st_ino, + parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) + 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(temporary.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(!FileManager.default.fileExists(atPath: temporary.path)) + } + + @Test("Cleanup accepts a missing child in its available original parent") + func cleanupAcceptsMissingChild() throws { + let f = try Fixture() + defer { f.remove() } + let temporary = f.url(".semper-image-copy-\(UUID()).tmp") + try Data("owned temporary image".utf8).write(to: temporary) + var info = stat() + try #require(lstat(temporary.path, &info) == 0) + var parentInfo = stat() + try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) + let token = ShelfImageTemporaryCopy( + url: temporary, device: info.st_dev, inode: info.st_ino, + parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) + try FileManager.default.removeItem(at: temporary) + try NativeShelfImageCopier().removeTemporaryCopy(token) + #expect(try FileManager.default.contentsOfDirectory(atPath: f.root.path).isEmpty) + } + } From 7043f993eb2da2b3020e90dd330102449b6f7049 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:24:27 -0700 Subject: [PATCH 3/5] Retain Shelf recovery and verify image copy ownership --- Semper/Modules/UtilityRuntime.swift | 11 +- Semper/Modules/UtilityShellView.swift | 74 +++- Semper/Shelf/ShelfImageCopy.swift | 111 ++---- Semper/Shelf/ShelfImageCopySession.swift | 49 ++- Semper/Shelf/ShelfImageCopyView.swift | 24 +- Semper/Shelf/ShelfImageFileOwnership.swift | 393 +++++++++++++++++++ Semper/Shelf/ShelfService.swift | 104 ++++- Semper/Shelf/ShelfViews.swift | 8 +- SemperTests/DirectUtilityRuntimeTests.swift | 180 ++++++++- SemperTests/ShelfImageCopySessionTests.swift | 227 ++++++++++- SemperTests/ShelfImageCopyTests.swift | 349 ++++++++++++++-- SemperTests/ShelfTests.swift | 8 +- guide/shelf-image-copy.md | 6 +- 13 files changed, 1355 insertions(+), 189 deletions(-) create mode 100644 Semper/Shelf/ShelfImageFileOwnership.swift diff --git a/Semper/Modules/UtilityRuntime.swift b/Semper/Modules/UtilityRuntime.swift index 67cab51..2f4c5c7 100644 --- a/Semper/Modules/UtilityRuntime.swift +++ b/Semper/Modules/UtilityRuntime.swift @@ -907,12 +907,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 9c4ce72..d191796 100644 --- a/Semper/Modules/UtilityShellView.swift +++ b/Semper/Modules/UtilityShellView.swift @@ -104,7 +104,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 +128,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 +233,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 +253,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") @@ -522,6 +536,62 @@ 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 + } + } +} + +private struct ShelfStopRecoveryView: View { + @Bindable var runtime: UtilityRuntime + @State private var retrying = false + + var body: some View { + 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 { + 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("Retry Cleanup") { + guard !retrying else { return } + retrying = true + Task { + do { 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 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 index 6abf4d9..757d009 100644 --- a/Semper/Shelf/ShelfImageCopy.swift +++ b/Semper/Shelf/ShelfImageCopy.swift @@ -50,18 +50,12 @@ nonisolated struct ShelfImageCopyReceipt: Sendable { let dimensions: ShelfImageDimensions } -nonisolated struct ShelfImageTemporaryCopy: Equatable, Sendable { - let url: URL - let device: Int32 - let inode: UInt64 - let parentDevice: Int32 - let parentInode: UInt64 -} - 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 { @@ -77,7 +71,12 @@ nonisolated enum ShelfImageCopyFailure: Error, Equatable, LocalizedError, Sendab 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: "A temporary image could not be removed. Retry cleanup before resizing another image." + 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." } } } @@ -87,11 +86,13 @@ nonisolated protocol ShelfImageCopying: Sendable { func writeCopy(_ plan: ShelfImageCopyPlan, size: ShelfImageCopySize, to destination: URL) throws -> ShelfImageCopyReceipt func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt } nonisolated struct NativeShelfImageCopier: ShelfImageCopying { - private static let temporaryPrefix = ".semper-image-copy-" - private static let temporarySuffix = ".tmp" + let fileOperations: ShelfImageFileOperations + + init(fileOperations: ShelfImageFileOperations = .native) { self.fileOperations = fileOperations } func inspect(_ source: URL, access: any ShelfFileAccess) throws -> ShelfImageCopyPlan { try Self.checkCancellation() @@ -178,38 +179,13 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { [plan.format.fileExtension, plan.format == .jpeg ? "jpeg" : "png"].contains( destination.pathExtension.lowercased()) else { throw ShelfImageCopyFailure.invalidDestination } - let directory = destination.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL let destinationName = destination.lastPathComponent guard !destinationName.isEmpty, !destinationName.contains("\0"), destinationName != ".", destinationName != ".." else { throw ShelfImageCopyFailure.invalidDestination } - let directoryFD = directory.withUnsafeFileSystemRepresentation { path in - guard let path else { return Int32(-1) } - return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) - } - guard directoryFD >= 0 else { throw ShelfImageCopyFailure.invalidDestination } - defer { close(directoryFD) } - var parent = stat() - guard fstat(directoryFD, &parent) == 0, parent.st_mode & S_IFMT == S_IFDIR else { - throw ShelfImageCopyFailure.invalidDestination - } - var existing = stat() - let exists = fstatat(directoryFD, destinationName, &existing, AT_SYMLINK_NOFOLLOW) - guard exists != 0 else { throw ShelfImageCopyFailure.destinationExists } - guard errno == ENOENT else { throw ShelfImageCopyFailure.invalidDestination } - let name = Self.temporaryPrefix + UUID().uuidString + Self.temporarySuffix - let temporary = directory.appendingPathComponent(name) - let descriptor = openat(directoryFD, name, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode_t(0o600)) - guard descriptor >= 0 else { throw ShelfImageCopyFailure.writeFailed } - defer { close(descriptor) } - var created = stat() - guard fstat(descriptor, &created) == 0 else { - throw ShelfImageCopyFailure.cleanupFailed( - .init(url: temporary, device: 0, inode: 0, parentDevice: parent.st_dev, parentInode: parent.st_ino)) - } - let owned = ShelfImageTemporaryCopy( - url: temporary, device: created.st_dev, inode: created.st_ino, - parentDevice: parent.st_dev, parentInode: parent.st_ino) + 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) @@ -241,63 +217,22 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { let encoded = try Self.readBounded(output) try autoreleasepool { try Self.verifyOutput(encoded, plan: plan, size: size) } try Self.checkCancellation() - guard try Self.verifyTemporaryIdentity(owned, directoryFD: directoryFD, name: name) else { - throw ShelfImageCopyFailure.writeFailed - } - guard renameatx_np(directoryFD, name, directoryFD, destinationName, UInt32(RENAME_EXCL)) == 0 else { - throw errno == EEXIST ? ShelfImageCopyFailure.destinationExists : ShelfImageCopyFailure.writeFailed - } - return ShelfImageCopyReceipt(url: destination, dimensions: plan.outputDimensions(for: size)) + return try owner.publish(encoded: encoded, dimensions: plan.outputDimensions(for: size)) } catch { - do { - if try Self.verifyTemporaryIdentity(owned, directoryFD: directoryFD, name: name), - unlinkat(directoryFD, name, 0) != 0, errno != ENOENT - { - throw ShelfImageCopyFailure.cleanupFailed(owned) - } - } catch { - throw ShelfImageCopyFailure.cleanupFailed(owned) - } + if owner.hasPublished { throw ShelfImageCopyFailure.publicationUncertain(owner.publishedCopy) } + try owner.cleanUp() throw error } } func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { - let url = temporary.url - let name = url.lastPathComponent - guard url.isFileURL, name.hasPrefix(Self.temporaryPrefix), name.hasSuffix(Self.temporarySuffix), - UUID(uuidString: String(name.dropFirst(Self.temporaryPrefix.count).dropLast(Self.temporarySuffix.count))) - != nil - else { throw ShelfImageCopyFailure.invalidTemporaryCopy } - let directoryFD = url.deletingLastPathComponent().withUnsafeFileSystemRepresentation { path in - guard let path else { return Int32(-1) } - return open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) - } - guard directoryFD >= 0 else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } - defer { close(directoryFD) } - guard try Self.verifyTemporaryIdentity(temporary, directoryFD: directoryFD, name: name) else { return } - guard unlinkat(directoryFD, name, 0) == 0 else { - throw ShelfImageCopyFailure.cleanupFailed(temporary) - } + guard let owner = temporary.owner else { throw ShelfImageCopyFailure.invalidTemporaryCopy } + try owner.cleanUp() } - @discardableResult - private static func verifyTemporaryIdentity(_ temporary: ShelfImageTemporaryCopy, directoryFD: Int32, name: String) - throws -> Bool - { - var parent = stat() - guard fstat(directoryFD, &parent) == 0, parent.st_mode & S_IFMT == S_IFDIR, - temporary.parentInode != 0, parent.st_dev == temporary.parentDevice, parent.st_ino == temporary.parentInode - else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } - var current = stat() - guard fstatat(directoryFD, name, ¤t, AT_SYMLINK_NOFOLLOW) == 0 else { - if errno == ENOENT { return false } - throw ShelfImageCopyFailure.cleanupFailed(temporary) - } - guard temporary.inode != 0, current.st_mode & S_IFMT == S_IFREG, current.st_nlink == 1, - current.st_dev == temporary.device, current.st_ino == temporary.inode - else { throw ShelfImageCopyFailure.cleanupFailed(temporary) } - return true + func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt { + guard let owner = published.owner else { throw ShelfImageCopyFailure.publicationUncertain(published) } + return try owner.recoverPublication() } private static func readBounded(_ file: FileHandle) throws -> Data { diff --git a/Semper/Shelf/ShelfImageCopySession.swift b/Semper/Shelf/ShelfImageCopySession.swift index b9dd78c..6292004 100644 --- a/Semper/Shelf/ShelfImageCopySession.swift +++ b/Semper/Shelf/ShelfImageCopySession.swift @@ -52,9 +52,21 @@ final class ShelfImageCopySession { var isActive: Bool { request != nil || isWorking || needsCleanup } var needsCleanup: Bool { pendingCleanup != nil } + 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 stage: ShelfImageTemporaryCopy + let copy: PendingCopy let destination: URL let scoped: Bool } @@ -178,10 +190,18 @@ final class ShelfImageCopySession { if scoped { access.end(destination) } self.receipt = receipt case .failure(let error): - if let failure = error as? ShelfImageCopyFailure, case .cleanupFailed(let stage) = failure { - pendingCleanup = PendingCleanup(stage: stage, destination: destination, scoped: scoped) - message = failure.localizedDescription - return + 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) } @@ -197,15 +217,24 @@ final class ShelfImageCopySession { private func retryCleanup() async -> Result { guard let pendingCleanup else { return .success(()) } let copier = copier - let stage = pendingCleanup.stage - let worker = Task.detached(priority: .utility) { try copier.removeTemporaryCopy(stage) } + 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: + case .success(let receipt): + if let receipt { self.receipt = receipt } if pendingCleanup.scoped { access.end(pendingCleanup.destination) } self.pendingCleanup = nil return .success(()) - case .failure: - message = ShelfImageCopyFailure.cleanupFailed(stage).localizedDescription + case .failure(let error): + message = error.localizedDescription return .failure(.storeWrite) } } diff --git a/Semper/Shelf/ShelfImageCopyView.swift b/Semper/Shelf/ShelfImageCopyView.swift index b70f79f..b800468 100644 --- a/Semper/Shelf/ShelfImageCopyView.swift +++ b/Semper/Shelf/ShelfImageCopyView.swift @@ -1,17 +1,18 @@ import SwiftUI struct ShelfImageCleanupView: View { - let session: ShelfImageCopySession + let service: ShelfService + private var session: ShelfImageCopySession { service.imageCopy } var body: some View { if session.needsCleanup { HStack(alignment: .top) { - Label("A temporary resized image still needs cleanup.", systemImage: "exclamationmark.circle") + 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 session.cancel(requestID: requestID) } + Task { await service.cancelImageCopy(requestID: requestID) } } .disabled(session.isWorking) }.font(.callout) @@ -20,7 +21,8 @@ struct ShelfImageCleanupView: View { } struct ShelfImageCopyView: View { - let session: ShelfImageCopySession + let service: ShelfService + private var session: ShelfImageCopySession { service.imageCopy } let request: ShelfImageCopyRequest @State private var size: ShelfImageCopySize = .pixels1024 @@ -44,7 +46,7 @@ struct ShelfImageCopyView: View { } } .pickerStyle(.radioGroup) - .disabled(session.isWorking) + .disabled(session.isWorking || session.needsCleanup) Text( "Smaller images keep their dimensions. The copy keeps its format, orientation, color profile, and PNG transparency." ) @@ -60,16 +62,22 @@ struct ShelfImageCopyView: View { Text(session.plan == nil ? "Reading image…" : "Saving copy…") }.font(.callout) } + if !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) } HStack { Spacer() - Button(session.receipt == nil ? "Cancel" : "Done") { - Task { await session.cancel(requestID: request.id) } + Button(session.needsCleanup ? "Retry Cleanup" : session.receipt == nil ? "Cancel" : "Done") { + Task { await service.cancelImageCopy(requestID: request.id) } } - .keyboardShortcut(.cancelAction) + .keyboardShortcut(session.needsCleanup ? nil : .cancelAction) if session.plan != nil && session.receipt == nil { Button("Save Copy…") { if session.request?.id == request.id { session.save(size: size) } diff --git a/Semper/Shelf/ShelfImageFileOwnership.swift b/Semper/Shelf/ShelfImageFileOwnership.swift new file mode 100644 index 0000000..e419857 --- /dev/null +++ b/Semper/Shelf/ShelfImageFileOwnership.swift @@ -0,0 +1,393 @@ +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 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/ShelfService.swift b/Semper/Shelf/ShelfService.swift index 8f13162..97c4100 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 @@ -41,6 +42,8 @@ final class ShelfService { 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? @@ -53,12 +56,15 @@ 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 = @@ -67,6 +73,7 @@ final class ShelfService { self.store = store self.access = access self.now = now + self.waitForExpiry = waitForExpiry self.fileChooser = fileChooser self.imageCopy = imageCopy ?? ShelfImageCopySession(access: access) self.importer = importer @@ -103,39 +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() { @@ -250,7 +285,7 @@ final class ShelfService { if imageRequest != nil { imageCopy.stop() } removingIDs.insert(id) defer { removingIDs.remove(id) } - if let imageRequest, case .failure(let failure) = await imageCopy.cancel(requestID: imageRequest.id) { + if let imageRequest, case .failure(let failure) = await cancelImageCopy(requestID: imageRequest.id) { report(failure) return } @@ -265,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 @@ -315,6 +351,7 @@ 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() @@ -333,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() } @@ -370,6 +407,28 @@ 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.needsCleanup { return .failure(.storeWrite) } + let request = imageCopy.request + let result = await imageCopy.cancel(requestID: requestID) + if let request { + 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 { + expiryBlockedRequests[request.itemID] = request.id + } + } + } + return result + } + func canResizeImage(_ item: ShelfItem) -> Bool { isRunning && !isStopping && !isClearing && !storeNeedsReset && !imageCopy.isActive && importTasks.isEmpty && !isChoosingFiles && importCancellationCount == 0 @@ -613,7 +672,7 @@ final class ShelfService { private func cancelWork() async -> Result { imageCopy.stop() let importCleanup = await cancelImports() - let imageCleanup = await imageCopy.cancel() + let imageCleanup = await cancelImageCopy() let workers = hashTasks for (id, task) in workers { task.cancel() @@ -673,10 +732,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 777e355..85beddd 100644 --- a/Semper/Shelf/ShelfViews.swift +++ b/Semper/Shelf/ShelfViews.swift @@ -43,7 +43,7 @@ struct ShelfCompactView: View { } if service.items.count > 3 { Button("View all \(service.items.count) items", action: openDetail) } } - ShelfImageCleanupView(session: service.imageCopy) + ShelfImageCleanupView(service: service) if service.importCount > 0 { HStack { ProgressView().controlSize(.small) @@ -124,7 +124,7 @@ struct ShelfDetailView: View { Button("Dismiss") { service.dismissMessage() } }.font(.callout).foregroundStyle(.orange) } - ShelfImageCleanupView(session: service.imageCopy) + ShelfImageCleanupView(service: service) if service.importCount > 0 { HStack { ProgressView().controlSize(.small) @@ -183,8 +183,8 @@ struct ShelfDetailView: View { }.padding().frame(minWidth: 540, minHeight: 380) } .sheet(item: $resizeRequest) { request in - ShelfImageCopyView(session: service.imageCopy, request: request) - .onDisappear { Task { await service.imageCopy.cancel(requestID: request.id) } } + 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 } diff --git a/SemperTests/DirectUtilityRuntimeTests.swift b/SemperTests/DirectUtilityRuntimeTests.swift index 51c5619..0ea04f3 100644 --- a/SemperTests/DirectUtilityRuntimeTests.swift +++ b/SemperTests/DirectUtilityRuntimeTests.swift @@ -505,6 +505,134 @@ 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("Direct service changes update permission and limitation badges without restarting services") func liveStatus() async throws { try await withRuntime { runtime, probe in @@ -696,6 +824,9 @@ private final class DirectRuntimeProbe { var creations: [UtilityModuleID: Int] = [:] var workspaceCreationFails = false var workspaceBackendOverride: (any WorkspaceWindowBackend)? + var shelfAccess: any ShelfFileAccess = DirectRuntimeFileAccess() + var shelfImporter: @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload = ShelfDropImporter + .load init(directory: URL) { self.directory = directory @@ -732,7 +863,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, + importer: shelfImporter) } func makeStorage() -> SafeEjectService { @@ -803,6 +935,52 @@ private struct DirectRuntimeFileAccess: ShelfFileAccess { func resolve(_ bookmark: Data) throws -> URL { throw ShelfFailure.unsupported } } +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 index 1ee7c05..6951fe0 100644 --- a/SemperTests/ShelfImageCopySessionTests.swift +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -88,19 +88,33 @@ nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecke private var writeCount = 0 private var removed: [ShelfImageTemporaryCopy] = [] private var cleanupFailure = false + private var recoveryFailure = true + private var recoveryCount = 0 let failWrite: Bool + let uncertainPublication: Bool + let recoveredOutput: URL var writes: Int { lock.withLock { writeCount } } + var recoveries: Int { lock.withLock { recoveryCount } } 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) { + 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) @@ -122,10 +136,21 @@ nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecke 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 removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws { lock.withLock { removed.append(temporary) } guard temporary == self.temporary else { throw ShelfImageCopyFailure.invalidTemporaryCopy } @@ -185,6 +210,38 @@ private final class ImageSessionIdle { } } +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 { @@ -201,6 +258,11 @@ struct ShelfImageCopySessionTests { 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())") @@ -225,13 +287,14 @@ struct ShelfImageCopySessionTests { let access = ImageSessionAccess() let plan = try NativeShelfImageCopier().inspect(source, access: access) let copier = ImageSessionCopier( - plan: plan, root: root, holdInspect: holdInspect, holdWrite: holdWrite, failWrite: failWrite) + 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, imageCopy: session) + access: access, now: now, waitForExpiry: waitForExpiry, imageCopy: session) service.start() let fixture = Fixture( root: root, source: source, output: output, access: access, @@ -250,6 +313,7 @@ struct ShelfImageCopySessionTests { 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 case .failure(let error) = await f.session.cancel() { Issue.record(error) } @@ -411,7 +475,73 @@ struct ShelfImageCopySessionTests { } } + @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 + try await f.session.cancel(requestID: request.id).get() + #expect(f.session.receipt?.url == f.copier.recoveredOutput) + #expect(!f.session.isActive && !f.session.needsCleanup && f.access.balanced) + #expect(f.copier.writes == 1 && f.copier.recoveries == 2) + #expect(f.copier.cleanupTokens.isEmpty) + #expect(!FileManager.default.fileExists(atPath: f.output.path)) + #expect(try Data(contentsOf: f.copier.recoveredOutput) == Data("published copy".utf8)) + } + } + 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 { @@ -489,4 +619,95 @@ struct ShelfImageCopySessionTests { #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 index 95cc85a..b9fd3c0 100644 --- a/SemperTests/ShelfImageCopyTests.swift +++ b/SemperTests/ShelfImageCopyTests.swift @@ -4,6 +4,7 @@ import CryptoKit import Darwin import Foundation import ImageIO +import Synchronization import Testing import UniformTypeIdentifiers @@ -339,6 +340,10 @@ struct ShelfImageCopyTests { #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") @@ -469,21 +474,36 @@ struct ShelfImageCopyTests { #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 temporary = f.url(".semper-image-copy-\(UUID()).tmp") - let retained = f.url("retained-owned-copy.tmp") - let original = Data("owned temporary image".utf8) - try original.write(to: temporary) - var info = stat() - try #require(lstat(temporary.path, &info) == 0) - var parentInfo = stat() - try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) - let token = ShelfImageTemporaryCopy( - url: temporary, device: info.st_dev, inode: info.st_ino, - parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) + 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) @@ -496,9 +516,9 @@ struct ShelfImageCopyTests { 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) } + do { try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: temporary.path) } catch { + Issue.record(error) + } } } #expect(throws: ShelfImageCopyFailure.cleanupFailed(token)) { try copier.removeTemporaryCopy(token) } @@ -509,54 +529,297 @@ struct ShelfImageCopyTests { try copier.removeTemporaryCopy(token) } - @Test( - "Cleanup retains a renamed or replaced parent until its original directory returns", arguments: [false, true]) + @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 parent = f.url("destination") - let moved = f.url("moved-destination") - try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: false) - let temporary = parent.appendingPathComponent(".semper-image-copy-\(UUID()).tmp") - let original = Data("owned temporary image".utf8) - try original.write(to: temporary) - var info = stat() - try #require(lstat(temporary.path, &info) == 0) - var parentInfo = stat() - try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) - let token = ShelfImageTemporaryCopy( - url: temporary, device: info.st_dev, inode: info.st_ino, - parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) + 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(temporary.lastPathComponent)) == original) + #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(!FileManager.default.fileExists(atPath: temporary.path)) + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) } - @Test("Cleanup accepts a missing child in its available original parent") + @Test("Cleanup accepts a missing child in its available original private parent") func cleanupAcceptsMissingChild() throws { let f = try Fixture() defer { f.remove() } - let temporary = f.url(".semper-image-copy-\(UUID()).tmp") - try Data("owned temporary image".utf8).write(to: temporary) - var info = stat() - try #require(lstat(temporary.path, &info) == 0) - var parentInfo = stat() - try #require(stat(temporary.deletingLastPathComponent().path, &parentInfo) == 0) - let token = ShelfImageTemporaryCopy( - url: temporary, device: info.st_dev, inode: info.st_ino, - parentDevice: parentInfo.st_dev, parentInode: parentInfo.st_ino) - try FileManager.default.removeItem(at: temporary) + 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: f.root.path).isEmpty) + #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) } } 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/shelf-image-copy.md b/guide/shelf-image-copy.md index 98a34a6..ec9d138 100644 --- a/guide/shelf-image-copy.md +++ b/guide/shelf-image-copy.md @@ -8,6 +8,10 @@ Camera, location, and descriptive metadata are removed, including source EXIF, G 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. Failed temporary cleanup remains visible and must be retried before another resize. +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. + +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. From 2260ce4894bdc4eb2b4a58e18fc9083047f1d0e8 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:35:08 -0700 Subject: [PATCH 4/5] Keep image recovery visible until acknowledgement --- Semper/Modules/UtilityShellView.swift | 64 +++++++- Semper/Shelf/ShelfImageCopy.swift | 8 + Semper/Shelf/ShelfImageCopySession.swift | 71 ++++++++- Semper/Shelf/ShelfImageCopyView.swift | 67 ++++++-- Semper/Shelf/ShelfImageFileOwnership.swift | 7 + Semper/Shelf/ShelfModels.swift | 2 + Semper/Shelf/ShelfService.swift | 48 ++++-- SemperTests/DirectUtilityRuntimeTests.swift | 155 ++++++++++++++++++- SemperTests/ShelfImageCopySessionTests.swift | 63 +++++++- SemperTests/ShelfImageCopyTests.swift | 90 +++++++++++ guide/shelf-image-copy.md | 4 +- 11 files changed, 541 insertions(+), 38 deletions(-) diff --git a/Semper/Modules/UtilityShellView.swift b/Semper/Modules/UtilityShellView.swift index d191796..8339716 100644 --- a/Semper/Modules/UtilityShellView.swift +++ b/Semper/Modules/UtilityShellView.swift @@ -1,3 +1,4 @@ +import Foundation import KeyboardShortcuts import SwiftUI @@ -553,6 +554,24 @@ nonisolated enum ShelfStopRecoveryRoute: Equatable, Sendable { 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 { @@ -560,6 +579,9 @@ private struct ShelfStopRecoveryView: View { @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) @@ -569,16 +591,28 @@ private struct ShelfStopRecoveryView: View { ) .foregroundStyle(.secondary) if let session = runtime.shelf?.imageCopy { - ForEach(session.recoveryLocations, id: \.self) { location in - Text(location.path).font(.callout).textSelection(.enabled) + 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("Retry Cleanup") { + Button(acknowledgementRequest == nil ? "Retry Cleanup" : "Done") { guard !retrying else { return } retrying = true Task { - do { try await ShelfStopRecoveryRoute.retry(in: runtime) } catch { + 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 @@ -586,6 +620,28 @@ private struct ShelfStopRecoveryView: View { } .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) diff --git a/Semper/Shelf/ShelfImageCopy.swift b/Semper/Shelf/ShelfImageCopy.swift index 757d009..1cf88e8 100644 --- a/Semper/Shelf/ShelfImageCopy.swift +++ b/Semper/Shelf/ShelfImageCopy.swift @@ -87,6 +87,7 @@ nonisolated protocol ShelfImageCopying: Sendable { -> ShelfImageCopyReceipt func removeTemporaryCopy(_ temporary: ShelfImageTemporaryCopy) throws func recoverPublishedCopy(_ published: ShelfImagePublishedCopy) throws -> ShelfImageCopyReceipt + func acknowledgeUnverifiedCopy(_ published: ShelfImagePublishedCopy) throws } nonisolated struct NativeShelfImageCopier: ShelfImageCopying { @@ -235,6 +236,13 @@ nonisolated struct NativeShelfImageCopier: ShelfImageCopying { 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 { diff --git a/Semper/Shelf/ShelfImageCopySession.swift b/Semper/Shelf/ShelfImageCopySession.swift index 6292004..7081ea1 100644 --- a/Semper/Shelf/ShelfImageCopySession.swift +++ b/Semper/Shelf/ShelfImageCopySession.swift @@ -49,9 +49,14 @@ final class ShelfImageCopySession { 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 @@ -132,13 +137,13 @@ final class ShelfImageCopySession { let currentWork = work let cancellation = Task { @MainActor in await currentWork?.value - let result = await self.retryCleanup() + var result = await self.retryCleanup() if case .success = result { - self.activeID = nil - self.request = nil - self.plan = nil - self.message = nil - self.cancellationRequested = false + if self.needsReceiptAcknowledgement { + result = .failure(.recoveredCopyNeedsAcknowledgement) + } else { + self.finishRequest() + } } self.cancellation = nil self.isWorking = false @@ -151,6 +156,54 @@ final class ShelfImageCopySession { 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 } @@ -229,7 +282,11 @@ final class ShelfImageCopySession { } switch await worker.result { case .success(let receipt): - if let receipt { self.receipt = receipt } + if let receipt { + self.receipt = receipt + needsReceiptAcknowledgement = true + message = nil + } if pendingCleanup.scoped { access.end(pendingCleanup.destination) } self.pendingCleanup = nil return .success(()) diff --git a/Semper/Shelf/ShelfImageCopyView.swift b/Semper/Shelf/ShelfImageCopyView.swift index b800468..ce60ba6 100644 --- a/Semper/Shelf/ShelfImageCopyView.swift +++ b/Semper/Shelf/ShelfImageCopyView.swift @@ -1,20 +1,49 @@ 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.needsCleanup { + if session.needsReceiptAcknowledgement, let receipt = session.receipt, let request = session.request { HStack(alignment: .top) { - Label("An image operation needs recovery.", systemImage: "exclamationmark.circle") - .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 6) { + Label("Copy recovered", systemImage: "checkmark.circle").foregroundStyle(.green) + Text(receipt.url.path).textSelection(.enabled) + } Spacer() - Button("Retry Cleanup") { - guard session.needsCleanup, let requestID = session.request?.id else { return } - Task { await service.cancelImageCopy(requestID: requestID) } + 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) } - .disabled(session.isWorking) }.font(.callout) } } @@ -62,7 +91,7 @@ struct ShelfImageCopyView: View { Text(session.plan == nil ? "Reading image…" : "Saving copy…") }.font(.callout) } - if !session.recoveryLocations.isEmpty { + 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) @@ -72,12 +101,28 @@ struct ShelfImageCopyView: View { 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.needsCleanup ? "Retry Cleanup" : session.receipt == nil ? "Cancel" : "Done") { - Task { await service.cancelImageCopy(requestID: request.id) } + Button( + session.needsReceiptAcknowledgement + ? "Done" + : session.needsCleanup ? "Retry Cleanup" : session.receipt == nil ? "Cancel" : "Done" + ) { + if session.needsReceiptAcknowledgement { + service.acknowledgeImageCopyReceipt(requestID: request.id) + } else { + Task { await service.cancelImageCopy(requestID: request.id) } + } } - .keyboardShortcut(session.needsCleanup ? nil : .cancelAction) + .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) } diff --git a/Semper/Shelf/ShelfImageFileOwnership.swift b/Semper/Shelf/ShelfImageFileOwnership.swift index e419857..e000eef 100644 --- a/Semper/Shelf/ShelfImageFileOwnership.swift +++ b/Semper/Shelf/ShelfImageFileOwnership.swift @@ -241,6 +241,13 @@ nonisolated final class ShelfImageFileOwner: Sendable { } } + 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) } 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 97c4100..f9c0f93 100644 --- a/Semper/Shelf/ShelfService.swift +++ b/Semper/Shelf/ShelfService.swift @@ -410,23 +410,47 @@ final class ShelfService { @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 { - 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 { - expiryBlockedRequests[request.itemID] = request.id - } + 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 } } - return result } func canResizeImage(_ item: ShelfItem) -> Bool { diff --git a/SemperTests/DirectUtilityRuntimeTests.swift b/SemperTests/DirectUtilityRuntimeTests.swift index 0ea04f3..35bb55f 100644 --- a/SemperTests/DirectUtilityRuntimeTests.swift +++ b/SemperTests/DirectUtilityRuntimeTests.swift @@ -633,6 +633,99 @@ struct DirectUtilityRuntimeTests { } } + @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 @@ -825,6 +918,7 @@ private final class DirectRuntimeProbe { 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 @@ -864,7 +958,7 @@ private final class DirectRuntimeProbe { creations[.shelf, default: 0] += 1 return ShelfService( store: ShelfStore(root: directory.appendingPathComponent("Shelf")), access: shelfAccess, - importer: shelfImporter) + imageCopy: shelfImageCopy, importer: shelfImporter) } func makeStorage() -> SafeEjectService { @@ -935,6 +1029,65 @@ 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? diff --git a/SemperTests/ShelfImageCopySessionTests.swift b/SemperTests/ShelfImageCopySessionTests.swift index 6951fe0..7c8d4b5 100644 --- a/SemperTests/ShelfImageCopySessionTests.swift +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -90,11 +90,13 @@ nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecke 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 } } @@ -151,6 +153,11 @@ nonisolated private final class ImageSessionCopier: ShelfImageCopying, @unchecke 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 } @@ -316,6 +323,10 @@ struct ShelfImageCopySessionTests { 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) } } @@ -494,16 +505,64 @@ struct ShelfImageCopySessionTests { #expect(f.session.receipt == nil && f.access.active(f.output) == 1) #expect(f.copier.writes == 1 && f.copier.recoveries == 1) f.copier.failRecovery = false - try await f.session.cancel(requestID: request.id).get() + 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.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("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 } diff --git a/SemperTests/ShelfImageCopyTests.swift b/SemperTests/ShelfImageCopyTests.swift index b9fd3c0..604da9d 100644 --- a/SemperTests/ShelfImageCopyTests.swift +++ b/SemperTests/ShelfImageCopyTests.swift @@ -822,4 +822,94 @@ struct ShelfImageCopyTests { 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/guide/shelf-image-copy.md b/guide/shelf-image-copy.md index ec9d138..20f5c76 100644 --- a/guide/shelf-image-copy.md +++ b/guide/shelf-image-copy.md @@ -10,7 +10,9 @@ Resize a Copy accepts one fully downloaded local JPEG or PNG of at most 32 MiB, 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. +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. From 2db9a4a57efa6577683f2b78428fdd89a67aea67 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:47:44 -0700 Subject: [PATCH 5/5] Capture Shelf image copy completion actions when rendered --- Semper/Shelf/ShelfImageCopyView.swift | 17 ++++++++---- SemperTests/ShelfImageCopySessionTests.swift | 29 +++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Semper/Shelf/ShelfImageCopyView.swift b/Semper/Shelf/ShelfImageCopyView.swift index ce60ba6..fcbafc3 100644 --- a/Semper/Shelf/ShelfImageCopyView.swift +++ b/Semper/Shelf/ShelfImageCopyView.swift @@ -55,7 +55,18 @@ struct ShelfImageCopyView: View { 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) @@ -116,11 +127,7 @@ struct ShelfImageCopyView: View { ? "Done" : session.needsCleanup ? "Retry Cleanup" : session.receipt == nil ? "Cancel" : "Done" ) { - if session.needsReceiptAcknowledgement { - service.acknowledgeImageCopyReceipt(requestID: request.id) - } else { - Task { await service.cancelImageCopy(requestID: request.id) } - } + Task { await close() } } .keyboardShortcut(session.needsCleanup || session.needsReceiptAcknowledgement ? nil : .cancelAction) if session.plan != nil && session.receipt == nil { diff --git a/SemperTests/ShelfImageCopySessionTests.swift b/SemperTests/ShelfImageCopySessionTests.swift index 7c8d4b5..a04813e 100644 --- a/SemperTests/ShelfImageCopySessionTests.swift +++ b/SemperTests/ShelfImageCopySessionTests.swift @@ -404,7 +404,8 @@ struct ShelfImageCopySessionTests { try #require(await ImageSessionIdle(f.session).wait()) #expect(f.session.receipt?.url == f.output) #expect(!f.session.save(size: .pixels1024)) - try await f.session.cancel().get() + 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) } @@ -528,6 +529,32 @@ struct ShelfImageCopySessionTests { } } + @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