Skip to content

Commit b8ab131

Browse files
Release 1.1.1: wire the macOS updater; fix silent set_setting data loss
- macOS host: Settings ▸ Check for Updates… drives the signed feed on demand (UpdateService sheet: checking → offer → progress → install); one silent launch check throttled to once per 4 h via the last-update-check config key; failed installs keep the running version - backend: get_setting RPC exposes raw config values to hosts; set_setting accepts the integer-as-string last-update-check key and the config writer persists it; +7 contract tests (62 → 69) - fix: set_setting built the new config with the immutable-only hash-set on the mutable read-config hash, so every preference write threw and hosts silently dropped it — theme/language/last-list never persisted - docs: UPDATE.md status table reflects the wired macOS updater; Windows/Linux host updaters stay documented follow-ups
1 parent 0fa9e4c commit b8ab131

15 files changed

Lines changed: 347 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.1] - 2026-10-09
9+
10+
### Added
11+
- **The macOS updater is wired in.** 1.1.0 shipped the signed update feed
12+
and the `UpdateService` engine, but nothing invoked it. `Settings ▸
13+
Check for Updates…` now checks the Ed25519-signed manifest on demand,
14+
and one silent check runs shortly after launch (throttled to once per
15+
4 hours via the `last-update-check` config key, bypassed by the menu).
16+
An available update is offered by version, downloads with visible
17+
progress, re-verifies the artifact sha256 and the unpacked bundle
18+
version, swaps the app in place and relaunches; a failed swap restores
19+
the previous bundle and the running version keeps working. Development
20+
copies (not under /Applications) get an honest "auto-update
21+
unavailable" answer instead of a network error.
22+
23+
### Fixed
24+
- **`set_setting` never persisted anything.** The RPC built the new
25+
config with the immutable-only `hash-set` on the mutable hash that
26+
`read-config` returns, so every write threw and the hosts silently
27+
swallowed the RPC error — theme, language and last-selected list never
28+
survived a relaunch. It now uses `hash-set!`; a new backend contract
29+
test (`racket/tests/backend-test.rkt`) covers the write path, growing
30+
the suite from 62 to 69 checks.
31+
32+
### Changed
33+
- Backend: a new `get_setting` RPC exposes raw config values to hosts
34+
(the macOS update throttle reads `last-update-check` through it, and
35+
the future Windows/Linux updaters will share it); `set_setting`
36+
accepts the integer-as-string `last-update-check` key and the config
37+
writer persists it (DATA-FORMAT §7).
38+
- The [UPDATE](shared/spec/UPDATE.md) status table reflects reality:
39+
the macOS updater is fully wired; Windows and Linux host updaters
40+
remain documented follow-ups — until they land, update those hosts by
41+
manual download from the releases page.
42+
843
## [1.1.0] - 2026-10-08
944

1045
### Added

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.1.0
1+
1.1.1

