From 28b31f70a4570ee12b8c41332df84339586d45b4 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 17:27:49 -0500 Subject: [PATCH 1/3] Add native in-app updates --- assets/Localizable.xcstrings | 384 ++++++++++++++++++++++++++ docs/cache.md | 8 + docs/release.md | 12 + src/AppConfiguration.swift | 11 + src/AppDownload.swift | 1 + src/AppInstallationPhase.swift | 38 +++ src/AppInstaller.swift | 39 +-- src/AppPackage.swift | 45 +-- src/CMakeLists.txt | 32 +++ src/MachOValidation.swift | 52 ++++ src/SelfUpdateClient.swift | 23 ++ src/SelfUpdateController.swift | 44 +++ src/SelfUpdateHelper.swift | 43 +++ src/SelfUpdateInstallation.swift | 124 +++++++++ src/SelfUpdatePackage.swift | 83 ++++++ src/SelfUpdateRelease.swift | 174 ++++++++++++ src/SelfUpdateStore.swift | 117 ++++++++ src/SelfUpdateView.swift | 119 ++++++++ src/SelfUpdater.swift | 123 +++++++++ src/SimStoreApp.swift | 10 + tests/CMakeLists.txt | 50 ++++ tests/LocalizationTests.swift | 5 + tests/SelfUpdateFixtures.swift | 124 +++++++++ tests/SelfUpdateInstallerTests.swift | 151 ++++++++++ tests/SelfUpdateIntegrationTest.swift | 125 +++++++++ tests/SelfUpdateModelTests.swift | 65 +++++ tests/SelfUpdatePackageTests.swift | 71 +++++ tests/SelfUpdateStoreTests.swift | 130 +++++++++ tests/SelfUpdateTests.swift | 19 ++ tests/SelfUpdateViewTests.swift | 57 ++++ 30 files changed, 2207 insertions(+), 72 deletions(-) create mode 100644 src/AppInstallationPhase.swift create mode 100644 src/MachOValidation.swift create mode 100644 src/SelfUpdateClient.swift create mode 100644 src/SelfUpdateController.swift create mode 100644 src/SelfUpdateHelper.swift create mode 100644 src/SelfUpdateInstallation.swift create mode 100644 src/SelfUpdatePackage.swift create mode 100644 src/SelfUpdateRelease.swift create mode 100644 src/SelfUpdateStore.swift create mode 100644 src/SelfUpdateView.swift create mode 100644 src/SelfUpdater.swift create mode 100644 tests/SelfUpdateFixtures.swift create mode 100644 tests/SelfUpdateInstallerTests.swift create mode 100644 tests/SelfUpdateIntegrationTest.swift create mode 100644 tests/SelfUpdateModelTests.swift create mode 100644 tests/SelfUpdatePackageTests.swift create mode 100644 tests/SelfUpdateStoreTests.swift create mode 100644 tests/SelfUpdateTests.swift create mode 100644 tests/SelfUpdateViewTests.swift diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index f6bd316..84c7ec4 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -81,6 +81,38 @@ } } }, + "A New Nightly Is Available": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A New Nightly Is Available" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有新的每夜构建可用" + } + } + } + }, + "A New Version Is Available": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A New Version Is Available" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有新版本可用" + } + } + } + }, "A simulator is where it starts.": { "localizations": { "en": { @@ -97,6 +129,22 @@ } } }, + "About SimStore": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About SimStore" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于 SimStore" + } + } + } + }, "All Apps": { "localizations": { "en": { @@ -209,6 +257,22 @@ } } }, + "Available updates have changed. Check for updates again before downloading.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Available updates have changed. Check for updates again before downloading." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可用的更新已发生变化,请重新检查更新后再下载。" + } + } + } + }, "Booted": { "localizations": { "en": { @@ -433,6 +497,38 @@ } } }, + "Check for Updates": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新" + } + } + } + }, + "Check for Updates…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新…" + } + } + } + }, "Check for iOS simulators": { "localizations": { "en": { @@ -609,6 +705,22 @@ } } }, + "Couldn’t Update SimStore": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t Update SimStore" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法更新 SimStore" + } + } + } + }, "Couldn’t check for simulators": { "localizations": { "en": { @@ -801,6 +913,22 @@ } } }, + "Get the latest SimStore version compatible with this Mac.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get the latest SimStore version compatible with this Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取兼容这台 Mac 的最新 SimStore 版本。" + } + } + } + }, "Health": { "localizations": { "en": { @@ -865,6 +993,22 @@ } } }, + "Install Nightly and Restart": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Nightly and Restart" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装每夜构建并重新启动" + } + } + } + }, "Install Xcode to add iPhone and iPad simulators.": { "localizations": { "en": { @@ -1057,6 +1201,22 @@ } } }, + "Move SimStore to a writable Applications folder before updating it.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move SimStore to a writable Applications folder before updating it." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请先将 SimStore 移至可写入的“应用程序”文件夹,再进行更新。" + } + } + } + }, "Newer compatible versions of your installed apps will appear here.": { "localizations": { "en": { @@ -1089,6 +1249,22 @@ } } }, + "Nightly builds are unstable and may contain bugs.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nightly builds are unstable and may contain bugs." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每夜构建是不稳定版本,可能存在问题。" + } + } + } + }, "No Apps Available": { "localizations": { "en": { @@ -1473,6 +1649,22 @@ } } }, + "Restarting SimStore…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restarting SimStore…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重新启动 SimStore…" + } + } + } + }, "Runtime": { "localizations": { "en": { @@ -1585,6 +1777,54 @@ } } }, + "SimStore Is Up to Date": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SimStore Is Up to Date" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SimStore 已是最新版本" + } + } + } + }, + "SimStore did not quit in time. The update was canceled.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SimStore did not quit in time. The update was canceled." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SimStore 未能及时退出,更新已取消。" + } + } + } + }, + "SimStore will quit and reopen to finish the update.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SimStore will quit and reopen to finish the update." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SimStore 将退出并重新打开以完成更新。" + } + } + } + }, "Simulator": { "localizations": { "en": { @@ -1889,6 +2129,22 @@ } } }, + "The downloaded app does not match the selected SimStore build.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded app does not match the selected SimStore build." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的应用与所选的 SimStore 构建不符。" + } + } + } + }, "The downloaded app does not match the selected app and version.": { "localizations": { "en": { @@ -1905,6 +2161,22 @@ } } }, + "The downloaded app is not compatible with this Mac.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded app is not compatible with this Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的应用与这台 Mac 不兼容。" + } + } + } + }, "The downloaded file failed its SHA-256 check. Try downloading it again.": { "localizations": { "en": { @@ -1937,6 +2209,22 @@ } } }, + "The previous app is saved at %@. Restore it before trying again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The previous app is saved at %@. Restore it before trying again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "旧版应用保存在 %@,请先恢复它再重试。" + } + } + } + }, "The simulator application could not be found. Check your Xcode installation.": { "localizations": { "en": { @@ -1969,6 +2257,54 @@ } } }, + "The update assistant could not start. SimStore has not been replaced.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update assistant could not start. SimStore has not been replaced." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启动更新助手,SimStore 未被替换。" + } + } + } + }, + "The update information is invalid. Check for updates again later.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The update information is invalid. Check for updates again later." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新信息无效,请稍后重新检查更新。" + } + } + } + }, + "The updated app could not start. The previous version has been restored.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The updated app could not start. The previous version has been restored." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新版应用无法启动,已恢复旧版。" + } + } + } + }, "This build requires iOS %@ or later. It may not work on iOS %@.": { "localizations": { "en": { @@ -1985,6 +2321,22 @@ } } }, + "This copy of SimStore has invalid version information. Reinstall it to update.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This copy of SimStore has invalid version information. Reinstall it to update." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前 SimStore 的版本信息无效,请重新安装后再更新。" + } + } + } + }, "Try Again": { "localizations": { "en": { @@ -2113,6 +2465,22 @@ } } }, + "Update and Restart": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update and Restart" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新并重新启动" + } + } + } + }, "Updates": { "localizations": { "en": { @@ -2289,6 +2657,22 @@ } } }, + "You’re using the latest version available for macOS %@.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You’re using the latest version available for macOS %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你正在使用可在 macOS %@ 上运行的最新版本。" + } + } + } + }, "iOS %@": { "localizations": { "en": { diff --git a/docs/cache.md b/docs/cache.md index d088150..a730258 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -31,3 +31,11 @@ Corrupt disk images are removed and fetched again. Failed requests and invalid r App archives are streamed to a separate, randomly named directory under `URL.temporaryDirectory` for each installation. The download session has its HTTP cache disabled, a 60-second inactivity timeout, and a one-hour resource timeout. Archives are not retained or shared as a persistent download cache; retrying an installation starts a new download. The archive's size and SHA-256 must match the selected catalog version before extraction. The temporary archive and extracted app are removed when the operation succeeds, fails, or finishes canceling. Cancellation during preparation waits for any running archive command to finish before cleanup; once `simctl install` starts, it is allowed to finish. A process exit can leave temporary files for the operating system to reclaim. + +## SimStore updates + +The client's own `version.json` feed is fetched on each explicit update check with the local HTTP cache bypassed and `Cache-Control: no-cache`. It has no disk cache, ETag storage, expiry timer, or offline fallback. The most recent result and selected release are kept in memory for the current application session. Before downloading a nightly, SimStore checks the current feed again and asks for another update check if the offered build has changed. + +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. diff --git a/docs/release.md b/docs/release.md index 775f462..93c99e2 100644 --- a/docs/release.md +++ b/docs/release.md @@ -60,3 +60,15 @@ python3 scripts/release.py index --repo iSimStore/simstore ``` This writes `build/version.json`. Add `--upload` to publish the file to the `latest` release. Python 3.9 or newer and an authenticated GitHub CLI are required for release administration; building requires Python but does not access GitHub. + +## In-app updates + +Use SimStore → Check for Updates… or the check button in About SimStore. About shows the bundle version, commit count, source commit link, and commit date. Checks run on request and fetch `version.json` without using the local HTTP cache; they do not download or install an update automatically. + +The client chooses the highest stable version whose minimum macOS requirement is met. If that version is newer than the installed `(version, build)` pair, it is offered before any nightly. Once no compatible stable upgrade remains, a newer compatible nightly can be installed explicitly. Nightlies are labeled unstable and use a separate Install Nightly and Restart action. A newer base version outranks a larger commit count on an older base, and older builds are never offered as upgrades. A Mac that cannot run newer releases can continue checking against its last compatible stable version. + +Updating streams the fixed release archive into a private staging directory beside the current app, so replacement stays on the same filesystem. The client checks the archive size and SHA-256 before extraction, then verifies the bundle identifier, version, commit count, revision, commit time, clean-source flag, minimum macOS version, executable permissions, symlink boundaries, and arm64 macOS Mach-O load commands. Both the main executable and the bundled update assistant must be present and compatible. If the selected nightly has changed before download, the client requests another check; an archive that changes during download fails its checksum rather than being installed. + +The current app's native update assistant is copied outside the bundle and started before SimStore quits. After validating its request, it signals readiness and waits up to 30 seconds for that specific process to exit. It then atomically exchanges the staged and installed bundles with `renameatx_np(RENAME_SWAP)`, launches the new app at the original path, and keeps the previous bundle until the new process has remained alive for three seconds. A launch failure exchanges the bundles back and relaunches the previous version. An unrecoverable rollback retains the previous bundle and reports its location. The installation directory must be writable by the current user; no administrator prompt or signing identity is required. + +Downloads and preparation can be canceled while keeping the installed app intact. Cancellation stops being available when the assistant takes over. Temporary data is removed after success, failure, or completed cancellation; an interrupted process can leave a hidden staging directory beside the app. Update failures after quitting are reported when the previous app reopens. diff --git a/src/AppConfiguration.swift b/src/AppConfiguration.swift index 66f6a8a..3bb4492 100644 --- a/src/AppConfiguration.swift +++ b/src/AppConfiguration.swift @@ -5,6 +5,17 @@ enum AppConfiguration { Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName static let cacheDirectory = URL.cachesDirectory.appending(component: applicationIdentifier) static let buildsRepositoryURL = URL(string: "https://github.com/iSimStore/simstore-builds")! + static let repositoryURL = URL(string: "https://github.com/iSimStore/simstore")! + static let archiveName = "SimStore.app.tar.bz2" + + static var updateIndexURL: URL { + updateAssetURL(tag: "latest", filename: "version.json") + } + + static func updateAssetURL(tag: String, filename: String) -> URL { + repositoryURL.appending(path: "releases/download").appending(component: tag) + .appending(component: filename) + } static var catalogURL: URL { buildsRepositoryURL.appending(path: "releases/download/latest/index.json") diff --git a/src/AppDownload.swift b/src/AppDownload.swift index ed39b94..91e81d2 100644 --- a/src/AppDownload.swift +++ b/src/AppDownload.swift @@ -2,6 +2,7 @@ import Foundation enum AppDownload { typealias Progress = @Sendable (Int64) -> Void + typealias Fetch = @Sendable (URL, URL, @escaping Progress) async throws -> Void static func fetch(from url: URL, to destination: URL, progress: @escaping Progress) async throws { try Task.checkCancellation() diff --git a/src/AppInstallationPhase.swift b/src/AppInstallationPhase.swift new file mode 100644 index 0000000..99242a7 --- /dev/null +++ b/src/AppInstallationPhase.swift @@ -0,0 +1,38 @@ +import Foundation + +enum AppInstallationPhase: Equatable, Sendable { + case downloading(Int64) + case verifying + case extracting + case installing + case canceling + case opening + case uninstalling + + var canCancel: Bool { + switch self { + case .downloading, .verifying, .extracting: true + case .installing, .canceling, .opening, .uninstalling: false + } + } + + var title: String { + switch self { + case .downloading: String(localized: "Downloading…") + case .verifying: String(localized: "Verifying…") + case .extracting: String(localized: "Preparing App…") + case .installing: String(localized: "Installing…") + case .canceling: String(localized: "Canceling…") + case .opening: String(localized: "Opening…") + case .uninstalling: String(localized: "Uninstalling…") + } + } + + func summary(totalBytes: Int64) -> String { + if case .downloading(let bytes) = self { + let fraction = Double(max(0, min(bytes, totalBytes))) / Double(max(1, totalBytes)) + return fraction.formatted(.percent.precision(.fractionLength(0))) + } + return title + } +} diff --git a/src/AppInstaller.swift b/src/AppInstaller.swift index 09a5c73..696a9d6 100644 --- a/src/AppInstaller.swift +++ b/src/AppInstaller.swift @@ -1,44 +1,7 @@ import Foundation -enum AppInstallationPhase: Equatable, Sendable { - case downloading(Int64) - case verifying - case extracting - case installing - case canceling - case opening - case uninstalling - - var canCancel: Bool { - switch self { - case .downloading, .verifying, .extracting: true - case .installing, .canceling, .opening, .uninstalling: false - } - } - - var title: String { - switch self { - case .downloading: String(localized: "Downloading…") - case .verifying: String(localized: "Verifying…") - case .extracting: String(localized: "Preparing App…") - case .installing: String(localized: "Installing…") - case .canceling: String(localized: "Canceling…") - case .opening: String(localized: "Opening…") - case .uninstalling: String(localized: "Uninstalling…") - } - } - - func summary(totalBytes: Int64) -> String { - if case .downloading(let bytes) = self { - let fraction = Double(max(0, min(bytes, totalBytes))) / Double(max(1, totalBytes)) - return fraction.formatted(.percent.precision(.fractionLength(0))) - } - return title - } -} - struct AppInstaller: Sendable { - typealias Download = @Sendable (URL, URL, @escaping AppDownload.Progress) async throws -> Void + typealias Download = AppDownload.Fetch typealias Progress = @MainActor @Sendable (AppInstallationPhase) -> Void var download: Download = { try await AppDownload.fetch(from: $0, to: $1, progress: $2) } diff --git a/src/AppPackage.swift b/src/AppPackage.swift index a797bfc..b5fb9d1 100644 --- a/src/AppPackage.swift +++ b/src/AppPackage.swift @@ -4,8 +4,12 @@ import MachO enum AppPackage { static func verify(_ archive: URL, version: CatalogVersion) throws { + try verify(archive, size: version.size, sha256: version.sha256) + } + + static func verify(_ archive: URL, size expectedSize: Int64, sha256: String) throws { let size = try archive.resourceValues(forKeys: [.fileSizeKey]).fileSize - guard size.map(Int64.init) == version.size else { throw Failure.sizeMismatch } + guard size.map(Int64.init) == expectedSize else { throw Failure.sizeMismatch } let file = try FileHandle(forReadingFrom: archive) defer { try? file.close() } var hash = SHA256() @@ -14,7 +18,7 @@ enum AppPackage { hash.update(data: chunk) } let digest = hash.finalize().map { String(format: "%02x", $0) }.joined() - guard digest == version.sha256 else { throw Failure.checksumMismatch } + guard digest == sha256 else { throw Failure.checksumMismatch } } static func extract( @@ -83,11 +87,7 @@ enum AppPackage { let file = try FileHandle(forReadingFrom: path) let magic = try file.read(upToCount: 4) try file.close() - guard let magic, magic.count == 4, - [ - MH_MAGIC_64, MH_CIGAM_64, MH_MAGIC, MH_CIGAM, - FAT_MAGIC, FAT_CIGAM, FAT_MAGIC_64, FAT_CIGAM_64, - ].contains(word(magic, at: 0)) + guard let magic, MachOValidation.isMachO(magic) else { continue } let data = try Data(contentsOf: path, options: .mappedIfSafe) try validateMachO(data, isMainExecutable: resolved == mainExecutable) @@ -123,37 +123,14 @@ enum AppPackage { } private static func validateMachO(_ data: Data, isMainExecutable: Bool) throws { - guard data.count >= 32, word(data, at: 0) == MH_MAGIC_64, - word(data, at: 4) == UInt32(CPU_TYPE_ARM64), - !isMainExecutable || word(data, at: 12) == MH_EXECUTE - else { throw Failure.unsupportedBinary } - let count = Int(word(data, at: 16)) - let end = 32 + Int(word(data, at: 20)) - guard end <= data.count, count <= (end - 32) / 8 else { throw Failure.unsupportedBinary } - var offset = 32 - var platforms: [UInt32] = [] - for _ in 0..= 8, offset + size <= end else { throw Failure.unsupportedBinary } - if command == LC_BUILD_VERSION { - guard size >= 24 else { throw Failure.unsupportedBinary } - platforms.append(word(data, at: offset + 8)) - } - offset += size - } - guard offset == end, platforms == [UInt32(PLATFORM_IOSSIMULATOR)] else { + do { + _ = try MachOValidation.validate( + data, platform: UInt32(PLATFORM_IOSSIMULATOR), isExecutable: isMainExecutable) + } catch { throw Failure.unsupportedBinary } } - private static func word(_ data: Data, at offset: Int) -> UInt32 { - data.withUnsafeBytes { - UInt32(littleEndian: $0.loadUnaligned(fromByteOffset: offset, as: UInt32.self)) - } - } - enum Failure: LocalizedError { case sizeMismatch, checksumMismatch, invalidArchive, wrongApp, invalidBundle, unsupportedBinary diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 325983b..bb8b516 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,7 +18,9 @@ add_executable(SimStore MACOSX_BUNDLE CatalogCategory.swift AppDownload.swift AppPackage.swift + MachOValidation.swift AppInstaller.swift + AppInstallationPhase.swift AppInstallationStore.swift AppInstallationAction.swift AppInstallButton.swift @@ -39,8 +41,38 @@ add_executable(SimStore MACOSX_BUNDLE StableSizeView.swift SimulatorCheckButton.swift PreviewScenario.swift + SelfUpdateRelease.swift + SelfUpdateClient.swift + SelfUpdatePackage.swift + SelfUpdateInstallation.swift + SelfUpdater.swift + SelfUpdateStore.swift + SelfUpdateView.swift + SelfUpdateController.swift ) +add_executable(SimStoreUpdater + SelfUpdateHelper.swift + SelfUpdateInstallation.swift + SelfUpdateRelease.swift + SelfUpdatePackage.swift + MachOValidation.swift + AppCatalog.swift +) +set(UPDATE_HELPER "${CMAKE_CURRENT_BINARY_DIR}/updater/SimStoreUpdater") +set_target_properties(SimStoreUpdater PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/updater" + Swift_MODULE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/updater/modules" +) +target_link_options(SimStoreUpdater PRIVATE + "SHELL:-Xclang-linker -isysroot -Xclang-linker \"${CMAKE_OSX_SYSROOT}\"" +) +set_source_files_properties("${UPDATE_HELPER}" PROPERTIES + GENERATED TRUE MACOSX_PACKAGE_LOCATION Helpers +) +target_sources(SimStore PRIVATE "${UPDATE_HELPER}") +add_dependencies(SimStore SimStoreUpdater) + target_compile_definitions(SimStore PRIVATE "$<$:DEBUG>") include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake") diff --git a/src/MachOValidation.swift b/src/MachOValidation.swift new file mode 100644 index 0000000..b0b95c4 --- /dev/null +++ b/src/MachOValidation.swift @@ -0,0 +1,52 @@ +import Foundation +import MachO + +enum MachOValidation { + static func validate( + _ data: Data, platform: UInt32, isExecutable: Bool + ) throws -> CatalogVersionNumber { + guard data.count >= 32, word(data, at: 0) == MH_MAGIC_64, + word(data, at: 4) == UInt32(CPU_TYPE_ARM64), + !isExecutable || word(data, at: 12) == MH_EXECUTE + else { throw Failure.invalidBinary } + let count = Int(word(data, at: 16)) + let end = 32 + Int(word(data, at: 20)) + guard end <= data.count, count <= (end - 32) / 8 else { throw Failure.invalidBinary } + var offset = 32 + var minimum: CatalogVersionNumber? + for _ in 0..= 8, size.isMultiple(of: 8), offset + size <= end else { + throw Failure.invalidBinary + } + if command == LC_BUILD_VERSION { + guard size >= 24, minimum == nil, word(data, at: offset + 8) == platform else { + throw Failure.invalidBinary + } + let value = word(data, at: offset + 12) + minimum = CatalogVersionNumber("\(value >> 16).\((value >> 8) & 0xff).\(value & 0xff)") + } + offset += size + } + guard offset == end, let minimum else { throw Failure.invalidBinary } + return minimum + } + + static func isMachO(_ data: Data) -> Bool { + data.count >= 4 + && [ + MH_MAGIC_64, MH_CIGAM_64, MH_MAGIC, MH_CIGAM, + FAT_MAGIC, FAT_CIGAM, FAT_MAGIC_64, FAT_CIGAM_64, + ].contains(word(data, at: 0)) + } + + private static func word(_ data: Data, at offset: Int) -> UInt32 { + data.withUnsafeBytes { + UInt32(littleEndian: $0.loadUnaligned(fromByteOffset: offset, as: UInt32.self)) + } + } + + enum Failure: Error { case invalidBinary } +} diff --git a/src/SelfUpdateClient.swift b/src/SelfUpdateClient.swift new file mode 100644 index 0000000..c64113f --- /dev/null +++ b/src/SelfUpdateClient.swift @@ -0,0 +1,23 @@ +import Foundation + +struct SelfUpdateClient: Sendable { + var url = AppConfiguration.updateIndexURL + var transport: @Sendable (URLRequest) async throws -> (Data, URLResponse) = { + try await URLSession.shared.data(for: $0) + } + + func fetch() async throws -> SelfUpdateIndex { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + request.timeoutInterval = 30 + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + let (data, response) = try await transport(request) + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { + throw AppDownload.Failure.invalidResponse + } + guard (200..<300).contains(response.statusCode) else { + throw AppDownload.Failure.httpStatus(response.statusCode) + } + return try SelfUpdateIndex.decode(data) + } +} diff --git a/src/SelfUpdateController.swift b/src/SelfUpdateController.swift new file mode 100644 index 0000000..396c043 --- /dev/null +++ b/src/SelfUpdateController.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI + +@MainActor +final class SelfUpdateController: NSObject, NSWindowDelegate { + let store: SelfUpdateStore + private(set) var panel: NSPanel? + + init(store: SelfUpdateStore = SelfUpdateStore()) { + self.store = store + } + + func show(check: Bool = false) { + if panel == nil { + let panel = NSPanel( + contentRect: .zero, styleMask: [.titled, .closable], backing: .buffered, defer: false) + panel.title = String(localized: "About SimStore") + panel.isReleasedWhenClosed = false + panel.isExcludedFromWindowsMenu = true + panel.hidesOnDeactivate = false + panel.tabbingMode = .disallowed + panel.delegate = self + panel.contentView = NSHostingView( + rootView: SelfUpdateView(store: store) { [weak self] in + self?.panel?.close() + }) + if let view = panel.contentView { panel.setContentSize(view.fittingSize) } + panel.center() + self.panel = panel + } + panel?.makeKeyAndOrderFront(nil) + if check { store.check() } + } + + func showPendingFailure() { + if store.consumeFailureReport() { show() } + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard store.phase != nil else { return true } + store.cancel() + return false + } +} diff --git a/src/SelfUpdateHelper.swift b/src/SelfUpdateHelper.swift new file mode 100644 index 0000000..64b6ae7 --- /dev/null +++ b/src/SelfUpdateHelper.swift @@ -0,0 +1,43 @@ +import AppKit + +@main +struct SelfUpdateHelper { + @MainActor + static func main() async { + guard CommandLine.arguments.count == 2 else { exit(EXIT_FAILURE) } + let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + .standardizedFileURL + var request: SelfUpdateRequest? + do { + let update = try SelfUpdateRequest.read(from: directory) + request = update + try SelfUpdatePackage.validate( + SelfUpdateRequest.payload(in: directory), release: update.release, bundleID: update.bundleID + ) + try Data().write(to: directory.appending(component: "ready"), options: .atomic) + try await SelfUpdateInstallation.waitForExit(update.parentPID) + try await SelfUpdateInstallation.replace(request: update, directory: directory) + try? FileManager.default.removeItem(at: directory) + } catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + if let request { + let bundle = SelfUpdateInstallation.localizationBundle( + application: request.application, language: request.language) + try? SelfUpdateInstallation.writeFailure(error, to: request.reportURL, bundle: bundle) + if !SelfUpdateInstallation.isRunning(request.parentPID) { + do { + guard + try SelfUpdateBuild.read(at: request.application).revision == request.currentRevision + else { throw error } + try await SelfUpdateInstallation.launchApplication(request.application) + } catch { + SelfUpdateInstallation.showFailure(error, bundle: bundle) + exit(EXIT_FAILURE) + } + } + try? FileManager.default.removeItem(at: directory) + } + exit(EXIT_FAILURE) + } + } +} diff --git a/src/SelfUpdateInstallation.swift b/src/SelfUpdateInstallation.swift new file mode 100644 index 0000000..a9c5d32 --- /dev/null +++ b/src/SelfUpdateInstallation.swift @@ -0,0 +1,124 @@ +import AppKit +import Darwin + +struct SelfUpdateRequest: Codable, Sendable { + let application: URL + let bundleID: String + let currentRevision: String + let parentPID: Int32 + let release: SelfUpdateRelease + let reportURL: URL + var language = "en" + + static let filename = "request.json" + + static func read(from directory: URL) throws -> SelfUpdateRequest { + let request = try JSONDecoder().decode( + Self.self, from: Data(contentsOf: directory.appending(component: filename))) + guard request.application.isFileURL, request.application.pathExtension == "app", + request.reportURL.isFileURL, request.parentPID > 1, request.parentPID != getpid(), + directory.lastPathComponent.hasPrefix(".simstore-update-"), + directory.deletingLastPathComponent().resolvingSymlinksInPath() + == request.application.deletingLastPathComponent().resolvingSymlinksInPath(), + directory.resolvingSymlinksInPath() == directory.standardizedFileURL, + try SelfUpdatePackage.info(at: request.application)["CFBundleIdentifier"] as? String + == request.bundleID, + try SelfUpdateBuild.read(at: request.application).revision == request.currentRevision + else { throw SelfUpdateError.wrongApplication } + return request + } + + static func payload(in directory: URL) -> URL { + directory.appending(path: "payload/SimStore.app") + } +} + +enum SelfUpdateInstallation { + typealias Launch = @MainActor (URL) async throws -> Void + + static func isRunning(_ pid: Int32) -> Bool { + kill(pid, 0) == 0 || errno == EPERM + } + + static func waitForExit(_ pid: Int32, timeout: Duration = .seconds(30)) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while isRunning(pid) { + guard ContinuousClock.now < deadline else { throw SelfUpdateError.quitTimedOut } + try await Task.sleep(for: .milliseconds(50)) + } + } + + @MainActor + static func replace( + request: SelfUpdateRequest, directory: URL, launch: Launch = launchApplication + ) async throws { + let payload = SelfUpdateRequest.payload(in: directory) + try SelfUpdatePackage.validate(payload, release: request.release, bundleID: request.bundleID) + try exchange(request.application, payload) + do { + try await launch(request.application) + } catch { + do { + try exchange(request.application, payload) + } catch { + throw SelfUpdateError.recoveryFailed(payload) + } + throw SelfUpdateError.launchFailed + } + } + + private static func exchange(_ application: URL, _ payload: URL) throws { + // Both directories are on the same volume. Keep the old app at the payload path until launch succeeds. + guard renameatx_np(AT_FDCWD, application.path, AT_FDCWD, payload.path, UInt32(RENAME_SWAP)) == 0 + else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + } + + @MainActor + static func launchApplication(_ application: URL) async throws { + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + configuration.allowsRunningApplicationSubstitution = false + let running = try await NSWorkspace.shared.openApplication( + at: application, configuration: configuration) + try await Task.sleep(for: .seconds(3)) + guard !running.isTerminated else { throw SelfUpdateError.launchFailed } + } + + static func localizationBundle(application: URL, language: String) -> Bundle { + guard let bundle = Bundle(url: application), + let resource = bundle.url(forResource: language, withExtension: "lproj"), + let translation = Bundle(url: resource) + else { return Bundle(url: application) ?? .main } + return translation + } + + static func message(_ error: Error, bundle: Bundle) -> String { + (error as? SelfUpdateError)?.message(in: bundle) ?? error.localizedDescription + } + + static func writeFailure(_ error: Error, to reportURL: URL, bundle: Bundle = .main) throws { + try FileManager.default.createDirectory( + at: reportURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(message(error, bundle: bundle)).write(to: reportURL, options: .atomic) + } + + static func consumeFailure(at reportURL: URL) -> String? { + guard let data = try? Data(contentsOf: reportURL), + let message = try? JSONDecoder().decode(String.self, from: data) + else { return nil } + try? FileManager.default.removeItem(at: reportURL) + return message + } + + @MainActor + static func showFailure(_ error: Error, bundle: Bundle) { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = String(localized: "Couldn’t Update SimStore", bundle: bundle) + alert.informativeText = message(error, bundle: bundle) + alert.addButton(withTitle: String(localized: "OK", bundle: bundle)) + NSApplication.shared.setActivationPolicy(.accessory) + NSApplication.shared.activate(ignoringOtherApps: true) + alert.runModal() + } +} diff --git a/src/SelfUpdatePackage.swift b/src/SelfUpdatePackage.swift new file mode 100644 index 0000000..e347732 --- /dev/null +++ b/src/SelfUpdatePackage.swift @@ -0,0 +1,83 @@ +import Foundation +import MachO + +enum SelfUpdatePackage { + static func validate( + _ application: URL, release: SelfUpdateRelease, bundleID: String, + os: String = currentOS + ) throws { + let root = application.standardizedFileURL + guard application.lastPathComponent == "SimStore.app", + try root.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink != true, + release.isCompatible(with: os) + else { throw SelfUpdateError.incompatiblePackage } + let info = try info(at: root) + let build = try SelfUpdateBuild.read(at: root) + guard info["CFBundlePackageType"] as? String == "APPL", + info["CFBundleIdentifier"] as? String == bundleID, + build.version == release.version, build.build == release.build, + build.revision == release.revision, build.time == release.time, !build.dirty, + info["LSMinimumSystemVersion"] as? String == release.minOS, + let name = info["CFBundleExecutable"] as? String, !name.isEmpty, + name != ".", name != "..", !name.contains("/") + else { throw SelfUpdateError.wrongApplication } + let main = root.appending(path: "Contents/MacOS").appending(component: name) + let helper = root.appending(path: "Contents/Helpers/SimStoreUpdater") + let required = Set([main, helper].map { $0.resolvingSymlinksInPath() }) + guard required.allSatisfy({ FileManager.default.isExecutableFile(atPath: $0.path) }) else { + throw SelfUpdateError.wrongApplication + } + var enumerationError: Error? + guard + let files = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: [.isRegularFileKey], + errorHandler: { _, error in + enumerationError = error + return false + }) + else { throw SelfUpdateError.wrongApplication } + var checked: Set = [] + for case let path as URL in files { + try Task.checkCancellation() + let resolved = path.resolvingSymlinksInPath().standardizedFileURL + guard resolved.pathComponents.starts(with: root.pathComponents), + FileManager.default.fileExists(atPath: resolved.path) + else { throw SelfUpdateError.wrongApplication } + guard try path.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true, + !checked.contains(resolved) + else { continue } + let file = try FileHandle(forReadingFrom: path) + let magic = try file.read(upToCount: 4) ?? Data() + try file.close() + guard MachOValidation.isMachO(magic) else { continue } + let minimum: CatalogVersionNumber + do { + minimum = try MachOValidation.validate( + Data(contentsOf: path, options: .mappedIfSafe), platform: UInt32(PLATFORM_MACOS), + isExecutable: required.contains(resolved)) + } catch { + throw SelfUpdateError.incompatiblePackage + } + guard let declaredMinimum = CatalogVersionNumber(release.minOS), minimum <= declaredMinimum, + resolved != main.resolvingSymlinksInPath() || minimum == declaredMinimum + else { throw SelfUpdateError.incompatiblePackage } + checked.insert(resolved) + } + if let enumerationError { throw enumerationError } + guard required.isSubset(of: checked) else { throw SelfUpdateError.wrongApplication } + } + + static var currentOS: String { + let os = ProcessInfo.processInfo.operatingSystemVersion + return "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" + } + + static func info(at application: URL) throws -> [String: Any] { + guard + let info = try PropertyListSerialization.propertyList( + from: Data(contentsOf: application.appending(path: "Contents/Info.plist")), format: nil + ) as? [String: Any] + else { throw SelfUpdateError.wrongApplication } + return info + } +} diff --git a/src/SelfUpdateRelease.swift b/src/SelfUpdateRelease.swift new file mode 100644 index 0000000..e30dba3 --- /dev/null +++ b/src/SelfUpdateRelease.swift @@ -0,0 +1,174 @@ +import Foundation + +struct SelfUpdateBuild: Equatable, Sendable { + let version: String + let build: Int + let revision: String + let time: Int64 + let dirty: Bool + + var displayVersion: String { "\(version) (\(build))" } + var description: String { + let value = build == 0 ? version : "\(version)-\(build)-g\(revision.prefix(7))" + return dirty ? "\(value)-dirty" : value + } + + static func read(at application: URL) throws -> SelfUpdateBuild { + let data = try Data(contentsOf: application.appending(path: "Contents/Info.plist")) + let info = try PropertyListDecoder().decode(BundleVersion.self, from: data) + guard validVersion(info.version), let build = Int(info.build), build >= 0, + String(build) == info.build, validRevision(info.revision), info.time >= 0 + else { throw SelfUpdateError.invalidVersion } + return Self( + version: info.version, build: build, revision: info.revision, time: info.time, + dirty: info.dirty) + } + + private struct BundleVersion: Decodable { + let version: String + let build: String + let revision: String + let time: Int64 + let dirty: Bool + + enum CodingKeys: String, CodingKey { + case version = "CFBundleShortVersionString" + case build = "CFBundleVersion" + case revision = "SimStoreRevision" + case time = "SimStoreCommitTime" + case dirty = "SimStoreSourceDirty" + } + } +} + +struct SelfUpdateRelease: Codable, Equatable, Sendable { + let tag: String + let version: String + let build: Int + let minOS: String + let revision: String + let time: Int64 + let size: Int64 + let sha256: String + + var isNightly: Bool { tag == "latest" } + var displayVersion: String { "\(version) (\(build))" } + + func isCompatible(with os: String) -> Bool { + guard let minimum = CatalogVersionNumber(minOS), let os = CatalogVersionNumber(os) else { + return false + } + return os >= minimum + } + + func isNewer(than current: SelfUpdateBuild) -> Bool { + guard let version = CatalogVersionNumber(version), + let installed = CatalogVersionNumber(current.version) + else { return false } + return version > installed || (version == installed && build > current.build) + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + tag = try values.decode(String.self, forKey: .tag) + version = try values.decode(String.self, forKey: .version) + build = try values.decode(Int.self, forKey: .build) + minOS = try values.decode(String.self, forKey: .minOS) + revision = try values.decode(String.self, forKey: .revision) + time = try values.decode(Int64.self, forKey: .time) + size = try values.decode(Int64.self, forKey: .size) + sha256 = try values.decode(String.self, forKey: .sha256) + guard validVersion(version), build >= 0, + tag == "latest" || (tag == version && build == 0), + CatalogVersionNumber(minOS) != nil, + minOS.range(of: #"^[1-9][0-9]*(?:\.[0-9]+){0,2}$"#, options: .regularExpression) != nil, + validRevision(revision), time >= 0, size > 0, + sha256.count == 64, sha256.range(of: #"^[0-9a-f]{64}$"#, options: .regularExpression) != nil + else { throw SelfUpdateError.invalidIndex } + } +} + +struct SelfUpdateIndex: Decodable, Sendable { + let versions: [SelfUpdateRelease] + + static func decode(_ data: Data) throws -> SelfUpdateIndex { + do { + let index = try JSONDecoder().decode(Self.self, from: data) + guard index.versions.filter(\.isNightly).count == 1, + Set(index.versions.map(\.tag)).count == index.versions.count + else { throw SelfUpdateError.invalidIndex } + return index + } catch { + throw SelfUpdateError.invalidIndex + } + } + + func recommendedUpdate(from current: SelfUpdateBuild, os: String) -> SelfUpdateRelease? { + let compatible = versions.filter { $0.isCompatible(with: os) } + let stable = compatible.filter { !$0.isNightly }.max { + CatalogVersionNumber($0.version)! < CatalogVersionNumber($1.version)! + } + if let stable, stable.isNewer(than: current) { return stable } + return compatible.first { $0.isNightly && $0.isNewer(than: current) } + } +} + +enum SelfUpdateError: LocalizedError { + case invalidIndex, invalidVersion, wrongApplication, incompatiblePackage, notWritable + case helperFailed, quitTimedOut, launchFailed, releaseChanged + case recoveryFailed(URL) + + var errorDescription: String? { + message(in: .main) + } + + func message(in bundle: Bundle) -> String { + switch self { + case .invalidIndex: + String( + localized: "The update information is invalid. Check for updates again later.", + bundle: bundle) + case .invalidVersion: + String( + localized: "This copy of SimStore has invalid version information. Reinstall it to update.", + bundle: bundle) + case .wrongApplication: + String( + localized: "The downloaded app does not match the selected SimStore build.", bundle: bundle) + case .incompatiblePackage: + String(localized: "The downloaded app is not compatible with this Mac.", bundle: bundle) + case .notWritable: + String( + localized: "Move SimStore to a writable Applications folder before updating it.", + bundle: bundle) + case .helperFailed: + String( + localized: "The update assistant could not start. SimStore has not been replaced.", + bundle: bundle) + case .quitTimedOut: + String(localized: "SimStore did not quit in time. The update was canceled.", bundle: bundle) + case .launchFailed: + String( + localized: "The updated app could not start. The previous version has been restored.", + bundle: bundle) + case .releaseChanged: + String( + localized: "Available updates have changed. Check for updates again before downloading.", + bundle: bundle) + case .recoveryFailed(let previous): + String( + localized: "The previous app is saved at \(previous.path). Restore it before trying again.", + bundle: bundle) + } + } +} + +private func validVersion(_ value: String) -> Bool { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + return parts.count == 3 && CatalogVersionNumber(value) != nil + && parts.allSatisfy { part in Int(part).map { String($0) == part } == true } +} + +private func validRevision(_ value: String) -> Bool { + value.count == 40 && value.range(of: #"^[0-9a-f]{40}$"#, options: .regularExpression) != nil +} diff --git a/src/SelfUpdateStore.swift b/src/SelfUpdateStore.swift new file mode 100644 index 0000000..0ca4c95 --- /dev/null +++ b/src/SelfUpdateStore.swift @@ -0,0 +1,117 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class SelfUpdateStore { + let current: SelfUpdateBuild? + let os: String + private(set) var release: SelfUpdateRelease? + private(set) var isChecking = false + private(set) var hasChecked = false + private(set) var phase: AppInstallationPhase? + private(set) var error: String? + + @ObservationIgnored private let client: SelfUpdateClient + @ObservationIgnored private let updater: SelfUpdater + @ObservationIgnored private let restart: @MainActor (PreparedSelfUpdate) async throws -> Void + @ObservationIgnored private var job: Task? + @ObservationIgnored private var requestID: UUID? + + var isBusy: Bool { isChecking || phase != nil } + + init( + current: SelfUpdateBuild? = try? SelfUpdateBuild.read(at: Bundle.main.bundleURL), + os: String = SelfUpdatePackage.currentOS, client: SelfUpdateClient = SelfUpdateClient(), + updater: SelfUpdater = SelfUpdater(), + restart: @escaping @MainActor (PreparedSelfUpdate) async throws -> Void = SelfUpdater.restart + ) { + self.current = current + self.os = os + self.client = client + self.updater = updater + self.restart = restart + } + + func check() { + guard !isBusy else { return } + error = nil + release = nil + guard let current else { + error = SelfUpdateError.invalidVersion.localizedDescription + return + } + isChecking = true + job = Task { [self] in + defer { + isChecking = false + job = nil + } + do { + let index = try await client.fetch() + release = index.recommendedUpdate(from: current, os: os) + hasChecked = true + } catch { + self.error = error.localizedDescription + } + } + } + + func install() { + guard !isBusy, let release, let current else { return } + let id = UUID() + requestID = id + error = nil + phase = .downloading(0) + job = Task { [self] in + defer { + phase = nil + job = nil + requestID = nil + } + do { + if release.isNightly { + let index = try await client.fetch() + guard index.recommendedUpdate(from: current, os: os) == release else { + throw SelfUpdateError.releaseChanged + } + } + let prepared = try await updater.prepare(release) { [weak self] value in + guard let self, requestID == id, phase != .canceling else { return } + if case .downloading(let bytes) = value, case .downloading(let previous) = phase { + phase = .downloading(max(bytes, previous)) + } else if case .downloading = value { + return + } else { + phase = value + } + } + defer { prepared.remove() } + try Task.checkCancellation() + phase = .installing + try await restart(prepared) + } catch is CancellationError { + } catch { + if !Task.isCancelled { + self.error = error.localizedDescription + self.release = nil + hasChecked = false + } + } + } + } + + func cancel() { + guard phase?.canCancel == true else { return } + phase = .canceling + job?.cancel() + } + + func consumeFailureReport() -> Bool { + guard !isBusy, let message = SelfUpdateInstallation.consumeFailure(at: updater.reportURL) else { + return false + } + error = message + return true + } +} diff --git a/src/SelfUpdateView.swift b/src/SelfUpdateView.swift new file mode 100644 index 0000000..bda33af --- /dev/null +++ b/src/SelfUpdateView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +@MainActor +struct SelfUpdateView: View { + let store: SelfUpdateStore + var close: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable().frame(width: 72, height: 72) + .accessibilityHidden(true) + Text(verbatim: "SimStore").font(.title.bold()) + if let current = store.current { + VStack(spacing: 5) { + Text(verbatim: current.displayVersion).font(.headline) + Link( + destination: AppConfiguration.commitURL( + repository: AppConfiguration.repositoryURL, revision: current.revision) + ) { + Text(verbatim: current.description).font(.caption.monospaced()) + } + Text(Date(timeIntervalSince1970: TimeInterval(current.time)), style: .date) + .font(.caption).foregroundStyle(.secondary) + } + } + Divider() + status.frame(maxWidth: .infinity, minHeight: 116) + HStack(spacing: 12) { + if let phase = store.phase { + Button("Cancel", action: store.cancel) + .disabled(!phase.canCancel) + .keyboardShortcut(.cancelAction) + } else { + Button("Done", action: close).keyboardShortcut(.cancelAction) + Spacer(minLength: 0) + if let release = store.release { + if release.isNightly { + Button("Install Nightly and Restart", action: store.install) + } else { + Button("Update and Restart", action: store.install).buttonStyle(.borderedProminent) + } + } else { + Button(action: store.check) { + StableSizeView(store.isChecking, states: [false, true]) { checking in + if checking { Text("Checking…") } else { Text("Check for Updates") } + } + } + .disabled(store.isChecking) + } + } + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(24) + .frame(width: 460) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var status: some View { + if let error = store.error { + VStack(spacing: 8) { + Label("Couldn’t Update SimStore", systemImage: "exclamationmark.triangle") + .font(.headline) + Text(verbatim: error).foregroundStyle(.secondary).textSelection(.enabled) + } + .multilineTextAlignment(.center) + } else if let phase = store.phase, let release = store.release { + VStack(spacing: 12) { + if case .downloading(let bytes) = phase { + DownloadProgressView( + receivedBytes: bytes, totalBytes: release.size, title: phase.title) + } else { + ProgressView().controlSize(.small) + if phase == .installing { + Text("Restarting SimStore…") + } else { + Text(verbatim: phase.title) + } + } + Text("SimStore will quit and reopen to finish the update.") + .font(.caption).foregroundStyle(.secondary) + } + } else if let release = store.release { + VStack(spacing: 8) { + if release.isNightly { + Label("A New Nightly Is Available", systemImage: "moon.stars") + .font(.headline) + Text("Nightly builds are unstable and may contain bugs.") + .foregroundStyle(.secondary) + } else { + Text("A New Version Is Available").font(.headline) + } + Link( + destination: AppConfiguration.repositoryURL.appending(path: "releases/tag") + .appending(component: release.tag) + ) { + Text(verbatim: release.displayVersion) + } + Text(verbatim: ByteCountFormatter.string(fromByteCount: release.size, countStyle: .file)) + .font(.caption).foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + } else if store.hasChecked && !store.isChecking { + VStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(.green) + Text("SimStore Is Up to Date").font(.headline) + Text("You’re using the latest version available for macOS \(store.os).") + .font(.callout).foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + } else { + Text("Get the latest SimStore version compatible with this Mac.") + .foregroundStyle(.secondary).multilineTextAlignment(.center) + } + } +} diff --git a/src/SelfUpdater.swift b/src/SelfUpdater.swift new file mode 100644 index 0000000..7348c76 --- /dev/null +++ b/src/SelfUpdater.swift @@ -0,0 +1,123 @@ +import AppKit + +struct PreparedSelfUpdate: Sendable { + let directory: URL + + func remove() { + try? FileManager.default.removeItem(at: directory) + } +} + +struct SelfUpdater: Sendable { + typealias Progress = @MainActor @Sendable (AppInstallationPhase) -> Void + var application = Bundle.main.bundleURL + var bundleID = AppConfiguration.applicationIdentifier + var reportURL = SelfUpdater.failureReportURL + var download: AppDownload.Fetch = { + try await AppDownload.fetch(from: $0, to: $1, progress: $2) + } + + static var failureReportURL: URL { + AppConfiguration.cacheDirectory.appending(component: "update-error.json") + } + + func prepare( + _ release: SelfUpdateRelease, progress: @escaping Progress + ) async throws -> PreparedSelfUpdate { + let application = application.resolvingSymlinksInPath().standardizedFileURL + let parent = application.deletingLastPathComponent() + guard FileManager.default.isWritableFile(atPath: parent.path), + application.pathExtension == "app", + !application.pathComponents.contains("AppTranslocation") + else { throw SelfUpdateError.notWritable } + let directory = parent.appending(component: ".simstore-update-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + var prepared = false + defer { if !prepared { try? FileManager.default.removeItem(at: directory) } } + let archive = directory.appending(component: AppConfiguration.archiveName) + await progress(.downloading(0)) + try await download( + AppConfiguration.updateAssetURL(tag: release.tag, filename: AppConfiguration.archiveName), + archive + ) { bytes in + Task { @MainActor in progress(.downloading(bytes)) } + } + try Task.checkCancellation() + await progress(.verifying) + let preparation = Task.detached(priority: .userInitiated) { + try AppPackage.verify(archive, size: release.size, sha256: release.sha256) + await progress(.extracting) + let payload = try AppPackage.extract(archive, to: directory.appending(component: "payload")) + try SelfUpdatePackage.validate(payload, release: release, bundleID: bundleID) + try Task.checkCancellation() + let current = try SelfUpdateBuild.read(at: application) + let request = SelfUpdateRequest( + application: application, bundleID: bundleID, currentRevision: current.revision, + parentPID: getpid(), release: release, reportURL: reportURL, + language: Bundle.main.preferredLocalizations.first ?? "en") + try JSONEncoder().encode(request).write( + to: directory.appending(component: SelfUpdateRequest.filename), options: .atomic) + // Run the current version's assistant, independently of the bundle being replaced. + try FileManager.default.copyItem( + at: application.appending(path: "Contents/Helpers/SimStoreUpdater"), + to: directory.appending(component: "SimStoreUpdater")) + try FileManager.default.removeItem(at: archive) + } + try await withTaskCancellationHandler { + try await preparation.value + } onCancel: { + preparation.cancel() + } + try Task.checkCancellation() + prepared = true + return PreparedSelfUpdate(directory: directory) + } + + @MainActor + static func restart(_ prepared: PreparedSelfUpdate) async throws { + let process = Process() + let directory = prepared.directory + let request = try JSONDecoder().decode( + SelfUpdateRequest.self, + from: Data(contentsOf: directory.appending(component: SelfUpdateRequest.filename))) + process.executableURL = directory.appending(component: "SimStoreUpdater") + process.arguments = [directory.path] + process.standardInput = FileHandle.nullDevice + let log = directory.appending(component: "helper.log") + FileManager.default.createFile(atPath: log.path, contents: nil) + let output = try FileHandle(forWritingTo: log) + defer { try? output.close() } + process.standardOutput = output + process.standardError = output + try process.run() + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + do { + while !FileManager.default.fileExists(atPath: directory.appending(component: "ready").path) { + guard process.isRunning else { + throw reportedFailure(at: request.reportURL, fallback: .helperFailed) + } + guard ContinuousClock.now < deadline else { + throw SelfUpdateError.helperFailed + } + try await Task.sleep(for: .milliseconds(50)) + } + } catch { + if process.isRunning { process.terminate() } + throw error + } + NSApplication.shared.terminate(nil) + // If another action prevents termination, the assistant times out without changing the app. + while process.isRunning { try await Task.sleep(for: .milliseconds(100)) } + throw reportedFailure(at: request.reportURL, fallback: .quitTimedOut) + } + + private static func reportedFailure(at url: URL, fallback: SelfUpdateError) -> NSError { + NSError( + domain: "SelfUpdate", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: SelfUpdateInstallation.consumeFailure(at: url) + ?? fallback.localizedDescription + ]) + } +} diff --git a/src/SimStoreApp.swift b/src/SimStoreApp.swift index dc278b9..fe60c5d 100644 --- a/src/SimStoreApp.swift +++ b/src/SimStoreApp.swift @@ -3,11 +3,21 @@ import SwiftUI @main struct SimStoreApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var updates = SelfUpdateController() var body: some Scene { Window(Text(verbatim: "SimStore"), id: "main") { ContentView() + .task { updates.showPendingFailure() } } .defaultSize(width: 1180, height: 780) + .commands { + CommandGroup(replacing: .appInfo) { + Button("About SimStore") { updates.show() } + } + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { updates.show(check: true) } + } + } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1b61693..ced8f9e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -57,7 +57,9 @@ add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTe "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" "${PROJECT_SOURCE_DIR}/src/AppDownload.swift" "${PROJECT_SOURCE_DIR}/src/AppPackage.swift" + "${PROJECT_SOURCE_DIR}/src/MachOValidation.swift" "${PROJECT_SOURCE_DIR}/src/AppInstaller.swift" + "${PROJECT_SOURCE_DIR}/src/AppInstallationPhase.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationStore.swift" "${PROJECT_SOURCE_DIR}/src/AppInstallationAction.swift" "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" @@ -161,3 +163,51 @@ add_test(NAME release-workflow COMMAND "${Python3_EXECUTABLE}" -m unittest disco -s "${CMAKE_CURRENT_SOURCE_DIR}" -p "test_*.py" ) set_tests_properties(release-workflow PROPERTIES TIMEOUT 60) + +add_executable(SimStoreSelfUpdateTest + SelfUpdateTests.swift SelfUpdateFixtures.swift SelfUpdateModelTests.swift + SelfUpdatePackageTests.swift SelfUpdateInstallerTests.swift SelfUpdateStoreTests.swift + SelfUpdateViewTests.swift + "${PROJECT_SOURCE_DIR}/src/SelfUpdateRelease.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateClient.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdatePackage.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateInstallation.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdater.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateStore.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateView.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateController.swift" + "${PROJECT_SOURCE_DIR}/src/AppCatalog.swift" + "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" + "${PROJECT_SOURCE_DIR}/src/AppDownload.swift" + "${PROJECT_SOURCE_DIR}/src/AppPackage.swift" + "${PROJECT_SOURCE_DIR}/src/MachOValidation.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" + "${PROJECT_SOURCE_DIR}/src/AppInstallationPhase.swift" + "${PROJECT_SOURCE_DIR}/src/DownloadProgressView.swift" + "${PROJECT_SOURCE_DIR}/src/StableSizeView.swift" +) +target_compile_options(SimStoreSelfUpdateTest PRIVATE -parse-as-library) +add_test(NAME self-update COMMAND SimStoreSelfUpdateTest) +set_tests_properties(self-update PROPERTIES TIMEOUT 60 RUN_SERIAL TRUE) + +add_executable(SimStoreSelfUpdateIntegrationTest + SelfUpdateIntegrationTest.swift SelfUpdateFixtures.swift + "${PROJECT_SOURCE_DIR}/src/SelfUpdateRelease.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdatePackage.swift" + "${PROJECT_SOURCE_DIR}/src/SelfUpdateInstallation.swift" + "${PROJECT_SOURCE_DIR}/src/AppCatalog.swift" + "${PROJECT_SOURCE_DIR}/src/MachOValidation.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) +add_dependencies(SimStoreSelfUpdateIntegrationTest SimStore) +target_compile_options(SimStoreSelfUpdateIntegrationTest PRIVATE -parse-as-library) +add_test(NAME self-update-integration COMMAND SimStoreSelfUpdateIntegrationTest "$") +set_tests_properties(self-update-integration PROPERTIES TIMEOUT 60 RUN_SERIAL TRUE) + +add_test(NAME update-assistant-metadata COMMAND "${CMAKE_COMMAND}" + "-DEXECUTABLE=$" + "-DINFO_PLIST=$/Contents/Info.plist" + "-DEXPECTED_SDK=${SDK_VERSION}" + "-DEXPECTED_MIN_OS=${CMAKE_OSX_DEPLOYMENT_TARGET}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/BundleMetadataTest.cmake" +) diff --git a/tests/LocalizationTests.swift b/tests/LocalizationTests.swift index c3e3b09..c88b355 100644 --- a/tests/LocalizationTests.swift +++ b/tests/LocalizationTests.swift @@ -52,6 +52,11 @@ struct LocalizationTests { ("Version Unavailable", "Version Unavailable", "版本未知"), ("Uninstall", "Uninstall", "卸载"), ("Uninstalling…", "Uninstalling…", "正在卸载…"), + ("About SimStore", "About SimStore", "关于 SimStore"), + ("Check for Updates…", "Check for Updates…", "检查更新…"), + ("Update and Restart", "Update and Restart", "更新并重新启动"), + ("Install Nightly and Restart", "Install Nightly and Restart", "安装每夜构建并重新启动"), + ("SimStore Is Up to Date", "SimStore Is Up to Date", "SimStore 已是最新版本"), ] for (key, source, translated) in labels { try expect( diff --git a/tests/SelfUpdateFixtures.swift b/tests/SelfUpdateFixtures.swift new file mode 100644 index 0000000..bb930b2 --- /dev/null +++ b/tests/SelfUpdateFixtures.swift @@ -0,0 +1,124 @@ +import CryptoKit +import Foundation + +enum SelfUpdateFixtures { + static let bundleID = "org.example.SimStoreTest" + + static func directory() throws -> URL { + let directory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appending(path: "self-update-tests/\(UUID())").resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func release(_ changes: [String: Any] = [:]) throws -> SelfUpdateRelease { + var value: [String: Any] = [ + "tag": "0.2.0", "version": "0.2.0", "build": 0, "minOS": "14.0", + "revision": String(repeating: "b", count: 40), "time": 1_700_000_000, + "size": 1, "sha256": String(repeating: "a", count: 64), + ] + value.merge(changes) { _, new in new } + return try JSONDecoder().decode( + SelfUpdateRelease.self, from: JSONSerialization.data(withJSONObject: value)) + } + + static func current(version: String = "0.1.0", build: Int = 0) -> SelfUpdateBuild { + SelfUpdateBuild( + version: version, build: build, revision: String(repeating: "a", count: 40), time: 1, + dirty: false) + } + + static func index(_ versions: [SelfUpdateRelease]) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "versions": try versions.map { + try JSONSerialization.jsonObject(with: JSONEncoder().encode($0)) + } + ]) + } + + static func bundle(in directory: URL, release: SelfUpdateRelease) throws -> URL { + let application = directory.appending(component: "SimStore.app") + for path in ["Contents/MacOS", "Contents/Helpers"] { + try FileManager.default.createDirectory( + at: application.appending(path: path), withIntermediateDirectories: true) + } + let info: [String: Any] = [ + "CFBundleIdentifier": bundleID, "CFBundlePackageType": "APPL", + "CFBundleExecutable": "SimStore", + "CFBundleShortVersionString": release.version, "CFBundleVersion": String(release.build), + "SimStoreRevision": release.revision, "SimStoreCommitTime": release.time, + "SimStoreSourceDirty": false, + "LSMinimumSystemVersion": release.minOS, + ] + try writeInfo(info, at: application) + for path in ["Contents/MacOS/SimStore", "Contents/Helpers/SimStoreUpdater"] { + let file = application.appending(path: path) + try machO().write(to: file) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) + } + return application + } + + static func writeInfo(_ info: [String: Any], at application: URL) throws { + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: application.appending(path: "Contents/Info.plist")) + } + + static func machO(platform: UInt32 = 1, cpu: UInt32 = 0x0100_000C, minOS: UInt32 = 14 << 16) + -> Data + { + [UInt32(0xFEED_FACF), cpu, 0, 2, 1, 24, 0, 0, 0x32, 24, platform, minOS, 0, 0].reduce( + into: Data() + ) { data, word in + var value = word.littleEndian + withUnsafeBytes(of: &value) { data.append(contentsOf: $0) } + } + } + + static func archive(_ application: URL, in directory: URL) throws -> URL { + let archive = directory.appending(component: "SimStore.app.tar.bz2") + _ = try Command.run( + "/usr/bin/tar", + arguments: [ + "-cjf", archive.path, "-C", application.deletingLastPathComponent().path, + application.lastPathComponent, + ]) + return archive + } + + static func archivedRelease(_ archive: URL, release: SelfUpdateRelease) throws + -> SelfUpdateRelease + { + let data = try Data(contentsOf: archive) + var value = + try JSONSerialization.jsonObject(with: JSONEncoder().encode(release)) as! [String: Any] + value["size"] = data.count + value["sha256"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return try JSONDecoder().decode( + SelfUpdateRelease.self, from: JSONSerialization.data(withJSONObject: value)) + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "SelfUpdateTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + + static func rejects(_ body: () throws -> Void) throws { + do { try body() } catch { return } + throw failure("Invalid update input must fail.") + } + + @MainActor + static func eventually(_ predicate: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !predicate() { + guard ContinuousClock.now < deadline else { + throw failure("Timed out waiting for the updater.") + } + try await Task.sleep(for: .milliseconds(5)) + } + } +} diff --git a/tests/SelfUpdateInstallerTests.swift b/tests/SelfUpdateInstallerTests.swift new file mode 100644 index 0000000..8f8b7c6 --- /dev/null +++ b/tests/SelfUpdateInstallerTests.swift @@ -0,0 +1,151 @@ +import Foundation + +enum SelfUpdateInstallerTests { + @MainActor + static func run() async throws { + try await preparation() + try await cancellation() + try await replacement() + try await timeout() + print( + "PASS: real archive preparation, cancellation cleanup, atomic replacement, rollback, and exit timeout." + ) + } + + @MainActor + static func preparation() async throws { + let directory = try SelfUpdateFixtures.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let release = try SelfUpdateFixtures.release() + let old = try SelfUpdateFixtures.bundle( + in: directory.appending(component: "current app ' 测试"), release: release) + let new = try SelfUpdateFixtures.bundle( + in: directory.appending(component: "new"), release: release) + let archive = try SelfUpdateFixtures.archive(new, in: directory) + let record = try SelfUpdateFixtures.archivedRelease(archive, release: release) + var updater = SelfUpdater( + application: old, bundleID: SelfUpdateFixtures.bundleID, + reportURL: directory.appending(component: "error.json")) + updater.download = { url, destination, progress in + guard + url + == AppConfiguration.updateAssetURL( + tag: record.tag, filename: AppConfiguration.archiveName) + else { + throw SelfUpdateFixtures.failure("Use the fixed release archive URL.") + } + try FileManager.default.copyItem(at: archive, to: destination) + progress(record.size) + } + var phases: [AppInstallationPhase] = [] + let prepared = try await updater.prepare(record) { phases.append($0) } + defer { prepared.remove() } + try SelfUpdateFixtures.require( + prepared.directory.deletingLastPathComponent() == old.deletingLastPathComponent(), + "Stage on the destination volume.") + try SelfUpdateFixtures.require( + phases.contains(.verifying) && phases.contains(.extracting), + "Report download and preparation phases.") + try SelfUpdatePackage.validate( + SelfUpdateRequest.payload(in: prepared.directory), release: record, + bundleID: SelfUpdateFixtures.bundleID) + try SelfUpdateFixtures.require( + FileManager.default.isExecutableFile( + atPath: prepared.directory.appending(component: "SimStoreUpdater").path), + "Copy the current assistant before quitting.") + let request = try JSONDecoder().decode( + SelfUpdateRequest.self, + from: Data(contentsOf: prepared.directory.appending(component: SelfUpdateRequest.filename))) + try SelfUpdateFixtures.require( + request.parentPID == getpid() && request.application == old, + "Wait for the actual parent and replace its actual path.") + do { + _ = try await updater.prepare(release) { _ in } + throw SelfUpdateFixtures.failure("A wrong archive size must fail before handoff.") + } catch AppPackage.Failure.sizeMismatch {} + let remaining = try FileManager.default.contentsOfDirectory( + at: old.deletingLastPathComponent(), includingPropertiesForKeys: nil + ) + .filter { $0.lastPathComponent.hasPrefix(".simstore-update-") } + try SelfUpdateFixtures.require( + remaining.map(\.path) == [prepared.directory.path], + "A failed preparation must remove its staging directory.") + } + + @MainActor + static func cancellation() async throws { + let directory = try SelfUpdateFixtures.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let release = try SelfUpdateFixtures.release() + let old = try SelfUpdateFixtures.bundle(in: directory, release: release) + var updater = SelfUpdater(application: old, bundleID: SelfUpdateFixtures.bundleID) + updater.download = { _, destination, progress in + try Data("partial download".utf8).write(to: destination) + progress(1) + try await Task.sleep(for: .seconds(60)) + } + var downloading = false + let task = Task { try await updater.prepare(release) { _ in downloading = true } } + try await SelfUpdateFixtures.eventually { downloading } + task.cancel() + do { + _ = try await task.value + throw SelfUpdateFixtures.failure("Cancel must interrupt the download.") + } catch is CancellationError {} + let children = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) + try SelfUpdateFixtures.require( + children.map(\.path) == [old.path], + "Cancel must preserve the original and remove partial downloads.") + } + + @MainActor + static func replacement() async throws { + for shouldFail in [false, true] { + let directory = try SelfUpdateFixtures.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let oldRelease = try SelfUpdateFixtures.release([ + "tag": "0.1.0", "version": "0.1.0", "revision": String(repeating: "a", count: 40), + ]) + let newRelease = try SelfUpdateFixtures.release() + let application = try SelfUpdateFixtures.bundle(in: directory, release: oldRelease) + let work = directory.appending(component: ".simstore-update-\(UUID())") + _ = try SelfUpdateFixtures.bundle( + in: work.appending(component: "payload"), release: newRelease) + let request = SelfUpdateRequest( + application: application, bundleID: SelfUpdateFixtures.bundleID, + currentRevision: oldRelease.revision, parentPID: 2, release: newRelease, + reportURL: directory.appending(component: "error.json")) + do { + try await SelfUpdateInstallation.replace(request: request, directory: work) { path in + try SelfUpdateFixtures.require( + try SelfUpdateBuild.read(at: path).version == "0.2.0", + "Launch the replaced app at its original path.") + try SelfUpdateFixtures.require( + try SelfUpdateBuild.read(at: SelfUpdateRequest.payload(in: work)).version == "0.1.0", + "Keep the previous bundle until launch succeeds.") + if shouldFail { throw SelfUpdateFixtures.failure("Injected launch failure") } + } + try SelfUpdateFixtures.require(!shouldFail, "A failed launch must fail the update.") + } catch SelfUpdateError.launchFailed { + try SelfUpdateFixtures.require(shouldFail, "Do not fail a successful launch.") + } + try SelfUpdateFixtures.require( + try SelfUpdateBuild.read(at: application).version == (shouldFail ? "0.1.0" : "0.2.0"), + "A failed launch must restore the previous version atomically.") + } + } + + static func timeout() async throws { + do { + try await SelfUpdateInstallation.waitForExit(getpid(), timeout: .milliseconds(75)) + throw SelfUpdateFixtures.failure( + "Never replace the bundle while its process is still running.") + } catch SelfUpdateError.quitTimedOut {} + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["0.1"] + try process.run() + try await SelfUpdateInstallation.waitForExit(process.processIdentifier, timeout: .seconds(2)) + } +} diff --git a/tests/SelfUpdateIntegrationTest.swift b/tests/SelfUpdateIntegrationTest.swift new file mode 100644 index 0000000..5be7e65 --- /dev/null +++ b/tests/SelfUpdateIntegrationTest.swift @@ -0,0 +1,125 @@ +import AppKit + +@main +struct SelfUpdateIntegrationTest { + @MainActor + static func main() async { + do { + try await run() + print( + "PASS: assistant waits for the running app, replaces an isolated copy, restarts it, and removes the previous bundle." + ) + } catch { + FileHandle.standardError.write( + Data("Self-update integration failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + @MainActor + static func run() async throws { + guard CommandLine.arguments.count == 2 else { + throw SelfUpdateFixtures.failure("Expected the built app path.") + } + let source = URL(fileURLWithPath: CommandLine.arguments[1]) + let directory = try SelfUpdateFixtures.directory() + let identifier = "org.example.SimStoreUpdateTest.\(UUID().uuidString)" + defer { + for app in NSRunningApplication.runningApplications(withBundleIdentifier: identifier) { + app.forceTerminate() + } + try? FileManager.default.removeItem(at: directory) + } + let application = directory.appending(component: "SimStore.app") + try FileManager.default.copyItem(at: source, to: application) + var info = try SelfUpdatePackage.info(at: application) + info["CFBundleIdentifier"] = identifier + try SelfUpdateFixtures.writeInfo(info, at: application) + let chinese = SelfUpdateInstallation.localizationBundle( + application: application, language: "zh-Hans") + try SelfUpdateFixtures.require( + SelfUpdateError.launchFailed.message(in: chinese) == "新版应用无法启动,已恢复旧版。", + "The assistant must localize failure reports using the app's resources.") + let current = try SelfUpdateBuild.read(at: application) + let work = directory.appending(component: ".simstore-update-\(UUID())") + try FileManager.default.createDirectory( + at: work.appending(component: "payload"), withIntermediateDirectories: true) + let payload = SelfUpdateRequest.payload(in: work) + try FileManager.default.copyItem(at: application, to: payload) + info["CFBundleVersion"] = String(current.build + 1) + info["SimStoreSourceDirty"] = false + info["SimStoreRevision"] = String(repeating: "c", count: 40) + try SelfUpdateFixtures.writeInfo(info, at: payload) + let release = try SelfUpdateFixtures.release([ + "tag": "latest", "version": current.version, "build": current.build + 1, + "revision": String(repeating: "c", count: 40), "time": current.time, + "minOS": info["LSMinimumSystemVersion"] as! String, + ]) + let helper = work.appending(component: "SimStoreUpdater") + try FileManager.default.copyItem( + at: application.appending(path: "Contents/Helpers/SimStoreUpdater"), to: helper) + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + configuration.allowsRunningApplicationSubstitution = false + let running = try await NSWorkspace.shared.openApplication( + at: application, configuration: configuration) + let report = directory.appending(component: "error.json") + let request = SelfUpdateRequest( + application: application, bundleID: identifier, currentRevision: current.revision, + parentPID: running.processIdentifier, release: release, reportURL: report) + try JSONEncoder().encode(request).write( + to: work.appending(component: SelfUpdateRequest.filename)) + let process = Process() + process.executableURL = helper + process.arguments = [work.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.standardError + try process.run() + defer { if process.isRunning { process.terminate() } } + try await wait { + FileManager.default.fileExists(atPath: work.appending(component: "ready").path) + || !process.isRunning + } + try SelfUpdateFixtures.require( + process.isRunning, "The assistant must validate the request and become ready.") + try await Task.sleep(for: .milliseconds(200)) + try SelfUpdateFixtures.require( + !running.isTerminated, "The assistant must not kill the current app.") + try SelfUpdateFixtures.require( + try SelfUpdateBuild.read(at: application) == current, + "Do not replace files before the parent exits.") + try SelfUpdateFixtures.require(running.terminate(), "The isolated old copy must quit normally.") + try await wait { !process.isRunning } + if FileManager.default.fileExists(atPath: report.path) { + throw SelfUpdateFixtures.failure( + try JSONDecoder().decode(String.self, from: Data(contentsOf: report))) + } + try SelfUpdateFixtures.require( + process.terminationStatus == 0, "The assistant must finish successfully.") + try SelfUpdateFixtures.require( + try SelfUpdateBuild.read(at: application).build == current.build + 1, + "Install the prepared build.") + let reopened = NSRunningApplication.runningApplications(withBundleIdentifier: identifier).first + { + $0.processIdentifier != running.processIdentifier && !$0.isTerminated + } + try SelfUpdateFixtures.require( + reopened != nil, "Restart the new bundle at its original location.") + try SelfUpdateFixtures.require( + !FileManager.default.fileExists(atPath: work.path), + "Remove the assistant and previous bundle after launch succeeds.") + reopened?.terminate() + try await wait { reopened?.isTerminated != false } + } + + @MainActor + static func wait(_ predicate: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(15)) + while !predicate() { + guard ContinuousClock.now < deadline else { + throw SelfUpdateFixtures.failure("The helper integration timed out.") + } + try await Task.sleep(for: .milliseconds(50)) + } + } +} diff --git a/tests/SelfUpdateModelTests.swift b/tests/SelfUpdateModelTests.swift new file mode 100644 index 0000000..d3b9d0d --- /dev/null +++ b/tests/SelfUpdateModelTests.swift @@ -0,0 +1,65 @@ +import Foundation + +enum SelfUpdateModelTests { + static func run() throws { + let nightly = try SelfUpdateFixtures.release(["tag": "latest", "build": 8]) + let stable = try SelfUpdateFixtures.release() + let old = try SelfUpdateFixtures.release(["tag": "0.1.10", "version": "0.1.10"]) + let older = try SelfUpdateFixtures.release(["tag": "0.1.9", "version": "0.1.9"]) + let index = try SelfUpdateIndex.decode(SelfUpdateFixtures.index([older, nightly, stable, old])) + try SelfUpdateFixtures.require( + index.recommendedUpdate(from: SelfUpdateFixtures.current(), os: "14.0") == stable, + "Stable must win over a newer nightly, regardless of input ordering.") + try SelfUpdateFixtures.require( + index.recommendedUpdate(from: SelfUpdateFixtures.current(version: "0.2.0"), os: "14.0") + == nightly, + "The latest compatible stable may opt into a newer nightly.") + try SelfUpdateFixtures.require( + index.recommendedUpdate( + from: SelfUpdateFixtures.current(version: "0.2.0", build: 8), os: "14.0") == nil, + "Do not reinstall the same nightly or downgrade to stable.") + try SelfUpdateFixtures.require( + index.recommendedUpdate( + from: SelfUpdateFixtures.current(version: "0.1.10", build: 999), os: "14.0") == stable, + "A stable version bump outranks the previous version's commit count.") + let futureStable = try SelfUpdateFixtures.release(["minOS": "26.0"]) + let futureNightly = try SelfUpdateFixtures.release([ + "tag": "latest", "build": 8, "minOS": "27.0", + ]) + let limited = try SelfUpdateIndex.decode( + SelfUpdateFixtures.index([futureStable, older, futureNightly, old])) + try SelfUpdateFixtures.require( + limited.recommendedUpdate(from: SelfUpdateFixtures.current(), os: "14.6.1") == old, + "Keep the newest stable that can run on the current OS and compare numeric versions.") + try SelfUpdateFixtures.require( + limited.recommendedUpdate(from: SelfUpdateFixtures.current(version: "0.1.10"), os: "14.6.1") + == nil, + "Never offer an incompatible nightly after reaching the compatible stable.") + try SelfUpdateFixtures.require( + !nightly.isCompatible(with: "invalid"), "Unknown OS versions must fail closed.") + try SelfUpdateFixtures.rejects { + _ = try SelfUpdateIndex.decode(SelfUpdateFixtures.index([stable])) + } + try SelfUpdateFixtures.rejects { + _ = try SelfUpdateIndex.decode(SelfUpdateFixtures.index([nightly, nightly])) + } + try SelfUpdateFixtures.rejects { + _ = try SelfUpdateIndex.decode(SelfUpdateFixtures.index([nightly, stable, stable])) + } + let invalid: [[String: Any]] = [ + ["version": "0.2"], ["version": "00.2.0"], ["tag": "v0.2.0"], ["build": 1], ["build": true], + ["build": -1], ["build": "0"], ["size": 0], ["time": -1], ["revision": "abc"], + ["sha256": "xyz"], + ["minOS": "0"], ["minOS": "14.0.0.1"], + ] + for value in invalid { + try SelfUpdateFixtures.rejects { _ = try SelfUpdateFixtures.release(value) } + } + try SelfUpdateFixtures.require( + SelfUpdateFixtures.current(build: 12).description == "0.1.0-12-gaaaaaaa", + "Show the Git-derived version in About.") + print( + "PASS: compatible stable priority, nightly opt-in, numeric ordering, and strict update metadata." + ) + } +} diff --git a/tests/SelfUpdatePackageTests.swift b/tests/SelfUpdatePackageTests.swift new file mode 100644 index 0000000..90b4c2f --- /dev/null +++ b/tests/SelfUpdatePackageTests.swift @@ -0,0 +1,71 @@ +import Foundation + +enum SelfUpdatePackageTests { + static func run() throws { + let directory = try SelfUpdateFixtures.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let release = try SelfUpdateFixtures.release() + let application = try SelfUpdateFixtures.bundle(in: directory, release: release) + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + let original = try SelfUpdatePackage.info(at: application) + let mutations: [[String: Any]] = [ + ["CFBundleIdentifier": "org.other.App"], ["CFBundleShortVersionString": "0.1.0"], + ["CFBundleVersion": "2"], ["CFBundleVersion": 0], ["CFBundleVersion": "00"], + ["SimStoreRevision": String(repeating: "c", count: 40)], ["SimStoreCommitTime": 1], + ["SimStoreSourceDirty": true], ["LSMinimumSystemVersion": "13.0"], + ["CFBundleExecutable": "../../outside"], ["CFBundlePackageType": "FMWK"], + ] + for changes in mutations { + var info = original + info.merge(changes) { _, new in new } + try SelfUpdateFixtures.writeInfo(info, at: application) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + } + } + try SelfUpdateFixtures.writeInfo(original, at: application) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID, os: "13.6") + } + let binary = application.appending(path: "Contents/MacOS/SimStore") + for data in [ + SelfUpdateFixtures.machO(platform: 7), SelfUpdateFixtures.machO(cpu: 0x0100_0007), + SelfUpdateFixtures.machO(minOS: 15 << 16), Data("not executable".utf8), + ] { + try data.write(to: binary) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + } + } + try SelfUpdateFixtures.machO().write(to: binary) + let hidden = application.appending(path: "Contents/Resources/hidden") + try FileManager.default.createDirectory( + at: hidden.deletingLastPathComponent(), withIntermediateDirectories: true) + try SelfUpdateFixtures.machO(platform: 7).write(to: hidden) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + } + try FileManager.default.removeItem(at: hidden) + try FileManager.default.createSymbolicLink( + atPath: hidden.path, withDestinationPath: directory.path) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + } + try FileManager.default.removeItem(at: hidden) + try FileManager.default.removeItem( + at: application.appending(path: "Contents/Helpers/SimStoreUpdater")) + try SelfUpdateFixtures.rejects { + try SelfUpdatePackage.validate( + application, release: release, bundleID: SelfUpdateFixtures.bundleID) + } + print( + "PASS: app identity, actual minimum OS/platform/architecture, helper executable, and symlink boundaries." + ) + } +} diff --git a/tests/SelfUpdateStoreTests.swift b/tests/SelfUpdateStoreTests.swift new file mode 100644 index 0000000..11bf3fd --- /dev/null +++ b/tests/SelfUpdateStoreTests.swift @@ -0,0 +1,130 @@ +import Foundation + +enum SelfUpdateStoreTests { + @MainActor + static func run() async throws { + let release = try SelfUpdateFixtures.release() + let nightly = try SelfUpdateFixtures.release(["tag": "latest", "build": 8]) + let data = try SelfUpdateFixtures.index([nightly, release]) + let counter = RequestCounter() + var client = SelfUpdateClient() + client.transport = { request in + await counter.increment() + try SelfUpdateFixtures.require( + request.cachePolicy == .reloadIgnoringLocalCacheData, + "Checks must bypass local HTTP caches.") + try SelfUpdateFixtures.require( + request.value(forHTTPHeaderField: "Cache-Control") == "no-cache", + "Revalidate the moving update feed.") + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + let store = SelfUpdateStore(current: SelfUpdateFixtures.current(), os: "14.0", client: client) + store.check() + store.check() + try await SelfUpdateFixtures.eventually { !store.isBusy } + try SelfUpdateFixtures.require( + store.release == release && store.error == nil && store.hasChecked, + "Offer the compatible stable update.") + try SelfUpdateFixtures.require(await counter.value == 1, "Coalesce repeated check actions.") + let current = SelfUpdateStore( + current: SelfUpdateFixtures.current(version: "0.2.0", build: 8), os: "14.0", client: client) + current.check() + try await SelfUpdateFixtures.eventually { !current.isBusy } + try SelfUpdateFixtures.require( + current.release == nil && current.hasChecked && current.error == nil, + "Show an up-to-date result.") + client.transport = { request in + ( + Data(), + HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)! + ) + } + let failed = SelfUpdateStore(current: SelfUpdateFixtures.current(), client: client) + failed.check() + try await SelfUpdateFixtures.eventually { !failed.isBusy } + try SelfUpdateFixtures.require( + failed.error?.contains("503") == true && !failed.hasChecked, + "A failed check must allow retry and never claim up-to-date.") + try await changedNightly(initial: data, nightly: nightly) + try await cancelInstallation(data: data) + print( + "PASS: cache-bypassing checks, coalesced actions, retry, changed nightly detection, and cancel state." + ) + } + + @MainActor + static func changedNightly(initial: Data, nightly: SelfUpdateRelease) async throws { + let next = try SelfUpdateFixtures.index([ + SelfUpdateFixtures.release(["tag": "latest", "build": 9]) + ]) + let sequence = ResponseSequence([initial, next]) + var client = SelfUpdateClient() + client.transport = { request in + ( + await sequence.next(), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + let store = SelfUpdateStore( + current: SelfUpdateFixtures.current(version: "0.2.0"), os: "14.0", client: client) + store.check() + try await SelfUpdateFixtures.eventually { !store.isBusy } + try SelfUpdateFixtures.require(store.release == nightly, "Offer the checked nightly first.") + store.install() + try await SelfUpdateFixtures.eventually { !store.isBusy } + try SelfUpdateFixtures.require( + store.release == nil && store.error == SelfUpdateError.releaseChanged.localizedDescription, + "Ask for a fresh check rather than silently installing a different nightly.") + } + + @MainActor + static func cancelInstallation(data: Data) async throws { + let directory = try SelfUpdateFixtures.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let application = try SelfUpdateFixtures.bundle( + in: directory, release: SelfUpdateFixtures.release()) + var updater = SelfUpdater( + application: application, bundleID: SelfUpdateFixtures.bundleID, + reportURL: directory.appending(component: "error.json")) + updater.download = { _, destination, progress in + try Data("partial".utf8).write(to: destination) + progress(1) + try await Task.sleep(for: .seconds(60)) + } + var client = SelfUpdateClient() + client.transport = { request in + ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + let store = SelfUpdateStore( + current: SelfUpdateFixtures.current(), os: "14.0", client: client, updater: updater) + store.check() + try await SelfUpdateFixtures.eventually { !store.isBusy } + store.install() + store.cancel() + try await SelfUpdateFixtures.eventually { !store.isBusy } + try SelfUpdateFixtures.require( + store.error == nil && store.release != nil, + "Cancellation must leave the offered update available without an error.") + try SelfUpdateInstallation.writeFailure(SelfUpdateError.launchFailed, to: updater.reportURL) + try SelfUpdateFixtures.require( + store.consumeFailureReport() && store.error != nil, "Surface helper failures after relaunch.") + try SelfUpdateFixtures.require(!store.consumeFailureReport(), "Consume helper failures once.") + } +} + +private actor RequestCounter { + private(set) var value = 0 + func increment() { value += 1 } +} + +private actor ResponseSequence { + private var values: [Data] + init(_ values: [Data]) { self.values = values } + func next() -> Data { values.removeFirst() } +} diff --git a/tests/SelfUpdateTests.swift b/tests/SelfUpdateTests.swift new file mode 100644 index 0000000..36b316c --- /dev/null +++ b/tests/SelfUpdateTests.swift @@ -0,0 +1,19 @@ +import Foundation + +@main +struct SelfUpdateTests { + @MainActor + static func main() async { + do { + try SelfUpdateModelTests.run() + try SelfUpdatePackageTests.run() + try await SelfUpdateInstallerTests.run() + try await SelfUpdateStoreTests.run() + try await SelfUpdateViewTests.run() + } catch { + FileHandle.standardError.write( + Data("Self-update test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } +} diff --git a/tests/SelfUpdateViewTests.swift b/tests/SelfUpdateViewTests.swift new file mode 100644 index 0000000..3deaba5 --- /dev/null +++ b/tests/SelfUpdateViewTests.swift @@ -0,0 +1,57 @@ +import AppKit + +enum SelfUpdateViewTests { + @MainActor + static func run() async throws { + _ = NSApplication.shared + let stable = try SelfUpdateFixtures.release() + let nightly = try SelfUpdateFixtures.release(["tag": "latest", "build": 8]) + for installed in [SelfUpdateFixtures.current(), SelfUpdateFixtures.current(version: "0.2.0")] { + var client = SelfUpdateClient() + let data = try SelfUpdateFixtures.index([stable, nightly]) + client.transport = { request in + ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + let store = SelfUpdateStore(current: installed, os: "14.0", client: client) + let controller = SelfUpdateController(store: store) + controller.show(check: true) + defer { controller.panel?.close() } + try await SelfUpdateFixtures.eventually { !store.isBusy } + try await Task.sleep(for: .milliseconds(100)) + guard let panel = controller.panel, let view = panel.contentView else { + throw SelfUpdateFixtures.failure("The About/update panel must exist.") + } + view.layoutSubtreeIfNeeded() + try SelfUpdateFixtures.require( + panel.frame.width >= 460 && panel.frame.height > 300 && panel.frame.height < 650, + "Fit the About and update content without a resizable main window.") + try SelfUpdateFixtures.require( + !panel.canBecomeMain && panel.tabbingMode == .disallowed && panel.isExcludedFromWindowsMenu, + "Keep the auxiliary panel separate from main-window navigation.") + let buttons = nativeButtons(in: view) + try SelfUpdateFixtures.require( + buttons.count >= 2, "Expose native buttons for keyboard and accessibility navigation.") + for button in buttons { + let frame = view.convert(button.bounds, from: button) + try SelfUpdateFixtures.require( + view.bounds.insetBy(dx: -1, dy: -1).contains(frame), + "Keep every action button inside the panel.") + } + panel.performClose(nil) + try SelfUpdateFixtures.require(!panel.isVisible, "An idle update panel must close normally.") + } + print( + "PASS: About/update panels fit stable and nightly content and do not add main windows or tabs." + ) + } + @MainActor + private static func nativeButtons(in view: NSView) -> [NSButton] { + view.subviews.flatMap { child in + (child as? NSButton).map { [$0] } ?? nativeButtons(in: child) + } + } + +} From c70c88d4ff7d6e741d64b893b124feb0260158eb Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 17:47:37 -0500 Subject: [PATCH 2/3] Match updater actions to standard app buttons --- src/SelfUpdateView.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/SelfUpdateView.swift b/src/SelfUpdateView.swift index bda33af..951ecfd 100644 --- a/src/SelfUpdateView.swift +++ b/src/SelfUpdateView.swift @@ -41,23 +41,30 @@ struct SelfUpdateView: View { Button("Update and Restart", action: store.install).buttonStyle(.borderedProminent) } } else { - Button(action: store.check) { - StableSizeView(store.isChecking, states: [false, true]) { checking in - if checking { Text("Checking…") } else { Text("Check for Updates") } - } + if store.isChecking { + checkButton + } else { + checkButton.buttonStyle(.borderedProminent) } - .disabled(store.isChecking) } } } .buttonStyle(.bordered) - .controlSize(.large) } .padding(24) .frame(width: 460) .fixedSize(horizontal: false, vertical: true) } + private var checkButton: some View { + Button(action: store.check) { + StableSizeView(store.isChecking, states: [false, true]) { checking in + if checking { Text("Checking…") } else { Text("Check for Updates") } + } + } + .disabled(store.isChecking) + } + @ViewBuilder private var status: some View { if let error = store.error { From e38ace46c37df1fc1afe7b438bd240deeaac5f40 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 17:50:07 -0500 Subject: [PATCH 3/3] Use standard buttons throughout simulator setup --- src/MissingXcodeView.swift | 1 - src/NoSimulatorsView.swift | 1 - src/SimulatorSelectionViews.swift | 1 - src/SimulatorStatusViews.swift | 1 - 4 files changed, 4 deletions(-) diff --git a/src/MissingXcodeView.swift b/src/MissingXcodeView.swift index 330ca52..f29fce1 100644 --- a/src/MissingXcodeView.swift +++ b/src/MissingXcodeView.swift @@ -31,7 +31,6 @@ struct MissingXcodeView: View { SimulatorCheckButton(isChecking: isChecking, action: checkAgain) .buttonStyle(.bordered) } - .controlSize(.large) .padding(.top, 25) } .padding(.horizontal, 30) diff --git a/src/NoSimulatorsView.swift b/src/NoSimulatorsView.swift index a814c73..4dbd87b 100644 --- a/src/NoSimulatorsView.swift +++ b/src/NoSimulatorsView.swift @@ -50,7 +50,6 @@ struct NoSimulatorsView: View { SimulatorCheckButton(isChecking: isChecking, action: checkAgain) .buttonStyle(.bordered) } - .controlSize(.large) .padding(.top, 25) if deviceManagerName != nil { diff --git a/src/SimulatorSelectionViews.swift b/src/SimulatorSelectionViews.swift index e31f22f..6a0cbbe 100644 --- a/src/SimulatorSelectionViews.swift +++ b/src/SimulatorSelectionViews.swift @@ -107,7 +107,6 @@ struct SimulatorDetailView: View { DeviceManagerButton(name: deviceManagerName, action: openDeviceManager) .buttonStyle(.bordered) } - .controlSize(.large) } .frame(maxWidth: 480) .padding(32) diff --git a/src/SimulatorStatusViews.swift b/src/SimulatorStatusViews.swift index d73973a..d1ecfff 100644 --- a/src/SimulatorStatusViews.swift +++ b/src/SimulatorStatusViews.swift @@ -37,7 +37,6 @@ struct SimulatorDiscoveryErrorView: View { Button("Open Xcode", systemImage: "hammer") { openXcode() } SimulatorCheckButton(isChecking: isChecking, isRetry: true, action: checkAgain) } - .controlSize(.large) DisclosureGroup("Details") { Text(error.localizedDescription) .font(.system(size: 11, design: .monospaced))