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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 384 additions & 0 deletions assets/Localizable.xcstrings

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions src/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions src/AppDownload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions src/AppInstallationPhase.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 1 addition & 38 deletions src/AppInstaller.swift
Original file line number Diff line number Diff line change
@@ -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) }
Expand Down
45 changes: 11 additions & 34 deletions src/AppPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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..<count {
guard offset + 8 <= end else { throw Failure.unsupportedBinary }
let command = word(data, at: offset)
let size = Int(word(data, at: offset + 4))
guard size >= 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

Expand Down
32 changes: 32 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "$<$<CONFIG:Debug>:DEBUG>")

include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake")
Expand Down
52 changes: 52 additions & 0 deletions src/MachOValidation.swift
Original file line number Diff line number Diff line change
@@ -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..<count {
guard offset + 8 <= end else { throw Failure.invalidBinary }
let command = word(data, at: offset)
let size = Int(word(data, at: offset + 4))
guard size >= 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 }
}
1 change: 0 additions & 1 deletion src/MissingXcodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ struct MissingXcodeView: View {
SimulatorCheckButton(isChecking: isChecking, action: checkAgain)
.buttonStyle(.bordered)
}
.controlSize(.large)
.padding(.top, 25)
}
.padding(.horizontal, 30)
Expand Down
1 change: 0 additions & 1 deletion src/NoSimulatorsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ struct NoSimulatorsView: View {
SimulatorCheckButton(isChecking: isChecking, action: checkAgain)
.buttonStyle(.bordered)
}
.controlSize(.large)
.padding(.top, 25)

if deviceManagerName != nil {
Expand Down
23 changes: 23 additions & 0 deletions src/SelfUpdateClient.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading