Builds older than the server's minimum ask to be updated
Grain/Services/AppUpdateGate.swiftnew+150−0
| @@ -0,0 +1,150 @@ | ||
import Foundation | ||
import os | ||
| ||
private let gateLogger = Logger(subsystem: "social.grain.grain", category: "AppUpdateGate") | ||
| ||
/// What `/app-version.json` says about iOS: the oldest build the server still | ||
/// supports, and where to send someone running one older than that. | ||
struct AppVersionPolicy: Equatable { | ||
static let fallbackStoreURL = URL(string: "https://apps.apple.com/app/id6747730230")! | ||
| ||
/// `nil` means the server sets no minimum. | ||
let minimumBuild: Int? | ||
let storeURL: URL | ||
| ||
init(minimumBuild: Int?, storeURL: URL = Self.fallbackStoreURL) { | ||
self.minimumBuild = minimumBuild | ||
self.storeURL = storeURL | ||
} | ||
| ||
private struct Document: Decodable { | ||
struct Platform: Decodable { | ||
let minimumBuild: Int? | ||
let storeUrl: String? | ||
} | ||
| ||
let ios: Platform? | ||
} | ||
| ||
/// Reads the document, or returns `nil` when it isn't one: not JSON, not an | ||
/// object, or a field of the wrong type. A missing `ios` key or | ||
/// `minimumBuild` is a valid document with no minimum. | ||
static func parse(_ data: Data) -> AppVersionPolicy? { | ||
guard let document = try? JSONDecoder().decode(Document.self, from: data) else { | ||
return nil | ||
} | ||
let storeURL = document.ios?.storeUrl.flatMap { URL(string: $0) }.flatMap { url in | ||
url.scheme == "https" || url.scheme == "itms-apps" ? url : nil | ||
} | ||
return AppVersionPolicy( | ||
minimumBuild: document.ios?.minimumBuild, | ||
storeURL: storeURL ?? fallbackStoreURL | ||
) | ||
} | ||
| ||
/// Whether a build is too old to use. A build number that couldn't be read | ||
/// never blocks: the gate exists to stop old builds, not to strand one it | ||
/// can't place. | ||
func blocks(build: Int?) -> Bool { | ||
guard let build, let minimumBuild else { return false } | ||
return build < minimumBuild | ||
} | ||
} | ||
| ||
/// The last policy fetched, so a blocked build stays blocked when it launches | ||
/// offline. | ||
enum AppVersionPolicyCache { | ||
private static let minimumBuildKey = "appVersion.ios.minimumBuild" | ||
private static let storeURLKey = "appVersion.ios.storeUrl" | ||
| ||
static func load(from defaults: UserDefaults = StorageEnvironment.defaults) -> AppVersionPolicy? { | ||
guard let minimumBuild = defaults.object(forKey: minimumBuildKey) as? Int else { return nil } | ||
let storeURL = defaults.string(forKey: storeURLKey).flatMap { URL(string: $0) } | ||
return AppVersionPolicy(minimumBuild: minimumBuild, storeURL: storeURL ?? AppVersionPolicy.fallbackStoreURL) | ||
} | ||
| ||
static func store(_ policy: AppVersionPolicy, in defaults: UserDefaults = StorageEnvironment.defaults) { | ||
if let minimumBuild = policy.minimumBuild { | ||
defaults.set(minimumBuild, forKey: minimumBuildKey) | ||
defaults.set(policy.storeURL.absoluteString, forKey: storeURLKey) | ||
} else { | ||
defaults.removeObject(forKey: minimumBuildKey) | ||
defaults.removeObject(forKey: storeURLKey) | ||
} | ||
} | ||
} | ||
| ||
/// Decides whether this build may run, from the server's minimum when it can | ||
/// be fetched and from the last one fetched when it can't. | ||
/// | ||
/// Starts from the cached policy so the decision is made before any request, | ||
/// and never waits on the network to let the app open. | ||
@Observable | ||
@MainActor | ||
final class AppUpdateGate { | ||
private(set) var isUpdateRequired: Bool | ||
private(set) var storeURL: URL | ||
| ||
private let currentBuild: Int? | ||
private let endpoint: URL | ||
private let session: URLSession | ||
private var isChecking = false | ||
| ||
nonisolated static let timeout: TimeInterval = 5 | ||
| ||
/// `CFBundleVersion`, which Xcode Cloud sets to its build counter. | ||
nonisolated static var runningBuild: Int? { | ||
(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String).flatMap { Int($0) } | ||
} | ||
| ||
init( | ||
currentBuild: Int? = AppUpdateGate.runningBuild, | ||
serverURL: URL = AuthManager.serverURL, | ||
session: URLSession = NetworkEnvironment.session | ||
) { | ||
self.currentBuild = currentBuild | ||
endpoint = serverURL.appendingPathComponent("app-version.json") | ||
self.session = session | ||
let cached = AppVersionPolicyCache.load() | ||
isUpdateRequired = cached?.blocks(build: currentBuild) ?? false | ||
storeURL = cached?.storeURL ?? AppVersionPolicy.fallbackStoreURL | ||
} | ||
| ||
/// Fetches the policy and applies it. A failed request or an unreadable | ||
/// document leaves the current decision, which came from the cache, alone. | ||
func check() async { | ||
guard !isChecking else { return } | ||
isChecking = true | ||
defer { isChecking = false } | ||
| ||
guard let policy = await fetch() else { return } | ||
AppVersionPolicyCache.store(policy) | ||
apply(policy) | ||
} | ||
| ||
func apply(_ policy: AppVersionPolicy) { | ||
isUpdateRequired = policy.blocks(build: currentBuild) | ||
storeURL = policy.storeURL | ||
} | ||
| ||
private func fetch() async -> AppVersionPolicy? { | ||
var request = URLRequest(url: endpoint) | ||
request.timeoutInterval = Self.timeout | ||
request.cachePolicy = .reloadIgnoringLocalCacheData | ||
do { | ||
let (data, response) = try await session.data(for: request) | ||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { | ||
gateLogger.info("[check] unexpected response") | ||
return nil | ||
} | ||
guard let policy = AppVersionPolicy.parse(data) else { | ||
gateLogger.info("[check] unreadable document") | ||
return nil | ||
} | ||
return policy | ||
} catch { | ||
gateLogger.info("[check] failed: \(error.localizedDescription, privacy: .public)") | ||
return nil | ||
} | ||
} | ||
} | ||
Grain/Views/UpdateRequiredView.swiftnew+73−0
| @@ -0,0 +1,73 @@ | ||
import SwiftUI | ||
| ||
/// Shown in place of the whole app when this build is older than the server | ||
/// supports. There is nothing to dismiss: the only way on is the App Store. | ||
struct UpdateRequiredView: View { | ||
let storeURL: URL | ||
| ||
@Environment(\.openURL) private var openURL | ||
| ||
var body: some View { | ||
GeometryReader { geo in | ||
let fullHeight = geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom | ||
ZStack { | ||
Image("login-bg") | ||
.resizable() | ||
.scaledToFill() | ||
.frame(width: geo.size.width, height: fullHeight) | ||
.clipped() | ||
.ignoresSafeArea() | ||
.allowsHitTesting(false) | ||
| ||
Color.black.opacity(0.4) | ||
.ignoresSafeArea() | ||
.allowsHitTesting(false) | ||
| ||
VStack(spacing: 0) { | ||
Spacer() | ||
| ||
Text("grain") | ||
.font(.custom("Syne", size: 44).weight(.heavy)) | ||
.foregroundStyle(.white) | ||
.padding(.bottom, 60) | ||
.accessibilityHidden(true) | ||
| ||
Text("Update Grain") | ||
.font(.title3.weight(.semibold)) | ||
.foregroundStyle(.white) | ||
.multilineTextAlignment(.center) | ||
.padding(.bottom, 8) | ||
.accessibilityAddTraits(.isHeader) | ||
| ||
Text("This version of Grain is no longer supported. Update to keep using it.") | ||
.font(.subheadline) | ||
.foregroundStyle(.white.opacity(0.8)) | ||
.multilineTextAlignment(.center) | ||
.padding(.bottom, 24) | ||
| ||
Button { | ||
openURL(storeURL) | ||
} label: { | ||
Text("Update") | ||
.font(.body.weight(.semibold)) | ||
.frame(maxWidth: .infinity) | ||
.padding(.vertical, 14) | ||
} | ||
.background(.white, in: RoundedRectangle(cornerRadius: 12)) | ||
.foregroundStyle(.black) | ||
| ||
Spacer() | ||
} | ||
.padding(24) | ||
.frame(maxWidth: 440) | ||
} | ||
.frame(maxWidth: .infinity, maxHeight: .infinity) | ||
} | ||
.ignoresSafeArea(.keyboard) | ||
.interactiveDismissDisabled() | ||
} | ||
} | ||
| ||
#Preview { | ||
UpdateRequiredView(storeURL: AppVersionPolicy.fallbackStoreURL) | ||
} | ||
Grain/GrainApp.swift+15−1
| @@ -46,6 +46,8 @@ struct GrainApp: App { | ||
@State private var labelDefsCache = LabelDefinitionsCache() | ||
@State private var uploadCenter = GalleryUploadCenter() | ||
@State private var pendingDeepLink: DeepLink? | ||
@State private var updateGate = AppUpdateGate() | ||
@Environment(\.scenePhase) private var scenePhase | ||
@AppStorage("appearance") private var appearance: String = "auto" | ||
| ||
private var colorScheme: ColorScheme? { | ||
| @@ -62,7 +64,11 @@ struct GrainApp: App { | ||
let isAuthed = authManager.isAuthenticated | ||
let _ = appSignposter.emitEvent("AuthGateResolved") | ||
Group { | ||
if isAuthed { | ||
if updateGate.isUpdateRequired { | ||
// Replaces the tree rather than covering it, so no sheet or | ||
// cover the app had open can sit above it. | ||
UpdateRequiredView(storeURL: updateGate.storeURL) | ||
} else if isAuthed { | ||
// Keyed on the account: switching rebuilds the whole tree so | ||
// no view model, scroll position, or half-loaded feed | ||
// carries over from the account being left. | ||
| @@ -119,6 +125,14 @@ struct GrainApp: App { | ||
.task(priority: .background) { | ||
GrainShortcuts.updateAppShortcutParameters() | ||
} | ||
.task { | ||
await updateGate.check() | ||
} | ||
.onChange(of: scenePhase) { _, phase in | ||
if phase == .active { | ||
Task { await updateGate.check() } | ||
} | ||
} | ||
.preferredColorScheme(colorScheme) | ||
} | ||
.environment(authManager) | ||
GrainTests/AppUpdateGateTests.swiftnew+188−0
| @@ -0,0 +1,188 @@ | ||
@testable import Grain | ||
import XCTest | ||
| ||
/// The force-update gate: whether a build is blocked, from what the server says | ||
/// and, when the server can't be reached or makes no sense, from what it last | ||
/// said. Getting it wrong either strands a supported build or lets an | ||
/// unsupported one through to errors. | ||
@MainActor | ||
final class AppUpdateGateTests: GrainTestCase { | ||
private let server = URL(string: "https://grain.test")! | ||
| ||
override func tearDown() async throws { | ||
MockURLProtocol.handler = nil | ||
try await super.tearDown() | ||
} | ||
| ||
private func document(minimumBuild: Int, storeUrl: String = "https://apps.apple.com/app/id1") -> String { | ||
""" | ||
{ | ||
"ios": { "minimumBuild": \(minimumBuild), "storeUrl": "\(storeUrl)" }, | ||
"android": { "minimumVersionCode": 0, "storeUrl": "https://play.google.com/store/apps/details?id=social.grain" } | ||
} | ||
""" | ||
} | ||
| ||
private func makeGate(build: Int?) -> AppUpdateGate { | ||
AppUpdateGate(currentBuild: build, serverURL: server, session: MockURLProtocol.mockSession()) | ||
} | ||
| ||
private func failRequests() { | ||
MockURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } | ||
} | ||
| ||
// MARK: - Build against minimum | ||
| ||
func testABuildBelowTheMinimumIsBlocked() { | ||
XCTAssertTrue(AppVersionPolicy(minimumBuild: 60).blocks(build: 59)) | ||
} | ||
| ||
func testABuildAtOrAboveTheMinimumIsNot() { | ||
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 59)) | ||
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 60)) | ||
} | ||
| ||
func testAnUnreadableBuildNumberIsNeverBlocked() { | ||
XCTAssertFalse(AppVersionPolicy(minimumBuild: 60).blocks(build: nil)) | ||
} | ||
| ||
// MARK: - Parsing | ||
| ||
func testParsesTheIOSMinimumAndStoreURL() throws { | ||
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(document(minimumBuild: 61).utf8))) | ||
XCTAssertEqual(policy.minimumBuild, 61) | ||
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id1")) | ||
} | ||
| ||
func testAMissingIOSKeyMeansNoMinimum() throws { | ||
let json = #"{ "android": { "minimumVersionCode": 99 } }"# | ||
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8))) | ||
XCTAssertNil(policy.minimumBuild) | ||
XCTAssertFalse(policy.blocks(build: 1)) | ||
} | ||
| ||
func testAMissingMinimumBuildMeansNoMinimum() throws { | ||
let json = #"{ "ios": { "storeUrl": "https://apps.apple.com/app/id1" } }"# | ||
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8))) | ||
XCTAssertNil(policy.minimumBuild) | ||
XCTAssertFalse(policy.blocks(build: 1)) | ||
} | ||
| ||
func testAMissingStoreURLFallsBackToTheAppStoreListing() throws { | ||
let json = #"{ "ios": { "minimumBuild": 60 } }"# | ||
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8))) | ||
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id6747730230")) | ||
} | ||
| ||
func testMalformedDocumentsAreRejected() { | ||
for body in ["", "not json", "[]", #"{ "ios": "60" }"#, #"{ "ios": { "minimumBuild": "60" } }"#] { | ||
XCTAssertNil(AppVersionPolicy.parse(Data(body.utf8)), "Should reject \(body.debugDescription)") | ||
} | ||
} | ||
| ||
// MARK: - Fetching and the cache | ||
| ||
func testNothingCachedAndNoFetchMeansNotBlocked() { | ||
XCTAssertFalse(makeGate(build: 59).isUpdateRequired) | ||
} | ||
| ||
func testAFetchedMinimumAboveTheBuildBlocksIt() async { | ||
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id2")) | ||
let gate = makeGate(build: 59) | ||
| ||
await gate.check() | ||
| ||
XCTAssertTrue(gate.isUpdateRequired) | ||
XCTAssertEqual(gate.storeURL, URL(string: "https://apps.apple.com/app/id2")) | ||
} | ||
| ||
func testAFailedFetchWithNothingCachedDoesNotBlock() async { | ||
failRequests() | ||
let gate = makeGate(build: 59) | ||
| ||
await gate.check() | ||
| ||
XCTAssertFalse(gate.isUpdateRequired) | ||
} | ||
| ||
func testAMalformedResponseWithNothingCachedDoesNotBlock() async { | ||
MockURLProtocol.respondWithJSON("<html>oops</html>") | ||
let gate = makeGate(build: 59) | ||
| ||
await gate.check() | ||
| ||
XCTAssertFalse(gate.isUpdateRequired) | ||
} | ||
| ||
func testAnErrorStatusWithNothingCachedDoesNotBlock() async { | ||
MockURLProtocol.respondWithError(statusCode: 503) | ||
let gate = makeGate(build: 59) | ||
| ||
await gate.check() | ||
| ||
XCTAssertFalse(gate.isUpdateRequired) | ||
} | ||
| ||
/// The case the cache exists for: a blocked build opened offline. | ||
func testTheCachedMinimumBlocksAtLaunchBeforeAnyFetch() async { | ||
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id3")) | ||
await makeGate(build: 59).check() | ||
| ||
let relaunched = makeGate(build: 59) | ||
| ||
XCTAssertTrue(relaunched.isUpdateRequired) | ||
XCTAssertEqual(relaunched.storeURL, URL(string: "https://apps.apple.com/app/id3")) | ||
} | ||
| ||
func testTheCachedMinimumStillBlocksWhenTheFetchFails() async { | ||
MockURLProtocol.respondWithJSON(document(minimumBuild: 60)) | ||
await makeGate(build: 59).check() | ||
| ||
failRequests() | ||
let relaunched = makeGate(build: 59) | ||
await relaunched.check() | ||
| ||
XCTAssertTrue(relaunched.isUpdateRequired) | ||
} | ||
| ||
func testTheCachedMinimumStillBlocksWhenTheResponseIsMalformed() async { | ||
MockURLProtocol.respondWithJSON(document(minimumBuild: 60)) | ||
await makeGate(build: 59).check() | ||
| ||
MockURLProtocol.respondWithJSON("{") | ||
let relaunched = makeGate(build: 59) | ||
await relaunched.check() | ||
| ||
XCTAssertTrue(relaunched.isUpdateRequired) | ||
} | ||
| ||
/// Lowering or removing the minimum has to reach a build that was blocked, | ||
/// or it stays locked out on the strength of an old answer. | ||
func testAFetchedLowerMinimumUnblocksAndReplacesTheCache() async { | ||
MockURLProtocol.respondWithJSON(document(minimumBuild: 60)) | ||
await makeGate(build: 59).check() | ||
| ||
MockURLProtocol.respondWithJSON(#"{ "ios": {} }"#) | ||
let relaunched = makeGate(build: 59) | ||
XCTAssertTrue(relaunched.isUpdateRequired) | ||
await relaunched.check() | ||
XCTAssertFalse(relaunched.isUpdateRequired) | ||
| ||
XCTAssertFalse(makeGate(build: 59).isUpdateRequired, "The cleared minimum should be what is remembered") | ||
} | ||
| ||
func testRequestsAppVersionJSONOnTheConfiguredServerWithAShortTimeout() async throws { | ||
var seen: URLRequest? | ||
MockURLProtocol.handler = { request in | ||
seen = request | ||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! | ||
return (Data(#"{}"#.utf8), response) | ||
} | ||
| ||
await makeGate(build: 59).check() | ||
| ||
let request = try XCTUnwrap(seen) | ||
XCTAssertEqual(request.url, URL(string: "https://grain.test/app-version.json")) | ||
XCTAssertLessThanOrEqual(request.timeoutInterval, AppUpdateGate.timeout) | ||
} | ||
} | ||