From ed5ddea9c6d533217213d3227d99f83021dbf554 Mon Sep 17 00:00:00 2001 From: Josusanz Date: Thu, 24 Sep 2026 10:35:45 +0200 Subject: [PATCH] Reload the open document when its package changes on disk When something else writes an open project's .comp package, the document follows it: same tab, same viewport, same selection where the layers still exist. Only a real change counts, unsaved work is never replaced without asking, and a package caught half written is left alone. - ProjectWatcher listens to the kernel's file system events for the package folder, its manifest and its images folder. No polling, and it sees writes from any process, coordinated or not, which NSFilePresenter would miss. Events are coalesced, and every event re-arms the watch by path because an atomic save swaps the package folder. - ProjectDigest hashes the manifest and every asset, so a package that was only touched (sync clients rewriting metadata, identical bytes saved again) is not a change. - ProjectController checks a change off the main thread, loads through the same ProjectStore path as an open, ignores its own saves and packages that fail to load, waits for an edit in progress to finish, and asks with a Revert / Keep Mine sheet when the document has unsaved changes; a hidden tab with unsaved changes is asked when it comes to the front. - EditorSession.reloadProject installs the snapshot while keeping the viewport, collapsed folders and selection. Undo history starts over, as after an open. No change to the .comp format. Closes #103. --- .../Document/EditorSession+Projects.swift | 19 ++ Compositor/Document/ProjectWorkspace.swift | 1 + .../ProjectController+ExternalChanges.swift | 112 +++++++++++ Compositor/IO/ProjectController.swift | 12 +- Compositor/IO/ProjectDigest.swift | 30 +++ Compositor/IO/ProjectWatcher.swift | 85 +++++++++ CompositorTests/ExternalChangeTests.swift | 174 ++++++++++++++++++ 7 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 Compositor/IO/ProjectController+ExternalChanges.swift create mode 100644 Compositor/IO/ProjectDigest.swift create mode 100644 Compositor/IO/ProjectWatcher.swift create mode 100644 CompositorTests/ExternalChangeTests.swift diff --git a/Compositor/Document/EditorSession+Projects.swift b/Compositor/Document/EditorSession+Projects.swift index 83de036a..30d48351 100644 --- a/Compositor/Document/EditorSession+Projects.swift +++ b/Compositor/Document/EditorSession+Projects.swift @@ -39,6 +39,25 @@ extension EditorSession { viewport.fit(documentSize: document!.size) } + /// Replaces the document with what its package holds now, after something else wrote it. Unlike `installProject` + /// it keeps the viewport, the collapsed folders and the selection where those layers still exist, so the + /// reload is invisible beyond the change itself. Undo history is session-only and starts over, as after an open. + func reloadProject(_ snapshot: ProjectSnapshot) { + guard let url = projectURL else { return } + let viewport = self.viewport + let collapsed = collapsedGroupIDs + let active = activeLayerID + let selected = selectedLayerIDs + installProject(snapshot, from: url) + self.viewport = viewport + let ids = Set(snapshot.manifest.layers.map(\.id)) + collapsedGroupIDs = collapsed.intersection(ids) + if let active, ids.contains(active) { + activeLayerID = active + selectedLayerIDs = selected.intersection(ids).union([active]) + } + } + func clearProject() { collapsedGroupIDs = [] isMaskSelected = false diff --git a/Compositor/Document/ProjectWorkspace.swift b/Compositor/Document/ProjectWorkspace.swift index b5c78637..1d3f6cd9 100644 --- a/Compositor/Document/ProjectWorkspace.swift +++ b/Compositor/Document/ProjectWorkspace.swift @@ -49,6 +49,7 @@ final class ProjectWorkspace { current.session.commitTransform() selectedID = id current.controller.window = window + current.controller.resumeExternalChangeCheck() } func newCanvas() { guard canSwitch else { return } diff --git a/Compositor/IO/ProjectController+ExternalChanges.swift b/Compositor/IO/ProjectController+ExternalChanges.swift new file mode 100644 index 00000000..9f57ae63 --- /dev/null +++ b/Compositor/IO/ProjectController+ExternalChanges.swift @@ -0,0 +1,112 @@ +import AppKit + +/// Keeps an open project in step with its package on disk. When something else writes the package, the document +/// is reloaded in place: same tab, same viewport, same selection where the layers still exist. Only a real change +/// in content counts; a package that was merely touched, or one caught half written, is left alone with no message. +/// Unsaved work is never replaced without asking. +extension ProjectController { + /// Starts (or restarts) watching the project the session has open. Called after a successful open and after + /// every save, so the digest of the package on disk is always the one we last read or wrote. + func watchProject(at url: URL) { + guard url != externalChanges.watcher?.url else { return } + externalChanges.watcher = ProjectWatcher(url: url) { [weak self] in self?.noteExternalChange() } + } + + func stopWatchingProject() { + externalChanges.watcher = nil + externalChanges.knownDigest = nil + externalChanges.recheck?.cancel() + externalChanges.recheck = nil + externalChanges.pending = false + } + + /// Remembers the package as it is now, so the next event compares against it. + func rememberProjectDigest(for url: URL) async { + externalChanges.knownDigest = await Task.detached(priority: .utility) { try? ProjectDigest.compute(for: url) }.value + } + + /// The tab came to the front: a change that arrived while it had unsaved work and was hidden can be asked about now. + func resumeExternalChangeCheck() { + if externalChanges.pending { noteExternalChange() } + } + + private func noteExternalChange() { + externalChanges.pending = true + guard !externalChanges.checking else { return } + Task { await checkExternalChange() } + } + + private func checkExternalChange() async { + externalChanges.checking = true + defer { externalChanges.checking = false } + while externalChanges.pending { + externalChanges.pending = false + guard let url = session.projectURL, session.document != nil, !externalChanges.saving else { return } + // Compare content, not modification dates: sync clients touch metadata without changing anything. + guard let digest = await Task.detached(priority: .utility) { try? ProjectDigest.compute(for: url) }.value, + digest != externalChanges.knownDigest else { continue } + // Wait for an edit in progress to finish rather than pulling the document out from under it. + guard session.canStartProjectOperation, session.transformEdit == nil, workspace?.isManaging != true else { + scheduleRecheck(); return + } + if session.isModified { + guard let window, isFrontmost else { externalChanges.pending = true; return } + guard await askToRevert(in: window) else { externalChanges.knownDigest = digest; continue } + } + await reloadFromDisk(url) + } + } + + private var isFrontmost: Bool { workspace.map { $0.current.controller === self } ?? true } + + /// Retries while the session is busy, backing off so a long operation is not polled. + private func scheduleRecheck() { + externalChanges.pending = true + externalChanges.recheck?.cancel() + let attempt = externalChanges.recheckAttempt + externalChanges.recheckAttempt = min(attempt + 1, 7) + externalChanges.recheck = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(250 * (1 << attempt))) + guard let self, !Task.isCancelled else { return } + self.noteExternalChange() + } + } + + private func reloadFromDisk(_ url: URL) async { + externalChanges.recheckAttempt = 0 + session.isProjectBusy = true + defer { session.isProjectBusy = false } + // Loading runs off the main thread, as an open does. A package that fails to load, half written or + // mid-sync, leaves the open document alone; the next change on disk is checked afresh. + guard let snapshot = try? await ProjectStore.shared.load(from: url) else { return } + guard session.projectURL == url, session.document != nil else { return } + session.reloadProject(snapshot) + // Remember the package as loaded, not as first seen: it may have changed again while a sheet was up. + await rememberProjectDigest(for: url) + externalChanges.reloadCount += 1 + } + + private func askToRevert(in window: NSWindow) async -> Bool { + let alert = NSAlert() + alert.messageText = "“\(session.projectURL?.lastPathComponent ?? "Untitled")” was changed on disk." + alert.informativeText = "Another app changed this project. You can revert to the version on disk, losing your unsaved changes, or keep what you have." + alert.addButton(withTitle: "Revert") + alert.addButton(withTitle: "Keep Mine") + return await alert.beginSheetModal(for: window) == .alertFirstButtonReturn + } +} + +/// The controller's bookkeeping for the watch: what the package looked like when last read or written, and +/// whether a check is running, waiting, or deferred because a save of our own is in flight. +@MainActor +final class ExternalChangeState { + var watcher: ProjectWatcher? + var knownDigest: ProjectDigest? + var checking = false + var pending = false + var saving = false + var recheck: Task? + var recheckAttempt = 0 + /// Reloads performed because the package changed on disk. Read by tests. + var reloadCount = 0 +} diff --git a/Compositor/IO/ProjectController.swift b/Compositor/IO/ProjectController.swift index bee28a9a..b1f6a49e 100644 --- a/Compositor/IO/ProjectController.swift +++ b/Compositor/IO/ProjectController.swift @@ -8,6 +8,8 @@ final class ProjectController { weak var window: NSWindow? weak var workspace: ProjectWorkspace? private var saveGeneration = 0 + /// Keeps the document in step with its package when something else writes it. See ProjectController+ExternalChanges. + let externalChanges = ExternalChangeState() var canStart: Bool { session.canStartProjectOperation && workspace?.isManaging != true } @@ -168,12 +170,17 @@ final class ProjectController { guard let destination else { return false } let scoped = destination.startAccessingSecurityScopedResource() defer { if scoped { destination.stopAccessingSecurityScopedResource() } } + // Our own save changes the package too; the watch ignores events until the saved bytes are remembered. + externalChanges.saving = true + defer { externalChanges.saving = false } do { try await ProjectStore.shared.save(snapshot, to: destination) session.projectURL = destination session.history.markSaved() saveGeneration += 1 NSDocumentController.shared.noteNewRecentDocumentURL(destination) + await rememberProjectDigest(for: destination) + watchProject(at: destination) return true } catch { await showError("Couldn’t save the project", error: error) @@ -215,6 +222,8 @@ final class ProjectController { } session.installProject(snapshot, from: source) NSDocumentController.shared.noteNewRecentDocumentURL(source) + await rememberProjectDigest(for: source) + watchProject(at: source) return true } catch { await showError("Couldn’t open the project", error: error) @@ -227,7 +236,7 @@ final class ProjectController { guard begin() else { return } let proceed = await confirmReplacement() session.isProjectBusy = false - if proceed { session.clearProject() } + if proceed { session.clearProject(); stopWatchingProject() } } func close(_ window: NSWindow) async { @@ -239,6 +248,7 @@ final class ProjectController { session.isProjectBusy = false if proceed { session.clearProject() + stopWatchingProject() window.close() } } diff --git a/Compositor/IO/ProjectDigest.swift b/Compositor/IO/ProjectDigest.swift new file mode 100644 index 00000000..6cd198fd --- /dev/null +++ b/Compositor/IO/ProjectDigest.swift @@ -0,0 +1,30 @@ +import CryptoKit +import Foundation + +/// A fingerprint of what a project package contains: the manifest and every asset, byte for byte. A package that +/// was only touched (a sync client rewriting metadata, a permission change, the same bytes saved again) has the +/// same digest as before, so it is not treated as a change. +nonisolated struct ProjectDigest: Equatable, Sendable { + let value: Data + + /// Reads the package outside file coordination on purpose: it is called after a change was already seen and + /// it must never wait on a writer. A package caught half written yields a digest that matches nothing, or an + /// error; both make the caller wait for the next change. + static func compute(for url: URL) throws -> ProjectDigest { + var hasher = SHA256() + let manifest = try Data(contentsOf: url.appendingPathComponent("manifest.json")) + hasher.update(data: manifest) + let images = url.appendingPathComponent("images", isDirectory: true) + let names = ((try? FileManager.default.contentsOfDirectory(atPath: images.path)) ?? []).sorted() + for name in names { + let file = images.appendingPathComponent(name) + guard try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + hasher.update(data: Data(name.utf8)) + let data = try Data(contentsOf: file, options: .mappedIfSafe) + var count = UInt64(data.count) + hasher.update(bufferPointer: UnsafeRawBufferPointer(start: &count, count: MemoryLayout.size)) + hasher.update(data: data) + } + return ProjectDigest(value: Data(hasher.finalize())) + } +} diff --git a/Compositor/IO/ProjectWatcher.swift b/Compositor/IO/ProjectWatcher.swift new file mode 100644 index 00000000..ce0ff2d1 --- /dev/null +++ b/Compositor/IO/ProjectWatcher.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Tells its owner when a project package changes on disk, whoever changed it: another app, an agent, a sync +/// client, a git checkout. It listens to the kernel's file system events for the package folder, its manifest and +/// its images folder, so there is no polling and no dependency on the writer using file coordination (which +/// `NSFilePresenter` needs and most other writers skip). Events are coalesced, and the handler runs on the main actor. +/// +/// A package is replaced atomically by renaming a sibling over it, which retires the file descriptors being watched; +/// every event therefore re-arms the watch by path, so the new package is watched after the swap. +@MainActor +final class ProjectWatcher { + let url: URL + private let onChange: @MainActor () -> Void + private let sources = SourceBox() + private var delivery: Task? + private var rearm: Task? + /// How long to wait after the last event before reporting, so a save that touches several files reports once. + static let coalescing: Duration = .milliseconds(300) + + init(url: URL, onChange: @escaping @MainActor () -> Void) { + self.url = url + self.onChange = onChange + arm() + } + + deinit { sources.cancelAll() } + + func stop() { + delivery?.cancel(); delivery = nil + rearm?.cancel(); rearm = nil + sources.cancelAll() + } + + private var watchedPaths: [String] { + [url.path, url.appendingPathComponent("manifest.json").path, url.appendingPathComponent("images", isDirectory: true).path] + } + + private func arm() { + sources.cancelAll() + for path in watchedPaths { + let descriptor = open(path, O_EVTONLY) + guard descriptor >= 0 else { continue } + let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor, + eventMask: [.write, .extend, .delete, .rename, .link, .attrib], queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.noteEvent() } + } + source.setCancelHandler { close(descriptor) } + source.resume() + sources.append(source) + } + } + + /// The dispatch sources, kept outside the actor so `deinit` can cancel them from any context. + private final class SourceBox: @unchecked Sendable { + private let lock = NSLock() + private var list: [DispatchSourceFileSystemObject] = [] + var count: Int { lock.withLock { list.count } } + func append(_ source: DispatchSourceFileSystemObject) { lock.withLock { list.append(source) } } + func cancelAll() { + let cancelled = lock.withLock { let l = list; list.removeAll(); return l } + for source in cancelled { source.cancel() } + } + } + + private func noteEvent() { + // Re-arm by path once the writer has finished swapping files, retrying briefly while the package + // is mid-replacement and a path does not exist yet. + rearm?.cancel() + rearm = Task { @MainActor [weak self] in + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(100)) + guard let self, !Task.isCancelled else { return } + self.arm() + if self.sources.count == self.watchedPaths.count { return } + } + } + delivery?.cancel() + delivery = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.coalescing) + guard let self, !Task.isCancelled else { return } + self.onChange() + } + } +} diff --git a/CompositorTests/ExternalChangeTests.swift b/CompositorTests/ExternalChangeTests.swift new file mode 100644 index 00000000..a142d850 --- /dev/null +++ b/CompositorTests/ExternalChangeTests.swift @@ -0,0 +1,174 @@ +import AppKit +import Testing +import UniformTypeIdentifiers +@testable import Compositor + +/// A project that something else writes while it is open: the document follows the package on disk, and only +/// when the package really changed. +@MainActor +struct ExternalChangeTests { + private func temporaryFolder() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("CompositorExternalChangeTests-\(UUID())") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + return url + } + + /// A saved two-layer project: an imported image below a blank layer. + private func savedProject(in root: URL) async throws -> URL { + let session = EditorSession() + await session.importImages([try ImageImportTests().fixture(.png)]) + session.renameLayer(try #require(session.activeLayerID), to: "Base") + session.addBlankLayer() + let url = root.appendingPathComponent("Watched.comp") + try await ProjectStore.shared.save(try #require(session.projectSnapshot()), to: url) + return url + } + + /// Opens the project the way the app does, so the watch is armed and the digest remembered. + private func opened(_ url: URL) async throws -> ProjectController { + let controller = ProjectController(session: EditorSession()) + #expect(await controller.open(url)) + return controller + } + + /// Rewrites the package the way another app would: the same store, a different layer name. + private func renameFirstLayerOnDisk(_ url: URL, to name: String) async throws { + var snapshot = try await ProjectStore.shared.load(from: url) + var layer = snapshot.manifest.layers[0] + layer = ProjectLayerRecord(id: layer.id, name: name, isVisible: layer.isVisible, transform: layer.transform, + imageFile: layer.imageFile, parentID: layer.parentID, isGroup: layer.isGroup, opacity: layer.opacity, + blendMode: layer.blendMode, maskFile: layer.maskFile, maskEnabled: layer.maskEnabled, maskSourceID: layer.maskSourceID, + adjustment: layer.adjustment, maskPlacement: layer.maskPlacement, maskLinked: layer.maskLinked, shape: layer.shape, + effects: layer.effects, text: layer.text) + snapshot = ProjectSnapshot(manifest: ProjectManifest(resolution: snapshot.manifest.resolution, documentID: snapshot.manifest.documentID, + width: snapshot.manifest.width, height: snapshot.manifest.height, activeLayerID: snapshot.manifest.activeLayerID, + layers: [layer] + snapshot.manifest.layers.dropFirst(), guides: snapshot.manifest.guides), images: snapshot.images, masks: snapshot.masks) + try await ProjectStore.shared.save(snapshot, to: url) + } + + private func eventually(_ timeout: Duration = .seconds(4), _ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return condition() + } + + private func settle() async { try? await Task.sleep(for: .milliseconds(900)) } + + @Test func digestFollowsContentNotMetadata() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let manifest = url.appendingPathComponent("manifest.json") + let before = try ProjectDigest.compute(for: url) + // Touched, and rewritten with the same bytes: what sync clients do. + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: 60)], ofItemAtPath: manifest.path) + try Data(contentsOf: manifest).write(to: manifest, options: .atomic) + #expect(try ProjectDigest.compute(for: url) == before) + // A real change to the manifest, then a real change to a layer's pixels. + try await renameFirstLayerOnDisk(url, to: "Renamed elsewhere") + let renamed = try ProjectDigest.compute(for: url) + #expect(renamed != before) + let images = url.appendingPathComponent("images") + let png = try #require(try FileManager.default.contentsOfDirectory(atPath: images.path).first { $0.hasSuffix(".png") }) + let other = try Data(contentsOf: try ImageImportTests().fixture(.jpeg)) + try other.write(to: images.appendingPathComponent(png), options: .atomic) + #expect(try ProjectDigest.compute(for: url) != renamed) + } + + @Test func reloadKeepsViewportSelectionAndFoldersButNotHistory() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let session = EditorSession() + session.installProject(try await ProjectStore.shared.load(from: url), from: url) + let size = try #require(session.document?.size) + session.viewport.viewSize = CGSize(width: 800, height: 600) + session.viewport.setZoom(3, anchoredAt: .zero, documentSize: size) + let active = try #require(session.document?.layers.first?.id) + session.activeLayerID = active + session.renameLayer(active, to: "Edited here") + #expect(session.isModified && session.canUndo) + try await renameFirstLayerOnDisk(url, to: "Renamed elsewhere") + session.reloadProject(try await ProjectStore.shared.load(from: url)) + #expect(session.document?.layers.first?.name == "Renamed elsewhere") + #expect(session.viewport.zoom == 3) + #expect(session.activeLayerID == active) + #expect(!session.isModified) + #expect(!session.canUndo) + #expect(session.projectURL == url) + } + + @Test func writingThePackageElsewhereReloadsTheOpenProject() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let controller = try await opened(url) + try await renameFirstLayerOnDisk(url, to: "Renamed elsewhere") + #expect(await eventually { controller.externalChanges.reloadCount == 1 }) + #expect(controller.session.document?.layers.first?.name == "Renamed elsewhere") + #expect(!controller.session.isModified) + // The manifest rewritten in place, as a script would do it, is seen too. + let manifest = url.appendingPathComponent("manifest.json") + let text = try String(contentsOf: manifest, encoding: .utf8).replacingOccurrences(of: "Renamed elsewhere", with: "Renamed again") + try text.write(to: manifest, atomically: true, encoding: .utf8) + #expect(await eventually { controller.externalChanges.reloadCount == 2 }) + #expect(controller.session.document?.layers.first?.name == "Renamed again") + } + + @Test func ourOwnSaveAndMetadataTouchesDoNotReload() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let controller = try await opened(url) + let session = controller.session + let active = try #require(session.document?.layers.first?.id) + session.renameLayer(active, to: "Saved by us") + #expect(await controller.save()) + await settle() + #expect(controller.externalChanges.reloadCount == 0) + #expect(session.document?.layers.first?.name == "Saved by us") + #expect(!session.isModified) + let manifest = url.appendingPathComponent("manifest.json") + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: 60)], ofItemAtPath: manifest.path) + try Data(contentsOf: manifest).write(to: manifest, options: .atomic) + await settle() + #expect(controller.externalChanges.reloadCount == 0) + } + + @Test func halfWrittenPackagesAreIgnoredUntilTheyLoad() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let controller = try await opened(url) + let manifest = url.appendingPathComponent("manifest.json") + let good = try Data(contentsOf: manifest) + try Data("{ \"format\": \"com.compositor.project\", \"version\": 9, \"layers\": [".utf8).write(to: manifest, options: .atomic) + await settle() + #expect(controller.externalChanges.reloadCount == 0) + #expect(controller.session.document?.layers.count == 2) + let fixed = try #require(String(data: good, encoding: .utf8)).replacingOccurrences(of: "\"Base\"", with: "\"Finished\"") + try fixed.write(to: manifest, atomically: true, encoding: .utf8) + #expect(await eventually { controller.externalChanges.reloadCount == 1 }) + #expect(controller.session.document?.layers.contains { $0.name == "Finished" } == true) + } + + @Test func unsavedWorkIsNeverReplacedWithoutAsking() async throws { + let root = try temporaryFolder() + defer { try? FileManager.default.removeItem(at: root) } + let url = try await savedProject(in: root) + let controller = try await opened(url) + let session = controller.session + let active = try #require(session.document?.layers.first?.id) + session.renameLayer(active, to: "Unsaved here") + try await renameFirstLayerOnDisk(url, to: "Renamed elsewhere") + await settle() + // No window to ask in, so the question waits and the document keeps the unsaved edit. + #expect(controller.externalChanges.reloadCount == 0) + #expect(controller.externalChanges.pending) + #expect(session.document?.layers.first?.name == "Unsaved here") + #expect(session.isModified) + } +}