From dc9087de419ac599af8d2959fe7f4fcc9aece322 Mon Sep 17 00:00:00 2001 From: Hituzi Ando Date: Wed, 19 Aug 2026 11:39:14 +0900 Subject: [PATCH 1/5] fix(scheduling): Record daily checks after successful lookup - Separate daily eligibility from success and review-attempt persistence - Add replaceable clock, state store, and App Store lookup dependencies - Cover failure, success, date transitions, and review scheduling - Document daily check and review attempt semantics Closes #14 --- Framework/Sources/DailySchedule.swift | 70 +++++ Framework/Sources/ITunesSearchAPI.swift | 13 + .../Sources/RequestReviewCondition.swift | 122 ++++++--- Framework/Sources/SUK.swift | 132 +++++---- Framework/Sources/VersionCheckCondition.swift | 59 ++-- .../SwiftyUpdateKit.xcodeproj/project.pbxproj | 6 + .../SwiftyUpdateKitTests.swift | 255 ++++++++++++++++++ README.md | 12 +- 8 files changed, 559 insertions(+), 110 deletions(-) create mode 100644 Framework/Sources/DailySchedule.swift diff --git a/Framework/Sources/DailySchedule.swift b/Framework/Sources/DailySchedule.swift new file mode 100644 index 0000000..2682da5 --- /dev/null +++ b/Framework/Sources/DailySchedule.swift @@ -0,0 +1,70 @@ +// +// DailySchedule.swift +// SwiftyUpdateKit +// +// Copyright © 2026 Hituzi Ando. All rights reserved. +// + +import Foundation + +protocol SUKClock { + func currentDate() -> Int +} + +struct SystemSUKClock: SUKClock { + func currentDate() -> Int { + DateUtils.currentDate() + } +} + +protocol SUKSchedulingStateStore { + func set(_ value: Int, forKey key: String) + func integer(forKey key: String) -> Int +} + +struct UserDefaultsSchedulingStateStore: SUKSchedulingStateStore { + func set(_ value: Int, forKey key: String) { + SUKUserDefaults.standard.set(value, forKey: key) + } + + func integer(forKey key: String) -> Int { + SUKUserDefaults.standard.integer(forKey: key) + } +} + +struct InMemorySchedulingStateStore: SUKSchedulingStateStore { + func set(_ value: Int, forKey key: String) { + sharedDictionary.setValue(value, forKey: key) + } + + func integer(forKey key: String) -> Int { + sharedDictionary.value(forKey: key) as? Int ?? 0 + } +} + +struct DailySchedule { + private let clock: SUKClock + private let stateStore: SUKSchedulingStateStore + private let key: String + + init(clock: SUKClock, stateStore: SUKSchedulingStateStore, key: String) { + self.clock = clock + self.stateStore = stateStore + self.key = key + } + + func shouldRun() -> Bool { + stateStore.integer(forKey: key) < clock.currentDate() + } + + func hasRecordedDate() -> Bool { + stateStore.integer(forKey: key) != 0 + } + + func recordCurrentDate() { + let currentDate = clock.currentDate() + guard stateStore.integer(forKey: key) < currentDate else { return } + + stateStore.set(currentDate, forKey: key) + } +} diff --git a/Framework/Sources/ITunesSearchAPI.swift b/Framework/Sources/ITunesSearchAPI.swift index 558b548..5bdd7c4 100644 --- a/Framework/Sources/ITunesSearchAPI.swift +++ b/Framework/Sources/ITunesSearchAPI.swift @@ -56,6 +56,19 @@ enum ITunesSearchAPIError: Error { case invalidResponseData } +protocol AppStoreLookup { + func lookUp(with config: SwiftyUpdateKitConfig, + completion: @escaping (Result<[LookUpResult], Error>) -> Void) +} + +struct ITunesAppStoreLookup: AppStoreLookup { + func lookUp(with config: SwiftyUpdateKitConfig, + completion: @escaping (Result<[LookUpResult], Error>) -> Void) + { + ITunesSearchAPI.lookUp(with: config, completion: completion) + } +} + struct ITunesSearchAPI { public static func lookUp(with config: SwiftyUpdateKitConfig, completion: @escaping (Result<[LookUpResult], Error>) -> Void) diff --git a/Framework/Sources/RequestReviewCondition.swift b/Framework/Sources/RequestReviewCondition.swift index 4bff314..b0441a7 100644 --- a/Framework/Sources/RequestReviewCondition.swift +++ b/Framework/Sources/RequestReviewCondition.swift @@ -9,7 +9,8 @@ import Foundation /// The key of UserDefaults.standard. -/// The value retrieved with this key is Int value as yyyyMMdd representation. +/// The value retrieved with this key is the last review request attempt date as an Int in yyyyMMdd +/// representation. Skip-first-day conditions initially store the first evaluation date. public let SwiftyUpdateKitLastRequireReviewDateKey = "jp.hituzi.SwiftyUpdateKit.lastRequireReviewDateKey" @@ -18,6 +19,15 @@ public protocol RequestReviewCondition: AnyObject { func shouldRequestReview() -> Bool } +/// Records a review request attempt for a condition that maintains scheduling state. +/// +/// StoreKit does not report whether the system displayed the review interface, so the stored value +/// represents an attempt rather than a confirmed presentation. +public protocol ReviewRequestAttemptRecording: AnyObject { + /// Records that the app called the StoreKit review request API. + func recordReviewRequestAttempt() +} + /// Always asks a user for a review. open class RequestReviewConditionAlways: RequestReviewCondition { public init() {} @@ -37,75 +47,109 @@ open class RequestReviewConditionDisable: RequestReviewCondition { } /// Asks a user for a review once a day. -open class RequestReviewConditionDaily: RequestReviewCondition { - public init() {} +open class RequestReviewConditionDaily: RequestReviewCondition, ReviewRequestAttemptRecording { + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: UserDefaultsSchedulingStateStore(), + key: SwiftyUpdateKitLastRequireReviewDateKey) - open func shouldRequestReview() -> Bool { - let lastDate = SUKUserDefaults.standard - .integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey) - let today = DateUtils.currentDate() + public init() {} - guard lastDate < today else { return false } + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastRequireReviewDateKey) + } - SUKUserDefaults.standard.set(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey) + open func shouldRequestReview() -> Bool { + schedule.shouldRun() + } - return true + open func recordReviewRequestAttempt() { + schedule.recordCurrentDate() } } /// Asks a user for a review once a day, but skips first day. -open class RequestReviewConditionDailySkipFirstDay: RequestReviewCondition { - public init() {} +open class RequestReviewConditionDailySkipFirstDay: RequestReviewCondition, + ReviewRequestAttemptRecording +{ + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: UserDefaultsSchedulingStateStore(), + key: SwiftyUpdateKitLastRequireReviewDateKey) - open func shouldRequestReview() -> Bool { - let lastDate = SUKUserDefaults.standard - .integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey) + public init() {} - // lastDate is 0 means the first day because the value is not set. - if lastDate == 0 { - let today = DateUtils.currentDate() - SUKUserDefaults.standard.set(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey) + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastRequireReviewDateKey) + } + open func shouldRequestReview() -> Bool { + if !schedule.hasRecordedDate() { + schedule.recordCurrentDate() return false } - return RequestReviewConditionDaily().shouldRequestReview() + return schedule.shouldRun() + } + + open func recordReviewRequestAttempt() { + schedule.recordCurrentDate() } } /// Asks a user for a review when the app is launched and once a day. -open class RequestReviewConditionLaunchingAndDaily: RequestReviewCondition { - public init() {} +open class RequestReviewConditionLaunchingAndDaily: RequestReviewCondition, + ReviewRequestAttemptRecording +{ + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: InMemorySchedulingStateStore(), + key: SwiftyUpdateKitLastRequireReviewDateKey) - open func shouldRequestReview() -> Bool { - let lastDate = sharedDictionary - .value(forKey: SwiftyUpdateKitLastRequireReviewDateKey) as? Int ?? 0 - let today = DateUtils.currentDate() + public init() {} - guard lastDate < today else { return false } + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastRequireReviewDateKey) + } - sharedDictionary.setValue(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey) + open func shouldRequestReview() -> Bool { + schedule.shouldRun() + } - return true + open func recordReviewRequestAttempt() { + schedule.recordCurrentDate() } } /// Asks a user for a review when the app is launched and once a day, but skips first day. -open class RequestReviewConditionLaunchingAndDailySkipFirstDay: RequestReviewCondition { - public init() {} +open class RequestReviewConditionLaunchingAndDailySkipFirstDay: RequestReviewCondition, + ReviewRequestAttemptRecording +{ + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: InMemorySchedulingStateStore(), + key: SwiftyUpdateKitLastRequireReviewDateKey) - open func shouldRequestReview() -> Bool { - let lastDate = sharedDictionary - .value(forKey: SwiftyUpdateKitLastRequireReviewDateKey) as? Int ?? 0 + public init() {} - // lastDate is 0 means the first day because the value is not set. - if lastDate == 0 { - let today = DateUtils.currentDate() - sharedDictionary.setValue(today, forKey: SwiftyUpdateKitLastRequireReviewDateKey) + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastRequireReviewDateKey) + } + open func shouldRequestReview() -> Bool { + if !schedule.hasRecordedDate() { + schedule.recordCurrentDate() return false } - return RequestReviewConditionLaunchingAndDaily().shouldRequestReview() + return schedule.shouldRun() + } + + open func recordReviewRequestAttempt() { + schedule.recordCurrentDate() } } diff --git a/Framework/Sources/SUK.swift b/Framework/Sources/SUK.swift index a0ee177..d867f38 100644 --- a/Framework/Sources/SUK.swift +++ b/Framework/Sources/SUK.swift @@ -78,45 +78,12 @@ public class SUK { forUserID userID: String = "SwiftyUpdateKitUser", noop: (() -> Void)? = nil) { - checkVersion(condition, update: update) { lookUpResult in - guard let newRelease else { - // Not need to show the new release. - noop?() - return - } - - if let result = lookUpResult { - // Use fetched lookUpResult. - checkNewRelease(result, newRelease: newRelease, forUserID: userID, noop: noop) - } else { - guard let config else { return } - - ITunesSearchAPI.lookUp(with: config) { result in - switch result { - case let .failure(error): - // Ignore an error. - logf(error.localizedDescription, log) - DispatchQueue.main.async { - noop?() - } - case let .success(lookUpResults): - guard let lookUpResult = lookUpResults.first else { - // Ignore an error. - logf("lookUpResult does not exist in the response data.", log) - DispatchQueue.main.async { - noop?() - } - return - } - - checkNewRelease(lookUpResult, - newRelease: newRelease, - forUserID: userID, - noop: noop) - } - } - } - } + checkVersion(condition, + update: update, + newRelease: newRelease, + forUserID: userID, + noop: noop, + lookup: ITunesAppStoreLookup()) } /// Opens the App Store. @@ -201,7 +168,7 @@ public class SUK { @available(macOS, deprecated: 13.0, message: "Use `requestReview(_:, in:)` instead.") public static func requestReview(_ condition: RequestReviewCondition) { DispatchQueue.main.async { - if condition.shouldRequestReview() { + requestReviewIfNeeded(condition) { SKStoreReviewController.requestReview() } } @@ -221,7 +188,7 @@ public class SUK { in controller: NSViewController) { DispatchQueue.main.async { - if condition.shouldRequestReview() { + requestReviewIfNeeded(condition) { AppStore.requestReview(in: controller) } } @@ -240,7 +207,7 @@ public class SUK { @available(iOS 16.0, *) public static func requestReview(_ condition: RequestReviewCondition, in scene: UIWindowScene) { DispatchQueue.main.async { - if condition.shouldRequestReview() { + requestReviewIfNeeded(condition) { AppStore.requestReview(in: scene) } } @@ -256,8 +223,10 @@ public class SUK { @available(iOS 16.0, *) public static func requestReview(_ condition: RequestReviewCondition, in view: UIView) { DispatchQueue.main.async { - if let scene = view.window?.windowScene, condition.shouldRequestReview() { - AppStore.requestReview(in: scene) + if let scene = view.window?.windowScene { + requestReviewIfNeeded(condition) { + AppStore.requestReview(in: scene) + } } } } @@ -275,10 +244,59 @@ public class SUK { } } -private extension SUK { +extension SUK { static func checkVersion(_ condition: VersionCheckCondition, update: UpdateHandler?, - next: @escaping (LookUpResult?) -> Void) + newRelease: NewReleaseHandler?, + forUserID userID: String, + noop: (() -> Void)?, + lookup: AppStoreLookup) + { + checkVersion(condition, update: update, lookup: lookup) { lookUpResult in + guard let newRelease else { + // Not need to show the new release. + noop?() + return + } + + if let result = lookUpResult { + // Use fetched lookUpResult. + checkNewRelease(result, newRelease: newRelease, forUserID: userID, noop: noop) + } else { + guard let config else { return } + + lookup.lookUp(with: config) { result in + switch result { + case let .failure(error): + // Ignore an error. + logf(error.localizedDescription, log) + DispatchQueue.main.async { + noop?() + } + case let .success(lookUpResults): + guard let lookUpResult = lookUpResults.first else { + // Ignore an error. + logf("lookUpResult does not exist in the response data.", log) + DispatchQueue.main.async { + noop?() + } + return + } + + checkNewRelease(lookUpResult, + newRelease: newRelease, + forUserID: userID, + noop: noop) + } + } + } + } + } + + private static func checkVersion(_ condition: VersionCheckCondition, + update: UpdateHandler?, + lookup: AppStoreLookup, + next: @escaping (LookUpResult?) -> Void) { DispatchQueue.main.async { guard let config else { @@ -294,7 +312,7 @@ private extension SUK { return } - ITunesSearchAPI.lookUp(with: config) { result in + lookup.lookUp(with: config) { result in switch result { case let .failure(error): // Ignore an error. @@ -309,6 +327,9 @@ private extension SUK { return } + (condition as? VersionCheckSuccessRecording)? + .recordSuccessfulVersionCheck() + let isOld = config.versionCompare.compare(storeVersion, with: config.version) @@ -334,10 +355,19 @@ private extension SUK { } } - static func checkNewRelease(_ lookUpResult: LookUpResult, - newRelease: @escaping NewReleaseHandler, - forUserID userID: String, - noop: (() -> Void)?) + static func requestReviewIfNeeded(_ condition: RequestReviewCondition, + request: () -> Void) + { + guard condition.shouldRequestReview() else { return } + + (condition as? ReviewRequestAttemptRecording)?.recordReviewRequestAttempt() + request() + } + + private static func checkNewRelease(_ lookUpResult: LookUpResult, + newRelease: @escaping NewReleaseHandler, + forUserID userID: String, + noop: (() -> Void)?) { guard let config else { return } diff --git a/Framework/Sources/VersionCheckCondition.swift b/Framework/Sources/VersionCheckCondition.swift index 4528126..56f3cec 100644 --- a/Framework/Sources/VersionCheckCondition.swift +++ b/Framework/Sources/VersionCheckCondition.swift @@ -9,7 +9,8 @@ import Foundation /// The key of UserDefaults.standard. -/// The value retrieved with this key is Int value as yyyyMMdd representation. +/// The value retrieved with this key is the last successful check date as an Int in yyyyMMdd +/// representation. public let SwiftyUpdateKitLastVersionCheckDateKey = "jp.hituzi.SwiftyUpdateKit.lastVersionCheckDateKey" @@ -18,6 +19,12 @@ public protocol VersionCheckCondition: AnyObject { func shouldCheckVersion() -> Bool } +/// Records a successful version check for a condition that maintains scheduling state. +public protocol VersionCheckSuccessRecording: AnyObject { + /// Records that the App Store returned a valid version for the current app. + func recordSuccessfulVersionCheck() +} + /// Always checks the app version. open class VersionCheckConditionAlways: VersionCheckCondition { public init() {} @@ -37,35 +44,49 @@ open class VersionCheckConditionDisable: VersionCheckCondition { } /// Checks the app version once a day. -open class VersionCheckConditionDaily: VersionCheckCondition { - public init() {} +open class VersionCheckConditionDaily: VersionCheckCondition, VersionCheckSuccessRecording { + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: UserDefaultsSchedulingStateStore(), + key: SwiftyUpdateKitLastVersionCheckDateKey) - open func shouldCheckVersion() -> Bool { - let lastDate = SUKUserDefaults.standard - .integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey) - let today = DateUtils.currentDate() + public init() {} - guard lastDate < today else { return false } + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastVersionCheckDateKey) + } - SUKUserDefaults.standard.set(today, forKey: SwiftyUpdateKitLastVersionCheckDateKey) + open func shouldCheckVersion() -> Bool { + schedule.shouldRun() + } - return true + open func recordSuccessfulVersionCheck() { + schedule.recordCurrentDate() } } /// Checks the app version when the app is launched and once a day. -open class VersionCheckConditionLaunchingAndDaily: VersionCheckCondition { - public init() {} +open class VersionCheckConditionLaunchingAndDaily: VersionCheckCondition, + VersionCheckSuccessRecording +{ + private var schedule = DailySchedule(clock: SystemSUKClock(), + stateStore: InMemorySchedulingStateStore(), + key: SwiftyUpdateKitLastVersionCheckDateKey) - open func shouldCheckVersion() -> Bool { - let lastDate = sharedDictionary - .value(forKey: SwiftyUpdateKitLastVersionCheckDateKey) as? Int ?? 0 - let today = DateUtils.currentDate() + public init() {} - guard lastDate < today else { return false } + init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + schedule = DailySchedule(clock: clock, + stateStore: stateStore, + key: SwiftyUpdateKitLastVersionCheckDateKey) + } - sharedDictionary.setValue(today, forKey: SwiftyUpdateKitLastVersionCheckDateKey) + open func shouldCheckVersion() -> Bool { + schedule.shouldRun() + } - return true + open func recordSuccessfulVersionCheck() { + schedule.recordCurrentDate() } } diff --git a/Framework/SwiftyUpdateKit.xcodeproj/project.pbxproj b/Framework/SwiftyUpdateKit.xcodeproj/project.pbxproj index 17e1470..2ad625c 100644 --- a/Framework/SwiftyUpdateKit.xcodeproj/project.pbxproj +++ b/Framework/SwiftyUpdateKit.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ 5ED85325271327F500CC0699 /* ReleaseNotesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED85324271327F500CC0699 /* ReleaseNotesController.swift */; }; 5ED85326271327F500CC0699 /* ReleaseNotesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED85324271327F500CC0699 /* ReleaseNotesController.swift */; }; 5ED853582715172A00CC0699 /* ReleaseNotesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED853572715172A00CC0699 /* ReleaseNotesTests.swift */; }; + 7A1400023C3A000100000001 /* DailySchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1400013C3A000100000001 /* DailySchedule.swift */; }; + 7A1400033C3A000100000001 /* DailySchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1400013C3A000100000001 /* DailySchedule.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -94,6 +96,7 @@ 5ED8531E2711DF6300CC0699 /* ReleaseNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotes.swift; sourceTree = ""; }; 5ED85324271327F500CC0699 /* ReleaseNotesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesController.swift; sourceTree = ""; }; 5ED853572715172A00CC0699 /* ReleaseNotesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesTests.swift; sourceTree = ""; }; + 7A1400013C3A000100000001 /* DailySchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailySchedule.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,6 +173,7 @@ children = ( 5EC35D9D271064D800CAC3C9 /* Alert.swift */, 5E71EE712BCE564A0093E9FE /* AtomicDictionary.swift */, + 7A1400013C3A000100000001 /* DailySchedule.swift */, 5EC35DA9271092AD00CAC3C9 /* DateUtils.swift */, 5E1AF2752710279700240C38 /* ITunesSearchAPI.swift */, 5EC35DA02710802E00CAC3C9 /* Log.swift */, @@ -366,6 +370,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7A1400023C3A000100000001 /* DailySchedule.swift in Sources */, 5E1AF2762710279700240C38 /* ITunesSearchAPI.swift in Sources */, 5EC35DA7271091BD00CAC3C9 /* VersionCheckCondition.swift in Sources */, 5E1AF26A2710257B00240C38 /* SwiftyUpdateKitConfig.swift in Sources */, @@ -397,6 +402,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7A1400033C3A000100000001 /* DailySchedule.swift in Sources */, 5E1AF27F27102B2B00240C38 /* ITunesSearchAPI.swift in Sources */, 5EC35DA8271091BD00CAC3C9 /* VersionCheckCondition.swift in Sources */, 5E1AF26F271025CF00240C38 /* SwiftyUpdateKitConfig.swift in Sources */, diff --git a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift index 9d3aa2c..207124b 100644 --- a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift +++ b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift @@ -58,3 +58,258 @@ class SwiftyUpdateKitTests: XCTestCase { XCTAssertTrue(SUKUserDefaults.standard.env == .production) } } + +final class SchedulingConditionTests: XCTestCase { + func testDailyVersionCheckRecordsOnlyAfterSuccess() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) + + XCTAssertTrue(condition.shouldCheckVersion()) + XCTAssertTrue(condition.shouldCheckVersion()) + XCTAssertEqual(stateStore.writeCount, 0) + + condition.recordSuccessfulVersionCheck() + condition.recordSuccessfulVersionCheck() + + XCTAssertEqual(stateStore.writeCount, 1) + XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 20_260_819) + XCTAssertFalse(condition.shouldCheckVersion()) + + clock.date = 20_260_820 + XCTAssertTrue(condition.shouldCheckVersion()) + } + + func testLaunchingAndDailyVersionCheckUsesSameTransitionRules() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = VersionCheckConditionLaunchingAndDaily(clock: clock, + stateStore: stateStore) + + XCTAssertTrue(condition.shouldCheckVersion()) + XCTAssertEqual(stateStore.writeCount, 0) + + condition.recordSuccessfulVersionCheck() + + XCTAssertEqual(stateStore.writeCount, 1) + XCTAssertFalse(condition.shouldCheckVersion()) + + clock.date = 20_260_820 + XCTAssertTrue(condition.shouldCheckVersion()) + } + + func testFinalLookupFailureDoesNotRecordSuccessfulCheck() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) + let lookupCompleted = expectation(description: "Lookup completed") + initializeSUKForSchedulingTests() + + let lookup = StubAppStoreLookup(result: .failure(TestLookupError.failed)) { + lookupCompleted.fulfill() + } + + SUK.checkVersion(condition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + + wait(for: [lookupCompleted], timeout: 1) + XCTAssertEqual(stateStore.writeCount, 0) + XCTAssertTrue(condition.shouldCheckVersion()) + } + + func testValidLookupRecordsSuccessfulCheckExactlyOnce() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) + let checkCompleted = expectation(description: "Check completed") + initializeSUKForSchedulingTests() + + let lookup = StubAppStoreLookup(result: .success([.stub(version: "1.0.0")])) + + SUK.checkVersion(condition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: { + checkCompleted.fulfill() + }, + lookup: lookup) + + wait(for: [checkCompleted], timeout: 1) + XCTAssertEqual(stateStore.writeCount, 1) + XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastVersionCheckDateKey), + 20_260_819) + XCTAssertFalse(condition.shouldCheckVersion()) + } + + func testResponseWithoutVersionDoesNotRecordSuccessfulCheck() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = VersionCheckConditionDaily(clock: clock, stateStore: stateStore) + let lookupCompleted = expectation(description: "Lookup completed") + initializeSUKForSchedulingTests() + + let lookup = StubAppStoreLookup(result: .success([.stub(version: nil)])) { + lookupCompleted.fulfill() + } + + SUK.checkVersion(condition, + update: nil, + newRelease: nil, + forUserID: "Test", + noop: nil, + lookup: lookup) + + wait(for: [lookupCompleted], timeout: 1) + XCTAssertEqual(stateStore.writeCount, 0) + XCTAssertTrue(condition.shouldCheckVersion()) + } + + func testDailyReviewConditionRecordsRequestAttempt() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = RequestReviewConditionDaily(clock: clock, stateStore: stateStore) + var requestCount = 0 + + XCTAssertTrue(condition.shouldRequestReview()) + XCTAssertEqual(stateStore.writeCount, 0) + + SUK.requestReviewIfNeeded(condition) { + requestCount += 1 + XCTAssertEqual(stateStore.writeCount, 1) + } + + XCTAssertEqual(requestCount, 1) + XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), + 20_260_819) + XCTAssertFalse(condition.shouldRequestReview()) + } + + func testSkipFirstDayRecordsInitializationSeparatelyFromRequestAttempt() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let condition = RequestReviewConditionDailySkipFirstDay(clock: clock, + stateStore: stateStore) + var requestCount = 0 + + SUK.requestReviewIfNeeded(condition) { + requestCount += 1 + } + + XCTAssertEqual(requestCount, 0) + XCTAssertEqual(stateStore.writeCount, 1) + XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), + 20_260_819) + + clock.date = 20_260_820 + SUK.requestReviewIfNeeded(condition) { + requestCount += 1 + } + + XCTAssertEqual(requestCount, 1) + XCTAssertEqual(stateStore.writeCount, 2) + XCTAssertEqual(stateStore.integer(forKey: SwiftyUpdateKitLastRequireReviewDateKey), + 20_260_820) + } +} + +private final class TestClock: SUKClock { + var date: Int + + init(currentDate: Int) { + date = currentDate + } + + func currentDate() -> Int { + date + } +} + +private final class TestSchedulingStateStore: SUKSchedulingStateStore { + private var values: [String: Int] = [:] + private(set) var writeCount = 0 + + func set(_ value: Int, forKey key: String) { + values[key] = value + writeCount += 1 + } + + func integer(forKey key: String) -> Int { + values[key] ?? 0 + } +} + +private struct StubAppStoreLookup: AppStoreLookup { + let result: Result<[LookUpResult], Error> + let completionHandler: (() -> Void)? + + init(result: Result<[LookUpResult], Error>, completionHandler: (() -> Void)? = nil) { + self.result = result + self.completionHandler = completionHandler + } + + func lookUp(with _: SwiftyUpdateKitConfig, + completion: @escaping (Result<[LookUpResult], Error>) -> Void) + { + completion(result) + completionHandler?() + } +} + +private enum TestLookupError: Error { + case failed +} + +private func initializeSUKForSchedulingTests() { + SUK.initialize(withConfig: SwiftyUpdateKitConfig(version: "1.0.0", + iTunesID: "1234567890", + storeURL: "https://apps.apple.com/app/id1234567890")) +} + +private extension LookUpResult { + static func stub(version: String?, releaseNotes: String? = nil) -> LookUpResult { + LookUpResult(artistId: nil, + artistName: nil, + artistViewUrl: nil, + artworkUrl100: nil, + artworkUrl512: nil, + artworkUrl60: nil, + averageUserRating: nil, + averageUserRatingForCurrentVersion: nil, + bundleId: nil, + contentAdvisoryRating: nil, + currency: nil, + currentVersionReleaseDate: nil, + description: nil, + fileSizeBytes: nil, + formattedPrice: nil, + genreIds: nil, + genres: nil, + isVppDeviceBasedLicensingEnabled: nil, + kind: nil, + languageCodesISO2A: nil, + minimumOsVersion: nil, + price: nil, + primaryGenreId: nil, + primaryGenreName: nil, + releaseDate: nil, + releaseNotes: releaseNotes, + screenshotUrls: nil, + sellerName: nil, + sellerUrl: nil, + trackCensoredName: nil, + trackContentRating: nil, + trackId: nil, + trackName: nil, + trackViewUrl: nil, + userRatingCount: nil, + userRatingCountForCurrentVersion: nil, + version: version, + wrapperType: nil) + } +} diff --git a/README.md b/README.md index 2cf8f57..47bcf4a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,17 @@ Finally, if the app version is latest and the release notes have already shown, +### Daily scheduling + +`VersionCheckConditionDaily` and `VersionCheckConditionLaunchingAndDaily` record the current +date only after the App Store returns a valid app version. A failed lookup does not consume that +day's check, so the next call can try again. + +Daily review conditions record the date when SwiftyUpdateKit calls the StoreKit review request +API. StoreKit does not report whether it displayed the review interface, so this date represents +an attempt. Conditions that skip the first day initially record the first evaluation date and do +not make a review request on that day. + ### Use custom UI You can use custom UI to show the update alert and the release notes. See following: @@ -191,4 +202,3 @@ jazzy version: 0.14.3 ./make_docs.sh ``` - From 7861e4ffb94f619b285d0878a2bae1e2dc329973 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:48:19 +0000 Subject: [PATCH 2/5] Use generic iOS simulator destination in CI Co-authored-by: HituziANDO <2204870+HituziANDO@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d5cbb..6e0b910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,5 @@ jobs: with: workspace: SwiftyUpdateKit.xcworkspace scheme: SwiftyUpdateKit - destination: 'platform=iOS Simulator,name=iPhone 12 Pro Max' + destination: 'platform=iOS Simulator' action: test From 482b45a1813f630cab935bb4d32915419b38b2e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:00:10 +0000 Subject: [PATCH 3/5] fix: update CI destination to use iPhone 17 simulator Co-authored-by: HituziANDO <2204870+HituziANDO@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e0b910..c6e751b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,5 @@ jobs: with: workspace: SwiftyUpdateKit.xcworkspace scheme: SwiftyUpdateKit - destination: 'platform=iOS Simulator' + destination: 'platform=iOS Simulator,name=iPhone 17' action: test From 037366944811d43e85e159a6fd0ea6208ce3fbb2 Mon Sep 17 00:00:00 2001 From: Hituzi Ando Date: Wed, 19 Aug 2026 14:51:32 +0900 Subject: [PATCH 4/5] fix(scheduling): Prevent concurrent daily lookups --- Framework/Sources/DailySchedule.swift | 42 ++++++++- Framework/Sources/SUK.swift | 40 +++++++-- Framework/Sources/VersionCheckCondition.swift | 49 ++++++++++- .../SwiftyUpdateKitTests.swift | 86 +++++++++++++++++++ 4 files changed, 208 insertions(+), 9 deletions(-) diff --git a/Framework/Sources/DailySchedule.swift b/Framework/Sources/DailySchedule.swift index 2682da5..3252f44 100644 --- a/Framework/Sources/DailySchedule.swift +++ b/Framework/Sources/DailySchedule.swift @@ -42,14 +42,46 @@ struct InMemorySchedulingStateStore: SUKSchedulingStateStore { } } +protocol SchedulingExecutionGating: AnyObject { + func beginExecution(forKey key: String) -> Bool + func finishExecution(forKey key: String) +} + +final class SchedulingExecutionGate: SchedulingExecutionGating { + private let lock = NSLock() + private var runningKeys: Set = [] + + func beginExecution(forKey key: String) -> Bool { + lock.lock() + defer { lock.unlock() } + + return runningKeys.insert(key).inserted + } + + func finishExecution(forKey key: String) { + lock.lock() + defer { lock.unlock() } + + runningKeys.remove(key) + } +} + +let sharedSchedulingExecutionGate = SchedulingExecutionGate() + struct DailySchedule { private let clock: SUKClock private let stateStore: SUKSchedulingStateStore + private let executionGate: SchedulingExecutionGating private let key: String - init(clock: SUKClock, stateStore: SUKSchedulingStateStore, key: String) { + init(clock: SUKClock, + stateStore: SUKSchedulingStateStore, + executionGate: SchedulingExecutionGating = sharedSchedulingExecutionGate, + key: String) + { self.clock = clock self.stateStore = stateStore + self.executionGate = executionGate self.key = key } @@ -67,4 +99,12 @@ struct DailySchedule { stateStore.set(currentDate, forKey: key) } + + func beginExecution() -> Bool { + executionGate.beginExecution(forKey: key) + } + + func finishExecution() { + executionGate.finishExecution(forKey: key) + } } diff --git a/Framework/Sources/SUK.swift b/Framework/Sources/SUK.swift index d867f38..1d1bad8 100644 --- a/Framework/Sources/SUK.swift +++ b/Framework/Sources/SUK.swift @@ -252,7 +252,12 @@ extension SUK { noop: (() -> Void)?, lookup: AppStoreLookup) { - checkVersion(condition, update: update, lookup: lookup) { lookUpResult in + checkVersion(condition, + update: update, + lookup: lookup, + inProgress: { + noop?() + }) { lookUpResult in guard let newRelease else { // Not need to show the new release. noop?() @@ -296,6 +301,7 @@ extension SUK { private static func checkVersion(_ condition: VersionCheckCondition, update: UpdateHandler?, lookup: AppStoreLookup, + inProgress: @escaping () -> Void, next: @escaping (LookUpResult?) -> Void) { DispatchQueue.main.async { @@ -304,12 +310,31 @@ extension SUK { return } - guard condition.shouldCheckVersion() else { - logf("Skips the version check.", log) - DispatchQueue.main.async { - next(nil) + let executionController = condition as? VersionCheckExecutionControlling + if let executionController { + switch executionController.beginVersionCheck() { + case .started: + break + case .inProgress: + logf("Skips the version check because a lookup is already in progress.", + log) + inProgress() + return + case .notEligible: + logf("Skips the version check.", log) + DispatchQueue.main.async { + next(nil) + } + return + } + } else { + guard condition.shouldCheckVersion() else { + logf("Skips the version check.", log) + DispatchQueue.main.async { + next(nil) + } + return } - return } lookup.lookUp(with: config) { result in @@ -317,6 +342,7 @@ extension SUK { case let .failure(error): // Ignore an error. logf(error.localizedDescription, log) + executionController?.finishVersionCheck() case let .success(lookUpResults): logf(lookUpResults.description, log) guard let lookUpResult = lookUpResults.first, @@ -324,11 +350,13 @@ extension SUK { else { // Ignore an error. logf("version does not exist in the response data.", log) + executionController?.finishVersionCheck() return } (condition as? VersionCheckSuccessRecording)? .recordSuccessfulVersionCheck() + executionController?.finishVersionCheck() let isOld = config.versionCompare.compare(storeVersion, with: config.version) diff --git a/Framework/Sources/VersionCheckCondition.swift b/Framework/Sources/VersionCheckCondition.swift index 56f3cec..fdb503a 100644 --- a/Framework/Sources/VersionCheckCondition.swift +++ b/Framework/Sources/VersionCheckCondition.swift @@ -25,6 +25,17 @@ public protocol VersionCheckSuccessRecording: AnyObject { func recordSuccessfulVersionCheck() } +enum VersionCheckExecutionDecision { + case started + case inProgress + case notEligible +} + +protocol VersionCheckExecutionControlling: AnyObject { + func beginVersionCheck() -> VersionCheckExecutionDecision + func finishVersionCheck() +} + /// Always checks the app version. open class VersionCheckConditionAlways: VersionCheckCondition { public init() {} @@ -51,9 +62,13 @@ open class VersionCheckConditionDaily: VersionCheckCondition, VersionCheckSucces public init() {} - init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + init(clock: SUKClock, + stateStore: SUKSchedulingStateStore, + executionGate: SchedulingExecutionGating = SchedulingExecutionGate()) + { schedule = DailySchedule(clock: clock, stateStore: stateStore, + executionGate: executionGate, key: SwiftyUpdateKitLastVersionCheckDateKey) } @@ -76,9 +91,13 @@ open class VersionCheckConditionLaunchingAndDaily: VersionCheckCondition, public init() {} - init(clock: SUKClock, stateStore: SUKSchedulingStateStore) { + init(clock: SUKClock, + stateStore: SUKSchedulingStateStore, + executionGate: SchedulingExecutionGating = SchedulingExecutionGate()) + { schedule = DailySchedule(clock: clock, stateStore: stateStore, + executionGate: executionGate, key: SwiftyUpdateKitLastVersionCheckDateKey) } @@ -90,3 +109,29 @@ open class VersionCheckConditionLaunchingAndDaily: VersionCheckCondition, schedule.recordCurrentDate() } } + +extension VersionCheckConditionDaily: VersionCheckExecutionControlling { + func beginVersionCheck() -> VersionCheckExecutionDecision { + guard shouldCheckVersion() else { return .notEligible } + guard schedule.beginExecution() else { return .inProgress } + + return .started + } + + func finishVersionCheck() { + schedule.finishExecution() + } +} + +extension VersionCheckConditionLaunchingAndDaily: VersionCheckExecutionControlling { + func beginVersionCheck() -> VersionCheckExecutionDecision { + guard shouldCheckVersion() else { return .notEligible } + guard schedule.beginExecution() else { return .inProgress } + + return .started + } + + func finishVersionCheck() { + schedule.finishExecution() + } +} diff --git a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift index 207124b..dfaabad 100644 --- a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift +++ b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift @@ -170,6 +170,57 @@ final class SchedulingConditionTests: XCTestCase { XCTAssertTrue(condition.shouldCheckVersion()) } + func testConcurrentLaunchingAndDailyChecksStartOneLookup() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let executionGate = SchedulingExecutionGate() + let firstCondition = VersionCheckConditionLaunchingAndDaily(clock: clock, + stateStore: stateStore, + executionGate: executionGate) + let secondCondition = VersionCheckConditionLaunchingAndDaily(clock: clock, + stateStore: stateStore, + executionGate: executionGate) + let lookup = ControlledAppStoreLookup() + initializeSUKForSchedulingTests() + + checkVersion(firstCondition, lookup: lookup) + checkVersion(secondCondition, lookup: lookup) + waitForMainQueue() + + XCTAssertEqual(lookup.requestCount, 1) + XCTAssertEqual(stateStore.writeCount, 0) + + lookup.completeNext(with: .failure(TestLookupError.failed)) + } + + func testLaunchingAndDailyCheckCanRetryAfterFailure() { + let clock = TestClock(currentDate: 20_260_819) + let stateStore = TestSchedulingStateStore() + let executionGate = SchedulingExecutionGate() + let firstCondition = VersionCheckConditionLaunchingAndDaily(clock: clock, + stateStore: stateStore, + executionGate: executionGate) + let retryCondition = VersionCheckConditionLaunchingAndDaily(clock: clock, + stateStore: stateStore, + executionGate: executionGate) + let lookup = ControlledAppStoreLookup() + initializeSUKForSchedulingTests() + + checkVersion(firstCondition, lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 1) + + lookup.completeNext(with: .failure(TestLookupError.failed)) + + checkVersion(retryCondition, lookup: lookup) + waitForMainQueue() + XCTAssertEqual(lookup.requestCount, 2) + + lookup.completeNext(with: .success([.stub(version: "1.0.0")])) + waitForMainQueue() + XCTAssertEqual(stateStore.writeCount, 1) + } + func testDailyReviewConditionRecordsRequestAttempt() { let clock = TestClock(currentDate: 20_260_819) let stateStore = TestSchedulingStateStore() @@ -261,6 +312,22 @@ private struct StubAppStoreLookup: AppStoreLookup { } } +private final class ControlledAppStoreLookup: AppStoreLookup { + private var completions: [(Result<[LookUpResult], Error>) -> Void] = [] + private(set) var requestCount = 0 + + func lookUp(with _: SwiftyUpdateKitConfig, + completion: @escaping (Result<[LookUpResult], Error>) -> Void) + { + requestCount += 1 + completions.append(completion) + } + + func completeNext(with result: Result<[LookUpResult], Error>) { + completions.removeFirst()(result) + } +} + private enum TestLookupError: Error { case failed } @@ -271,6 +338,25 @@ private func initializeSUKForSchedulingTests() { storeURL: "https://apps.apple.com/app/id1234567890")) } +private func checkVersion(_ condition: VersionCheckCondition, + lookup: AppStoreLookup) +{ + SUK.checkVersion(condition, + update: nil, + newRelease: { _, _, _ in }, + forUserID: "Test", + noop: nil, + lookup: lookup) +} + +private func waitForMainQueue() { + let mainQueueProcessed = XCTestExpectation(description: "Main queue processed") + DispatchQueue.main.async { + mainQueueProcessed.fulfill() + } + XCTAssertEqual(XCTWaiter().wait(for: [mainQueueProcessed], timeout: 1), .completed) +} + private extension LookUpResult { static func stub(version: String?, releaseNotes: String? = nil) -> LookUpResult { LookUpResult(artistId: nil, From 4679622d68628e5f8a347883f2eb9279cc3e9fb1 Mon Sep 17 00:00:00 2001 From: Hituzi Ando Date: Wed, 19 Aug 2026 16:52:17 +0900 Subject: [PATCH 5/5] fix(scheduling): Suppress noop during active lookup --- Framework/Sources/SUK.swift | 9 +-------- .../SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift | 11 ++++++++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Framework/Sources/SUK.swift b/Framework/Sources/SUK.swift index 1d1bad8..e7a18a6 100644 --- a/Framework/Sources/SUK.swift +++ b/Framework/Sources/SUK.swift @@ -252,12 +252,7 @@ extension SUK { noop: (() -> Void)?, lookup: AppStoreLookup) { - checkVersion(condition, - update: update, - lookup: lookup, - inProgress: { - noop?() - }) { lookUpResult in + checkVersion(condition, update: update, lookup: lookup) { lookUpResult in guard let newRelease else { // Not need to show the new release. noop?() @@ -301,7 +296,6 @@ extension SUK { private static func checkVersion(_ condition: VersionCheckCondition, update: UpdateHandler?, lookup: AppStoreLookup, - inProgress: @escaping () -> Void, next: @escaping (LookUpResult?) -> Void) { DispatchQueue.main.async { @@ -318,7 +312,6 @@ extension SUK { case .inProgress: logf("Skips the version check because a lookup is already in progress.", log) - inProgress() return case .notEligible: logf("Skips the version check.", log) diff --git a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift index dfaabad..9363a48 100644 --- a/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift +++ b/Framework/SwiftyUpdateKitTests/SwiftyUpdateKitTests.swift @@ -181,13 +181,17 @@ final class SchedulingConditionTests: XCTestCase { stateStore: stateStore, executionGate: executionGate) let lookup = ControlledAppStoreLookup() + var noopCallCount = 0 initializeSUKForSchedulingTests() checkVersion(firstCondition, lookup: lookup) - checkVersion(secondCondition, lookup: lookup) + checkVersion(secondCondition, lookup: lookup) { + noopCallCount += 1 + } waitForMainQueue() XCTAssertEqual(lookup.requestCount, 1) + XCTAssertEqual(noopCallCount, 0) XCTAssertEqual(stateStore.writeCount, 0) lookup.completeNext(with: .failure(TestLookupError.failed)) @@ -339,13 +343,14 @@ private func initializeSUKForSchedulingTests() { } private func checkVersion(_ condition: VersionCheckCondition, - lookup: AppStoreLookup) + lookup: AppStoreLookup, + noop: (() -> Void)? = nil) { SUK.checkVersion(condition, update: nil, newRelease: { _, _, _ in }, forUserID: "Test", - noop: nil, + noop: noop, lookup: lookup) }