From ebe64ff348df16065f8fa6dfca7b50f96a6e9ae2 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sun, 17 May 2026 00:41:01 -0400 Subject: [PATCH 1/2] Show notification when Syncthing has alerts Syncthing's web UI shows 5 types of alerts: * A reminder to restart the service when changing certain config values (currently only options related to audit log files). * New remote devices attempting to connect. * Remote devices attempting to share new folders. * Errors reported by slogutil.ErrorRecorder. * Errors when watching folders for changes. This commit adds support for detecting all of those except for the 4th one. Unfortunately, there is no unified way to handle all of these so alert type needs its own event handler. It is too painful to add support for retrieving errors from slogutil.ErrorRecorder since the entire slogutil package is private. On the UI side, things are intentionally kept very basic. The Android notification just shows how many Syncthing alerts there are, but not the nature of the alerts. When the user taps on it, it will open Syncthing's web UI. Signed-off-by: Andrew Gunnerson --- .../com/chiller3/basicsync/Notifications.kt | 43 ++++++ .../basicsync/syncthing/SyncthingService.kt | 17 +++ app/src/main/res/values/strings.xml | 9 ++ stbridge/stbridge.go | 138 ++++++++++++++++-- 4 files changed, 198 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/chiller3/basicsync/Notifications.kt b/app/src/main/java/com/chiller3/basicsync/Notifications.kt index 5c3e4fe..2fa7506 100644 --- a/app/src/main/java/com/chiller3/basicsync/Notifications.kt +++ b/app/src/main/java/com/chiller3/basicsync/Notifications.kt @@ -26,12 +26,14 @@ class Notifications(private val context: Context) { private const val CHANNEL_ID_PERSISTENT = "persistent" private const val CHANNEL_ID_FAILURE = "failure" private const val CHANNEL_ID_CONFLICTS = "conflicts" + private const val CHANNEL_ID_ALERTS = "alerts" private val LEGACY_CHANNEL_IDS = arrayOf() const val ID_PERSISTENT = -1 private const val ID_FAILURE = -2 private const val ID_CONFLICTS = -3 + private const val ID_ALERTS = -4 } private val notificationManager = context.getSystemService(NotificationManager::class.java) @@ -62,6 +64,14 @@ class Notifications(private val context: Context) { description = context.getString(R.string.notification_channel_conflicts_desc) } + private fun createSyncthingAlertsChannel() = NotificationChannel( + CHANNEL_ID_ALERTS, + context.getString(R.string.notification_channel_alerts_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.notification_channel_alerts_desc) + } + /** * Ensure notification channels are up-to-date. * @@ -72,6 +82,7 @@ class Notifications(private val context: Context) { createPersistentChannel(), createFailureAlertsChannel(), createConflictsAlertsChannel(), + createSyncthingAlertsChannel(), )) LEGACY_CHANNEL_IDS.forEach { notificationManager.deleteNotificationChannel(it) } } @@ -240,4 +251,36 @@ class Notifications(private val context: Context) { notificationManager.notify(ID_CONFLICTS, notification) } + + fun sendOrClearAlertsNotification(alertCount: Int) { + if (alertCount == 0) { + notificationManager.cancel(ID_ALERTS) + return + } + + val notification = Notification.Builder(context, CHANNEL_ID_ALERTS).run { + setContentTitle(context.resources.getQuantityString( + R.plurals.notification_syncthing_alerts_title, + alertCount, + alertCount, + )) + setSmallIcon(R.drawable.ic_notifications) + setOnlyAlertOnce(true) + + val intent = Intent(context, WebUiActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + setContentIntent(pendingIntent) + + build() + } + + notificationManager.notify(ID_ALERTS, notification) + } } diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt index 9318f8b..7d69950 100644 --- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt +++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt @@ -228,6 +228,15 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener notifications.sendOrClearConflictsNotification(conflicts) } } + @GuardedBy("stateLock") + private var syncthingAlerts = 0 + set(count) { + if (field != count) { + field = count + + notifications.sendOrClearAlertsNotification(count) + } + } private val isResumed: Boolean @GuardedBy("stateLock") @@ -577,6 +586,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener deviceStateTracker.updateConnectedDevices(0) syncthingConflicts = emptyList() + syncthingAlerts = 0 syncthingApp = null stateChanged() @@ -599,6 +609,13 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener } } + @WorkerThread + override fun onAlertsUpdated(count: Int) { + synchronized(stateLock) { + syncthingAlerts = count + } + } + @WorkerThread override fun onBusyFoldersUpdated(count: Int) { deviceStateTracker.updateBusyFolders(count) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2a2bc8..5250b29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,6 +226,10 @@ Sync conflicts Alerts for conflicts encountered during syncing + + Syncthing alerts + + Device/folder requests and other alerts reported by Syncthing Syncthing is running @@ -251,6 +255,11 @@ %d sync conflict found %d sync conflicts found + + + %d Syncthing alert + %d Syncthing alerts + Auto mode diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go index e9dacae..56c0baf 100644 --- a/stbridge/stbridge.go +++ b/stbridge/stbridge.go @@ -12,6 +12,7 @@ import ( "context" "crypto/x509" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -68,14 +69,14 @@ func cleanOldFiles() { for glob, dur := range globs { entries, err := configFs.Glob(glob) if err != nil { - log.Printf("Failed to match glob: %q: %w", glob, err) + log.Printf("Failed to match glob: %q: %v", glob, err) continue } for _, entry := range entries { info, err := configFs.Lstat(entry) if err != nil { - log.Printf("Failed to stat config: %q: %w", entry, err) + log.Printf("Failed to stat config: %q: %v", entry, err) continue } @@ -85,11 +86,11 @@ func cleanOldFiles() { } if err = configFs.RemoveAll(entry); err != nil { - log.Printf("Failed to delete old config: %q: %w", entry, err) + log.Printf("Failed to delete old config: %q: %v", entry, err) continue } - log.Printf("Deleted old config: %q: %w", entry, err) + log.Printf("Deleted old config: %q: %v", entry, err) } } } @@ -226,7 +227,7 @@ func tryPreserveGuiHostPort(c *config.Configuration) error { guiHost, guiPort, err := guiHostPort(c) if err != nil { - log.Printf("Resetting GUI address: %w", err) + log.Printf("Resetting GUI address: %v", err) guiHost = "127.0.0.1" guiPort = defaultPort } @@ -293,6 +294,8 @@ type SyncthingStatusReceiver interface { // paths on Linux. OnConflictsUpdated(paths0Sep string) + OnAlertsUpdated(count int32) + OnBusyFoldersUpdated(count int32) OnConnectedDevicesUpdated(count int32) @@ -331,6 +334,32 @@ func dispatchConflicts( receiver.OnConflictsUpdated(paths0Sep.String()) } +// The only type of alerts we currently don't track are those associated +// slogutil.ErrorRecorder because it's a pain to deal with more internal types. +type alertsInfo struct { + needsRestart bool + pendingDevices map[string]struct{} + pendingFolders map[string]struct{} + watcherErrors map[string]string +} + +func dispatchAlerts( + alertsInfo *alertsInfo, + receiver SyncthingStatusReceiver, +) { + count := 0 + + if alertsInfo.needsRestart { + count += 1 + } + + count += len(alertsInfo.pendingDevices) + count += len(alertsInfo.pendingFolders) + count += len(alertsInfo.watcherErrors) + + receiver.OnAlertsUpdated(int32(count)) +} + // The scanning states are intentionally excluded because we only want mutating // operations to interrupt the idle timer. var busyEvents = []string{ @@ -368,8 +397,10 @@ func eventLoop( ctx context.Context, stopped chan struct{}, evLogger events.Logger, + cfgWrapper config.Wrapper, folderConflicts map[string]map[string]struct{}, folderToPath map[string]string, + alertsInfo *alertsInfo, receiver SyncthingStatusReceiver, ) { defer close(stopped) @@ -377,6 +408,9 @@ func eventLoop( sub := evLogger.Subscribe( events.LocalChangeDetected | events.RemoteChangeDetected | + events.PendingDevicesChanged | + events.PendingFoldersChanged | + events.FolderWatchStateChanged | events.StateChanged | events.DeviceConnected | events.DeviceDisconnected | @@ -415,6 +449,75 @@ func eventLoop( dispatchConflicts(folderConflicts, folderToPath, receiver) + case events.PendingDevicesChanged: + if data, ok := evt.Data.(map[string][]interface{}); ok { + for _, device := range data["added"] { + deviceID := device.(map[string]string)["deviceID"] + + alertsInfo.pendingDevices[deviceID] = struct{}{} + } + } else if data, ok := evt.Data.(map[string]interface{}); ok { + devices := data["removed"].([]map[string]string) + + for _, device := range devices { + deviceID := device["deviceID"] + + delete(alertsInfo.pendingDevices, deviceID) + } + } + + dispatchAlerts(alertsInfo, receiver) + + case events.PendingFoldersChanged: + data := evt.Data.(map[string]interface{}) + + if added, ok := data["added"]; ok { + // This is ugly and slow, but better than using + // unsafe.Pointer to cast to model.updatedPendingFolder. + rawJson, err := json.Marshal(added) + if err != nil { + log.Printf("Failed to serialize JSON: %+v: %v", added, err) + continue + } + + folders := []map[string]interface{}{} + err = json.Unmarshal(rawJson, &folders) + if err != nil { + log.Printf("Failed to deserialize JSON: %q: %v", string(rawJson), err) + continue + } + + for _, folder := range folders { + folderID := folder["folderID"].(string) + + alertsInfo.pendingFolders[folderID] = struct{}{} + } + } + + if removed, ok := data["removed"]; ok { + folders := removed.([]map[string]string) + + for _, folder := range folders { + folderID := folder["folderID"] + + delete(alertsInfo.pendingFolders, folderID) + } + } + + dispatchAlerts(alertsInfo, receiver) + + case events.FolderWatchStateChanged: + data := evt.Data.(map[string]interface{}) + folderID := data["folder"].(string) + + if errMsg, ok := data["to"]; ok { + alertsInfo.watcherErrors[folderID] = errMsg.(string) + } else { + delete(alertsInfo.watcherErrors, folderID) + } + + dispatchAlerts(alertsInfo, receiver) + case events.StateChanged: data := evt.Data.(map[string]interface{}) folder := data["folder"].(string) @@ -467,11 +570,17 @@ func eventLoop( dispatchConflicts(folderConflicts, folderToPath, receiver) dispatchBusyFolders(folderStates, receiver) - // Unlike folders, we don't need to remove deleted devices from // devicesConnected. We'll always receive a disconnection event // when connections are closed during deletion. + // A config.Wrapper is needed to determine if a restart is + // required. config.Configuration does not contain enough info. + // We do not need to worry about TOCTOU here because once the + // flag is set, it cannot ever be unset. + alertsInfo.needsRestart = cfgWrapper.RequiresRestart() + dispatchAlerts(alertsInfo, receiver) + default: log.Printf("Unexpected event: %+v", evt) } @@ -519,12 +628,23 @@ func startEventLoop( dispatchConflicts(folderConflicts, folderToPath, receiver) + alertsInfo := alertsInfo{ + needsRestart: cfg.RequiresRestart(), + pendingDevices: map[string]struct{}{}, + pendingFolders: map[string]struct{}{}, + watcherErrors: map[string]string{}, + } + + dispatchAlerts(&alertsInfo, receiver) + go eventLoop( ctx, stopped, evLogger, + cfg, folderConflicts, folderToPath, + &alertsInfo, receiver, ) @@ -576,7 +696,7 @@ func Run(startup *SyncthingStartupConfig) error { // possible. This intentionally does not use c.ProbeFreePorts() because // that always resets the listen addresses for the sync protocol. if err = tryPreserveGuiHostPort(c); err != nil { - log.Printf("Failed to set GUI listen address: %w", err) + log.Printf("Failed to set GUI listen address: %v", err) } // Try to prevent users from locking themselves out. @@ -720,7 +840,7 @@ func tryAtomicSwap(path1 string, path2 string) error { return err } - log.Printf("Using non-atomic rename because RENAME_EXCHANGE is not supported: %w", err) + log.Printf("Using non-atomic rename because RENAME_EXCHANGE is not supported: %v", err) swapDir, err := os.MkdirTemp(filepath.Dir(path2), "swap") if err != nil { @@ -766,7 +886,7 @@ func ImportConfiguration(fd int, name string, password string) error { } defer func() { if err := os.RemoveAll(tempDir); err != nil { - log.Printf("failed to delete: %q: %w", tempDir, err) + log.Printf("failed to delete: %q: %v", tempDir, err) } }() From d842418b9668baaabdbf18ebaa112280d6b7cf0c Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sun, 17 May 2026 01:15:08 -0400 Subject: [PATCH 2/2] stbridge: Refactor conflicts state to a separate type Signed-off-by: Andrew Gunnerson --- stbridge/stbridge.go | 58 +++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go index 56c0baf..dfa63c8 100644 --- a/stbridge/stbridge.go +++ b/stbridge/stbridge.go @@ -313,15 +313,19 @@ func isConflict(name string) bool { return strings.Contains(filepath.Base(name), ".sync-conflict-") } +type conflictsInfo struct { + byFolder map[string]map[string]struct{} + folderPaths map[string]string +} + func dispatchConflicts( - folderConflicts map[string]map[string]struct{}, - folderToPath map[string]string, + conflictsInfo *conflictsInfo, receiver SyncthingStatusReceiver, ) { var paths0Sep strings.Builder - for folder, names := range folderConflicts { - folderPath := folderToPath[folder] + for folder, names := range conflictsInfo.byFolder { + folderPath := conflictsInfo.folderPaths[folder] for name, _ := range names { if paths0Sep.Len() > 0 { @@ -398,8 +402,7 @@ func eventLoop( stopped chan struct{}, evLogger events.Logger, cfgWrapper config.Wrapper, - folderConflicts map[string]map[string]struct{}, - folderToPath map[string]string, + conflictsInfo *conflictsInfo, alertsInfo *alertsInfo, receiver SyncthingStatusReceiver, ) { @@ -439,15 +442,15 @@ func eventLoop( } if data["action"] == "deleted" { - delete(folderConflicts[folderID], path) + delete(conflictsInfo.byFolder[folderID], path) } else { - if _, ok := folderConflicts[folderID]; !ok { - folderConflicts[folderID] = map[string]struct{}{} + if _, ok := conflictsInfo.byFolder[folderID]; !ok { + conflictsInfo.byFolder[folderID] = map[string]struct{}{} } - folderConflicts[folderID][path] = struct{}{} + conflictsInfo.byFolder[folderID][path] = struct{}{} } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchConflicts(conflictsInfo, receiver) case events.PendingDevicesChanged: if data, ok := evt.Data.(map[string][]interface{}); ok { @@ -549,26 +552,26 @@ func eventLoop( case events.ConfigSaved: cfg := evt.Data.(config.Configuration) - clear(folderToPath) + clear(conflictsInfo.folderPaths) for _, folder := range cfg.Folders { // Never fails on Android. - folderToPath[folder.ID], _ = fs.ExpandTilde(folder.Path) + conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path) } - for key := range folderConflicts { - if _, ok := folderToPath[key]; !ok { - delete(folderConflicts, key) + for key := range conflictsInfo.byFolder { + if _, ok := conflictsInfo.folderPaths[key]; !ok { + delete(conflictsInfo.byFolder, key) } } for key := range folderStates { - if _, ok := folderToPath[key]; !ok { + if _, ok := conflictsInfo.folderPaths[key]; !ok { delete(folderStates, key) } } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchConflicts(conflictsInfo, receiver) dispatchBusyFolders(folderStates, receiver) // Unlike folders, we don't need to remove deleted devices from // devicesConnected. We'll always receive a disconnection event @@ -599,8 +602,10 @@ func startEventLoop( allLocalFiles allLocalFilesFunc, receiver SyncthingStatusReceiver, ) error { - folderConflicts := map[string]map[string]struct{}{} - folderToPath := map[string]string{} + conflictsInfo := conflictsInfo{ + byFolder: map[string]map[string]struct{}{}, + folderPaths: map[string]string{}, + } // Find the initial set of conflicts from the database before starting the // service. Any newly added or deleted conflicts will be reported by the @@ -613,20 +618,20 @@ func startEventLoop( continue } - if _, ok := folderConflicts[folder.ID]; !ok { - folderConflicts[folder.ID] = map[string]struct{}{} + if _, ok := conflictsInfo.byFolder[folder.ID]; !ok { + conflictsInfo.byFolder[folder.ID] = map[string]struct{}{} } - folderConflicts[folder.ID][dbFile.Name] = struct{}{} + conflictsInfo.byFolder[folder.ID][dbFile.Name] = struct{}{} } if err := errFn(); err != nil { return fmt.Errorf("failed to query database for: %q: %w", folder.ID, err) } // Never fails on Android. - folderToPath[folder.ID], _ = fs.ExpandTilde(folder.Path) + conflictsInfo.folderPaths[folder.ID], _ = fs.ExpandTilde(folder.Path) } - dispatchConflicts(folderConflicts, folderToPath, receiver) + dispatchConflicts(&conflictsInfo, receiver) alertsInfo := alertsInfo{ needsRestart: cfg.RequiresRestart(), @@ -642,8 +647,7 @@ func startEventLoop( stopped, evLogger, cfg, - folderConflicts, - folderToPath, + &conflictsInfo, &alertsInfo, receiver, )