‎linux/GeneratedBackend.hpp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace rivet_app {
1818
inline constexpr char kModuleName[] = "backend";
1919
inline constexpr char kEntryName[] = "start";
2020
inline constexpr char kDisplayName[] = "taskly";
21-
inline constexpr char kVersion[] = "1.1.0";
21+
inline constexpr char kVersion[] = "1.1.1";
2222
inline constexpr std::int64_t kBuild = 1;
2323
inline constexpr char kIdentifier[] = "app.taskly.Taskly";
2424
inline constexpr char kReleaseChannel[] = "stable";
@@ -142,6 +142,8 @@ class API {
142142
[[nodiscard]] std::uint64_t delete_list_async(std::int64_t id, std::function<void(Result<bool>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("delete_list", rivet::Value::List{detail::encode_Int64(id)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult raw) mutable { Result<bool> result; if (raw.error) { result.error = raw.error; } else { try { if (!raw.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Bool(*raw.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); }
143143
std::future<bool> delete_task(std::int64_t id) { auto raw = backend_.call("delete_task", rivet::Value::List{detail::encode_Int64(id)}); return std::async(std::launch::deferred, [raw = std::move(raw)]() mutable -> bool { return detail::decode_Bool(raw.get()); }); }
144144
[[nodiscard]] std::uint64_t delete_task_async(std::int64_t id, std::function<void(Result<bool>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("delete_task", rivet::Value::List{detail::encode_Int64(id)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult raw) mutable { Result<bool> result; if (raw.error) { result.error = raw.error; } else { try { if (!raw.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Bool(*raw.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); }
145+
std::future<std::string> get_setting(std::string key) { auto raw = backend_.call("get_setting", rivet::Value::List{detail::encode_String(key)}); return std::async(std::launch::deferred, [raw = std::move(raw)]() mutable -> std::string { return detail::decode_String(raw.get()); }); }
146+
[[nodiscard]] std::uint64_t get_setting_async(std::string key, std::function<void(Result<std::string>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("get_setting", rivet::Value::List{detail::encode_String(key)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult raw) mutable { Result<std::string> result; if (raw.error) { result.error = raw.error; } else { try { if (!raw.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_String(*raw.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); }
145147
std::future<Settings> get_settings() { auto raw = backend_.call("get_settings", rivet::Value::List{}); return std::async(std::launch::deferred, [raw = std::move(raw)]() mutable -> Settings { return detail::decode_Settings(raw.get()); }); }
146148
[[nodiscard]] std::uint64_t get_settings_async(std::function<void(Result<Settings>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("get_settings", rivet::Value::List{}, [completion = std::move(completion)](rivet::linux_runtime::CallResult raw) mutable { Result<Settings> result; if (raw.error) { result.error = raw.error; } else { try { if (!raw.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Settings(*raw.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); }
147149
std::future<Snapshot> load_snapshot(std::string view, std::optional<std::int64_t> list_id, bool show_completed) { auto raw = backend_.call("load_snapshot", rivet::Value::List{detail::encode_String(view), detail::encode__Optional_Int64_(list_id), detail::encode_Bool(show_completed)}); return std::async(std::launch::deferred, [raw = std::move(raw)]() mutable -> Snapshot { return detail::decode_Snapshot(raw.get()); }); }

‎macos-host/Sources/RivetHost/AppModel.swift‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

‎macos-host/Sources/RivetHost/GeneratedBackend.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public enum RivetGeneratedConfig {
77
public static let moduleName = "backend"
88
public static let entryName = "start"
99
public static let displayName = "taskly"
10-
public static let version = "1.1.0"
10+
public static let version = "1.1.1"
1111
public static let build: Int64 = 1
1212
public static let identifier = "app.taskly.Taskly"
1313
public static let releaseChannel = "stable"
@@ -172,6 +172,10 @@ public struct RivetAPI: Sendable {
172172
let result = try await client.call("delete_task", arguments: [encode_Int64(id)])
173173
return try decode_Bool(result)
174174
}
175+
public func get_setting(key: String) async throws -> String {
176+
let result = try await client.call("get_setting", arguments: [encode_String(key)])
177+
return try decode_String(result)
178+
}
175179
public func get_settings() async throws -> Settings {
176180
let result = try await client.call("get_settings", arguments: [])
177181
return try decode_Settings(result)

‎macos-host/Sources/RivetHost/MainWindowView.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ struct MainWindowView: View {
3333
set: { model.aboutVisible = $0 })) {
3434
AboutSheet()
3535
}
36+
.sheet(isPresented: Binding(
37+
get: { model.updateSheetVisible },
38+
set: { newValue in
39+
model.updateSheetVisible = newValue
40+
if !newValue { model.dismissUpdateSheet() }
41+
})) {
42+
UpdateSheet()
43+
}
3644
.confirmationDialog(
3745
model.confirmContext?.title ?? "",
3846
isPresented: Binding(

‎macos-host/Sources/RivetHost/Sheets.swift‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,79 @@ struct AboutSheet: View {
279279
.background(model.theme.background)
280280
}
281281
}
282+
283+
/// Update sheet (shared/spec/UPDATE.md): one phase machine driving check →
284+
/// offer → download → install. A failed install leaves the running version
285+
/// untouched; the download phase cannot be dismissed (no cancel support).
286+
struct UpdateSheet: View {
287+
@Environment(AppModel.self) private var model
288+
@Environment(\.dismiss) private var dismiss
289+
290+
var body: some View {
291+
VStack(spacing: 16) {
292+
Image(systemName: "arrow.down.circle")
293+
.font(.system(size: 44))
294+
.foregroundStyle(model.theme.accent)
295+
296+
switch model.updatePhase {
297+
case .checking:
298+
ProgressView()
299+
300+
case .available:
301+
Text(model.t("updateAvailableTitle"))
302+
.font(.system(size: 15, weight: .semibold))
303+
.foregroundStyle(model.theme.onSurface)
304+
Text(model.t("updateAvailableBody", model.updateAvailableVersion))
305+
.font(.system(size: 13))
306+
.foregroundStyle(model.theme.secondaryText)
307+
.multilineTextAlignment(.center)
308+
HStack(spacing: 12) {
309+
Button(model.t("dialogCancel")) { dismiss() }
310+
Button(model.t("updateRestart")) { model.installUpdate() }
311+
.keyboardShortcut(.defaultAction)
312+
.buttonStyle(.borderedProminent)
313+
.tint(model.theme.accent)
314+
}
315+
316+
case .downloading:
317+
Text(model.t("updateDownloading"))
318+
.font(.system(size: 13))
319+
.foregroundStyle(model.theme.onSurface)
320+
ProgressView(value: Double(model.updateProgressPercent), total: 100)
321+
.frame(width: 240)
322+
.tint(model.theme.accent)
323+
Text("\(model.updateProgressPercent)%")
324+
.font(.system(size: 12))
325+
.foregroundStyle(model.theme.secondaryText)
326+
327+
case .upToDate:
328+
Text(model.t("updateUpToDate"))
329+
.font(.system(size: 14, weight: .medium))
330+
.foregroundStyle(model.theme.onSurface)
331+
Button(model.t("dialogConfirm")) { dismiss() }
332+
.keyboardShortcut(.defaultAction)
333+
.buttonStyle(.borderedProminent)
334+
.tint(model.theme.accent)
335+
336+
case .failed:
337+
Text(model.updateErrorMessage)
338+
.font(.system(size: 13))
339+
.foregroundStyle(model.theme.secondaryText)
340+
.multilineTextAlignment(.center)
341+
.frame(maxWidth: 320)
342+
.fixedSize(horizontal: false, vertical: true)
343+
HStack(spacing: 12) {
344+
Button(model.t("dialogCancel")) { dismiss() }
345+
Button(model.t("menuCheckUpdates")) { model.checkForUpdates() }
346+
}
347+
348+
case .idle:
349+
EmptyView()
350+
}
351+
}
352+
.padding(28)
353+
.frame(width: 360)
354+
.background(model.theme.background)
355+
.interactiveDismissDisabled(model.updatePhase == .downloading)
356+
}
357+
}

‎macos-host/Sources/RivetHost/TasklyApp.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ struct TasklyCommands: Commands {
9595
model.setTheme("dark")
9696
}
9797
}
98+
Divider()
99+
Button(model.t("menuCheckUpdates")) { model.checkForUpdates() }
98100
}
99101
}
100102

‎macos-host/Sources/RivetHost/UpdateService.swift‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,10 @@ public final class UpdateService {
189189

190190
/// Downloads, verifies (sha256, then unpacked bundle version), swaps the
191191
/// bundle in place and relaunches. Throws before touching the running
192-
/// install when anything is off.
193-
public func downloadAndInstall(_ manifest: Manifest) async throws {
192+
/// install when anything is off. `onProgress` reports download percent
193+
/// (0…100) on the main actor for the update sheet.
194+
public func downloadAndInstall(_ manifest: Manifest,
195+
onProgress: ((Int) -> Void)? = nil) async throws {
194196
guard let bundleURL = installedBundleURL else { throw UpdateError.notInstalled }
195197
guard let artifact = manifest.platforms["macos"] else {
196198
throw UpdateError.manifestMissing
@@ -211,9 +213,18 @@ public final class UpdateService {
211213
var hasher = SHA256()
212214
var data = Data()
213215
data.reserveCapacity(artifact.size)
216+
let total = max(artifact.size, 1)
217+
var lastPercent = -1
214218
for try await byte in bytes {
215219
hasher.update(data: [byte])
216220
data.append(byte)
221+
if onProgress != nil {
222+
let percent = min(100, data.count * 100 / total)
223+
if percent != lastPercent {
224+
lastPercent = percent
225+
onProgress?(percent)
226+
}
227+
}
217228
}
218229
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
219230
guard digest == artifact.sha256.lowercased() else {

0 commit comments

Comments
 (0)