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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Compositor/Document/EditorSession+Projects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Compositor/Document/ProjectWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
112 changes: 112 additions & 0 deletions Compositor/IO/ProjectController+ExternalChanges.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
var recheckAttempt = 0
/// Reloads performed because the package changed on disk. Read by tests.
var reloadCount = 0
}
12 changes: 11 additions & 1 deletion Compositor/IO/ProjectController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -239,6 +248,7 @@ final class ProjectController {
session.isProjectBusy = false
if proceed {
session.clearProject()
stopWatchingProject()
window.close()
}
}
Expand Down
30 changes: 30 additions & 0 deletions Compositor/IO/ProjectDigest.swift
Original file line number Diff line number Diff line change
@@ -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<UInt64>.size))
hasher.update(data: data)
}
return ProjectDigest(value: Data(hasher.finalize()))
}
}
85 changes: 85 additions & 0 deletions Compositor/IO/ProjectWatcher.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
private var rearm: Task<Void, Never>?
/// 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()
}
}
}
Loading