Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Sources/CoffeeBarPower/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,36 @@ public enum SettingsKey {
/// string itself and pins it apart from every other key here.
public static let lastUpdateCheck = "lastUpdateCheck"

/// What coffee-bar's last look concluded (issue #147).
///
/// A LIST OF STRINGS: a tag naming the conclusion, then its payload where
/// the conclusion has one. Never a sentence.
/// `UpdateCheck.StoredVerdict` owns the shape and says why the words are
/// rebuilt by the running build rather than restored from here.
///
/// **It is the twin of `lastUpdateCheck` and the two are written together.**
/// Writing the stamp alone is issue #147: the window restored WHEN
/// coffee-bar last looked and nothing about what it found, so every relaunch
/// printed "coffee-bar has not looked for a newer version yet." directly
/// above "Last checked: 2026-08-19 12:35." Read on a maintainer's machine,
/// where `defaults read` carried the stamp and no verdict at all.
///
/// Absent means the conclusion is NOT KNOWN, which is neither "never
/// checked" nor "up to date". Every install made before this key existed is
/// in that state at its first launch on this build, and the surface says so
/// in its own sentence rather than picking one of the other two.
///
/// A `[String]` and not an `Int`, which puts it out of reach of the three
/// `Int` keys above and into the company of `demotableProcessNames` and
/// `agentTools`: those two are the neighbours a crossed read answers
/// cleanly rather than as `nil`. A collision either way is survivable here,
/// and deliberately so. A list of process names matches no tag, so it reads
/// as unknown and shows the not-recorded sentence rather than a wrong
/// conclusion, and nothing in this key can be mistaken for a tool name.
/// `theLastUpdateVerdictKeyStringNeverChangesAndCollidesWithNothing` holds
/// the string itself and pins it apart from every other key here.
public static let lastUpdateVerdict = "lastUpdateVerdict"

/// Whether the user asked coffee-bar to open at login (issue #48).
///
/// Absent by default, and the default is `false`: nothing is installed until
Expand Down
61 changes: 55 additions & 6 deletions Sources/CoffeeBarUI/ServingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,18 @@ public final class ServingModel {
// the truth on the surface.
self.lastUpdateCheck = settings.integer(forKey: SettingsKey.lastUpdateCheck)
.map { Date(timeIntervalSince1970: TimeInterval($0)) }
// Read once, here, for the reason above, and beside the stamp because it
// is the stamp's twin. Issue #147 is what happened while only one of the
// pair survived a quit: the window restored WHEN coffee-bar last looked,
// had nothing to say about what it found, and printed "has not looked
// yet" directly above the time it did.
//
// A stored value this build cannot read restores NOTHING rather than a
// guess, and `updateStatusLine` has a sentence for that state which
// claims neither "never checked" nor "up to date".
self.updateVerdict = settings.stringArray(forKey: SettingsKey.lastUpdateVerdict)
.flatMap(UpdateCheck.storedVerdict(from:))
.map(UpdateCheck.verdict(of:))
// Read once, here, for the reason above, and `?? false` is the whole of
// issue #48's posture: a key nobody wrote is a user who never asked, and
// nothing is installed for them.
Expand Down Expand Up @@ -2154,8 +2166,22 @@ public final class ServingModel {
public private(set) var lastUpdateCheck: Date?

/// What the window says about the last check.
///
/// THREE states and not two, and the missing third was issue #147. A model
/// with no verdict but a stamp HAS looked, so saying it has not, directly
/// above the time it did, is a window contradicting itself in two lines a
/// user reads as one. It says what it actually knows instead.
///
/// It still refuses to say "up to date" in that state, which is the whole of
/// `UpdateCheck.neverCheckedLine`'s argument carried into the new case:
/// silence read as good news is how a check broken for a year goes
/// unnoticed, and a missing verdict is silence.
public var updateStatusLine: String {
guard let updateVerdict else { return UpdateCheck.neverCheckedLine }
guard let updateVerdict else {
return lastUpdateCheck == nil
? UpdateCheck.neverCheckedLine
: UpdateCheck.verdictNotRecordedLine
}
return UpdateCheck.sentence(for: updateVerdict)
}

Expand All @@ -2180,7 +2206,8 @@ public final class ServingModel {
/// a check can hand in a stamp of its own.
///
/// **It replaces the bundle with nothing and downloads no release.** The
/// entire effect is `updateVerdict` and `lastUpdateCheck`.
/// entire effect is `updateVerdict` and `lastUpdateCheck`, and the two
/// settings keys those two are read back from at the next launch.
public func checkForUpdates(
version: String = AppVersion.display(from: Bundle.main.infoDictionary)
) async {
Expand All @@ -2194,25 +2221,47 @@ public final class ServingModel {
// drifts the interval outward by however long the network took.
let attempted = now()

// What the check concluded, in the form that survives a quit, decided
// HERE where the cause is still in hand. Deriving it afterwards from the
// sentence the verdict carries would mean matching prose, and the prose
// is the thing this whole shape exists to keep out of the store.
let concluded: UpdateCheck.StoredVerdict

do {
let fetched = try await updates.fetch()
switch UpdateCheck.manifest(from: fetched.body, statusCode: fetched.statusCode) {
case .success(let manifest):
updateVerdict = UpdateCheck.compare(running: version,
published: manifest.version)
switch UpdateCheck.compare(running: version, published: manifest.version) {
case .upToDate:
concluded = .upToDate
case .updateAvailable(let published):
concluded = .updateAvailable(published)
case .cannotCompare:
// `compare` refuses one of two stamps, and the running one
// parsed at the guard above, so the published one is what it
// could not read.
concluded = .refused(.unreadable)
}
case .failure(let refusal):
updateVerdict = .cannotCompare(UpdateCheck.sentence(for: refusal))
concluded = .refused(refusal)
}
} catch {
// Every transport failure reads the same to a user: it did not
// happen. The error's own text is a `URLError` code that names a
// library rather than a thing to do about it.
updateVerdict = .cannotCompare(UpdateCheck.unreachableLine)
concluded = .unreachable
}

updateVerdict = UpdateCheck.verdict(of: concluded)

lastUpdateCheck = attempted
settings.setInteger(Int(attempted.timeIntervalSince1970),
forKey: SettingsKey.lastUpdateCheck)
// Written WITH the stamp and never without it. The pair is the fix for
// issue #147: a stamp that outlives its verdict is what put "has not
// looked yet" above "Last checked".
settings.setStringArray(UpdateCheck.fields(of: concluded),
forKey: SettingsKey.lastUpdateVerdict)
}

/// Looks for a newer published version only if the stated interval has run
Expand Down
160 changes: 160 additions & 0 deletions Sources/CoffeeBarUI/UpdateCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ public enum UpdateCheck {
/// how a check that has been broken for a year goes unnoticed.
static let neverCheckedLine = "coffee-bar has not looked for a newer version yet."

/// What the window says when a check ran and what it concluded did not come
/// back with the stamp.
///
/// The THIRD state, and issue #147 is what its absence cost. A stamp used to
/// outlive its verdict, so a relaunch put `neverCheckedLine` directly above
/// a real "Last checked" time: the window said it had not looked, and said
/// when it last looked, in two sentences a user reads as one. Every install
/// made before the verdict was written down lands here once, and so does a
/// stored value this build cannot read.
///
/// It reports rather than assumes, for the reason `neverCheckedLine` gives
/// above: "up to date" here would state a conclusion nobody established, and
/// it would keep stating it for a user whose checks have been failing since
/// the day they installed.
static let verdictNotRecordedLine =
"coffee-bar looked for a newer version, but what it concluded is not recorded. "
+ "It will look again."

static let upToDateLine = "coffee-bar is up to date."

static let unreachableLine = "coffee-bar could not reach the published version file."
Expand Down Expand Up @@ -238,6 +256,148 @@ public enum UpdateCheck {
}
}

// MARK: - What a check leaves behind for the next launch

/// What a completed check concluded, in the form it is written down in.
///
/// **A discriminator, and the smallest payload that rebuilds the sentence.
/// Never the sentence.** Every line in the section above is prose, and prose
/// is rewritten between releases; a stored one is replayed by a build that
/// no longer agrees with it. That is the same class of defect as issue #147
/// itself, where a stamp restored beside a verdict that did not, so storing
/// the words would fix one half by shipping the other.
///
/// `verdict(of:)` is applied on the way out, at the check and at the
/// restore alike, so what a user reads always comes from the running build.
///
/// Four cases, and they are exactly what `ServingModel.checkForUpdates` can
/// conclude once a request has gone out. `cannotCompare(unstampedLine)` is
/// deliberately not among them: that verdict is reached BEFORE the fetch and
/// records no attempt at all, so there is no stamp for it to sit beside and
/// nothing about it to restore.
enum StoredVerdict: Equatable, Sendable {
case upToDate
/// A newer release is published. Carries the published version.
case updateAvailable(String)
/// A published answer arrived and was not believed.
case refused(ManifestRefusal)
/// No answer arrived at all.
case unreachable
}

/// The tags a stored verdict is written under.
///
/// Spelled once each, for the reason `SettingsKey` gives about key strings:
/// a tag is written on one launch and read on the next, so a writer and a
/// reader that disagree about one restore nothing while both still compile.
private enum StoredTag {
static let upToDate = "upToDate"
static let updateAvailable = "updateAvailable"
static let unreachable = "unreachable"
static let status = "status"
static let tooLarge = "tooLarge"
static let unreadablePublished = "unreadablePublished"
}

/// The verdict a stored one reads as, in this build's words.
///
/// The ONE place a stored form becomes a sentence, called by the check that
/// reached it and by the launch that restores it, so a relaunch cannot show
/// a second spelling of what the last check said.
static func verdict(of stored: StoredVerdict) -> UpdateVerdict {
switch stored {
case .upToDate:
return .upToDate
case .updateAvailable(let version):
return .updateAvailable(version)
case .refused(let refusal):
return .cannotCompare(sentence(for: refusal))
case .unreachable:
return .cannotCompare(unreachableLine)
}
}

/// How a verdict is written down: the tag, then its payload where it has
/// one.
///
/// A LIST OF STRINGS, because the store already holds one of those and this
/// needed no fourth type for one setting. It is also what a maintainer sees
/// in a `defaults read` pasted into a bug report, which a packed single
/// string is not.
///
/// The byte count on `tooLarge` is carried even though no sentence prints
/// it, so that reading a stored form back answers the value that was
/// written rather than one this file invented to fill the case.
static func fields(of stored: StoredVerdict) -> [String] {
switch stored {
case .upToDate:
return [StoredTag.upToDate]
case .updateAvailable(let version):
return [StoredTag.updateAvailable, version]
case .unreachable:
return [StoredTag.unreachable]
case .refused(.status(let code)):
return [StoredTag.status, String(code)]
case .refused(.tooLarge(let bytes)):
return [StoredTag.tooLarge, String(bytes)]
case .refused(.unreadable):
return [StoredTag.unreadablePublished]
}
}

/// What stored fields mean, or `nil` when this build cannot read them.
///
/// **`nil` and never a guess.** These fields can be written by a NEWER build
/// that knows a case this one does not, by a hand edit, or by a key
/// collision with one of the other two lists in the store. Answering
/// `upToDate` for anything unrecognised would state a conclusion no check
/// reached, which is what `neverCheckedLine` exists to refuse; the caller
/// says `verdictNotRecordedLine` instead, which claims nothing.
///
/// The arity is checked as well as the tag, so a payload that is missing,
/// doubled or left over from another case is unknown rather than read as a
/// neighbouring one.
///
/// ASCII digits only, for the reason `releaseCore(of:)` gives above.
static func storedVerdict(from fields: [String]) -> StoredVerdict? {
guard let tag = fields.first else { return nil }
switch tag {
case StoredTag.upToDate where fields.count == 1:
return .upToDate
case StoredTag.updateAvailable where fields.count == 2:
let version = fields[1].trimmingCharacters(in: .whitespacesAndNewlines)
return version.isEmpty ? nil : .updateAvailable(version)
case StoredTag.unreachable where fields.count == 1:
return .unreachable
case StoredTag.unreadablePublished where fields.count == 1:
return .refused(.unreadable)
case StoredTag.status where fields.count == 2:
guard let code = wholeNumber(fields[1]) else { return nil }
return .refused(.status(code))
case StoredTag.tooLarge where fields.count == 2:
guard let bytes = wholeNumber(fields[1]) else { return nil }
return .refused(.tooLarge(bytes))
default:
return nil
}
}

/// A whole number written in ASCII digits, or `nil`.
///
/// `Character.isNumber` is true of `١` and of `½`, so a parser written on it
/// alone accepts strings whose value is not what the glyphs say. The same
/// care `releaseCore(of:)` takes, for the same reason, and a leading sign is
/// refused with everything else: neither an HTTP status nor a byte count is
/// ever negative, and one that reads as negative is a file this app did not
/// write.
private static func wholeNumber(_ text: String) -> Int? {
guard !text.isEmpty,
text.allSatisfy({ $0.isASCII && $0.isNumber }),
let value = Int(text)
else { return nil }
return value
}

// MARK: - What the published bytes mean

/// The manifest those bytes carry, or why they were not believed.
Expand Down
29 changes: 29 additions & 0 deletions Tests/CoffeeBarPowerTests/SettingsStore_test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,35 @@ struct SettingsStoreTests {
#expect(SettingsKey.lastUpdateCheck != SettingsKey.quickStartCompleted)
}

@Test func theLastUpdateVerdictKeyStringNeverChangesAndCollidesWithNothing() {
// Held for the reason every other key is, and issue #147 is what this
// one costs when it goes missing rather than when it is renamed. The
// stamp beside it already survived a quit; the verdict did not, so a
// relaunch printed "coffee-bar has not looked for a newer version yet."
// directly above a real "Last checked" time. A rename puts the window
// straight back in that state, with nothing anywhere reporting it.
//
// Its dangerous neighbours are the two other `[String]` keys, not the
// `Int` ones: a crossed read against `demotableProcessNames` or
// `agentTools` answers a value cleanly rather than `nil`.
#expect(SettingsKey.lastUpdateVerdict == "lastUpdateVerdict")
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.lastUpdateCheck)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.demotableProcessNames)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.agentTools)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.holdDisplayAwake)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.batteryFloorPercent)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.quietEverythingElse)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.lidClosedHoldSeconds)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.quickStartCompleted)
#expect(SettingsKey.lastUpdateVerdict != SettingsKey.launchAtLogin)

// A prefix is not a match, and this is the pair that makes that worth
// asserting: `lastUpdateCheck` is a prefix of nothing, but a store keyed
// on `hasPrefix` would read the two of them as one setting and the
// window would restore a verdict as a timestamp.
#expect(!SettingsKey.lastUpdateVerdict.hasPrefix(SettingsKey.lastUpdateCheck))
}

@Test func anUnrunUpdateCheckReadsAsAbsentRatherThanAsTheEpoch() throws {
// The `Int?` on `integer(forKey:)` earning its keep for a third setting.
// `UserDefaults.integer(forKey:)` answers 0 for a key nobody wrote, and
Expand Down
Loading