From 5a68bce9534f45c26ee32ab3d754f5622fde876f Mon Sep 17 00:00:00 2001 From: turinglambdaai Date: Fri, 23 Oct 2026 17:54:47 +0800 Subject: [PATCH 1/2] feat(settings): automatically-check-for-updates toggle on every host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent launch + 4-hour update checks were invisible to the user; the Raycast study called that out (updates behavior belongs in the open). PRODUCT-SPEC §8's Settings menu gains a checkable Automatically Check for Updates item (config `auto-check-updates`, default "1"): silent checks skip when off, the manual Check for Updates… item always runs and ignores the throttle too. - macOS: Settings-menu Toggle bound to AppModel.autoCheckUpdates; bootstrap reads the setting before the silent check; persists via set_setting. - Windows: ToggleMenuFlyoutItem + auto_check_updates_ member, loaded over the backend get_setting RPC when the backend comes up, gated at the top of RunSilentUpdateCheck, persisted via SaveSettingAsync. - Linux: stateful win.auto-check-updates Gio action (popover menus render the checkmark), gate at the top of on_update_auto_check, persisted through save_setting, initial state synced when the startup get_settings lands. Backend: set_setting's grammar whitelists the key ("0"/"1") and — the actual persistence bug this slice caught — the config writer's key list now carries it too; set_setting round-trips are pinned by a backend-test case. UPDATE.md documents the toggle; DATA-FORMAT §7 lists the key. --- CHANGELOG.md | 4 ++ linux/src/main.cpp | 35 +++++++++++++++++ macos-host/Sources/RivetHost/AppModel.swift | 16 ++++++++ macos-host/Sources/RivetHost/TasklyApp.swift | 4 ++ racket/taskly/backend.rkt | 5 ++- racket/taskly/config.rkt | 2 +- racket/tests/backend-test.rkt | 8 ++++ shared/i18n/en.json | 1 + shared/i18n/zh.json | 1 + shared/spec/DATA-FORMAT.md | 1 + shared/spec/UPDATE.md | 5 +++ windows/MainWindow.Update.cpp | 40 ++++++++++++++++++++ windows/MainWindow.xaml | 2 + windows/MainWindow.xaml.cpp | 3 ++ windows/MainWindow.xaml.h | 6 +++ 15 files changed, 131 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0afbc41..202e8ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- An Automatically Check for Updates toggle in the Settings menu on every + platform (config `auto-check-updates`, default on): silent launch and + 4-hour checks honor it, the manual Check for Updates… item always runs + (#58). - Network transparency: the macOS bundle ships an `InternetAccessPolicy.plist` declaring the update feed as the app's only network use — firewall apps (Little Snitch and friends) show the diff --git a/linux/src/main.cpp b/linux/src/main.cpp index beab5d8d..4107f597 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -1658,12 +1658,19 @@ void on_menu_check_updates(GSimpleAction*, GVariant*, gpointer) { // Silent launch check: once per process, 3 s after the backend is up, gated // by the 4-hour throttle. A failed throttle read counts as "never checked" — // silent mode ignores errors by contract. +// UPDATE.md auto-check toggle (config `auto-check-updates`, default on); +// the win.auto-check-updates action state is the single source of truth. +bool g_auto_check_updates = true; + gboolean on_update_auto_check(gpointer) { g_update.auto_check_source = 0; if (g_state.api == nullptr || g_state.shutting_down.load(std::memory_order_acquire)) { return G_SOURCE_REMOVE; } + if (!g_auto_check_updates) { + return G_SOURCE_REMOVE; // silent checks skip; manual still runs + } g_state.api->get_setting_async( kUpdateCheckSetting, [](rivet_app::Result result) { @@ -3085,6 +3092,8 @@ void build_menubar() { g_menu_append(theme_section, tr("themeDark").c_str(), "win.theme-dark"); g_menu_append_section(settings_menu, nullptr, G_MENU_MODEL(theme_section)); g_object_unref(theme_section); + g_menu_append(settings_menu, tr("settingsAutoCheckUpdates").c_str(), + "win.auto-check-updates"); g_menu_append_submenu(menu, tr("menuSettings").c_str(), G_MENU_MODEL(settings_menu)); g_object_unref(settings_menu); @@ -3520,6 +3529,19 @@ int on_backend_finished(gpointer) { apply_theme(settings.theme.empty() ? "system" : settings.theme); apply_language(); + g_state.api->get_setting_async( + "auto-check-updates", + [](rivet_app::Result toggle) { + if (!toggle.succeeded()) return; + g_auto_check_updates = toggle.get() != "0"; + if (auto* action = G_SIMPLE_ACTION( + g_action_map_lookup_action( + G_ACTION_MAP(g_state.window), + "auto-check-updates"))) { + g_simple_action_set_state( + action, g_variant_new_boolean(g_auto_check_updates)); + } + }); // Stamp the install shape so the backend picks the matching // update flavor (UPDATE.md): appimage in-place, package // manager guidance, portable tarball download. @@ -4008,6 +4030,19 @@ void on_activate(GtkApplication* app, gpointer) { on_action_focus_search, false); add_window_action(GTK_APPLICATION_WINDOW(window), "toggle-completed", on_action_toggle_completed, false); + auto* auto_check_action = g_simple_action_new_stateful( + "auto-check-updates", nullptr, g_variant_new_boolean(true)); + g_signal_connect( + auto_check_action, "change-state", + G_CALLBACK(+[](GSimpleAction* action, GVariant* value, gpointer) { + g_auto_check_updates = g_variant_get_boolean(value); + g_simple_action_set_state(action, value); + save_setting("auto-check-updates", + g_auto_check_updates ? "1" : "0"); + }), + nullptr); + g_action_map_add_action(G_ACTION_MAP(window), G_ACTION(auto_check_action)); + g_object_unref(auto_check_action); // Window-wide accelerators (PRODUCT-SPEC §8; Things-style keyboard-first). static char const* const accels_today[] = {"1", nullptr}; diff --git a/macos-host/Sources/RivetHost/AppModel.swift b/macos-host/Sources/RivetHost/AppModel.swift index 8fb9881f..a482ea47 100644 --- a/macos-host/Sources/RivetHost/AppModel.swift +++ b/macos-host/Sources/RivetHost/AppModel.swift @@ -98,6 +98,8 @@ final class AppModel { var searchFocusToken = 0 var isSidebarVisible = true var statusMessage = "" + /// UPDATE.md auto-check toggle (config `auto-check-updates`, default on). + var autoCheckUpdates = true var languageChangedToken = 0 /// Completed section collapsed (hidden rows are skipped by ↑/↓). var completedCollapsed = false @@ -222,6 +224,9 @@ final class AppModel { refreshStatusPersistent() flashStatus(i18n.t("statusDatabaseConnected")) reminder?.start() + if let stored = try? await api.get_setting(key: "auto-check-updates") { + autoCheckUpdates = stored != "0" + } autoCheckForUpdates() UpdateService.sweepStaleWorkDirs() } catch { @@ -284,12 +289,23 @@ final class AppModel { /// Silent launch check: once shortly after startup, throttled to one /// attempt per 4 h (config `last-update-check`); failures never nag. func autoCheckForUpdates() { + guard autoCheckUpdates else { return } _Concurrency.Task { try? await _Concurrency.Task.sleep(nanoseconds: 3_000_000_000) await runUpdateCheck(present: false) } } + /// §8: the toggle persists immediately; the manual check ignores it. + func setAutoCheckUpdates(_ on: Bool) { + autoCheckUpdates = on + guard let api else { return } + _Concurrency.Task { + _ = try? await api.set_setting(key: "auto-check-updates", + value: on ? "1" : "0") + } + } + private func runUpdateCheck(present: Bool) async { guard updatePhase != .downloading else { return } if !present { diff --git a/macos-host/Sources/RivetHost/TasklyApp.swift b/macos-host/Sources/RivetHost/TasklyApp.swift index 139f5ae0..010ef51c 100644 --- a/macos-host/Sources/RivetHost/TasklyApp.swift +++ b/macos-host/Sources/RivetHost/TasklyApp.swift @@ -108,6 +108,10 @@ struct TasklyCommands: Commands { model.setTheme("dark") } } + Divider() + Toggle(model.t("settingsAutoCheckUpdates"), isOn: Binding( + get: { model.autoCheckUpdates }, + set: { model.setAutoCheckUpdates($0) })) } } diff --git a/racket/taskly/backend.rkt b/racket/taskly/backend.rkt index 3066f584..283ef29e 100644 --- a/racket/taskly/backend.rkt +++ b/racket/taskly/backend.rkt @@ -210,7 +210,10 @@ ;; Host-declared install shape (Windows: msi vs portable zip; Linux: ;; appimage vs deb/rpm vs tarball) — the updater picks the download ;; flavor from it (UPDATE.md). - ("install-flavor" ("zip" "msi" "appimage" "package" "tarball")))) + ("install-flavor" ("zip" "msi" "appimage" "package" "tarball")) + ;; UPDATE.md auto-check toggle: silent launch/4h checks skip when "0"; + ;; the manual Check for Updates… item always runs. + ("auto-check-updates" ("0" "1")))) ;; Integer-as-string per DATA-FORMAT §7: sidebar selection and the ;; update-check throttle (shared/spec/UPDATE.md) persist as plain integers. diff --git a/racket/taskly/config.rkt b/racket/taskly/config.rkt index 3606974e..70d9dff5 100644 --- a/racket/taskly/config.rkt +++ b/racket/taskly/config.rkt @@ -71,7 +71,7 @@ (for ([key (in-list '("last-db-path" "last_db_path" "language" "last-selected-list-id" "theme" "close-to-tray" "last-update-check" - "rollout-bucket"))]) + "rollout-bucket" "auto-check-updates"))]) (define value (config-ref config key #f)) (when value (fprintf out "~a = ~a\n" key value)))))) diff --git a/racket/tests/backend-test.rkt b/racket/tests/backend-test.rkt index de139a5c..9c3d5a5c 100644 --- a/racket/tests/backend-test.rkt +++ b/racket/tests/backend-test.rkt @@ -39,6 +39,14 @@ (set_setting "last-selected-list-id" "7") (check-equal? (get_setting "last-selected-list-id") "7") + ;; UPDATE.md auto-check toggle: round-trips as a string enum, rejects + ;; anything but "0"/"1", and survives the config writer's key list. + (set_setting "auto-check-updates" "0") + (check-equal? (get_setting "auto-check-updates") "0") + (check-equal? (config-ref (read-config) "auto-check-updates") "0") + (set_setting "auto-check-updates" "1") + (check-exn exn:fail? (lambda () (set_setting "auto-check-updates" "off"))) + ;; open_database remembers the opened file (DATA-FORMAT §7) so hosts ;; restart against it — the 0.2.1 OTA regression: manually opened DBs ;; were forgotten because nothing ever wrote last-db-path. diff --git a/shared/i18n/en.json b/shared/i18n/en.json index d19d0b2b..9323b7a8 100644 --- a/shared/i18n/en.json +++ b/shared/i18n/en.json @@ -135,5 +135,6 @@ "aboutChangelog": "Changelog", "aboutCopyDiagnostics": "Copy Diagnostics", "statusDiagnosticsCopied": "Diagnostics copied — paste it into your issue", + "settingsAutoCheckUpdates": "Automatically Check for Updates", "updateCopyError": "Copy error" } diff --git a/shared/i18n/zh.json b/shared/i18n/zh.json index c287cc06..a5aacae5 100644 --- a/shared/i18n/zh.json +++ b/shared/i18n/zh.json @@ -135,5 +135,6 @@ "aboutChangelog": "更新日志", "aboutCopyDiagnostics": "复制诊断信息", "statusDiagnosticsCopied": "已复制诊断信息,可直接粘贴到 issue", + "settingsAutoCheckUpdates": "自动检查更新", "updateCopyError": "复制错误信息" } diff --git a/shared/spec/DATA-FORMAT.md b/shared/spec/DATA-FORMAT.md index f6694331..d7652a96 100644 --- a/shared/spec/DATA-FORMAT.md +++ b/shared/spec/DATA-FORMAT.md @@ -113,6 +113,7 @@ sides trimmed; keys case-insensitive. Writer emits two fixed header lines: | `theme` | `system` / `light` / `dark`; Windows applies it before the first window paint | `system` | | `last-selected-list-id` | int as string; unparseable → 0 | `0` | | `last-update-check` | unix seconds of the last auto-update check (UPDATE.md throttle) | `0` | +| `auto-check-updates` | `1`/`0` — whether the silent update checks run (UPDATE.md) | `1` | ## 8. Conformance diff --git a/shared/spec/UPDATE.md b/shared/spec/UPDATE.md index 41961fdf..20cdb57c 100644 --- a/shared/spec/UPDATE.md +++ b/shared/spec/UPDATE.md @@ -15,6 +15,11 @@ manual `检查更新… / Check for Updates…` menu item (family menu convention: Help menu on Windows/Linux, App menu below About on macOS) that bypasses the throttle. +- **Auto-check toggle**: config key `auto-check-updates`, `"1"` (default) + or `"0"`, exposed as a checkable 自动检查更新 / Automatically Check for + Updates item in the Settings menu. When `"0"`, the silent launch and + periodic checks skip; the manual item always checks and is unaffected + by the toggle or the throttle. - **Silent mode**: network failure / rate limit → silently ignore (never nag). Manual mode reports every outcome. - **Newer version found** (semver compare, downgrades ignored) → dialog diff --git a/windows/MainWindow.Update.cpp b/windows/MainWindow.Update.cpp index ce851a1e..17eb04ac 100644 --- a/windows/MainWindow.Update.cpp +++ b/windows/MainWindow.Update.cpp @@ -167,6 +167,9 @@ void MainWindow::RunSilentUpdateCheck() { if (IsDevCopy()) { return; // dev copies never phone home (UPDATE.md silent mode) } + if (!auto_check_updates_) { + return; // UPDATE.md auto-check toggle: silent checks skip + } // Host-side throttle: at most one silent check per 4 h, persisted as the // `last-update-check` setting (unix epoch seconds, shared with the other // platforms' hosts). Manual checks bypass this entirely. @@ -763,3 +766,40 @@ winrt::fire_and_forget MainWindow::ShowUpdateDialog( } } // namespace winrt::RivetHost::implementation + +void MainWindow::LoadAutoCheckUpdatesSetting() { + auto const weak = get_weak(); + auto const dispatcher = DispatcherQueue(); + auto backend = backend_; + if (backend == nullptr || !backend->running()) { + return; + } + try { + rivet_app::API api(*backend); + (void)api.get_setting_async( + "auto-check-updates", + [dispatcher, weak](rivet_app::Result result) { + bool on = true; + try { + on = result.get() != "0"; // missing or unreadable → default on + } catch (...) { + } + dispatcher.TryEnqueue([weak, on] { + if (auto window = weak.get()) { + window->auto_check_updates_ = on; + window->MenuAutoCheckUpdates().IsChecked(on); + } + }); + }); + } catch (...) { + // Silent: the toggle defaults to on until the backend answers. + } +} + +void MainWindow::OnAutoCheckUpdatesToggled( + winrt::Windows::Foundation::IInspectable const&, + Microsoft::UI::Xaml::RoutedEventArgs const&) { + auto_check_updates_ = MenuAutoCheckUpdates().IsChecked(); + SaveSettingAsync("auto-check-updates", + auto_check_updates_ ? "1" : "0"); +} diff --git a/windows/MainWindow.xaml b/windows/MainWindow.xaml index 1447efc0..9073ff3d 100644 --- a/windows/MainWindow.xaml +++ b/windows/MainWindow.xaml @@ -70,6 +70,8 @@ + +