From 7dfb57ab2a12f881e44a0c106b95d0d9e842da6b Mon Sep 17 00:00:00 2001 From: IlyaZar Date: Thu, 27 Aug 2026 16:44:31 +0200 Subject: [PATCH 1/6] fix: reconcile persistent service state - keep runtime and startup choices independent - surface silent service enablement drift --- Panel.qml | 9 ++- Service.qml | 32 ++++++++ config/settings.toml | 4 + core/InstallationController.qml | 55 ++++++++++++- core/SettingsController.qml | 35 ++++++++- models/ServiceStateModel.js | 93 ++++++++++++++++++++++ models/SettingsModel.js | 40 ++++++---- scripts/syncthing-install.sh | 7 ++ scripts/syncthing-settings.sh | 109 ++++++++++++++++++++++---- tests-service-state.qml | 69 +++++++++++++++++ tests/run.qml | 132 +++++++++++++++++++++++++++++++- tests/scripts.test.sh | 77 +++++++++++++++++++ tests/service-state.test.sh | 56 ++++++++++++++ ui/ServiceStateDialog.qml | 124 ++++++++++++++++++++++++++++++ ui/SyncthingPanelPopup.qml | 41 +++++++++- 15 files changed, 840 insertions(+), 43 deletions(-) create mode 100644 models/ServiceStateModel.js create mode 100644 tests-service-state.qml create mode 100755 tests/service-state.test.sh create mode 100644 ui/ServiceStateDialog.qml diff --git a/Panel.qml b/Panel.qml index d3989ff..03e8642 100644 --- a/Panel.qml +++ b/Panel.qml @@ -101,7 +101,9 @@ Panel { readonly property string visibleNotice: syncthing ? syncthing.folderMutationNotice || syncthing.settingsNotice : "" readonly property string visibleWarning: syncthing - ? syncthing.recoveryWarning : "" + ? syncthing.recoveryWarning || syncthing.serviceStateWarning : "" + readonly property bool serviceStateDialogOpen: syncthing + && syncthing.serviceStateDrift readonly property string visibleSyncActivity: syncthing ? syncthing.syncActivity : "" readonly property string visibleSyncDots: syncthing @@ -136,6 +138,10 @@ Panel { copyToClipboard(folderId, "Folder ID copied") } + function chooseServiceStateAction(index) { + if (syncthing) syncthing.chooseServiceStateAction(index) + } + function configureService() { if (!syncthing) return syncthing.setRefreshInterval(setting("refreshIntervalSec", 60)) @@ -533,6 +539,7 @@ Panel { root.addSubmissionPending = false } } + } Process { diff --git a/Service.qml b/Service.qml index e580ad6..747e4f1 100644 --- a/Service.qml +++ b/Service.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import "core" +import "models/ServiceStateModel.js" as ServiceStateModel QtObject { id: root @@ -55,6 +56,26 @@ QtObject { readonly property bool settingsBusy: settings.busy readonly property string settingsError: settings.error readonly property string settingsNotice: settings.notice + readonly property bool settingsReady: settings.settingsReady + readonly property string configuredServiceState: settings.serviceState + readonly property string serviceActiveState: installation.serviceActiveState + readonly property string serviceUnitFileState: installation.unitFileState + readonly property var serviceStateDecision: ServiceStateModel.decision( + configuredServiceState, serviceUnitFileState, serviceActiveState) + readonly property bool serviceStateDrift: settingsReady && serviceAvailable + && serviceStateDecision.status === "drift" + readonly property bool serviceStateActionRunning: + settings.serviceStateActionRunning || installation.unitFileActionRunning + readonly property string serviceStateMessage: serviceStateDrift + ? serviceStateDecision.message : "" + readonly property string serviceStatePrimaryLabel: serviceStateDrift + ? serviceStateDecision.first.label : "" + readonly property string serviceStateSecondaryLabel: serviceStateDrift + ? serviceStateDecision.second.label : "" + readonly property string serviceStateWarning: settingsReady && serviceAvailable + && serviceStateDecision.status === "unsupported" + && serviceUnitFileState !== "not-found" + ? serviceStateDecision.reason : "" property string _apiKey: "" property bool _useTls: false @@ -220,6 +241,17 @@ QtObject { settings.clearNotice() } + function chooseServiceStateAction(index) { + if (!serviceStateDrift || serviceStateActionRunning) return false + var action = index === 0 + ? serviceStateDecision.first : serviceStateDecision.second + if (action.side === "config") return settings.setServiceState(action.value) + if (action.side === "system") { + return installation.setUnitFileState(action.value) + } + return false + } + function requestSelfRemoval(deletePluginSettings) { settings.requestSelfRemoval(deletePluginSettings) } diff --git a/config/settings.toml b/config/settings.toml index 195cb27..02d84d9 100644 --- a/config/settings.toml +++ b/config/settings.toml @@ -5,3 +5,7 @@ version = 1 icon_style = "branded" # "default" vs. "omarchy": Syncthing styling vs. following the active Omarchy theme web_ui_theme = "omarchy" + +[service] +# Persistent Syncthing user-service state, independent of the runtime toggle +service_state = "enabled" diff --git a/core/InstallationController.qml b/core/InstallationController.qml index 53f5ba8..58e53db 100644 --- a/core/InstallationController.qml +++ b/core/InstallationController.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import "../models/ServiceStateModel.js" as ServiceStateModel QtObject { id: root @@ -12,6 +13,8 @@ QtObject { property string executablePath: "" property bool serviceAvailable: false property bool serviceRunning: false + property string serviceActiveState: "inactive" + property string unitFileState: "not-found" property bool operationRunning: false property bool refreshing: false property bool packageActionRunning: false @@ -19,7 +22,10 @@ QtObject { property string packageError: "" property string controlError: "" property int _desiredServiceState: -1 + property string _desiredUnitFileState: "" + property string _controlKind: "" property bool _operationSeen: false + property bool _statusRefreshPending: false property string _statusOutput: "" property string _statusErrorOutput: "" property string _controlErrorOutput: "" @@ -27,6 +33,8 @@ QtObject { readonly property bool serviceActive: _desiredServiceState === -1 ? serviceRunning : _desiredServiceState === 1 readonly property bool serviceActionRunning: controlProcess.running + readonly property bool unitFileActionRunning: + _controlKind === "unit-file" || _desiredUnitFileState !== "" readonly property bool canUseRuntime: state === "existing" && executablePath !== "" readonly property bool canControlService: canUseRuntime && serviceAvailable @@ -41,7 +49,10 @@ QtObject { signal serviceStarted function updateStatus() { - if (statusProcess.running) return + if (statusProcess.running) { + _statusRefreshPending = true + return + } refreshing = true packageError = "" _statusOutput = "" @@ -67,8 +78,13 @@ QtObject { executablePath = String(data.executable || "") serviceAvailable = data.serviceAvailable === true serviceRunning = data.serviceRunning === true + serviceActiveState = String(data.serviceActiveState || "") + unitFileState = String(data.unitFileState || "") operationRunning = data.operationRunning === true - reconcileDesiredServiceState() + if (!_statusRefreshPending) { + reconcileDesiredServiceState() + reconcileDesiredUnitFileState() + } reconcilePackageOperation() if (!canUseRuntime) runtimeUnavailable(state) else if (serviceAvailable && !serviceActive) runtimeUnavailable("stopped") @@ -88,6 +104,19 @@ QtObject { } } + function reconcileDesiredUnitFileState() { + if (_desiredUnitFileState === "") return + if (unitFileState === _desiredUnitFileState) { + _desiredUnitFileState = "" + } else if (!controlProcess.running) { + var desired = _desiredUnitFileState + _desiredUnitFileState = "" + controlError = desired === "enabled" + ? "Syncthing service was not enabled" + : "Syncthing service was not disabled" + } + } + function reconcilePackageOperation() { if (packageActionRunning && operationRunning) _operationSeen = true if (!packageActionRunning || !_operationSeen || operationRunning) return @@ -113,6 +142,7 @@ QtObject { if (!canControlService || controlProcess.running || folderMutationBusy) return var start = !serviceActive _desiredServiceState = start ? 1 : 0 + _controlKind = "runtime" controlError = "" _controlErrorOutput = "" controlProcess.command = [ @@ -123,6 +153,19 @@ QtObject { controlProcess.running = true } + function setUnitFileState(state) { + var command = ServiceStateModel.persistenceCommand(String(state || "")) + if (!canControlService || controlProcess.running || folderMutationBusy + || command.length === 0) return false + _desiredUnitFileState = String(state) + _controlKind = "unit-file" + controlError = "" + _controlErrorOutput = "" + controlProcess.command = command + controlProcess.running = true + return true + } + property Timer statusTimer: Timer { interval: 15000 repeat: true @@ -165,6 +208,7 @@ QtObject { onStreamFinished: root._statusErrorOutput = text } onExited: function(exitCode) { + var rerun = root._statusRefreshPending root.refreshing = false if (exitCode === 0) { root.applyStatus(root._statusOutput) @@ -172,6 +216,8 @@ QtObject { root.packageError = String(root._statusErrorOutput || "Could not check Syncthing installation").trim() } + root._statusRefreshPending = false + if (rerun) Qt.callLater(root.updateStatus) } } @@ -183,13 +229,16 @@ QtObject { onStreamFinished: root._controlErrorOutput = text } onExited: function(exitCode) { + var kind = root._controlKind if (exitCode !== 0) { root.controlError = String(root._controlErrorOutput || "Could not change Syncthing service state").trim() root._desiredServiceState = -1 - } else if (root.serviceActive) { + root._desiredUnitFileState = "" + } else if (kind === "runtime" && root._desiredServiceState === 1) { root.serviceStarted() } + root._controlKind = "" root.updateStatus() } } diff --git a/core/SettingsController.qml b/core/SettingsController.qml index fa65e4d..0998ae8 100644 --- a/core/SettingsController.qml +++ b/core/SettingsController.qml @@ -28,6 +28,7 @@ QtObject { property bool settingsExists: false property string iconStyle: SettingsModel.DefaultIconStyle property string webUiTheme: SettingsModel.DefaultWebUiTheme + property string serviceState: SettingsModel.DefaultServiceState property string currentWebUiTheme: "" property string guiAssetsPath: "" property string error: "" @@ -39,8 +40,13 @@ QtObject { property bool _reconcileAgain: false property string _themeBeforeGeneration: "" property bool _openAfterEnsure: false + property string _settingsAction: "" property bool _deleteSettingsAfterRemoval: false + readonly property bool settingsReady: _settingsLoaded && _settingsValid + readonly property bool serviceStateActionRunning: + settingsProcess.running && _settingsAction === "service-state" + function localPath(url) { var value = String(url || "") if (value.indexOf("file://") === 0) value = value.slice(7) @@ -59,6 +65,7 @@ QtObject { _settingsValid = true iconStyle = parsed.iconStyle webUiTheme = parsed.webUiTheme + serviceState = parsed.serviceState error = "" scheduleReconcile() } @@ -70,6 +77,7 @@ QtObject { _settingsValid = true iconStyle = values.iconStyle webUiTheme = values.webUiTheme + serviceState = values.serviceState error = "" scheduleReconcile() } @@ -88,6 +96,7 @@ QtObject { } if (settingsProcess.running) return _openAfterEnsure = true + _settingsAction = "ensure" busy = true settingsProcess.command = [ "bash", settingsHelperPath, "ensure", settingsTemplatePath, @@ -96,6 +105,21 @@ QtObject { settingsProcess.running = true } + function setServiceState(state) { + var desired = String(state || "") + if (["enabled", "disabled"].indexOf(desired) < 0 + || !settingsReady || busy || settingsProcess.running) return false + _settingsAction = "service-state" + busy = true + error = "" + settingsProcess.command = [ + "bash", settingsHelperPath, "set-service-state", settingsTemplatePath, + settingsPath, iconStyle, desired + ] + settingsProcess.running = true + return true + } + function clearNotice() { notice = "" } @@ -258,6 +282,7 @@ QtObject { id: settingsProcess command: [] onExited: function(exitCode) { + var action = root._settingsAction root.busy = false if (exitCode === 0) { settingsFile.reload() @@ -266,8 +291,16 @@ QtObject { "omarchy", "launch", "config-editor", root.settingsPath ]) } - } else root.error = "Could not create Syncthing plugin settings" + if (action === "service-state") { + root.notice = "Syncthing service preference updated" + } + } else { + root.error = action === "service-state" + ? "Could not update Syncthing service preference" + : "Could not create Syncthing plugin settings" + } root._openAfterEnsure = false + root._settingsAction = "" } } diff --git a/models/ServiceStateModel.js b/models/ServiceStateModel.js new file mode 100644 index 0000000..abc7425 --- /dev/null +++ b/models/ServiceStateModel.js @@ -0,0 +1,93 @@ +.pragma library + +function configuredStateSupported(state) { + return state === "enabled" || state === "disabled" +} + +function unitFileStateSupported(state) { + return state === "enabled" || state === "disabled" +} + +function activeStateSupported(state) { + return state === "active" || state === "inactive" +} + +function systemAction(state) { + return { + side: "system", + value: state, + label: state === "enabled" + ? "Enable systemd autostart" : "Disable systemd autostart" + } +} + +function configAction(state) { + return { + side: "config", + value: state, + label: "Set Syncthing config to " + state + } +} + +function markPreferred(action, preferredState) { + return { + side: action.side, + value: action.value, + preferred: action.value === preferredState, + label: action.label + (action.value === preferredState + ? " (preferred)" : "") + } +} + +function decision(configState, unitFileState, activeState) { + var config = String(configState || "") + var unit = String(unitFileState || "") + var active = String(activeState || "") + var key = active + "|" + config + "|" + unit + + if (!configuredStateSupported(config)) { + return { + status: "unsupported", + key: key, + reason: "Configured service state is unsupported: " + (config || "empty") + } + } + if (!unitFileStateSupported(unit)) { + return { + status: "unsupported", + key: key, + reason: "Systemd unit-file state is unsupported: " + (unit || "empty") + } + } + if (config === unit) return { status: "aligned", key: key } + if (!activeStateSupported(active)) { + return { + status: "unsupported", + key: key, + reason: "Systemd runtime state is not settled: " + (active || "empty") + } + } + + var preferredState = active === "active" ? "enabled" : "disabled" + var enforceConfig = markPreferred(systemAction(config), preferredState) + var adoptSystem = markPreferred(configAction(unit), preferredState) + var first = enforceConfig.preferred ? enforceConfig : adoptSystem + var second = enforceConfig.preferred ? adoptSystem : enforceConfig + return { + status: "drift", + key: key, + message: "Syncthing config (" + config + + ") differs from its systemd autostart setting (" + unit + + ").\n\nPlease choose:", + first: first, + second: second + } +} + +function persistenceCommand(state) { + if (!configuredStateSupported(state)) return [] + return [ + "systemctl", "--user", state === "enabled" ? "enable" : "disable", + "syncthing.service" + ] +} diff --git a/models/SettingsModel.js b/models/SettingsModel.js index 0cba84d..5825c9f 100644 --- a/models/SettingsModel.js +++ b/models/SettingsModel.js @@ -2,6 +2,7 @@ var DefaultIconStyle = "branded" var DefaultWebUiTheme = "omarchy" +var DefaultServiceState = "enabled" var SupportedVersion = 1 function stripComment(line) { @@ -35,8 +36,8 @@ function parse(raw) { var lines = String(raw || "").split("\n") var section = "" var version = null - var structured = false var styleSectionSeen = false + var serviceSectionSeen = false var rootStyleSetting = false for (var i = 0; i < lines.length; i++) { @@ -44,16 +45,18 @@ function parse(raw) { if (!line) continue var header = line.match(/^\[([A-Za-z_][A-Za-z0-9_-]*)\]$/) if (header) { - structured = true section = header[1] - if (section !== "style") { + if (section !== "style" && section !== "service") { return { error: "Unknown settings section " + section + " on line " + (i + 1) } } - if (styleSectionSeen) { - return { error: "Duplicate settings section style on line " + (i + 1) } + if ((section === "style" && styleSectionSeen) + || (section === "service" && serviceSectionSeen)) { + return { error: "Duplicate settings section " + section + + " on line " + (i + 1) } } - styleSectionSeen = true + if (section === "style") styleSectionSeen = true + else serviceSectionSeen = true continue } var assignment = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/) @@ -62,7 +65,6 @@ function parse(raw) { } var key = assignment[1] if (key === "version" && !section) { - structured = true if (version !== null) { return { error: "Duplicate setting version on line " + (i + 1) } } @@ -72,13 +74,14 @@ function parse(raw) { } continue } - if (key !== "icon_style" && key !== "web_ui_theme") { + var styleSetting = key === "icon_style" || key === "web_ui_theme" + var serviceSetting = key === "service_state" + if ((section === "service" && !serviceSetting) + || (section !== "service" && !styleSetting)) { return { error: "Unknown setting " + (section ? section + "." : "") + key + " on line " + (i + 1) } } - if (!section) { - rootStyleSetting = true - } + if (!section && styleSetting) rootStyleSetting = true if (values[key] !== undefined) { return { error: "Duplicate setting " + key + " on line " + (i + 1) } } @@ -89,7 +92,7 @@ function parse(raw) { values[key] = value } - if (structured) { + if (version !== null || styleSectionSeen) { if (version === null) return { error: "Missing setting version" } if (version !== SupportedVersion) { return { error: "Unsupported settings version " + version } @@ -112,17 +115,24 @@ function parse(raw) { if (["default", "omarchy"].indexOf(values.web_ui_theme) < 0) { return { error: "web_ui_theme must be default or omarchy" } } - + if (values.service_state === undefined) { + values.service_state = DefaultServiceState + } + if (["enabled", "disabled"].indexOf(values.service_state) < 0) { + return { error: "service_state must be enabled or disabled" } + } return { error: "", iconStyle: values.icon_style, - webUiTheme: values.web_ui_theme + webUiTheme: values.web_ui_theme, + serviceState: values.service_state } } function defaults(legacyThemedIcon) { return { iconStyle: legacyThemedIcon === true ? "themed" : DefaultIconStyle, - webUiTheme: DefaultWebUiTheme + webUiTheme: DefaultWebUiTheme, + serviceState: DefaultServiceState } } diff --git a/scripts/syncthing-install.sh b/scripts/syncthing-install.sh index 3eaf2a5..fd3ba71 100755 --- a/scripts/syncthing-install.sh +++ b/scripts/syncthing-install.sh @@ -37,6 +37,7 @@ operation_running() { detect_status() { local active_state="inactive" executable="" executable_path="" local label="Not installed" load_state="not-found" state="missing" + local unit_file_state="not-found" executable="$(command -v syncthing 2>/dev/null || true)" if [[ -n $executable ]]; then @@ -46,8 +47,10 @@ detect_status() { load_state="$(service_property LoadState)" active_state="$(service_property ActiveState)" + unit_file_state="$(service_property UnitFileState)" [[ -n $load_state ]] || load_state="not-found" [[ -n $active_state ]] || active_state="inactive" + [[ -n $unit_file_state ]] || unit_file_state="not-found" if [[ -n $executable_path ]]; then state="existing" @@ -63,6 +66,8 @@ detect_status() { --arg state "$state" \ --arg label "$label" \ --arg executable "$executable_path" \ + --arg serviceActiveState "$active_state" \ + --arg unitFileState "$unit_file_state" \ --argjson serviceAvailable \ "$([[ $load_state != not-found ]] && echo true || echo false)" \ --argjson serviceRunning \ @@ -75,6 +80,8 @@ detect_status() { executable: $executable, serviceAvailable: $serviceAvailable, serviceRunning: $serviceRunning, + serviceActiveState: $serviceActiveState, + unitFileState: $unitFileState, operationRunning: $operationRunning }' } diff --git a/scripts/syncthing-settings.sh b/scripts/syncthing-settings.sh index a23ae00..d1176bb 100755 --- a/scripts/syncthing-settings.sh +++ b/scripts/syncthing-settings.sh @@ -1,24 +1,33 @@ #!/bin/bash set -euo pipefail +temporary="" + +cleanup() { + [[ -z $temporary ]] || rm -f -- "$temporary" +} + +trap cleanup EXIT + usage() { - printf 'Usage: syncthing-settings.sh ensure