From b4bb66cb1a749bd9b8126cbb1dc56e03470537f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 19:28:26 +0000 Subject: [PATCH] feat: make app updates user-configurable - add a persisted auto-update setting that defaults to enabled when unset - show a General settings control for automatic vs manual updates - keep auto-apply behavior for automatic mode and surface a toolbar update CTA in manual mode - add updater tests and regenerate Wails event bindings for update availability Co-authored-by: Aram --- .../wailsapp/wails/v3/internal/eventcreate.js | 10 +- .../wailsapp/wails/v3/internal/eventdata.d.ts | 4 + .../components/settings/general-settings.tsx | 23 +++- frontend/src/components/update-status.tsx | 80 ++++++++++++++ frontend/src/routes/__root.tsx | 6 +- frontend/src/stores/settings-store.ts | 17 +++ internal/settings/service.go | 1 + internal/updater/updater.go | 100 +++++++++++++++-- internal/updater/updater_test.go | 101 ++++++++++++++++++ main.go | 59 +++++++++- 10 files changed, 384 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/update-status.tsx create mode 100644 internal/updater/updater_test.go diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js index 9788106..9b7d854 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js @@ -6,6 +6,9 @@ // @ts-ignore: Unused imports import { Create as $Create } from "@wailsio/runtime"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as updater$0 from "../../../../focusd-so/focusd/internal/updater/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as usage$0 from "../../../../focusd-so/focusd/internal/usage/models.js"; @@ -13,12 +16,15 @@ import * as usage$0 from "../../../../focusd-so/focusd/internal/usage/models.js" function configure() { Object.freeze(Object.assign($Create.Events, { "protection:status": $$createType0, - "usage:update": $$createType1, + "update:available": $$createType2, + "usage:update": $$createType3, })); } // Private type creation functions const $$createType0 = usage$0.ProtectionPause.createFrom; -const $$createType1 = usage$0.ApplicationUsage.createFrom; +const $$createType1 = updater$0.UpdateInfo.createFrom; +const $$createType2 = $Create.Nullable($$createType1); +const $$createType3 = usage$0.ApplicationUsage.createFrom; configure(); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 8f0949d..829b2b8 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -5,6 +5,9 @@ // @ts-ignore: Unused imports import type { Events } from "@wailsio/runtime"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import type * as updater$0 from "../../../../focusd-so/focusd/internal/updater/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import type * as usage$0 from "../../../../focusd-so/focusd/internal/usage/models.js"; @@ -14,6 +17,7 @@ declare module "@wailsio/runtime" { interface CustomEvents { "authctx:updated": any; "protection:status": usage$0.ProtectionPause; + "update:available": updater$0.UpdateInfo | null; "usage:update": usage$0.ApplicationUsage; } } diff --git a/frontend/src/components/settings/general-settings.tsx b/frontend/src/components/settings/general-settings.tsx index eaddec1..48feeb0 100644 --- a/frontend/src/components/settings/general-settings.tsx +++ b/frontend/src/components/settings/general-settings.tsx @@ -5,7 +5,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { SettingsKey } from "../../../bindings/github.com/focusd-so/focusd/internal/settings/models"; export function GeneralSettings() { - const { idleThreshold, historyRetention, distractionAllowance, updateSetting } = useSettingsStore(); + const { idleThreshold, historyRetention, distractionAllowance, autoUpdate, updateSetting } = useSettingsStore(); return (
@@ -18,6 +18,27 @@ export function GeneralSettings() { +
+
+ +
+ Install new versions automatically or show a manual update prompt. +
+
+ +
+
diff --git a/frontend/src/components/update-status.tsx b/frontend/src/components/update-status.tsx new file mode 100644 index 0000000..cb67f79 --- /dev/null +++ b/frontend/src/components/update-status.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { IconDownload } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { useSettingsStore } from "@/stores/settings-store"; +import { ApplyUpdate, GetPendingUpdate, RefreshPendingUpdate } from "../../bindings/github.com/focusd-so/focusd/internal/updater/service"; +import type { UpdateInfo } from "../../bindings/github.com/focusd-so/focusd/internal/updater/models"; + +export function UpdateStatus() { + const autoUpdate = useSettingsStore((state) => state.autoUpdate); + const [pendingUpdate, setPendingUpdate] = useState(null); + const [isApplying, setIsApplying] = useState(false); + + useEffect(() => { + if (autoUpdate) { + setPendingUpdate(null); + return; + } + + let active = true; + const handlePendingUpdate = (event: Event) => { + if (!active) { + return; + } + + setPendingUpdate((event as CustomEvent).detail ?? null); + }; + + window.addEventListener("update:available", handlePendingUpdate); + + const loadPendingUpdate = async () => { + try { + const currentPending = await GetPendingUpdate(); + if (active) { + setPendingUpdate(currentPending); + } + + const latestPending = await RefreshPendingUpdate(); + if (active) { + setPendingUpdate(latestPending); + } + } catch (error) { + console.error("Failed to fetch pending update:", error); + } + }; + + void loadPendingUpdate(); + + return () => { + active = false; + window.removeEventListener("update:available", handlePendingUpdate); + }; + }, [autoUpdate]); + + if (autoUpdate || pendingUpdate == null) { + return null; + } + + const handleApplyUpdate = async () => { + try { + setIsApplying(true); + await ApplyUpdate(); + } catch (error) { + console.error("Failed to apply update:", error); + setIsApplying(false); + } + }; + + return ( + + ); +} diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index b5b4241..0508e70 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { useOnboardingStore } from "@/stores/onboarding-store"; import { useEffect } from "react"; import { StartObserver, EnableLoginItem } from "../../bindings/github.com/focusd-so/focusd/internal/native/nativeservice"; import { AccountStatus } from "@/components/account-status"; +import { UpdateStatus } from "@/components/update-status"; const routeTitles: Record = { "/activity": "Smart Blocking", @@ -81,7 +82,10 @@ function RootLayout() { )}
- +
+ + +
diff --git a/frontend/src/stores/settings-store.ts b/frontend/src/stores/settings-store.ts index 4d19c20..ade66db 100644 --- a/frontend/src/stores/settings-store.ts +++ b/frontend/src/stores/settings-store.ts @@ -13,6 +13,7 @@ interface SettingsState { idleThreshold: string; historyRetention: string; distractionAllowance: string; + autoUpdate: boolean; customRulesHistory: Settings[]; isLoading: boolean; error: string | null; @@ -23,12 +24,21 @@ interface SettingsState { getSettingValue: (key: string) => string | undefined; } +function parseBooleanSetting(value: string | undefined, fallback: boolean) { + if (value == null || value === "") { + return fallback; + } + + return value.toLowerCase() !== "false"; +} + export const useSettingsStore = create()((set, get) => ({ settings: [], customRules: "", idleThreshold: "120", // default 120 historyRetention: "7", // default 7 distractionAllowance: "0", // default 0 / unlimited + autoUpdate: true, customRulesHistory: [], isLoading: false, error: null, @@ -49,6 +59,10 @@ export const useSettingsStore = create()((set, get) => ({ const distractionAllowance = settings?.find((s) => s.key === SettingsKey.SettingsKeyDistractionAllowance) ?.value || "0"; + const autoUpdate = parseBooleanSetting( + settings?.find((s) => s.key === SettingsKey.SettingsKeyAutoUpdate)?.value, + true + ); set({ settings: settings || [], @@ -56,6 +70,7 @@ export const useSettingsStore = create()((set, get) => ({ idleThreshold, historyRetention, distractionAllowance, + autoUpdate, isLoading: false }); } catch (error) { @@ -90,6 +105,8 @@ export const useSettingsStore = create()((set, get) => ({ set({ historyRetention: value }); } else if (key === SettingsKey.SettingsKeyDistractionAllowance) { set({ distractionAllowance: value }); + } else if (key === SettingsKey.SettingsKeyAutoUpdate) { + set({ autoUpdate: parseBooleanSetting(value, true) }); } // Refresh to get the updated version from backend diff --git a/internal/settings/service.go b/internal/settings/service.go index 946afeb..9f720de 100644 --- a/internal/settings/service.go +++ b/internal/settings/service.go @@ -14,6 +14,7 @@ const ( SettingsKeyIdleThreshold SettingsKey = "idle_threshold" SettingsKeyHistoryRetention SettingsKey = "history_retention" SettingsKeyDistractionAllowance SettingsKey = "distraction_allowance" + SettingsKeyAutoUpdate SettingsKey = "auto_update" ) type Settings struct { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 8480c86..619f5d1 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/Masterminds/semver/v3" @@ -31,9 +32,17 @@ type Service struct { currentVersion *semver.Version repo selfupdate.RepositorySlug source *selfupdate.GitHubSource + autoUpdate func() bool + onPending func(*UpdateInfo) + + checkForUpdateFn func(context.Context) (*UpdateInfo, error) + applyUpdateFn func(context.Context) error + + pendingMu sync.RWMutex + pendingUpdate *UpdateInfo } -func NewService(version, owner, repo string) *Service { +func NewService(version, owner, repo string, autoUpdate func() bool, onPending func(*UpdateInfo)) *Service { source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) if err != nil { slog.Error("failed to create GitHub source for updater", "error", err) @@ -46,11 +55,17 @@ func NewService(version, owner, repo string) *Service { return nil } - return &Service{ + service := &Service{ currentVersion: v, repo: selfupdate.NewRepositorySlug(owner, repo), source: source, + autoUpdate: autoUpdate, + onPending: onPending, } + service.checkForUpdateFn = service.checkForUpdate + service.applyUpdateFn = service.checkAndApply + + return service } func (s *Service) GetCurrentVersion() string { @@ -60,6 +75,10 @@ func (s *Service) GetCurrentVersion() string { // CheckForUpdate queries GitHub for the latest release and returns update info // if a newer version is available. func (s *Service) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) { + return s.checkForUpdate(ctx) +} + +func (s *Service) checkForUpdate(ctx context.Context) (*UpdateInfo, error) { rel, _, err := s.findLatestRelease(ctx) if err != nil { return nil, err @@ -73,12 +92,26 @@ func (s *Service) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) { }, nil } -// ApplyUpdate checks for and applies the latest update immediately (used by -// the manual "Check for Updates" tray menu item). -func (s *Service) ApplyUpdate(ctx context.Context) { - if err := s.checkAndApply(ctx); err != nil { - slog.Error("manual update failed", "error", err) +func (s *Service) GetPendingUpdate() *UpdateInfo { + s.pendingMu.RLock() + defer s.pendingMu.RUnlock() + + return cloneUpdateInfo(s.pendingUpdate) +} + +func (s *Service) RefreshPendingUpdate(ctx context.Context) (*UpdateInfo, error) { + info, err := s.checkForUpdateFn(ctx) + if err != nil { + return nil, err } + + s.setPendingUpdate(info) + return info, nil +} + +// ApplyUpdate checks for and applies the latest update immediately. +func (s *Service) ApplyUpdate(ctx context.Context) error { + return s.applyUpdateFn(ctx) } // Start runs the silent background update loop. It checks for updates after an @@ -92,8 +125,8 @@ func (s *Service) Start(ctx context.Context) { } for { - if err := s.checkAndApply(ctx); err != nil { - slog.Error("auto-update check failed", "error", err) + if err := s.runScheduledCheck(ctx); err != nil { + slog.Error("scheduled update check failed", "error", err) } select { @@ -104,6 +137,16 @@ func (s *Service) Start(ctx context.Context) { } } +func (s *Service) runScheduledCheck(ctx context.Context) error { + if s.shouldAutoUpdate() { + s.setPendingUpdate(nil) + return s.applyUpdateFn(ctx) + } + + _, err := s.RefreshPendingUpdate(ctx) + return err +} + func (s *Service) checkAndApply(ctx context.Context) error { slog.Info("checking for updates", "current", s.currentVersion) @@ -155,6 +198,45 @@ func (s *Service) checkAndApply(ctx context.Context) error { return relaunch(appPath) } +func (s *Service) shouldAutoUpdate() bool { + if s.autoUpdate == nil { + return true + } + + return s.autoUpdate() +} + +func (s *Service) setPendingUpdate(info *UpdateInfo) { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + + if updateInfoEqual(s.pendingUpdate, info) { + return + } + + s.pendingUpdate = cloneUpdateInfo(info) + if s.onPending != nil { + s.onPending(cloneUpdateInfo(info)) + } +} + +func cloneUpdateInfo(info *UpdateInfo) *UpdateInfo { + if info == nil { + return nil + } + + copy := *info + return © +} + +func updateInfoEqual(a, b *UpdateInfo) bool { + if a == nil || b == nil { + return a == b + } + + return a.Version == b.Version && a.ReleaseNotes == b.ReleaseNotes +} + // findLatestRelease returns the newest non-draft, non-prerelease release that // is newer than the current version and contains a Focusd.zip asset. Returns // nil, nil, nil when already up to date. diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..876c7d1 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,101 @@ +package updater + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunScheduledCheckAutoUpdateAppliesAndClearsPending(t *testing.T) { + var applied int + var emitted []*UpdateInfo + + service := &Service{ + autoUpdate: func() bool { return true }, + onPending: func(info *UpdateInfo) { + emitted = append(emitted, cloneUpdateInfo(info)) + }, + applyUpdateFn: func(context.Context) error { + applied++ + return nil + }, + pendingUpdate: &UpdateInfo{Version: "v1.2.3", ReleaseNotes: "pending"}, + } + + err := service.runScheduledCheck(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, applied) + require.Nil(t, service.GetPendingUpdate()) + require.Len(t, emitted, 1) + require.Nil(t, emitted[0]) +} + +func TestRunScheduledCheckManualModeCachesPendingUpdate(t *testing.T) { + var applied int + var emitted []*UpdateInfo + expected := &UpdateInfo{Version: "v1.2.3", ReleaseNotes: "notes"} + + service := &Service{ + autoUpdate: func() bool { return false }, + onPending: func(info *UpdateInfo) { + emitted = append(emitted, cloneUpdateInfo(info)) + }, + checkForUpdateFn: func(context.Context) (*UpdateInfo, error) { + return expected, nil + }, + applyUpdateFn: func(context.Context) error { + applied++ + return nil + }, + } + + err := service.runScheduledCheck(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, applied) + require.Equal(t, expected, service.GetPendingUpdate()) + require.Equal(t, []*UpdateInfo{expected}, emitted) +} + +func TestRefreshPendingUpdateDeduplicatesEvents(t *testing.T) { + var emitted int + expected := &UpdateInfo{Version: "v1.2.3", ReleaseNotes: "notes"} + + service := &Service{ + onPending: func(*UpdateInfo) { + emitted++ + }, + checkForUpdateFn: func(context.Context) (*UpdateInfo, error) { + return expected, nil + }, + } + + _, err := service.RefreshPendingUpdate(context.Background()) + require.NoError(t, err) + _, err = service.RefreshPendingUpdate(context.Background()) + require.NoError(t, err) + + require.Equal(t, 1, emitted) + require.Equal(t, expected, service.GetPendingUpdate()) +} + +func TestRefreshPendingUpdateClearsPendingState(t *testing.T) { + var emitted []*UpdateInfo + + service := &Service{ + onPending: func(info *UpdateInfo) { + emitted = append(emitted, cloneUpdateInfo(info)) + }, + checkForUpdateFn: func(context.Context) (*UpdateInfo, error) { + return nil, nil + }, + pendingUpdate: &UpdateInfo{Version: "v1.2.3", ReleaseNotes: "notes"}, + } + + info, err := service.RefreshPendingUpdate(context.Background()) + require.NoError(t, err) + require.Nil(t, info) + require.Nil(t, service.GetPendingUpdate()) + require.Len(t, emitted, 1) + require.Nil(t, emitted[0]) +} diff --git a/main.go b/main.go index 2328ce9..cdae9af 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -55,6 +56,7 @@ func init() { application.RegisterEvent[usage.ApplicationUsage]("usage:update") application.RegisterEvent[usage.ProtectionPause]("protection:status") application.RegisterEvent[any]("authctx:updated") + application.RegisterEvent[*updater.UpdateInfo]("update:available") } // main function serves as the application's entry point. It initializes the application, creates a window, @@ -195,9 +197,38 @@ func main() { } }) + isAutoUpdateEnabled := func() bool { + setting, err := settingsService.GetLatest(settings.SettingsKeyAutoUpdate) + if err != nil { + slog.Error("failed to read auto-update setting", "error", err) + return true + } + if setting == nil { + return true + } + + enabled, err := strconv.ParseBool(setting.Value) + if err != nil { + slog.Warn("invalid auto-update setting, falling back to enabled", "value", setting.Value, "error", err) + return true + } + + return enabled + } + var updaterService *updater.Service if isProductionBuild { - updaterService = updater.NewService(Version, "focusd-so", "focusd") + updaterService = updater.NewService( + Version, + "focusd-so", + "focusd", + isAutoUpdateEnabled, + func(info *updater.UpdateInfo) { + if wailsAppPtr != nil { + wailsAppPtr.Event.Emit("update:available", info) + } + }, + ) } nativeService := native.NewNativeService() @@ -318,7 +349,25 @@ func main() { if updaterService != nil { menu.Add("Check for Updates...").OnClick(func(_ *application.Context) { go func() { - info, err := updaterService.CheckForUpdate(ctx) + if isAutoUpdateEnabled() { + info, err := updaterService.CheckForUpdate(ctx) + if err != nil { + slog.Error("manual update check failed", "error", err) + return + } + if info == nil { + slog.Info("manual update check: already up to date") + return + } + + slog.Info("manual update check: update available, applying", "version", info.Version) + if err := updaterService.ApplyUpdate(ctx); err != nil { + slog.Error("manual update failed", "error", err) + } + return + } + + info, err := updaterService.RefreshPendingUpdate(ctx) if err != nil { slog.Error("manual update check failed", "error", err) return @@ -327,8 +376,10 @@ func main() { slog.Info("manual update check: already up to date") return } - slog.Info("manual update check: update available, applying", "version", info.Version) - updaterService.ApplyUpdate(ctx) + + slog.Info("manual update check: update available", "version", info.Version) + window.Show() + window.Focus() }() }) }