Skip to content

Commit 101402d

Browse files
author
pavelhov
authored
Merge pull request #44 from pavelhov/codex/persistent-update-reminder
fix(macos): retain update reminders and check on launch for 0.1.8
2 parents 891f6e3 + 5cdbe1f commit 101402d

6 files changed

Lines changed: 148 additions & 14 deletions

File tree

‎app/Info.plist‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
<integer>0</integer>
3535
<key>SUAllowsAutomaticUpdates</key>
3636
<false/>
37+
<key>SUScheduledCheckInterval</key>
38+
<integer>21600</integer>
3739
<key>SUEnableAutomaticChecks</key>
3840
<true/>
3941
<key>LSUIElement</key>

‎app/Sources/MenuBarUI/UpdateController.swift‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,19 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
2727
private lazy var standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self)
2828
private let hostBundle: Bundle
2929
private let confirmInstallation: () -> Bool
30-
package private(set) var updateAvailable = false
30+
// A known eligible release outlives the window/installer session. Revalidate
31+
// through Sparkle on every launch rather than persisting stale appcast data.
32+
package private(set) var availableVersion: String?
33+
package var updateAvailable: Bool { availableVersion != nil }
34+
35+
private func rememberAvailableVersion(_ version: String) {
36+
if let installed = hostBundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String,
37+
SUStandardVersionComparator.default.compareVersion(version, toVersion: installed) != .orderedDescending {
38+
availableVersion = nil
39+
} else {
40+
availableVersion = version
41+
}
42+
}
3143
private var userInitiated = false
3244
private let boundary: Boundary
3345

@@ -58,11 +70,11 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
5870
public var supportsGentleScheduledUpdateReminders: Bool { true }
5971
public func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem, andInImmediateFocus immediateFocus: Bool) -> Bool { false }
6072
public func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) {
61-
updateAvailable = true
73+
rememberAvailableVersion(update.versionString)
6274
changed()
6375
}
6476
public func standardUserDriverWillFinishUpdateSession() {
65-
updateAvailable = false
77+
// Ending a presentation does not mean the release stopped being available.
6678
changed()
6779
}
6880

@@ -73,6 +85,11 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
7385

7486
private func changed() { boundary.stateChanged(coordinator) }
7587

88+
package func didNotFindEligibleUpdate() {
89+
availableVersion = nil
90+
changed()
91+
}
92+
7693
public func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState,
7794
reply: @escaping (SPUUserUpdateChoice) -> Void) {
7895
userInitiated = state.userInitiated
@@ -92,7 +109,7 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
92109
reply(.dismiss)
93110
return
94111
}
95-
updateAvailable = true
112+
rememberAvailableVersion(target)
96113
changed()
97114
let offerGeneration = coordinator.generation
98115
var replied = false
@@ -108,6 +125,7 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
108125
return
109126
}
110127
guard choice == .install else {
128+
if choice == .skip { self.availableVersion = nil }
111129
self.coordinator.cancel()
112130
self.changed()
113131
respond(choice)
@@ -156,8 +174,8 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
156174
public func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { userInitiated = true; standard.showUserInitiatedUpdateCheck(cancellation: cancellation) }
157175
public func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { standard.showUpdateReleaseNotes(with: downloadData) }
158176
public func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) { standard.showUpdateReleaseNotesFailedToDownloadWithError(error) }
159-
public func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { updateAvailable = false; changed(); if userInitiated { standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) } else { acknowledgement() } }
160-
public func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { let showError = userInitiated || [.preparing, .prepared, .armed].contains(coordinator.phase); coordinator.installerSessionEnded(); updateAvailable = false; changed(); if showError { standard.showUpdaterError(error, acknowledgement: acknowledgement) } else { acknowledgement() } }
177+
public func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { didNotFindEligibleUpdate(); if userInitiated { standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) } else { acknowledgement() } }
178+
public func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { let showError = userInitiated || [.preparing, .prepared, .armed].contains(coordinator.phase); coordinator.installerSessionEnded(); changed(); if showError { standard.showUpdaterError(error, acknowledgement: acknowledgement) } else { acknowledgement() } }
161179
public func showDownloadInitiated(cancellation: @escaping () -> Void) {
162180
standard.showDownloadInitiated { [weak self] in
163181
self?.coordinator.cancel(); self?.changed(); cancellation()
@@ -179,7 +197,7 @@ public final class UpdateController: NSObject, SPUUserDriver, SPUStandardUserDri
179197
reply(.install)
180198
}
181199
public func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) }
182-
public func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { coordinator.installerSessionEnded(); changed(); standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) }
183-
public func dismissUpdateInstallation() { if coordinator.phase == .offered { coordinator.cancel() }; coordinator.installerSessionEnded(); updateAvailable = false; userInitiated = false; changed(); standard.dismissUpdateInstallation() }
200+
public func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { coordinator.installerSessionEnded(); availableVersion = nil; changed(); standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) }
201+
public func dismissUpdateInstallation() { if coordinator.phase == .offered { coordinator.cancel() }; coordinator.installerSessionEnded(); userInitiated = false; changed(); standard.dismissUpdateInstallation() }
184202
public func showUpdateInFocus() { userInitiated = true; standard.showUpdateInFocus() }
185203
}

‎app/Sources/MenuBarUI/UpdateSession.swift‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ public final class UpdateSession {
124124
}
125125
}
126126

127+
/// The startup boundary is testable without starting a live network update session.
128+
@MainActor
129+
package protocol UpdateDiscoveryStarting: AnyObject {
130+
func start() throws
131+
func checkForUpdatesInBackground()
132+
}
133+
extension SPUUpdater: UpdateDiscoveryStarting {}
134+
127135
/// Owns Sparkle only in explicitly enabled, keyed distribution bundles.
128136
@MainActor
129137
package final class AppUpdater: NSObject, SPUUpdaterDelegate {
@@ -171,7 +179,7 @@ package final class AppUpdater: NSObject, SPUUpdaterDelegate {
171179
updater.automaticallyChecksForUpdates = true
172180
updater.automaticallyDownloadsUpdates = false
173181
do {
174-
try updater.start()
182+
try Self.startDiscovery(updater)
175183
self.updater = updater
176184
} catch {
177185
unavailableReason = "Updates could not start. Reopen the app to retry."
@@ -180,6 +188,13 @@ package final class AppUpdater: NSObject, SPUUpdaterDelegate {
180188
return ordinaryStartup
181189
}
182190

191+
package static func startDiscovery(_ updater: any UpdateDiscoveryStarting) throws {
192+
try updater.start()
193+
// Sparkle explicitly supports forcing a launch check before the next runloop.
194+
// Its scheduler owns subsequent checks; no competing app timer is needed.
195+
updater.checkForUpdatesInBackground()
196+
}
197+
183198
func check() {
184199
Task { @MainActor in
185200
if session.blocksLifecycle { _ = await session.recover() }
@@ -196,6 +211,11 @@ package final class AppUpdater: NSObject, SPUUpdaterDelegate {
196211

197212
package func updaterShouldPromptForPermissionToCheck(forUpdates updater: SPUUpdater) -> Bool { false }
198213

214+
package func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
215+
// Background checks may skip the user driver's no-update presentation.
216+
driver?.didNotFindEligibleUpdate()
217+
}
218+
199219
package func bestValidUpdate(in appcast: SUAppcast, for updater: SPUUpdater) -> SUAppcastItem? {
200220
bestValidUpdate(in: appcast.items)
201221
}

‎app/Sources/MenuBarUITests/main.swift‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ app.setActivationPolicy(.prohibited)
1111

1212
let runner = TestRunner()
1313

14+
@MainActor
15+
final class RecordingDiscovery: UpdateDiscoveryStarting {
16+
var calls: [String] = []
17+
var failStart = false
18+
func start() throws {
19+
calls.append("start")
20+
if failStart { throw NSError(domain: "test.discovery", code: 1) }
21+
}
22+
func checkForUpdatesInBackground() { calls.append("background") }
23+
}
24+
1425
final class ApplicationMenuTarget: NSObject {
1526
@objc func stopCodexCommanderAndQuit(_ sender: Any?) {}
1627
}
@@ -1890,7 +1901,7 @@ MainActor.assumeIsolated {
18901901
decide?(.install)
18911902
runner.equal(disclosures, 1, "retained reply consumed once")
18921903
driver.dismissUpdateInstallation()
1893-
runner.equal(driver.updateAvailable, false)
1904+
runner.equal(driver.updateAvailable, true, "dismissal preserves the known release")
18941905
}
18951906

18961907
runner.test("updater UI: Sparkle scheduled offers stay hidden, including critical releases; manual offers open") {
@@ -1923,8 +1934,89 @@ MainActor.assumeIsolated {
19231934
"user action focuses the retained scheduled offer")
19241935
}
19251936
driver.dismissUpdateInstallation()
1926-
runner.equal(driver.updateAvailable, false)
1937+
runner.equal(driver.updateAvailable, true, "dismissal preserves the known release")
1938+
}
1939+
}
1940+
}
1941+
1942+
runner.test("updater UI: launch discovery starts quietly only after successful updater startup") {
1943+
let updater = RecordingDiscovery()
1944+
try! AppUpdater.startDiscovery(updater)
1945+
runner.equal(updater.calls, ["start", "background"])
1946+
let failed = RecordingDiscovery()
1947+
failed.failStart = true
1948+
do { try AppUpdater.startDiscovery(failed); runner.expect(false, "start must fail") } catch {}
1949+
runner.equal(failed.calls, ["start"])
1950+
let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Info.plist")
1951+
let info = try! PropertyListSerialization.propertyList(from: Data(contentsOf: source), format: nil) as! [String: Any]
1952+
runner.equal(info["SUScheduledCheckInterval"] as? Int, 21600)
1953+
runner.equal(info["SUEnableAutomaticChecks"] as? Bool, true)
1954+
}
1955+
1956+
runner.test("updater UI: known release survives network errors but authoritative no-update clears it") {
1957+
let driver = UpdateController(hostBundle: .main, boundary: .init(
1958+
prepare: { _ in false }, persistArmed: { _ in }, cancelPreparation: { true },
1959+
stateChanged: { _ in }), confirmInstallation: { false })
1960+
driver.reconcileStartup(installerDisarmed: true)
1961+
driver.handleOffer(target: "2", stage: .notDownloaded, present: { $0(.dismiss) }, reply: { _ in })
1962+
driver.dismissUpdateInstallation()
1963+
driver.showUpdaterError(NSError(domain: "test.offline", code: 1)) {}
1964+
runner.equal(driver.availableVersion, "2")
1965+
driver.didNotFindEligibleUpdate()
1966+
runner.equal(driver.updateAvailable, false, "background no-update result clears without UI")
1967+
driver.handleOffer(target: "2", stage: .notDownloaded, present: { $0(.dismiss) }, reply: { _ in })
1968+
driver.dismissUpdateInstallation()
1969+
driver.showUpdateNotFoundWithError(NSError(domain: "test.no-update", code: 1)) {}
1970+
runner.equal(driver.updateAvailable, false)
1971+
}
1972+
1973+
runner.test("updater UI: installed and older builds do not advertise availability") {
1974+
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".app")
1975+
let contents = folder.appendingPathComponent("Contents")
1976+
try! FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true)
1977+
let info: [String: Any] = ["CFBundleIdentifier": "test.reminder." + UUID().uuidString,
1978+
"CFBundleName": "Reminder test", "CFBundleVersion": "20"]
1979+
try! PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: contents.appendingPathComponent("Info.plist"))
1980+
defer { try? FileManager.default.removeItem(at: folder) }
1981+
let driver = UpdateController(hostBundle: Bundle(url: folder)!, boundary: .init(
1982+
prepare: { _ in false }, persistArmed: { _ in }, cancelPreparation: { true }, stateChanged: { _ in }))
1983+
driver.reconcileStartup(installerDisarmed: true)
1984+
for (version, available) in [("21", true), ("20", false), ("9", false)] {
1985+
driver.handleOffer(target: version, stage: .notDownloaded, present: { $0(.dismiss) }, reply: { _ in })
1986+
driver.dismissUpdateInstallation()
1987+
runner.equal(driver.updateAvailable, available)
1988+
}
1989+
}
1990+
1991+
runner.test("updater UI: real Sparkle Later and close preserve reminder while Skip clears it") {
1992+
for action in ["remindMeLater:", "close", "skipThisVersion:"] {
1993+
let driver = UpdateController(hostBundle: .main, boundary: .init(
1994+
prepare: { _ in fatalError("unsolicited preparation") }, persistArmed: { _ in },
1995+
cancelPreparation: { true }, stateChanged: { _ in }), confirmInstallation: { false })
1996+
driver.reconcileStartup(installerDisarmed: true)
1997+
let archive = NSKeyedArchiver(requiringSecureCoding: true)
1998+
archive.encode(0, forKey: "SPUUserUpdateStateStage")
1999+
archive.encode(true, forKey: "SPUUserUpdateStateUserInitiated")
2000+
archive.finishEncoding()
2001+
let decoder = try! NSKeyedUnarchiver(forReadingFrom: archive.encodedData)
2002+
let state = SPUUserUpdateState(coder: decoder)!
2003+
decoder.finishDecoding()
2004+
let item = SUAppcastItem(dictionary: ["enclosure": ["url": "https://example.test/2.zip", "sparkle:version": "2"]])!
2005+
let existing = Set(NSApp.windows.map(ObjectIdentifier.init))
2006+
var choice: SPUUserUpdateChoice?
2007+
driver.showUpdateFound(with: item, state: state) { choice = $0 }
2008+
let window = NSApp.windows.first { !existing.contains(ObjectIdentifier($0)) && $0.isVisible }
2009+
runner.expect(window != nil, "real Sparkle offer is visible")
2010+
if action == "close" { window?.performClose(nil) }
2011+
else {
2012+
// Invoke Sparkle's actual button action even if this test host hides Later.
2013+
let target = window?.windowController
2014+
runner.expect(target?.responds(to: NSSelectorFromString(action)) == true, "Sparkle action exists")
2015+
_ = target?.perform(NSSelectorFromString(action), with: nil)
19272016
}
2017+
runner.equal(choice, action == "skipThisVersion:" ? .skip : .dismiss)
2018+
driver.dismissUpdateInstallation()
2019+
runner.equal(driver.updateAvailable, action != "skipThisVersion:")
19282020
}
19292021
}
19302022

‎docs-site/src/content/docs/guides/macos-menu-bar.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ once using the steps above.
3131

3232
![macOS menu with an update action](/macos-updates.png)
3333

34-
The app checks for compatible stable releases quietly in the background. When it finds one,
35-
the menu shows **Update Available…**. Select it to review the version and release notes.
34+
The app checks for compatible stable releases quietly in the background. Starting with v0.1.8, it checks
35+
on launch and every six hours while running. When it finds an update, the menu shows
36+
**Update Available…**. Closing the update window or choosing **Later** keeps that reminder available
37+
so you can return to it. Select it to review the version and release notes.
3638
You can also choose **Check for Updates…** to check manually. Checking, including a failed or
3739
offline background check, leaves the proxy running and does not open an unsolicited update window.
3840

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexcommander",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"private": true,
55
"description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
66
"type": "module",

0 commit comments

Comments
 (0)