From 0590f62fe9f1ec05c011d34998a6b4a037569ae0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 31 Mar 2026 11:06:29 +0000 Subject: [PATCH] fix(AutoUpdater): atomic install to /Applications to avoid removing app on copy failure - Stage Synapse.app beside destination, then replaceItemAt for in-place swap - Prevents data loss when copy/move fails after deleting the existing app - Add SynapseAppInstaller tests for fresh install, replace, and failed source Co-authored-by: Danny Peck --- macOS/Synapse/AutoUpdater.swift | 45 ++++++++++++---- macOS/SynapseTests/AutoUpdaterTests.swift | 62 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/macOS/Synapse/AutoUpdater.swift b/macOS/Synapse/AutoUpdater.swift index 6b83d04..ad06b53 100644 --- a/macOS/Synapse/AutoUpdater.swift +++ b/macOS/Synapse/AutoUpdater.swift @@ -143,18 +143,9 @@ class AutoUpdater: NSObject, ObservableObject { } private func copyApp(from mountPoint: String) throws { - let fm = FileManager.default let sourceURL = URL(fileURLWithPath: mountPoint).appendingPathComponent("Synapse.app") let destURL = URL(fileURLWithPath: "/Applications/Synapse.app") - - guard fm.fileExists(atPath: sourceURL.path) else { - throw UpdateError.installFailed - } - - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) - } - try fm.copyItem(at: sourceURL, to: destURL) + try SynapseAppInstaller.installBundle(from: sourceURL, to: destURL) } private func unmountDMG(at mountPoint: String) async { @@ -241,3 +232,37 @@ enum UpdateError: Error { case installFailed case unsupportedFormat } + +/// Copies `Synapse.app` into place without removing an existing install until the new bundle is on disk. +/// Uses a same-volume staging name under the destination parent, then `replaceItemAt` for an atomic swap. +enum SynapseAppInstaller { + static func installBundle(from sourceAppURL: URL, to destinationAppURL: URL) throws { + let fm = FileManager.default + + guard fm.fileExists(atPath: sourceAppURL.path) else { + throw UpdateError.installFailed + } + + let parent = destinationAppURL.deletingLastPathComponent() + let stagingName = ".Synapse.app.install-\(Process().processIdentifier)-\(UUID().uuidString.prefix(8))" + let stagingURL = parent.appendingPathComponent(stagingName) + + try fm.copyItem(at: sourceAppURL, to: stagingURL) + defer { + if fm.fileExists(atPath: stagingURL.path) { + try? fm.removeItem(at: stagingURL) + } + } + + if fm.fileExists(atPath: destinationAppURL.path) { + _ = try fm.replaceItemAt( + destinationAppURL, + withItemAt: stagingURL, + backupItemName: nil, + options: [] + ) + } else { + try fm.moveItem(at: stagingURL, to: destinationAppURL) + } + } +} diff --git a/macOS/SynapseTests/AutoUpdaterTests.swift b/macOS/SynapseTests/AutoUpdaterTests.swift index 6a11014..f29b80e 100644 --- a/macOS/SynapseTests/AutoUpdaterTests.swift +++ b/macOS/SynapseTests/AutoUpdaterTests.swift @@ -87,4 +87,66 @@ final class AutoUpdaterTests: XCTestCase { let result = updater.isNewerVersion(latest: "abc", current: "1.0.0") XCTAssertFalse(result, "Invalid version should not be newer") } + + // MARK: - SynapseAppInstaller (atomic install) + + func testInstallBundleCopiesToEmptyDestination() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("synapse-install-test-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let source = root.appendingPathComponent("Source.app") + let dest = root.appendingPathComponent("Dest.app") + try makeFakeAppBundle(at: source, marker: "fresh") + + try SynapseAppInstaller.installBundle(from: source, to: dest) + + XCTAssertTrue(fm.fileExists(atPath: dest.path)) + XCTAssertEqual(try String(contentsOf: dest.appendingPathComponent("Contents/marker.txt")), "fresh") + } + + func testInstallBundleReplacesExistingWithoutLeavingBrokenState() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("synapse-replace-test-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let sourceV1 = root.appendingPathComponent("SourceV1.app") + let sourceV2 = root.appendingPathComponent("SourceV2.app") + let dest = root.appendingPathComponent("Dest.app") + try makeFakeAppBundle(at: sourceV1, marker: "v1") + try makeFakeAppBundle(at: sourceV2, marker: "v2") + + try SynapseAppInstaller.installBundle(from: sourceV1, to: dest) + XCTAssertEqual(try String(contentsOf: dest.appendingPathComponent("Contents/marker.txt")), "v1") + + try SynapseAppInstaller.installBundle(from: sourceV2, to: dest) + XCTAssertEqual(try String(contentsOf: dest.appendingPathComponent("Contents/marker.txt")), "v2") + let leftovers = try fm.contentsOfDirectory(atPath: root.path).filter { $0.hasPrefix(".Synapse.app.install") } + XCTAssertTrue(leftovers.isEmpty, "staging files should be removed: \(leftovers)") + } + + func testInstallBundleMissingSourceLeavesDestinationIntact() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("synapse-fail-test-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let missingSource = root.appendingPathComponent("Nope.app") + let dest = root.appendingPathComponent("Dest.app") + try makeFakeAppBundle(at: dest, marker: "keep") + + XCTAssertThrowsError(try SynapseAppInstaller.installBundle(from: missingSource, to: dest)) { error in + XCTAssertEqual(error as? UpdateError, .installFailed) + } + XCTAssertEqual(try String(contentsOf: dest.appendingPathComponent("Contents/marker.txt")), "keep") + } + + private func makeFakeAppBundle(at url: URL, marker: String) throws { + let fm = FileManager.default + let contents = url.appendingPathComponent("Contents") + try fm.createDirectory(at: contents, withIntermediateDirectories: true) + try marker.write(to: contents.appendingPathComponent("marker.txt"), atomically: true, encoding: .utf8) + } }