Add Twinkle to your Package.swift:
dependencies: [
.package(url: "https://github.com/Aayush9029/twinkle.git", from: "1.0.0")
]import Twinkle
@main
struct MyApp: App {
@State private var twinkle = Twinkle(owner: "Aayush9029", repo: "MyApp")
var body: some Scene {
WindowGroup {
ContentView()
.environment(twinkle)
}
}
}struct SettingsView: View {
@Environment(Twinkle.self) var twinkle
var body: some View {
Button("Check for Updates") {
Task { await twinkle.check() }
}
}
}switch twinkle.state {
case .idle: Text("Check for updates")
case .checking: ProgressView()
case .upToDate: Text("Up to date")
case .available(let release): Text("v\(release.version) available")
case .downloading(_, let progress): ProgressView(value: progress)
case .ready: Button("Install") { Task { try await twinkle.install() } }
case .installing: ProgressView()
case .failed(let error): Text(error.localizedDescription)
}Fetches GitHub releases β Downloads .zip asset β Validates code signing β Installs & relaunches
- Tag with semver:
v2.0.0 - Attach
.zipof your app - Write changelog in release body
let twinkle = Twinkle(
owner: "Aayush9029",
repo: "MyApp",
checkInterval: .seconds(24 * 60 * 60), // Daily checks
backgroundTimeout: .seconds(60) // Timeout for background checks
)
// Enable beta updates (persisted across app restarts)
twinkle.allowPrereleases = true
// Ignore a specific version
twinkle.ignoreVersion("2.0.0")
// Cancel an in-progress download
twinkle.cancelDownload()- Code signing must match - The downloaded app must be signed with the same identity as the running app
- Version must increment - New releases need a higher semver (e.g.,
v1.0.0βv1.1.0) - Include a
.zipasset - Releases without a.zipfile are ignored - Single app per archive - The
.zipmust contain exactly one.appbundle
Twinkle provides specific error types for different failure scenarios:
switch error {
case .invalidBundle:
// No valid .app found in download
case .codeSigningMismatch:
// Downloaded app signed with different identity
case .versionMismatch(let expected, let actual):
// Bundle version doesn't match release
case .downloadFailed(let reason):
// Network or file system error during download
case .downloadCancelled:
// User cancelled the download
case .diskSpaceLow(let required, let available):
// Insufficient disk space for download
case .rateLimited(let retryAfter):
// GitHub API rate limit hit
case .installationFailed(let reason):
// Error during app replacement
case .multipleAppsFound(let count):
// Archive contains more than one .app
case .networkError(let code):
// URLSession error
}Twinkle compares releases using build numbers computed from semantic versions:
v1.0.0 β 10000 (1Γ10000 + 0Γ100 + 0)
v1.2.3 β 10203 (1Γ10000 + 2Γ100 + 3)
v2.0.0 β 20000 (2Γ10000 + 0Γ100 + 0)
For non-semver tags, releases are compared by their published date.
Twinkleis marked@MainActor- all property access and method calls happen on the main thread- State updates are always delivered on the main thread via
@Observable - Background operations (downloads, extraction, code signing) run on background queues
- Multiple concurrent
check()calls are prevented automatically
Update not detected
- Verify the release has a
.zipasset attached - Check that the new version has a higher build number
- Ensure the release is not a prerelease (unless
allowPrereleases = true)
Code signing mismatch
- Both apps must be signed with the same Developer ID or development certificate
- Check
codesign -dvvv YourApp.appfor both versions
Installation fails
- Ensure the app has write permission to its own location
- Twinkle creates a backup before replacing; check for
.twinkle-backup-*files
Rate limited
- GitHub allows 60 unauthenticated requests/hour
- Wait for the retry-after period or reduce check frequency