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()
}()
})
}