Snippet
Write local recovery copies without autosaving over user files
A complete Swift recovery-copy service that writes atomic text and metadata files into a user-selected folder and cleans them up after save or discard.
AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy
What it does#
Writes recovery copies to a folder the user chose, stores metadata next to the text, reloads saved recovery records on the next launch, and removes recovery files when the app no longer needs them. It does not overwrite the user’s real document behind their back.
You need#
- macOS 15 or later
- Xcode 27 or Swift 6.4
- Foundation only
- A Settings UI that lets the user pick a local recovery folder
Where it goes#
Save it as RecoveryCopyService.swift in the core layer of your app. Call writeRecoveryCopy
after a debounced edit event and cleanup after a successful save or explicit discard.
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
import Foundation
import Darwin
struct AutosaveSettings: Codable, Equatable, Sendable {
var hasCompletedFirstRunPrompt: Bool
var folderURL: URL?
init(hasCompletedFirstRunPrompt: Bool = false, folderURL: URL? = nil) {
self.hasCompletedFirstRunPrompt = hasCompletedFirstRunPrompt
self.folderURL = folderURL
}
}
struct RecoverableTab: Equatable, Sendable {
var id: UUID
var title: String
var text: String
var originalFileURL: URL?
var dirtyGeneration: Int
var recoveryRecordID: UUID?
init(
id: UUID = UUID(),
title: String = "Untitled",
text: String = "",
originalFileURL: URL? = nil,
dirtyGeneration: Int = 0,
recoveryRecordID: UUID? = nil
) {
self.id = id
self.title = title
self.text = text
self.originalFileURL = originalFileURL
self.dirtyGeneration = dirtyGeneration
self.recoveryRecordID = recoveryRecordID
}
}
struct RecoveryRecord: Codable, Equatable, Sendable {
var id: UUID
var tabID: UUID
var recoveryPath: String
var metadataPath: String
var originalFilePath: String?
var generation: Int
var createdAt: Date
var updatedAt: Date
}
enum RecoveryCopyError: Error, Equatable, LocalizedError, Sendable {
case folderNotConfigured
case folderUnavailable(String)
case invalidRecoveryPath(String)
case staleGeneration(Int)
case writeFailed(String)
case cleanupFailed(String)
var errorDescription: String? {
switch self {
case .folderNotConfigured:
"Autosave not set up"
case .folderUnavailable(let path):
"Recovery folder is missing or not writable: \(path)"
case .invalidRecoveryPath(let path):
"Recovery metadata points outside the selected recovery folder: \(path)"
case .staleGeneration(let generation):
"A newer recovery copy already exists at generation \(generation)."
case .writeFailed(let message):
"Could not write recovery copy: \(message)"
case .cleanupFailed(let message):
"Could not clean recovery copy: \(message)"
}
}
}
final class RecoveryCopyService {
var fileManager: FileManager
var encoder: JSONEncoder
var decoder: JSONDecoder
private let writeFile: (Data, URL, Bool) throws -> Void
private let writeLock = NSLock()
private var savedGenerations: [UUID: Int] = [:]
init(
fileManager: FileManager = .default,
writeFile: @escaping (Data, URL, Bool) throws -> Void = { data, url, replaceExisting in
if replaceExisting {
try safeReplaceRegularFile(data, at: url)
} else {
try safeCreateRegularFile(data, at: url)
}
}
) {
self.fileManager = fileManager
self.writeFile = writeFile
self.encoder = JSONEncoder()
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
}
func validateFolder(_ folderURL: URL?) -> Result<URL, RecoveryCopyError> {
guard let folderURL else { return .failure(.folderNotConfigured) }
var isDirectory: ObjCBool = false
let resolvedFolder = canonicalFolder(folderURL)
let path = resolvedFolder.path
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue,
fileManager.isWritableFile(atPath: path) else {
return .failure(.folderUnavailable(path))
}
return .success(resolvedFolder)
}
func writeRecoveryCopy(
for tab: RecoverableTab,
settings: AutosaveSettings
) -> Result<RecoveryRecord, RecoveryCopyError> {
switch validateFolder(settings.folderURL) {
case .failure(let error):
return .failure(error)
case .success(let folder):
let recordID = tab.recoveryRecordID ?? UUID()
let recoveryURL = stagedRecoveryTextURL(
recordID: recordID,
generation: tab.dirtyGeneration,
stageID: UUID(),
in: folder
)
let metadataURL = recoveryMetadataURL(recordID: recordID, in: folder)
let now = Date()
let record = RecoveryRecord(
id: recordID,
tabID: tab.id,
recoveryPath: recoveryURL.path,
metadataPath: metadataURL.path,
originalFilePath: tab.originalFileURL?.path,
generation: tab.dirtyGeneration,
createdAt: now,
updatedAt: now
)
writeLock.lock()
defer { writeLock.unlock() }
do {
let previousRecord = try loadRecord(recordID: recordID, from: metadataURL)
if let savedGeneration = savedGenerations[recordID],
savedGeneration >= tab.dirtyGeneration {
return .failure(.staleGeneration(savedGeneration))
}
if let existing = previousRecord,
existing.generation > tab.dirtyGeneration {
return .failure(.staleGeneration(existing.generation))
}
guard let textData = tab.text.data(using: .utf8) else {
return .failure(.writeFailed("Recovery text could not be encoded as UTF-8."))
}
try writeFile(textData, recoveryURL, false)
do {
try writeFile(encoder.encode(record), metadataURL, true)
} catch {
try? deleteRegularFile(recoveryURL)
return .failure(.writeFailed(error.localizedDescription))
}
if let previousRecord,
previousRecord.recoveryPath != record.recoveryPath {
let previousPathIsValid = isValidRecoveryTextPath(
previousRecord.recoveryPath,
recordID: previousRecord.id,
generation: previousRecord.generation,
in: folder
)
if previousPathIsValid {
try? deleteRegularFile(URL(fileURLWithPath: previousRecord.recoveryPath))
}
}
return .success(record)
} catch {
return .failure(.writeFailed(error.localizedDescription))
}
}
}
func loadRecords(from folder: URL) -> [RecoveryRecord] {
let folder = canonicalFolder(folder)
guard let urls = try? fileManager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
) else {
return []
}
return urls
.filter { $0.pathExtension == "json" && $0.lastPathComponent.hasSuffix(".recovery.json") }
.compactMap { url in
guard (try? requireRegularFile(url)) != nil,
let data = try? readRegularFile(url) else { return nil }
guard let record = try? decoder.decode(RecoveryRecord.self, from: data) else { return nil }
let expectedMetadataURL = recoveryMetadataURL(recordID: record.id, in: folder)
guard isValidRecoveryTextPath(record.recoveryPath, recordID: record.id, generation: record.generation, in: folder),
record.metadataPath == expectedMetadataURL.path,
url.lastPathComponent == expectedMetadataURL.lastPathComponent,
canonicalFolder(url.deletingLastPathComponent()).path == folder.path else {
return nil
}
return record
}
.sorted { $0.updatedAt < $1.updatedAt }
}
func loadRecoveryText(record: RecoveryRecord, from folder: URL) -> String? {
let folder = canonicalFolder(folder)
guard isValidRecoveryTextPath(record.recoveryPath, recordID: record.id, generation: record.generation, in: folder),
let data = try? readRegularFile(URL(fileURLWithPath: record.recoveryPath)) else {
return nil
}
return String(data: data, encoding: .utf8)
}
func cleanup(record: RecoveryRecord, from folder: URL, minimumGeneration: Int) -> Result<Void, RecoveryCopyError> {
let folder = canonicalFolder(folder)
guard record.generation <= minimumGeneration else { return .success(()) }
writeLock.lock()
defer { writeLock.unlock() }
do {
let metadataURL = recoveryMetadataURL(recordID: record.id, in: folder)
let diskRecord = try loadRecord(recordID: record.id, from: metadataURL) ?? record
if diskRecord.generation > minimumGeneration {
return .success(())
}
guard isValidRecoveryTextPath(
diskRecord.recoveryPath,
recordID: diskRecord.id,
generation: diskRecord.generation,
in: folder
),
diskRecord.metadataPath == metadataURL.path else {
return .failure(.invalidRecoveryPath(record.id.uuidString))
}
let recoveryURL = URL(fileURLWithPath: diskRecord.recoveryPath)
if fileManager.fileExists(atPath: recoveryURL.path) {
try deleteRegularFile(recoveryURL)
}
if fileManager.fileExists(atPath: metadataURL.path) {
try deleteRegularFile(metadataURL)
}
savedGenerations[record.id] = max(savedGenerations[record.id] ?? 0, minimumGeneration)
return .success(())
} catch let error as RecoveryCopyError {
return .failure(error)
} catch {
return .failure(.cleanupFailed(error.localizedDescription))
}
}
private func loadRecord(recordID: UUID, from metadataURL: URL) throws -> RecoveryRecord? {
guard fileManager.fileExists(atPath: metadataURL.path) else { return nil }
let data = try readRegularFile(metadataURL)
let record = try decoder.decode(RecoveryRecord.self, from: data)
guard record.id == recordID else {
throw RecoveryCopyError.invalidRecoveryPath(metadataURL.path)
}
return record
}
private func stagedRecoveryTextURL(recordID: UUID, generation: Int, stageID: UUID, in folder: URL) -> URL {
folder.appendingPathComponent("\(recordID.uuidString).\(generation).\(stageID.uuidString).tmp")
}
private func recoveryMetadataURL(recordID: UUID, in folder: URL) -> URL {
folder.appendingPathComponent("\(recordID.uuidString).recovery.json")
}
private func isValidRecoveryTextPath(_ path: String, recordID: UUID, generation: Int, in folder: URL) -> Bool {
let url = URL(fileURLWithPath: path)
let folderURL = canonicalFolder(folder)
guard canonicalFolder(url.deletingLastPathComponent()).path == folderURL.path else { return false }
// Accept only the exact name stagedRecoveryTextURL generates: <record>.<generation>.<canonical UUID>.tmp
let name = url.lastPathComponent
let prefix = "\(recordID.uuidString).\(generation)."
let suffix = ".tmp"
guard name.hasPrefix(prefix), name.hasSuffix(suffix),
name.count > prefix.count + suffix.count else { return false }
let stageText = String(name.dropFirst(prefix.count).dropLast(suffix.count))
guard let stageID = UUID(uuidString: stageText) else { return false }
return stageID.uuidString == stageText
}
private func canonicalFolder(_ folder: URL) -> URL {
if let resolvedPath = realpath(folder.standardizedFileURL.path, nil) {
defer { free(resolvedPath) }
return URL(fileURLWithPath: String(cString: resolvedPath), isDirectory: true)
}
return folder.standardizedFileURL
}
}
private func safeCreateRegularFile(_ data: Data, at url: URL) throws {
let descriptor = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, S_IRUSR | S_IWUSR)
guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
do {
try handle.write(contentsOf: data)
try handle.close()
} catch {
try? handle.close()
try? deleteRegularFile(url)
throw error
}
}
private func safeReplaceRegularFile(_ data: Data, at url: URL) throws {
if FileManager.default.fileExists(atPath: url.path) {
try requireRegularFile(url)
}
let stagingURL = url.deletingLastPathComponent()
.appendingPathComponent("\(url.lastPathComponent).\(UUID().uuidString).tmp")
try safeCreateRegularFile(data, at: stagingURL)
do {
guard rename(stagingURL.path, url.path) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
} catch {
try? deleteRegularFile(stagingURL)
throw error
}
}
private func readRegularFile(_ url: URL) throws -> Data {
try requireRegularFile(url)
let descriptor = open(url.path, O_RDONLY | O_NOFOLLOW)
guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
return try handle.readToEnd() ?? Data()
}
private func deleteRegularFile(_ url: URL) throws {
try requireRegularFile(url)
guard unlink(url.path) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
private func requireRegularFile(_ url: URL) throws {
var status = stat()
guard lstat(url.path, &status) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard (status.st_mode & S_IFMT) == S_IFREG else {
throw POSIXError(.EFTYPE)
}
}
Check that it works#
Create a temporary folder, write a recovery copy for a dirty tab, load the record, load its
text, retry the same generation with an injected metadata-write failure, try a stale write
with an older generation, create a newer generation before cleanup, then call cleanup. The
failed metadata write should remove only its own staged text file and leave the previously
committed text and metadata loadable. The stale write should fail, cleanup should skip newer
metadata, a pending older write after cleanup should fail, and metadata whose paths do not
match the service-derived naming pattern should be ignored. A symlink named like a valid
recovery text file should not be read or followed during cleanup, and a failed staged write
should remove only the partial file it just created.