UpdatePrompt tells your users that a newer version of your app is available, the modern SwiftUI way. It reads the App Store (or your own JSON file), decides whether to ask, and shows a native Liquid Glass sheet with the release notes and an Update button. Old versions can be forced to update.
One observable checker, one modifier:
@State private var updates = UpdateChecker(source: AppStoreUpdateSource())
ContentView()
.updatePrompt(updates)Captured from the example app on an iOS 26 simulator by CI.
| Optional update | Forced update | Dark mode |
|---|---|---|
![]() |
![]() |
![]() |
- π App Store source built on the public iTunes lookup API: version, release notes, release date, minimum OS and product page, no API key.
- π§Ύ Remote JSON source for the rules the App Store cannot express, like a minimum supported version.
- π Pluggable: conform to
UpdateSourceto read from Firebase Remote Config or your backend, or merge several withCombinedUpdateSource. - π’ Correct version math:
1.10is newer than1.9,1.0equals1.0.0, and2.0-beta.2comes before2.0. - βοΈ Forced updates below a minimum version: no Later, no Skip, no swipe to dismiss.
- π Respectful: Skip This Version is remembered, Later waits a configurable interval, and nobody is asked to install a version their OS cannot run.
- π§ Liquid Glass sheet and buttons on iOS 26 / macOS 26, materials on iOS 17 to 18.
- π§ Pure decision engine (
UpdateDecisionEngine) with no I/O and no clock, fully unit tested. - π§΅ Swift 6 strict concurrency,
@Observable, zero dependencies, tested with Swift Testing and JSON fixtures.
Add the package in Xcode via File βΊ Add Package Dependenciesβ¦:
https://github.com/halilozel1903/UpdatePrompt
or in Package.swift:
dependencies: [
.package(url: "https://github.com/halilozel1903/UpdatePrompt", from: "1.0.0")
]import SwiftUI
import UpdatePrompt
@main
struct MyApp: App {
@State private var updates = UpdateChecker(source: AppStoreUpdateSource())
var body: some Scene {
WindowGroup {
ContentView()
.updatePrompt(updates)
}
}
}That's it. The checker runs when the view appears and every time the app returns to the foreground. When a newer version is on the App Store, the sheet appears; tapping Update Now opens the product page through SwiftUI's openURL.
| Source | Reads | Good for |
|---|---|---|
AppStoreUpdateSource(bundleID:country:) |
https://itunes.apple.com/lookup?bundleId=β¦&country=β¦ |
Optional updates with the real release notes |
RemoteJSONUpdateSource(url:) |
A JSON file you host | Forced updates, staged rollouts, non App Store builds |
CombinedUpdateSource(a, b, β¦) |
Several sources | App Store version + your minimum version |
Your own UpdateSource |
Anything | Remote Config, your API, tests and previews |
bundleID defaults to Bundle.main and country to the current region. If your app is not sold in every storefront, pass the country explicitly (for example "tr"), otherwise the lookup may find nothing.
Host a small JSON file, for example on GitHub Pages:
{
"latestVersion": "3.1.0",
"minimumVersion": "3.0.0",
"releaseNotes": "A security fix everyone needs.",
"minimumOSVersion": "17.0",
"updateURL": "https://apps.apple.com/app/id1234567890"
}Only latestVersion is required. Every installed version below minimumVersion gets a sheet that cannot be dismissed. To keep the App Store's release notes, combine both sources; the newest version wins and the highest minimum applies:
let source = CombinedUpdateSource(
AppStoreUpdateSource(country: "us"),
RemoteJSONUpdateSource(url: URL(string: "https://example.com/app-version.json")!)
)
@State private var updates = UpdateChecker(source: source)struct RemoteConfigSource: UpdateSource {
func latestUpdate() async throws -> UpdateInfo {
let config = try await RemoteConfig.fetch()
return UpdateInfo(
version: AppVersion(config.latest) ?? "0",
minimumRequiredVersion: AppVersion(config.minimum),
storeURL: URL(string: "https://apps.apple.com/app/id1234567890")
)
}
}UpdateChecker(
source: AppStoreUpdateSource(),
policy: .reasking(everyDays: 3, allowsSkipping: false)
)| Option | Default | Meaning |
|---|---|---|
reaskInterval |
1 day | After Later, wait this long before asking again |
allowsSkipping |
true |
Show Skip This Version and honor it |
UpdateDecisionEngine.decide(update:currentVersion:osVersion:state:policy:now:) is a pure function. Rules, in order:
| # | Condition | Decision | Prompt |
|---|---|---|---|
| 1 | Newer version needs a newer OS than the device has | .unsupportedOS |
No |
| 2 | Installed version is below minimumRequiredVersion |
.required |
Forced |
| 3 | Store version is not newer than the installed one | .upToDate |
No |
| 4 | The user skipped exactly this version | .skipped |
No |
| 5 | The user was asked less than reaskInterval ago |
.postponed(until:) |
No |
| 6 | Otherwise | .available |
Optional |
@State private var updates = UpdateChecker(source: AppStoreUpdateSource())
ContentView()
.updatePrompt(updates, checksAutomatically: false)
// For example from a "Check for Updates" button:
Button("Check for Updates") {
Task {
let decision = await updates.check()
if decision == .upToDate { showUpToDateAlert = true }
}
}UpdateChecker is @Observable, so decision, offer, isChecking and lastError can drive your own UI. The modifier also puts the checker into the environment: @Environment(UpdateChecker.self).
let installed: AppVersion = "1.9"
AppVersion("1.10") > installed // true
AppVersion("1.0") == AppVersion("1.0.0") // true
AppVersion("2.0-beta.2") < "2.0" // true
AppVersion(serverString) // AppVersion? β nil when invalidNote:
AppVersionisExpressibleByStringLiteral, soAppVersion("2.0")written with a literal is not optional (an invalid literal traps). Parsing aStringvariable, for example from a server, uses the failable initializer and returnsnilfor invalid input.
Every dependency can be injected:
let checker = UpdateChecker(
source: AppStoreUpdateSource(bundleID: "com.example.app", transport: HTTPTransport { request in
(fixtureData, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}),
currentVersion: "2.3.1",
osVersion: "26.0",
store: InMemoryUpdatePromptStore(),
now: { fixedDate }
)Both network sources also accept a URLSession, for example one configured with a custom URLProtocol.
| iOS 26 / macOS 26 | iOS 17 β 18 / macOS 14 β 15 | |
|---|---|---|
| Sheet | System Liquid Glass sheet at a partial height | .regularMaterial presentation background |
| Buttons | .glassProminent and .glass |
.borderedProminent and .bordered |
| Version badge | glassEffect(.regular) capsule |
.thinMaterial capsule |
The Example folder contains a demo app with an in-memory fake source, so you can try optional, forced, up-to-date and unsupported-OS answers without a network. It uses XcodeGen so no project file has to live in the repo:
brew install xcodegen
cd Example && xcodegen generate
open UpdatePromptDemo.xcodeproj- Xcode 26 or later (Swift 6.2 toolchain)
- iOS 17+ / macOS 14+ (Liquid Glass automatically on 26+)
Issues and pull requests are welcome. Please run swift test before opening a PR.
UpdatePrompt is available under the MIT license. See LICENSE.


