diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 1fd1142..2fcc870 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -769,6 +769,22 @@ } } }, + "Couldn’t Uninstall SimStore": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Uninstall SimStore" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法卸载 SimStore" + } + } + } + }, "Couldn’t Update SimStore": { "localizations": { "en": { @@ -1377,6 +1393,22 @@ } } }, + "Move SimStore to a writable folder before uninstalling it.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move SimStore to a writable folder before uninstalling it." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先将 SimStore 移到可写入的文件夹,再卸载。" + } + } + } + }, "Newer compatible versions of your installed apps will appear here.": { "localizations": { "en": { @@ -2033,6 +2065,22 @@ } } }, + "SimStore, its caches, settings, and temporary files will be permanently deleted. SimStore will then quit.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SimStore, its caches, settings, and temporary files will be permanently deleted. SimStore will then quit." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将永久删除 SimStore 及其缓存、设置和临时文件。随后 SimStore 将退出。" + } + } + } + }, "Simulator": { "localizations": { "en": { @@ -2465,6 +2513,22 @@ } } }, + "The running SimStore app could not be verified. Nothing was uninstalled.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The running SimStore app could not be verified. Nothing was uninstalled." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法验证当前运行的 SimStore,未执行卸载。" + } + } + } + }, "The simulator application could not be found. Check your Xcode installation.": { "localizations": { "en": { @@ -2497,6 +2561,22 @@ } } }, + "The uninstall assistant could not finish. Try again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The uninstall assistant could not finish. Try again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载助手未能完成操作,请重试。" + } + } + } + }, "The update assistant could not start. SimStore has not been replaced.": { "localizations": { "en": { @@ -2657,6 +2737,38 @@ } } }, + "Uninstall SimStore?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall SimStore?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载 SimStore?" + } + } + } + }, + "Uninstall SimStore…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall SimStore…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载 SimStore…" + } + } + } + }, "Uninstalling…": { "localizations": { "en": { diff --git a/docs/cache.md b/docs/cache.md index 1a4fed7..455e3d1 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -41,3 +41,9 @@ The client's own `version.json` feed is fetched on each explicit update check wi Self-update archives use the same uncached download session as Simulator app installations. They are streamed to a private, randomly named `.simstore-update-*` directory beside the installed SimStore app, rather than a persistent download cache, so an atomic replacement can stay on one filesystem. The verified archive is deleted after preparation. The extracted bundle, copied assistant, and previous version remain only until the update succeeds or finishes failing/canceling. An interrupted process can leave a staging directory for manual cleanup. If the update assistant encounters an error, it writes a translated message to `update-error.json` in the client's cache directory. The running or relaunched client consumes and removes that message when reporting the failure. This file is a one-time failure report, not a version or package cache; losing it does not affect the installed application. + +## Uninstalling SimStore + +SimStore → Uninstall SimStore… shows a confirmation with the running application's path. Uninstall removes that bundle, its catalog and icon caches, application support data, preferences including host-specific preferences, saved window state, HTTP storage, WebKit data, logs, and installation temporary directories belonging to its bundle identifier. It also removes abandoned uninstall staging directories and self-update staging directories beside the app; a staging request pointing to another application is preserved. + +The existing native assistant runs from a private temporary directory and verifies the target process and bundle. It pauses the app before synchronizing and clearing its preference domains and deleting files, removes its own staging directory, then sends SIGKILL to the target PID. This prevents the app from recreating caches or saving window state during normal termination. Cleanup failure resumes the app and returns an error. Uninstall is unavailable while a SimStore update is in progress. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a271082..42249c1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -55,10 +55,14 @@ add_executable(SimStore MACOSX_BUNDLE SelfUpdateStore.swift SelfUpdateView.swift SelfUpdateController.swift + SelfUninstallation.swift + SelfUninstaller.swift + SelfUninstallController.swift ) add_executable(SimStoreUpdater SelfUpdateHelper.swift + SelfUninstallation.swift SelfUpdateInstallation.swift SelfUpdateRelease.swift SelfUpdatePackage.swift diff --git a/src/SelfUninstallController.swift b/src/SelfUninstallController.swift new file mode 100644 index 0000000..5b5a87c --- /dev/null +++ b/src/SelfUninstallController.swift @@ -0,0 +1,53 @@ +import AppKit +import Observation + +@MainActor +@Observable +final class SelfUninstallController { + private(set) var isRunning = false + @ObservationIgnored private let application: URL + @ObservationIgnored private let uninstall: @MainActor () async throws -> Void + @ObservationIgnored private let failureTitle = String(localized: "Couldn’t Uninstall SimStore") + + init( + application: URL = Bundle.main.bundleURL, + uninstall: @escaping @MainActor () async throws -> Void = SelfUninstaller.start + ) { + self.application = application + self.uninstall = uninstall + } + + func confirm(beforeUninstall: () -> Void = {}) { + guard !isRunning else { return } + isRunning = true + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "Uninstall SimStore?") + alert.informativeText = + String( + localized: + "SimStore, its caches, settings, and temporary files will be permanently deleted. SimStore will then quit." + ) + + "\n\n" + application.path + alert.addButton(withTitle: String(localized: "Cancel")).keyEquivalent = "\u{1b}" + alert.addButton(withTitle: String(localized: "Uninstall")).hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { + isRunning = false + return + } + beforeUninstall() + Task { + defer { isRunning = false } + do { + try await uninstall() + } catch { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = failureTitle + failure.informativeText = error.localizedDescription + failure.addButton(withTitle: String(localized: "OK")) + failure.runModal() + } + } + } +} diff --git a/src/SelfUninstallation.swift b/src/SelfUninstallation.swift new file mode 100644 index 0000000..4fb65e8 --- /dev/null +++ b/src/SelfUninstallation.swift @@ -0,0 +1,181 @@ +import AppKit +import CoreFoundation +import Darwin + +struct SelfUninstallRequest: Codable { + let application: URL + let bundleID: String + let parentPID: Int32 + var language = "en" +} + +struct SelfUninstallPlan { + let application: URL + let bundleID: String + let library: URL + let temporary: URL + + init( + application: URL, bundleID: String, + library: URL = .libraryDirectory, temporary: URL = .temporaryDirectory + ) throws { + let application = application.resolvingSymlinksInPath().standardizedFileURL + guard application.isFileURL, application.pathExtension == "app", + !bundleID.isEmpty, ![".", ".."].contains(bundleID), + !bundleID.contains("/"), !bundleID.contains("\\"), + let info = try PropertyListSerialization.propertyList( + from: Data(contentsOf: application.appending(path: "Contents/Info.plist")), + format: nil) as? [String: Any], + info["CFBundleIdentifier"] as? String == bundleID + else { throw SelfUninstallError.invalidApplication } + self.application = application + self.bundleID = bundleID + self.library = library + self.temporary = temporary + } + + func dataPaths(excluding work: URL? = nil) throws -> [URL] { + var paths = [ + "Application Support/\(bundleID)", "Caches/\(bundleID)", "HTTPStorages/\(bundleID)", + "HTTPStorages/\(bundleID).binarycookies", "Logs/\(bundleID)", "WebKit/\(bundleID)", + "Preferences/\(bundleID).plist", "Saved Application State/\(bundleID).savedState", + ].map { library.appending(path: $0) } + for url in try children(of: library.appending(path: "Preferences/ByHost")) { + let name = url.lastPathComponent + if name.hasPrefix(bundleID + "."), name.hasSuffix(".plist"), + UUID(uuidString: String(name.dropFirst(bundleID.count + 1).dropLast(6))) != nil + { + paths.append(url) + } + } + for url in try children(of: temporary) + where url.standardizedFileURL.path != work?.standardizedFileURL.path { + if owns(url, prefix: bundleID + "-") || owns(url, prefix: bundleID + ".uninstall-") { + paths.append(url) + } + } + for url in try children(of: application.deletingLastPathComponent()) { + if owns(url, prefix: ".simstore-update-") { + if let data = try? Data(contentsOf: url.appending(component: "request.json")), + let owner = try? JSONDecoder().decode(UpdateOwner.self, from: data), + owner.application.resolvingSymlinksInPath().path != application.path + { + continue + } + paths.append(url) + } + } + return paths + } + + func removeData(excluding work: URL? = nil) throws { + for url in try dataPaths(excluding: work) { try SelfUninstallation.removeIfPresent(url) } + } + + private func owns(_ url: URL, prefix: String) -> Bool { + let name = url.lastPathComponent + return name.hasPrefix(prefix) && UUID(uuidString: String(name.dropFirst(prefix.count))) != nil + } + + private struct UpdateOwner: Decodable { let application: URL } + + private func children(of directory: URL) throws -> [URL] { + guard FileManager.default.fileExists(atPath: directory.path) else { return [] } + return try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) + } +} + +enum SelfUninstallation { + static func removeIfPresent(_ url: URL) throws { + do { + try FileManager.default.removeItem(at: url) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + } + } + + static func clearPreferences(_ bundleID: String) throws { + let application = bundleID as CFString + for host in [kCFPreferencesAnyHost, kCFPreferencesCurrentHost] { + if let keys = CFPreferencesCopyKeyList(application, kCFPreferencesCurrentUser, host) { + CFPreferencesSetMultiple(nil, keys, application, kCFPreferencesCurrentUser, host) + } + guard CFPreferencesSynchronize(application, kCFPreferencesCurrentUser, host) else { + throw SelfUninstallError.helperFailed + } + } + } + + @MainActor + static func run(from directory: URL) throws { + let request = try JSONDecoder().decode( + SelfUninstallRequest.self, + from: Data(contentsOf: directory.appending(component: "request.json"))) + let plan = try SelfUninstallPlan(application: request.application, bundleID: request.bundleID) + let prefix = request.bundleID + ".uninstall-" + guard + directory.deletingLastPathComponent().resolvingSymlinksInPath() + == URL.temporaryDirectory.resolvingSymlinksInPath(), + directory.lastPathComponent.hasPrefix(prefix), + UUID(uuidString: String(directory.lastPathComponent.dropFirst(prefix.count))) + != nil, + request.parentPID > 1, request.parentPID != getpid(), + let running = NSRunningApplication(processIdentifier: request.parentPID), + running.bundleIdentifier == request.bundleID, + running.bundleURL?.resolvingSymlinksInPath().path == plan.application.path + else { throw SelfUninstallError.invalidApplication } + guard + FileManager.default.isWritableFile(atPath: plan.application.deletingLastPathComponent().path), + !plan.application.pathComponents.contains("AppTranslocation") + else { throw SelfUninstallError.notWritable } + guard kill(request.parentPID, SIGSTOP) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .ESRCH) + } + var completed = false + defer { if !completed { kill(request.parentPID, SIGCONT) } } + // Wait for every app thread to stop before removing files its background tasks can write. + let deadline = Date().addingTimeInterval(5) + while true { + var info = proc_bsdinfo() + guard + proc_pidinfo( + request.parentPID, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info))) + > 0 + else { throw POSIXError(.ESRCH) } + if info.pbi_status == SSTOP { break } + guard Date() < deadline else { throw SelfUninstallError.helperFailed } + Thread.sleep(forTimeInterval: 0.01) + } + try clearPreferences(request.bundleID) + try plan.removeData(excluding: directory) + try FileManager.default.removeItem(at: plan.application) + try removeIfPresent(directory) + // A normal quit can save preferences or window state again after they have been deleted. + guard kill(request.parentPID, SIGKILL) == 0 || errno == ESRCH else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + completed = true + } +} + +enum SelfUninstallError: LocalizedError { + case invalidApplication, notWritable, helperFailed + + var errorDescription: String? { + message(in: .main) + } + + func message(in bundle: Bundle) -> String { + switch self { + case .invalidApplication: + String( + localized: "The running SimStore app could not be verified. Nothing was uninstalled.", + bundle: bundle) + case .notWritable: + String( + localized: "Move SimStore to a writable folder before uninstalling it.", bundle: bundle) + case .helperFailed: + String(localized: "The uninstall assistant could not finish. Try again.", bundle: bundle) + } + } +} diff --git a/src/SelfUninstaller.swift b/src/SelfUninstaller.swift new file mode 100644 index 0000000..2fb2c81 --- /dev/null +++ b/src/SelfUninstaller.swift @@ -0,0 +1,39 @@ +import AppKit + +enum SelfUninstaller { + @MainActor + static func start() async throws { + let application = Bundle.main.bundleURL.resolvingSymlinksInPath() + let bundleID = AppConfiguration.applicationIdentifier + _ = try SelfUninstallPlan(application: application, bundleID: bundleID) + let directory = URL.temporaryDirectory.appending( + component: "\(bundleID).uninstall-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: directory) } + let request = SelfUninstallRequest( + application: application, bundleID: bundleID, parentPID: getpid(), + language: Bundle.main.preferredLocalizations.first ?? "en") + try JSONEncoder().encode(request).write( + to: directory.appending(component: "request.json"), options: .atomic) + let helper = directory.appending(component: "SimStoreUpdater") + try FileManager.default.copyItem( + at: application.appending(path: "Contents/Helpers/SimStoreUpdater"), to: helper) + let process = Process() + process.executableURL = helper + process.arguments = ["--uninstall", directory.path] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + while process.isRunning { try await Task.sleep(for: .milliseconds(100)) } + // Successful cleanup kills this process. A return means the assistant failed. + if let data = try? Data(contentsOf: directory.appending(component: "error.json")), + let message = try? JSONDecoder().decode(String.self, from: data) + { + throw NSError( + domain: "SelfUninstall", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + throw SelfUninstallError.helperFailed + } +} diff --git a/src/SelfUpdateHelper.swift b/src/SelfUpdateHelper.swift index 64b6ae7..00ad7ea 100644 --- a/src/SelfUpdateHelper.swift +++ b/src/SelfUpdateHelper.swift @@ -4,6 +4,27 @@ import AppKit struct SelfUpdateHelper { @MainActor static func main() async { + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--uninstall" { + let directory = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + .standardizedFileURL + do { + try SelfUninstallation.run(from: directory) + } catch { + var message = error.localizedDescription + if let uninstallError = error as? SelfUninstallError, + let data = try? Data(contentsOf: directory.appending(component: "request.json")), + let request = try? JSONDecoder().decode(SelfUninstallRequest.self, from: data) + { + message = uninstallError.message( + in: SelfUpdateInstallation.localizationBundle( + application: request.application, language: request.language)) + } + try? JSONEncoder().encode(message).write( + to: directory.appending(component: "error.json"), options: .atomic) + exit(EXIT_FAILURE) + } + return + } guard CommandLine.arguments.count == 2 else { exit(EXIT_FAILURE) } let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) .standardizedFileURL diff --git a/src/SimStoreApp.swift b/src/SimStoreApp.swift index fe60c5d..224551a 100644 --- a/src/SimStoreApp.swift +++ b/src/SimStoreApp.swift @@ -4,6 +4,7 @@ import SwiftUI struct SimStoreApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var updates = SelfUpdateController() + @State private var uninstaller = SelfUninstallController() var body: some Scene { Window(Text(verbatim: "SimStore"), id: "main") { @@ -13,10 +14,16 @@ struct SimStoreApp: App { .defaultSize(width: 1180, height: 780) .commands { CommandGroup(replacing: .appInfo) { - Button("About SimStore") { updates.show() } + Button("About SimStore") { updates.show() }.disabled(uninstaller.isRunning) } CommandGroup(after: .appInfo) { Button("Check for Updates…") { updates.show(check: true) } + .disabled(uninstaller.isRunning) + Divider() + Button("Uninstall SimStore…") { + uninstaller.confirm { updates.panel?.close() } + } + .disabled(uninstaller.isRunning || updates.store.phase != nil) } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 59731dc..d2c8377 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -239,3 +239,14 @@ add_test(NAME update-assistant-metadata COMMAND "${CMAKE_COMMAND}" "-DEXPECTED_MIN_OS=${CMAKE_OSX_DEPLOYMENT_TARGET}" -P "${CMAKE_CURRENT_SOURCE_DIR}/BundleMetadataTest.cmake" ) + +add_executable(SimStoreSelfUninstallTest SelfUninstallTests.swift + "${PROJECT_SOURCE_DIR}/src/SelfUninstallation.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUninstaller.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUninstallController.swift" + "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" +) +add_dependencies(SimStoreSelfUninstallTest SimStore) +target_compile_options(SimStoreSelfUninstallTest PRIVATE -parse-as-library) +add_test(NAME self-uninstall COMMAND SimStoreSelfUninstallTest "$") +set_tests_properties(self-uninstall PROPERTIES TIMEOUT 60 RUN_SERIAL TRUE) diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index 3f1e3d5..35d4dab 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -62,6 +62,29 @@ struct LocalizationTests { ("Uninstall", "Uninstall", "卸载"), ("Uninstalling…", "Uninstalling…", "正在卸载…"), ("About SimStore", "About SimStore", "关于 SimStore"), + ("Uninstall SimStore…", "Uninstall SimStore…", "卸载 SimStore…"), + ("Uninstall SimStore?", "Uninstall SimStore?", "卸载 SimStore?"), + ("Couldn’t Uninstall SimStore", "Couldn’t Uninstall SimStore", "无法卸载 SimStore"), + ( + "SimStore, its caches, settings, and temporary files will be permanently deleted. SimStore will then quit.", + "SimStore, its caches, settings, and temporary files will be permanently deleted. SimStore will then quit.", + "将永久删除 SimStore 及其缓存、设置和临时文件。随后 SimStore 将退出。" + ), + ( + "The running SimStore app could not be verified. Nothing was uninstalled.", + "The running SimStore app could not be verified. Nothing was uninstalled.", + "无法验证当前运行的 SimStore,未执行卸载。" + ), + ( + "Move SimStore to a writable folder before uninstalling it.", + "Move SimStore to a writable folder before uninstalling it.", + "请先将 SimStore 移到可写入的文件夹,再卸载。" + ), + ( + "The uninstall assistant could not finish. Try again.", + "The uninstall assistant could not finish. Try again.", + "卸载助手未能完成操作,请重试。" + ), ("Check for Updates…", "Check for Updates…", "检查更新…"), ("Update and Restart", "Update and Restart", "更新并重新启动"), ("Install Nightly and Restart", "Install Nightly and Restart", "安装每夜构建并重新启动"), diff --git a/tests/SelfUninstallTests.swift b/tests/SelfUninstallTests.swift new file mode 100644 index 0000000..dfc9b14 --- /dev/null +++ b/tests/SelfUninstallTests.swift @@ -0,0 +1,295 @@ +import AppKit +import CoreFoundation +import Darwin + +@main +@MainActor +struct SelfUninstallTests { + static func main() async { + do { + guard CommandLine.arguments.count == 2 else { throw failure("Expected the built app path.") } + try cleanupScope() + try await confirmation() + try await helperIntegration(source: URL(fileURLWithPath: CommandLine.arguments[1])) + print( + "PASS: uninstall confirmation, scoped cleanup, helper validation, failure recovery, and final process kill." + ) + } catch { + FileHandle.standardError.write( + Data("Self-uninstall test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + private static func cleanupScope() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let identifier = "org.example.Uninstall" + let application = root.appending(component: "SimStore fixture.app") + try writeInfo(["CFBundleIdentifier": identifier], application: application) + let library = root.appending(component: "Library") + let temporary = root.appending(component: "temporary") + let plan = try SelfUninstallPlan( + application: application, bundleID: identifier, library: library, temporary: temporary) + for path in try plan.dataPaths() { try marker(at: path) } + let byHost = library.appending(path: "Preferences/ByHost/\(identifier).\(UUID()).plist") + let download = temporary.appending(component: "\(identifier)-\(UUID())") + let update = root.appending(component: ".simstore-update-\(UUID())") + let orphan = temporary.appending(component: "\(identifier).uninstall-\(UUID())") + for path in [byHost, download, update, orphan] { try marker(at: path) } + let work = temporary.appending(component: "\(identifier).uninstall-\(UUID())") + let otherUpdate = root.appending(component: ".simstore-update-\(UUID())") + try marker(at: work) + try FileManager.default.createDirectory(at: otherUpdate, withIntermediateDirectories: true) + try JSONEncoder().encode(["application": root.appending(component: "Other.app")]).write( + to: otherUpdate.appending(component: "request.json")) + let unrelated = [ + library.appending(path: "Caches/another-app"), + library.appending(path: "Preferences/ByHost/\(identifier).beta.\(UUID()).plist"), + temporary.appending(component: "\(identifier)-notes"), + temporary.appending(component: "\(identifier).beta-\(UUID())"), + root.appending(component: ".simstore-update-notes"), + otherUpdate, + ] + for path in unrelated { try marker(at: path) } + let cache = library.appending(path: "Caches/\(identifier)") + try FileManager.default.removeItem(at: cache) + let external = root.appending(component: "unrelated target") + try marker(at: external) + try FileManager.default.createSymbolicLink(at: cache, withDestinationURL: external) + let removed = try plan.dataPaths(excluding: work) + try plan.removeData(excluding: work) + for path in removed { try require(!exists(path), "Remove owned data at \(path.path).") } + for path in unrelated + [work, external, application] { + try require( + exists(path), + "Keep unrelated files, symlink targets, the helper, and app until final cleanup.") + } + try plan.removeData(excluding: work) + for id in ["", "../Uninstall", ".", "..", "org.example/Uninstall"] { + do { + _ = try SelfUninstallPlan( + application: application, bundleID: id, library: library, temporary: temporary) + throw failure("Reject invalid application identifiers.") + } catch is SelfUninstallError {} + } + } + + private static func confirmation() async throws { + NSApplication.shared.setActivationPolicy(.regular) + var calls = 0 + var prepared = false + let controller = SelfUninstallController( + application: URL(fileURLWithPath: "/Example/SimStore.app"), + uninstall: { calls += 1 }) + let cancel = clickWhenShown("Cancel") + controller.confirm { prepared = true } + cancel.invalidate() + try require( + calls == 0 && !prepared && !controller.isRunning, + "Cancel must leave the app and data untouched.") + let uninstall = clickWhenShown("Uninstall") + controller.confirm { prepared = true } + uninstall.invalidate() + try await eventually { !controller.isRunning } + try require(calls == 1 && prepared, "Only explicit confirmation may invoke uninstall.") + + var reported = false + let failed = SelfUninstallController(uninstall: { throw failure("Fixture uninstall failure") }) + let accept = clickWhenShown("Uninstall") + let dismiss = clickWhenShown("OK") { reported = true } + failed.confirm() + try await eventually { !failed.isRunning } + accept.invalidate() + dismiss.invalidate() + try require(reported, "A failed uninstall must report an error and allow another attempt.") + } + + private static func clickWhenShown(_ title: String, clicked: @escaping @MainActor () -> Void = {}) + -> Timer + { + let timer = Timer(timeInterval: 0.02, repeats: true) { timer in + let didClick = MainActor.assumeIsolated { + @MainActor + func find(_ view: NSView) -> NSButton? { + if let button = view as? NSButton, button.title == title { return button } + return view.subviews.lazy.compactMap { find($0) }.first + } + if let view = NSApplication.shared.modalWindow?.contentView, let button = find(view) { + clicked() + button.performClick(nil) + return true + } + return false + } + if didClick { timer.invalidate() } + } + RunLoop.main.add(timer, forMode: .modalPanel) + return timer + } + + private static func helperIntegration(source: URL) async throws { + for shouldFail in [false, true] { + let root = try directory() + let identifier = "org.example.SimStoreUninstall.\(UUID())" + let application = root.appending(component: "SimStore fixture.app") + try FileManager.default.copyItem(at: source, to: application) + let infoURL = application.appending(path: "Contents/Info.plist") + var info = + try PropertyListSerialization.propertyList(from: Data(contentsOf: infoURL), format: nil) + as! [String: Any] + info["CFBundleIdentifier"] = identifier + try writeInfo(info, application: application) + let config = NSWorkspace.OpenConfiguration() + config.createsNewApplicationInstance = true + config.allowsRunningApplicationSubstitution = false + let running = try await NSWorkspace.shared.openApplication( + at: application, configuration: config) + let plan = try SelfUninstallPlan(application: application, bundleID: identifier) + var locked: URL? + defer { + if let locked { + try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: locked.path) + } + kill(running.processIdentifier, SIGCONT) + if !running.isTerminated { running.forceTerminate() } + try? SelfUninstallation.clearPreferences(identifier) + try? plan.removeData() + try? FileManager.default.removeItem(at: root) + } + try require(running.bundleIdentifier == identifier, "Run only the isolated app copy.") + let defaults = UserDefaults(suiteName: identifier)! + defaults.set("uninstall fixture", forKey: "marker") + try require(defaults.synchronize(), "Write isolated preferences.") + let cache = plan.library.appending(path: "Caches/\(identifier)") + let state = plan.library.appending(path: "Saved Application State/\(identifier).savedState") + let download = plan.temporary.appending(component: "\(identifier)-\(UUID())") + let update = root.appending(component: ".simstore-update-\(UUID())") + for path in [cache, state, download, update] { try marker(at: path) } + let protected = cache.appending(component: "marker") + if shouldFail { + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: protected.path) + locked = protected + } + if !shouldFail { + let rejected = try await runHelper( + application: application, identifier: identifier, parentPID: getpid(), language: "zh-Hans" + ) + defer { try? FileManager.default.removeItem(at: rejected.directory) } + try require( + rejected.status != 0 && !running.isTerminated && exists(cache), + "An unrelated PID cannot authorize removal of the app or data.") + let message = try JSONDecoder().decode( + String.self, from: Data(contentsOf: rejected.directory.appending(component: "error.json")) + ) + try require( + message.contains("无法验证"), + "Localize assistant validation errors using the app's resources.") + } + let result = try await runHelper( + application: application, identifier: identifier, parentPID: running.processIdentifier) + defer { try? FileManager.default.removeItem(at: result.directory) } + if shouldFail { + try require( + result.status != 0 && !running.isTerminated && exists(application), + "A cleanup failure must keep the app and process available.") + var process = proc_bsdinfo() + _ = proc_pidinfo( + running.processIdentifier, PROC_PIDTBSDINFO, 0, &process, + Int32(MemoryLayout.size(ofValue: process))) + try require(process.pbi_status != SSTOP, "Resume the original app after cleanup fails.") + try require( + exists(result.directory.appending(component: "error.json")), + "Return the cleanup failure to the app.") + } else { + try await eventually { running.isTerminated } + try require(result.status == 0, "The uninstall helper must finish successfully.") + for path in [application, cache, state, download, update, result.directory] { + try require( + !exists(path), + "Remove the app, data, staging, and helper before ending the target process.") + } + try await Task.sleep(for: .milliseconds(200)) + CFPreferencesSynchronize( + identifier as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) + try require( + CFPreferencesCopyAppValue("marker" as CFString, identifier as CFString) == nil, + "Remove preferences from the daemon as well as the plist file.") + try require( + !exists(plan.library.appending(path: "Preferences/\(identifier).plist")), + "Do not recreate the preference file after the app exits.") + } + } + } + + private static func runHelper( + application: URL, identifier: String, parentPID: Int32, language: String = "en" + ) async throws -> (directory: URL, status: Int32) { + let directory = URL.temporaryDirectory.appending(component: "\(identifier).uninstall-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + let helper = directory.appending(component: "SimStoreUpdater") + try FileManager.default.copyItem( + at: application.appending(path: "Contents/Helpers/SimStoreUpdater"), to: helper) + try JSONEncoder().encode( + SelfUninstallRequest( + application: application, bundleID: identifier, parentPID: parentPID, language: language) + ).write( + to: directory.appending(component: "request.json")) + let process = Process() + process.executableURL = helper + process.arguments = ["--uninstall", directory.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.standardError + try process.run() + defer { if process.isRunning { process.terminate() } } + try await eventually { !process.isRunning } + return (directory, process.terminationStatus) + } + + private static func directory() throws -> URL { + let result = URL.temporaryDirectory.appending(component: "self-uninstall-test-\(UUID())") + try FileManager.default.createDirectory(at: result, withIntermediateDirectories: true) + return result + } + + private static func writeInfo(_ info: [String: Any], application: URL) throws { + try FileManager.default.createDirectory( + at: application.appending(component: "Contents"), withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write( + to: application.appending(path: "Contents/Info.plist")) + } + + private static func marker(at url: URL) throws { + if ["plist", "binarycookies"].contains(url.pathExtension) { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("marker".utf8).write(to: url) + } else { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + try Data("marker".utf8).write(to: url.appending(component: "marker")) + } + } + + private static func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + || (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } + + private static func eventually(_ predicate: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(15)) + while !predicate() { + guard ContinuousClock.now < deadline else { + throw failure("Timed out waiting for uninstall behavior.") + } + try await Task.sleep(for: .milliseconds(20)) + } + } + + private static func require(_ value: Bool, _ message: String) throws { + if !value { throw failure(message) } + } + + private static func failure(_ message: String) -> NSError { + NSError(domain: "SelfUninstallTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +}