@@ -37,6 +37,18 @@ enum SmartView: Hashable {
3737 }
3838}
3939
40+ /// Update-sheet phase machine (shared/spec/UPDATE.md behavior contract):
41+ /// check → offer → download → install; silent checks open the sheet only
42+ /// for an offer.
43+ enum UpdatePhase : Equatable {
44+ case idle
45+ case checking
46+ case available
47+ case downloading
48+ case upToDate
49+ case failed
50+ }
51+
4052/// Application state: embedded-backend lifecycle, settings, lists, current
4153/// view, tasks, status line. All data flows through the typed RivetAPI;
4254/// every backend mutation publishes `changed`, which re-reads the snapshot.
@@ -96,12 +108,23 @@ final class AppModel {
96108 var confirmContext : ConfirmContext ?
97109 var aboutVisible = false
98110
111+ // Updater (shared/spec/UPDATE.md)
112+ var updateSheetVisible = false
113+ var updatePhase : UpdatePhase = . idle
114+ var updateProgressPercent = 0
115+ var updateAvailableVersion = " "
116+ var updateErrorMessage = " "
117+
99118 /// Transient delete banner with a one-step undo (Reminders-style).
100119 var undoBannerText : String ?
101120 var undoBannerAction : ( ( ) -> Void ) ?
102121 @ObservationIgnored private var undoBannerTask : _Concurrency . Task < Void , Never > ?
103122 @ObservationIgnored private var transientDeadline : _Concurrency . Task < Void , Never > ?
104123 @ObservationIgnored private var appearanceObserver : NSKeyValueObservation ?
124+ /// Update offer accepted but not yet installed (lost on relaunch — the
125+ /// user re-offers on the next check).
126+ @ObservationIgnored private var pendingUpdateManifest : UpdateService . Manifest ?
127+ @ObservationIgnored private lazy var updateService = UpdateService ( i18n: i18n)
105128
106129 init ( ) {
107130 statusMessage = i18n. t ( " statusDatabaseNotConnected " )
@@ -167,6 +190,7 @@ final class AppModel {
167190 refreshStatusPersistent ( )
168191 flashStatus ( i18n. t ( " statusDatabaseConnected " ) )
169192 reminder? . start ( )
193+ autoCheckForUpdates ( )
170194 } catch {
171195 statusMessage = " \( error) "
172196 }
@@ -210,6 +234,118 @@ final class AppModel {
210234 }
211235 }
212236
237+ // MARK: - Updates (shared/spec/UPDATE.md)
238+
239+ /// Silent checks run at most once per 4 hours (UPDATE.md triggers).
240+ static let updateThrottleInterval : TimeInterval = 4 * 60 * 60
241+
242+ /// Manual entry point: Settings ▸ Check for Updates…. The sheet reports
243+ /// every outcome (up to date, offer, failure, dev copy).
244+ func checkForUpdates( ) {
245+ guard updatePhase != . downloading else { return }
246+ updateSheetVisible = true
247+ updatePhase = . checking
248+ _Concurrency. Task { await runUpdateCheck ( present: true ) }
249+ }
250+
251+ /// Silent launch check: once shortly after startup, throttled to one
252+ /// attempt per 4 h (config `last-update-check`); failures never nag.
253+ func autoCheckForUpdates( ) {
254+ _Concurrency. Task {
255+ try ? await _Concurrency. Task. sleep ( nanoseconds: 3_000_000_000 )
256+ await runUpdateCheck ( present: false )
257+ }
258+ }
259+
260+ private func runUpdateCheck( present: Bool ) async {
261+ guard updatePhase != . downloading else { return }
262+ if !present {
263+ guard await updateThrottleElapsed ( ) else { return }
264+ }
265+ do {
266+ let result = try await updateService. check ( )
267+ await recordUpdateCheck ( )
268+ switch result {
269+ case . available( let manifest) :
270+ pendingUpdateManifest = manifest
271+ updateAvailableVersion = manifest. version
272+ updatePhase = . available
273+ updateSheetVisible = true
274+ case . upToDate:
275+ updatePhase = present ? . upToDate : . idle
276+ if present { updateSheetVisible = true }
277+ }
278+ } catch {
279+ await recordUpdateCheck ( )
280+ guard present else {
281+ updatePhase = . idle
282+ return
283+ }
284+ if case UpdateService . UpdateError . notInstalled = error {
285+ updateErrorMessage = t ( " updateNotInstalled " )
286+ } else {
287+ updateErrorMessage = t ( " updateCheckFailed " , Self . cleanError ( error) )
288+ }
289+ updatePhase = . failed
290+ updateSheetVisible = true
291+ }
292+ }
293+
294+ /// The throttle lives in config.ini (DATA-FORMAT §7 `last-update-check`,
295+ /// unix epoch seconds), shared with the future Windows/Linux updaters.
296+ private func updateThrottleElapsed( ) async -> Bool {
297+ guard let api else { return false }
298+ let raw = ( try ? await api. get_setting ( key: " last-update-check " ) ) ?? " "
299+ let last = TimeInterval ( raw. trimmingCharacters ( in: . whitespaces) ) ?? 0
300+ return Date ( ) . timeIntervalSince1970 - last >= Self . updateThrottleInterval
301+ }
302+
303+ private func recordUpdateCheck( ) async {
304+ guard let api else { return }
305+ _ = try ? await api. set_setting (
306+ key: " last-update-check " ,
307+ value: String ( Int ( Date ( ) . timeIntervalSince1970) ) )
308+ }
309+
310+ /// Offer accepted: download (progress in the sheet) → sha256 + Ed25519
311+ /// already verified → in-place swap → relaunch. UpdateService terminates
312+ /// the app on success; any earlier throw leaves this version running.
313+ func installUpdate( ) {
314+ guard updatePhase == . available, let manifest = pendingUpdateManifest else { return }
315+ updatePhase = . downloading
316+ updateProgressPercent = 0
317+ updateErrorMessage = " "
318+ _Concurrency. Task { [ weak self] in
319+ guard let self else { return }
320+ do {
321+ try await updateService. downloadAndInstall ( manifest) { [ weak self] percent in
322+ self ? . updateProgressPercent = percent
323+ }
324+ } catch {
325+ updateErrorMessage = t ( " updateInstallFailed " , Self . cleanError ( error) )
326+ updatePhase = . failed
327+ }
328+ }
329+ }
330+
331+ func dismissUpdateSheet( ) {
332+ updateSheetVisible = false
333+ if updatePhase != . downloading {
334+ updatePhase = . idle
335+ updateErrorMessage = " "
336+ pendingUpdateManifest = nil
337+ }
338+ }
339+
340+ static func cleanError( _ error: Error ) -> String {
341+ let text = error. localizedDescription
342+ // RVT1 failures arrive as "...error: <message>"; keep the message.
343+ if let range = text. range ( of: " error: " ) {
344+ return String ( text [ range. upperBound... ] )
345+ }
346+ return text
347+ }
348+
213349 // MARK: - Database lifecycle (File menu)
214350
215351 /// Full snapshot of the planned view — every dated incomplete task,
0 commit comments