From 4747aa5a933a926561614f5ceefca10e913ecfd8 Mon Sep 17 00:00:00 2001 From: Nick A <60294463+technicks89@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:21:52 -0400 Subject: [PATCH] phase 8 and 9 done --- CHANGELOG.md | 27 ++ TASKS.md | 2 +- .../quickshell/appearance/AppearanceModel.qml | 42 ++- config/quickshell/core/ShellButton.qml | 7 +- .../notifications/NotificationModel.qml | 185 ++++++++++++- .../settings/AppearanceSettingsPane.qml | 172 +++++++++++- .../settings/DisplaySettingsPane.qml | 157 +++++++++-- .../quickshell/settings/InputSettingsPane.qml | 2 +- config/quickshell/settings/SettingsModel.qml | 147 +++++++++- config/quickshell/settings/SettingsWindow.qml | 4 + config/quickshell/shell.qml | 33 +++ docs/SYNC-P8-NOTIFICATIONS.md | 208 -------------- docs/SYNC-P9-DISPLAY-APPLY.md | 202 -------------- docs/UPSTREAM-SYNC.md | 68 ++++- docs/src/settings.md | 21 +- scripts/autostart.sh | 6 +- scripts/dwm-settings-provider | 118 +++++++- tests/test-autostart.sh | 27 +- tests/test-quickshell-appearance-model.sh | 12 +- tests/test-quickshell-large-surfaces-xvfb.sh | 257 +++++++++++++++++- tests/test-quickshell-notifications.sh | 45 ++- tests/test-quickshell-settings-xvfb.sh | 56 ++++ tests/test-settings.sh | 148 +++++++++- 23 files changed, 1454 insertions(+), 492 deletions(-) delete mode 100644 docs/SYNC-P8-NOTIFICATIONS.md delete mode 100644 docs/SYNC-P9-DISPLAY-APPLY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4814e1f..b450aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,33 @@ month) from `config.mk`. A pre-release appends `-alpha.N`, `-beta.N` or automatically only when already resolvable, and every code path degrades cleanly to an explicit unsupported state when it is absent. +- Add a managed notification policy: Do Not Disturb and a configurable popup + duration (4/6/10 seconds), in a new Settings → Appearance → Notifications + section. The existing D-Bus notification owner is never touched -- the + policy gates which popups *display*, not which notifications are + *received*, so history keeps recording everything even while Do Not + Disturb is on. Critical-urgency notifications always show regardless of + the policy. The policy fails closed: an unreadable or malformed policy + file suppresses all non-critical popups rather than defaulting to + "show everything." `dwm-settings-provider`'s `accessibility-notifications` + capability now inspects the real D-Bus owner process (via + `/proc//exe` and its Quickshell config selectors) to confirm it is + actually the managed Lyona shell before reporting `available`, rather + than just checking that some owner exists. + +- Replace the Displays pane's single mode-cycling button with dependent + resolution and refresh-rate dropdowns, and replace immediate mode changes + with an explicit **Apply changes** step: a 15-second countdown, **Keep + changes** to confirm, or **Revert**/timeout/closing Settings to restore the + captured layout automatically. `ShellButton` gains a `primary` visual state + for the Apply/Keep/Use-at-next-login actions. Saved layouts are relabeled + from implementation-oriented wording ("Profile", "Install persistent", + "Rollback system") to "Layout name", "Use at next login", and "Restore + login backup". `AppearanceModel`'s theme-mutation readiness probe now + queues and re-checks itself instead of racing a concurrent action or + refresh, so a refresh that lands while a readiness check or mutation is + already running no longer reports a stale `mutationReady` value. + ### Security - `install-mybash`'s Starship fallback no longer pipes a remote script diff --git a/TASKS.md b/TASKS.md index 67f22e9..e3c86a9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -93,7 +93,7 @@ Acceptance: - [x] Apply reduced-motion and contrast choices consistently to managed Quickshell surfaces without introducing a Wayland, compositor, or polling dependency. -- [ ] Add notification behavior controls that preserve the existing D-Bus owner, +- [x] Add notification behavior controls that preserve the existing D-Bus owner, history lifecycle, urgency semantics, and safe failure isolation. Acceptance: diff --git a/config/quickshell/appearance/AppearanceModel.qml b/config/quickshell/appearance/AppearanceModel.qml index c5eede4..7fee6d8 100644 --- a/config/quickshell/appearance/AppearanceModel.qml +++ b/config/quickshell/appearance/AppearanceModel.qml @@ -11,6 +11,7 @@ Scope { property bool settingsVisible: false property bool busy: false property bool mutationReady: false + property bool mutationReadinessPending: false property string providerState: "idle" property string providerDetail: "Appearance has not been loaded" property string sourceKind: "none" @@ -519,8 +520,13 @@ Scope { } function refreshMutationReadiness() { - if (!readinessProcess.running && !actionProcess.running) - readinessProcess.running = true; + root.mutationReady = false; + if (readinessProcess.running || actionProcess.running) { + root.mutationReadinessPending = true; + return; + } + root.mutationReadinessPending = false; + readinessProcess.running = true; } function refreshWallpaperStatus() { @@ -625,6 +631,7 @@ Scope { root.wallpaperStatusPending = false; root.inventoryPending = false; root.inventoryPendingAllowUnwatched = false; + root.mutationReadinessPending = false; } function nextPreviewToken() { @@ -650,20 +657,23 @@ Scope { } function startPreview(theme) { - if (!root.mutationReady || !root.validThemeName(theme) || root.previewState !== "none" + if (!root.mutationReady || root.mutationReadinessPending + || !root.validThemeName(theme) || root.previewState !== "none" || root.recoveryState !== "none") return; const token = root.nextPreviewToken(); root.runAction("preview", [token, "30", theme], theme, token); } function applyTheme(theme) { - if (!root.mutationReady || !root.validThemeName(theme) || root.previewState !== "none" + if (!root.mutationReady || root.mutationReadinessPending + || !root.validThemeName(theme) || root.previewState !== "none" || root.recoveryState !== "none") return; root.runAction("apply", [theme], theme, ""); } function resetTheme() { - if (!root.mutationReady || root.previewState !== "none" || root.recoveryState !== "none") return; + if (!root.mutationReady || root.mutationReadinessPending + || root.previewState !== "none" || root.recoveryState !== "none") return; root.runAction("reset", [], "", ""); } @@ -1547,7 +1557,15 @@ Scope { command: Commands.booleanStatusCommand(Commands.settingsThemeCommand("mutation-ready", [])) running: false stdout: StdioCollector { - onStreamFinished: root.mutationReady = this.text.trim() === "available" + onStreamFinished: root.mutationReady = !root.mutationReadinessPending + && this.text.trim() === "available" + } + onRunningChanged: { + if (!running && root.mutationReadinessPending && !actionProcess.running) { + root.mutationReady = false; + root.mutationReadinessPending = false; + Qt.callLater(root.refreshMutationReadiness); + } } } @@ -1804,7 +1822,17 @@ Scope { running: false stdout: StdioCollector { onStreamFinished: root.parseActionResult(this.text) } stderr: StdioCollector { onStreamFinished: root.actionError = this.text.trim() } - onRunningChanged: if (!running && root.busy) root.finishAction() + onRunningChanged: { + if (running) return; + if (root.busy) { + root.finishAction(); + return; + } + if (root.mutationReadinessPending) { + root.mutationReadinessPending = false; + Qt.callLater(root.refreshMutationReadiness); + } + } } Process { diff --git a/config/quickshell/core/ShellButton.qml b/config/quickshell/core/ShellButton.qml index a69dbcb..67b4185 100644 --- a/config/quickshell/core/ShellButton.qml +++ b/config/quickshell/core/ShellButton.qml @@ -7,6 +7,7 @@ Rectangle { required property string label property string accessibleDescription: "" property bool danger: false + property bool primary: false property bool compact: true property bool hovered: buttonMouse.containsMouse @@ -20,10 +21,13 @@ Rectangle { Accessible.description: root.accessibleDescription Accessible.onPressAction: root.requestActivation() color: !root.enabled ? Theme.controlDisabledFill + : root.danger ? (root.hovered ? Theme.controlHoverFill : Theme.controlNormalFill) + : root.primary ? (root.hovered ? Theme.accentSecondary : Theme.accent) : root.hovered ? Theme.controlHoverFill : Theme.controlNormalFill - border.color: root.activeFocus ? Theme.controlFocusBorder + border.color: root.activeFocus ? (root.primary ? Theme.textStrong : Theme.controlFocusBorder) : !root.enabled ? Theme.controlDisabledBorder : root.danger ? Theme.danger + : root.primary ? Theme.accent : root.hovered ? Theme.controlHoverBorder : Theme.controlNormalBorder border.width: root.activeFocus ? Theme.controlFocusBorderWidth : Theme.controlBorderWidth radius: Theme.controlRadius @@ -47,6 +51,7 @@ Rectangle { text: root.label color: !root.enabled ? Theme.controlDisabledText : root.danger ? Theme.textStrong + : root.primary ? Theme.accentText : root.hovered ? Theme.controlHoverText : Theme.controlNormalText font.family: Theme.fontFamily font.pixelSize: root.compact ? Theme.fontBodySmallSize : Theme.fontBodySize diff --git a/config/quickshell/notifications/NotificationModel.qml b/config/quickshell/notifications/NotificationModel.qml index 5991c9e..74b8542 100644 --- a/config/quickshell/notifications/NotificationModel.qml +++ b/config/quickshell/notifications/NotificationModel.qml @@ -12,15 +12,132 @@ Scope { property var history: [] property bool historyVisible: false property int sequence: 0 + property bool doNotDisturb: false + property int popupTimeoutMs: 6000 + property string policyState: "loading" + property string policyDetail: "Loading notification policy" + property bool policySaving: false + property bool policyReloadPending: false + property bool confirmedDoNotDisturb: false + property int confirmedPopupTimeoutMs: 6000 - readonly property int popupTimeoutMs: 6000 readonly property int criticalTimeoutMs: 10000 readonly property int maxVisible: 4 readonly property int maxHistory: 50 + readonly property var popupTimeoutOptions: [4000, 6000, 10000] + readonly property bool popupSuppressed: root.policyState === "loading" + || root.policyState === "partial" + || root.policyState === "unavailable" + || root.policySaving || root.doNotDisturb + readonly property bool policyMutationReady: !root.policySaving + && (root.policyState === "available" || root.policyState === "defaults") + readonly property bool policyResetReady: !root.policySaving + && root.policyState !== "loading" + readonly property string homeDir: Quickshell.env("HOME") || "" + readonly property string configuredConfigHome: Quickshell.env("XDG_CONFIG_HOME") || "" + readonly property string configHome: root.configuredConfigHome.startsWith("/") + ? root.configuredConfigHome : root.homeDir + "/.config" + readonly property string configDir: root.configHome + "/lyona" + readonly property string policyPath: root.configDir + "/notification-settings.json" readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (Quickshell.env("HOME") + "/.cache")) + "/lyona" readonly property string historyPath: cacheDir + "/notification-history.json" - Component.onCompleted: Quickshell.execDetached(["mkdir", "-p", cacheDir]) + Component.onCompleted: Quickshell.execDetached(["mkdir", "-p", configDir, cacheDir]) + + function validPopupTimeout(value) { + return root.popupTimeoutOptions.indexOf(value) >= 0; + } + + function usePolicyDefaults() { + root.doNotDisturb = false; + root.popupTimeoutMs = 6000; + } + + function dismissNonCriticalPopups() { + const current = root.notifications.slice(); + for (const item of current) { + if (item.urgencyName !== "critical") root.closeItem(item, false); + } + } + + function applyDoNotDisturb(enabled) { + root.doNotDisturb = enabled; + if (enabled) root.dismissNonCriticalPopups(); + } + + function beginPolicyReload(dismissExisting) { + root.policyState = "loading"; + root.policyDetail = "Reloading notification policy"; + if (dismissExisting !== false) root.dismissNonCriticalPopups(); + policyFile.reload(); + } + + function loadPolicy() { + let policy = null; + try { + policy = JSON.parse(policyFile.text()); + } catch (error) { + root.usePolicyDefaults(); + root.dismissNonCriticalPopups(); + root.policyState = "partial"; + root.policyDetail = "Saved notification policy could not be parsed; safe defaults are active until reset"; + return; + } + if (policy === null || typeof policy !== "object" || Array.isArray(policy) + || policy.version !== 1 || typeof policy.doNotDisturb !== "boolean" + || !root.validPopupTimeout(policy.popupTimeoutMs)) { + root.usePolicyDefaults(); + root.dismissNonCriticalPopups(); + root.policyState = "partial"; + root.policyDetail = "Saved notification policy is invalid; safe defaults are active until reset"; + return; + } + root.applyDoNotDisturb(policy.doNotDisturb); + root.popupTimeoutMs = policy.popupTimeoutMs; + if (root.policySaving) return; + root.confirmedDoNotDisturb = root.doNotDisturb; + root.confirmedPopupTimeoutMs = root.popupTimeoutMs; + root.policyState = "available"; + root.policyDetail = "Managed notification policy is active"; + } + + function savePolicy() { + root.policySaving = true; + root.policyState = "saving"; + root.policyDetail = "Saving notification policy"; + policyFile.setText(JSON.stringify({ + "version": 1, + "doNotDisturb": root.doNotDisturb, + "popupTimeoutMs": root.popupTimeoutMs + }) + "\n"); + } + + function setDoNotDisturb(enabled) { + if (!root.policyMutationReady || root.doNotDisturb === enabled) return; + root.applyDoNotDisturb(enabled); + root.savePolicy(); + } + + function setPopupTimeout(value) { + if (!root.policyMutationReady || !root.validPopupTimeout(value) + || root.popupTimeoutMs === value) return; + root.popupTimeoutMs = value; + root.savePolicy(); + } + + function resetPolicy() { + if (!root.policyResetReady) return; + if ((root.policyState === "available" || root.policyState === "defaults") + && !root.doNotDisturb + && root.popupTimeoutMs === 6000) return; + root.usePolicyDefaults(); + root.savePolicy(); + } + + function policyStatus() { + return "notification-policy\t1\t" + Quickshell.processId + "\t" + + (root.policyMutationReady ? "available" : "unavailable"); + } function urgencyName(urgency) { if (urgency === NotificationUrgency.Critical) { @@ -52,6 +169,12 @@ Scope { }; notification.closed.connect(() => root.remove(item.key)); + root.addHistory(item); + + if (root.popupSuppressed && item.urgencyName !== "critical") { + notification.expire(); + return; + } const existing = root.notifications.filter(n => n.notification && n.notification.id !== notification.id); const candidates = [item].concat(existing); @@ -61,8 +184,6 @@ Scope { for (const overflowItem of overflow) { root.closeItem(overflowItem, false); } - - root.addHistory(item); } function remove(key) { @@ -148,6 +269,62 @@ Scope { root.closeItem(root.notifications.find(n => n.key === key), true); } + FileView { + id: policyFile + + path: root.policyPath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: root.loadPolicy() + onFileChanged: { + if (root.policySaving) root.policyReloadPending = true; + else root.beginPolicyReload(false); + } + onSaved: { + root.confirmedDoNotDisturb = root.doNotDisturb; + root.confirmedPopupTimeoutMs = root.popupTimeoutMs; + root.policySaving = false; + if (root.policyReloadPending) { + root.policyReloadPending = false; + root.policyState = "loading"; + root.policyDetail = "Reloading notification policy"; + Qt.callLater(policyFile.reload); + } else { + root.policyState = "available"; + root.policyDetail = "Managed notification policy is active"; + } + } + onSaveFailed: error => { + root.applyDoNotDisturb(root.confirmedDoNotDisturb); + root.popupTimeoutMs = root.confirmedPopupTimeoutMs; + root.policySaving = false; + if (root.policyReloadPending) { + root.policyReloadPending = false; + root.policyState = "loading"; + root.policyDetail = "Reloading notification policy after save error " + error; + Qt.callLater(policyFile.reload); + } else { + root.policyState = "unavailable"; + root.policyDetail = "Notification policy could not be saved (error " + error + ")"; + } + } + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) { + root.usePolicyDefaults(); + root.policyState = "defaults"; + root.policyDetail = "Default notification policy is active"; + Qt.callLater(root.savePolicy); + } else { + root.applyDoNotDisturb(root.confirmedDoNotDisturb); + root.popupTimeoutMs = root.confirmedPopupTimeoutMs; + root.dismissNonCriticalPopups(); + root.policyState = "unavailable"; + root.policyDetail = "Notification policy could not be loaded"; + } + } + } + FileView { id: historyFile diff --git a/config/quickshell/settings/AppearanceSettingsPane.qml b/config/quickshell/settings/AppearanceSettingsPane.qml index 17df9d6..fde71ce 100644 --- a/config/quickshell/settings/AppearanceSettingsPane.qml +++ b/config/quickshell/settings/AppearanceSettingsPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import QtQuick.Controls as Controls import qs.core pragma ComponentBehavior: Bound @@ -9,6 +10,8 @@ Flickable { required property var appearanceModel required property var accessibilityModel + required property var notificationModel + required property var notificationCapability required property var panelSettingsModel required property var capabilities property string selectedThemeId: "" @@ -33,12 +36,14 @@ Flickable { || root.appearanceModel.wallpaperPreviewActionBusy readonly property bool fontControlsBusy: root.appearanceBusy || root.appearanceModel.fontStatusBusy || root.appearanceModel.wallpaperStatusBusy - // accessibility-contrast and accessibility-reduced-motion now have their - // own dedicated controls above; showing them again as generic read-only - // cards here would be redundant. + // accessibility-contrast, accessibility-reduced-motion, and + // accessibility-notifications now have their own dedicated controls + // above; showing them again as generic read-only cards would be + // redundant. readonly property var additionalCapabilities: root.capabilities.filter(function(capability) { return capability.id !== "accessibility-contrast" - && capability.id !== "accessibility-reduced-motion"; + && capability.id !== "accessibility-reduced-motion" + && capability.id !== "accessibility-notifications"; }) contentWidth: width contentHeight: content.implicitHeight @@ -242,6 +247,45 @@ Flickable { } } + component NotificationTimeoutComboBox: Controls.ComboBox { + id: notificationTimeoutCombo + + readonly property var timeoutValues: root.notificationModel.popupTimeoutOptions + + Layout.preferredWidth: 150 + implicitHeight: Theme.controlHeight + activeFocusOnTab: enabled + model: notificationTimeoutCombo.timeoutValues.map(function(value) { + return (value / 1000) + " seconds"; + }) + currentIndex: notificationTimeoutCombo.timeoutValues.indexOf( + root.notificationModel.popupTimeoutMs) + font.family: Theme.fontFamily + font.pixelSize: Theme.inputFontSize + palette.button: Theme.controlNormalFill + palette.buttonText: Theme.controlNormalText + palette.base: Theme.popupBackground + palette.window: Theme.popupBackground + palette.text: Theme.popupText + palette.highlight: Theme.controlSelectedFill + palette.highlightedText: Theme.controlSelectedText + Accessible.name: "Notification popup duration" + Accessible.description: "Choose how long non-critical notification popups remain visible" + onActivated: index => root.notificationModel.setPopupTimeout( + notificationTimeoutCombo.timeoutValues[index]) + + delegate: Controls.ItemDelegate { + required property var modelData + required property int index + + width: notificationTimeoutCombo.width + text: modelData + font: notificationTimeoutCombo.font + highlighted: notificationTimeoutCombo.highlightedIndex === index + hoverEnabled: notificationTimeoutCombo.hoverEnabled + } + } + ColumnLayout { id: content width: root.width @@ -1084,6 +1128,126 @@ Flickable { } } + SectionLabel { label: "Notifications" } + + UiText { + Layout.fillWidth: true + text: "Do Not Disturb suppresses low and normal urgency popups while retaining history. Critical notifications always remain visible for ten seconds." + color: Theme.menuMutedText + wrapMode: Text.WordWrap + } + + StatusCard { + visible: root.notificationCapability.status !== "available" + || (root.notificationModel.policyState !== "available" + && root.notificationModel.policyState !== "defaults") + label: "Managed notification policy" + statusState: root.notificationCapability.status !== "available" + ? root.notificationCapability.status : root.notificationModel.policyState + value: root.notificationCapability.status === "available" + ? root.notificationModel.policyState : root.notificationCapability.status + detail: root.notificationCapability.status !== "available" + ? root.notificationCapability.detail : root.notificationModel.policyDetail + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.max(64, + notificationToggleContent.implicitHeight + Theme.spacingLg * 2) + color: Theme.controlNormalFill + border.color: Theme.controlNormalBorder + border.width: Theme.controlBorderWidth + radius: Theme.controlRadius + + RowLayout { + id: notificationToggleContent + anchors.fill: parent + anchors.margins: Theme.spacingLg + spacing: Theme.spacingLg + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + UiText { + Layout.fillWidth: true + text: "Do Not Disturb" + color: Theme.controlNormalText + font.bold: true + } + + UiText { + Layout.fillWidth: true + text: "Keep non-critical notifications in history without showing popups." + color: Theme.menuMutedText + wrapMode: Text.WordWrap + } + } + + PanelToggleSwitch { + visible: root.notificationCapability.status === "available" + checked: root.notificationModel.doNotDisturb + enabled: root.notificationCapability.status === "available" + && root.notificationModel.policyMutationReady + accessibleName: "Do Not Disturb" + accessibleDescription: "Suppress non-critical notification popups while preserving history" + onToggled: root.notificationModel.setDoNotDisturb( + !root.notificationModel.doNotDisturb) + } + } + } + + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + UiText { + Layout.fillWidth: true + text: "Popup duration" + color: Theme.controlNormalText + font.bold: true + } + + UiText { + Layout.fillWidth: true + text: "Applies to low and normal urgency notifications." + color: Theme.menuMutedText + wrapMode: Text.WordWrap + } + } + + NotificationTimeoutComboBox { + visible: root.notificationCapability.status === "available" + enabled: root.notificationCapability.status === "available" + && root.notificationModel.policyMutationReady + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.notificationCapability.status === "available" + + UiText { + Layout.fillWidth: true + text: root.notificationModel.policyDetail + color: root.notificationModel.policyState === "unavailable" + ? Theme.danger : Theme.menuMutedText + wrapMode: Text.WordWrap + } + + ShellButton { + visible: root.notificationCapability.status === "available" + label: root.notificationModel.policyState === "unavailable" + ? "Retry notification reset" : "Reset notifications" + enabled: root.notificationCapability.status === "available" + && root.notificationModel.policyResetReady + onActivated: root.notificationModel.resetPolicy() + } + } + SectionLabel { label: "Application status" } Repeater { diff --git a/config/quickshell/settings/DisplaySettingsPane.qml b/config/quickshell/settings/DisplaySettingsPane.qml index 2ed6789..6677749 100644 --- a/config/quickshell/settings/DisplaySettingsPane.qml +++ b/config/quickshell/settings/DisplaySettingsPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import QtQuick.Controls as Controls import qs.core pragma ComponentBehavior: Bound @@ -11,6 +12,38 @@ Flickable { property string profileName: "" property string confirmation: "" + component DisplayComboBox: Controls.ComboBox { + id: comboBox + + required property string accessibleLabel + property string valueSuffix: "" + + implicitHeight: Theme.controlHeight + activeFocusOnTab: enabled + displayText: currentIndex >= 0 ? currentText + valueSuffix : "" + font.family: Theme.fontFamily + font.pixelSize: Theme.inputFontSize + palette.button: Theme.controlNormalFill + palette.buttonText: Theme.controlNormalText + palette.base: Theme.popupBackground + palette.window: Theme.popupBackground + palette.text: Theme.popupText + palette.highlight: Theme.controlSelectedFill + palette.highlightedText: Theme.controlSelectedText + Accessible.name: accessibleLabel + + delegate: Controls.ItemDelegate { + required property var modelData + required property int index + + width: comboBox.width + text: modelData + comboBox.valueSuffix + font: comboBox.font + highlighted: comboBox.highlightedIndex === index + hoverEnabled: comboBox.hoverEnabled + } + } + readonly property var dpiPresets: [ { "label": "100%", "value": 96 }, { "label": "125%", "value": 120 }, @@ -60,7 +93,14 @@ Flickable { } ShellButton { label: "Refresh"; enabled: root.settingsModel.displayState !== "loading"; onActivated: root.settingsModel.refreshDisplays() } - ShellButton { label: "Preview"; enabled: root.settingsModel.displayOutputs.length > 0 && !root.settingsModel.previewOperationLocked; onActivated: root.settingsModel.previewDisplay() } + ShellButton { + label: "Apply changes" + primary: true + enabled: root.settingsModel.displayState === "ready" + && root.settingsModel.displayHasPendingChanges + && !root.settingsModel.previewOperationLocked + onActivated: root.settingsModel.previewDisplay() + } } Rectangle { @@ -78,14 +118,18 @@ Flickable { Text { Layout.fillWidth: true text: root.settingsModel.previewSeconds > 0 - ? "Preview reverts in " + root.settingsModel.previewSeconds + " seconds" - : "Automatic rollback failed. Revert retries the captured layout; Keep accepts the current layout." + ? "Keep these display settings? Reverting in " + root.settingsModel.previewSeconds + " seconds." + : "Could not restore the previous layout automatically. Revert retries it; Keep current accepts this layout." color: Theme.textStrong font.family: Theme.fontFamily font.pixelSize: Theme.bodyFontSize wrapMode: Text.WordWrap } - ShellButton { label: root.settingsModel.previewRollbackFailed ? "Accept current" : "Keep"; onActivated: root.settingsModel.keepPreview(root.profileName.trim()) } + ShellButton { + label: root.settingsModel.previewRollbackFailed ? "Keep current" : "Keep changes" + primary: true + onActivated: root.settingsModel.keepPreview() + } ShellButton { label: "Revert"; danger: true; onActivated: root.settingsModel.revertPreview() } } } @@ -129,7 +173,7 @@ Flickable { Text { Layout.fillWidth: true; text: outputCard.modelData.name; color: Theme.textStrong; font.family: Theme.fontFamily; font.pixelSize: Theme.bodyFontSize; font.bold: true } Text { text: outputCard.modelData.fullCompositionPipeline === "available" - ? "NVIDIA full composition on persistent install" + ? "NVIDIA anti-tearing available at next login" : (outputCard.modelData.tearfree === "available" ? "TearFree available" : "Anti-tearing unsupported") color: Theme.textMuted font.family: Theme.fontFamily @@ -139,10 +183,75 @@ Flickable { ShellButton { label: outputCard.modelData.primary ? "Primary" : "Make primary"; enabled: outputCard.modelData.enabled; onActivated: root.settingsModel.updateDisplay(outputCard.index, "primary", true) } } - RowLayout { + Flow { Layout.fillWidth: true - ShellButton { label: outputCard.modelData.mode + " @ " + outputCard.modelData.rate + " Hz"; enabled: outputCard.modelData.enabled; onActivated: root.settingsModel.cycleDisplayMode(outputCard.index) } + Layout.preferredHeight: implicitHeight + spacing: Theme.tightSpacing + + Row { + spacing: Theme.tightSpacing + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Resolution" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.bodyFontSize + } + DisplayComboBox { + id: resolutionSelector + + width: 180 + accessibleLabel: "Resolution for " + outputCard.modelData.name + enabled: outputCard.modelData.enabled + model: root.settingsModel.displayResolutionChoices(outputCard.index) + currentIndex: root.settingsModel.displayResolutionIndex(outputCard.index) + onActivated: function(index) { + root.settingsModel.setDisplayResolution(outputCard.index, model[index]); + } + } + } + + Row { + spacing: Theme.tightSpacing + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Refresh rate" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.bodyFontSize + } + DisplayComboBox { + id: refreshRateSelector + + readonly property var rateChoices: root.settingsModel.displayRefreshRateChoices(outputCard.index) + + width: 110 + accessibleLabel: "Refresh rate for " + outputCard.modelData.name + enabled: outputCard.modelData.enabled + visible: rateChoices.length > 1 + model: rateChoices + currentIndex: root.settingsModel.displayRefreshRateIndex(outputCard.index) + valueSuffix: " Hz" + onActivated: function(index) { + root.settingsModel.setDisplayRefreshRate(outputCard.index, model[index]); + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: refreshRateSelector.rateChoices.length <= 1 + text: outputCard.modelData.rate + " Hz" + color: outputCard.modelData.enabled ? Theme.textStrong : Theme.controlDisabledText + font.family: Theme.fontFamily + font.pixelSize: Theme.bodyFontSize + } + } ShellButton { label: "Rotation: " + outputCard.modelData.rotation; enabled: outputCard.modelData.enabled; onActivated: root.settingsModel.cycleRotation(outputCard.index) } + } + + RowLayout { + Layout.fillWidth: true Text { text: "X"; color: Theme.textMuted; font.family: Theme.fontFamily; font.pixelSize: Theme.bodyFontSize } Rectangle { Layout.preferredWidth: 60 @@ -157,7 +266,10 @@ Flickable { font.family: Theme.fontFamily font.pixelSize: Theme.inputFontSize validator: IntValidator {} - onEditingFinished: root.settingsModel.updateDisplay(outputCard.index, "x", Number(text)) + onTextEdited: if (acceptableInput) + root.settingsModel.updateDisplay(outputCard.index, "x", Number(text)) + onEditingFinished: if (acceptableInput) + root.settingsModel.updateDisplay(outputCard.index, "x", Number(text)) } Binding { target: xPositionInput @@ -181,7 +293,10 @@ Flickable { font.family: Theme.fontFamily font.pixelSize: Theme.inputFontSize validator: IntValidator {} - onEditingFinished: root.settingsModel.updateDisplay(outputCard.index, "y", Number(text)) + onTextEdited: if (acceptableInput) + root.settingsModel.updateDisplay(outputCard.index, "y", Number(text)) + onEditingFinished: if (acceptableInput) + root.settingsModel.updateDisplay(outputCard.index, "y", Number(text)) } Binding { target: yPositionInput @@ -284,7 +399,7 @@ Flickable { RowLayout { Layout.fillWidth: true - Text { text: "Profile"; color: Theme.textMuted; font.family: Theme.fontFamily; font.pixelSize: Theme.bodyFontSize } + Text { text: "Layout name"; color: Theme.textMuted; font.family: Theme.fontFamily; font.pixelSize: Theme.bodyFontSize } Rectangle { Layout.fillWidth: true Layout.preferredHeight: Math.max(Theme.controlHeight, @@ -292,13 +407,13 @@ Flickable { color: Theme.controlNormalFill; border.color: Theme.controlNormalBorder; radius: Theme.controlRadius TextInput { id: profileNameInput; anchors.fill: parent; anchors.margins: 6; text: root.profileName; color: Theme.textStrong; font.family: Theme.fontFamily; font.pixelSize: Theme.inputFontSize; onTextChanged: root.profileName = text } } - ShellButton { label: "Save profile"; enabled: root.profileName.trim().length > 0; onActivated: root.settingsModel.saveDisplay(root.profileName.trim()) } + ShellButton { label: "Save layout"; enabled: root.profileName.trim().length > 0; onActivated: root.settingsModel.saveDisplay(root.profileName.trim()) } ShellButton { - label: "Install persistent" + label: "Use at next login" enabled: root.settingsModel.displayPersistenceAvailable && root.settingsModel.displayProfiles.indexOf(root.profileName.trim()) >= 0 onActivated: root.confirmation = "install" } - ShellButton { label: "Rollback system"; danger: true; enabled: root.settingsModel.displayPersistenceAvailable; onActivated: root.confirmation = "rollback" } + ShellButton { label: "Restore login backup"; danger: true; enabled: root.settingsModel.displayPersistenceAvailable; onActivated: root.confirmation = "rollback" } } Text { @@ -324,12 +439,13 @@ Flickable { Text { Layout.fillWidth: true text: root.confirmation === "install" - ? "Authorize installation of profile '" + root.profileName + "' to the managed Xorg fragment. A backup will be created." - : "Authorize restoring the newest managed Xorg backup. This affects the next X11 login." + ? "Use saved layout '" + root.profileName + "' automatically at the next login? Administrator approval is required; the previous next-login layout will be backed up." + : "Restore the previous next-login layout? Administrator approval is required. This changes the next login only." color: Theme.textStrong; font.family: Theme.fontFamily; font.pixelSize: Theme.bodyFontSize; wrapMode: Text.WordWrap } ShellButton { - label: "Authorize" + label: root.confirmation === "install" ? "Use at next login" : "Restore backup" + primary: root.confirmation === "install" onActivated: { if (root.confirmation === "install") root.settingsModel.installDisplayProfile(root.profileName.trim()); else root.settingsModel.rollbackDisplaySystem(); @@ -345,7 +461,12 @@ Flickable { spacing: Theme.tightSpacing Repeater { model: root.settingsModel.displayProfiles - delegate: ShellButton { required property string modelData; label: "Preview " + modelData; enabled: !root.settingsModel.previewOperationLocked; onActivated: root.settingsModel.previewDisplayProfile(modelData) } + delegate: ShellButton { + required property string modelData + label: "Try " + modelData + enabled: !root.settingsModel.previewOperationLocked + onActivated: root.settingsModel.previewDisplayProfile(modelData) + } } } @@ -354,7 +475,7 @@ Flickable { delegate: Text { required property var modelData Layout.fillWidth: true - text: "Profile " + modelData.name + ": " + modelData.detail + text: "Saved layout " + modelData.name + ": " + modelData.detail color: Theme.warning font.family: Theme.fontFamily font.pixelSize: Theme.smallFontSize diff --git a/config/quickshell/settings/InputSettingsPane.qml b/config/quickshell/settings/InputSettingsPane.qml index 7cbdcbf..1eedda7 100644 --- a/config/quickshell/settings/InputSettingsPane.qml +++ b/config/quickshell/settings/InputSettingsPane.qml @@ -42,7 +42,7 @@ Flickable { font.family: Theme.fontFamily font.pixelSize: Theme.bodyFontSize } - ShellButton { label: "Keep"; onActivated: root.settingsModel.keepPreview("") } + ShellButton { label: "Keep"; onActivated: root.settingsModel.keepPreview() } ShellButton { label: "Revert"; danger: true; onActivated: root.settingsModel.revertPreview() } } } diff --git a/config/quickshell/settings/SettingsModel.qml b/config/quickshell/settings/SettingsModel.qml index ff67b6f..f435620 100644 --- a/config/quickshell/settings/SettingsModel.qml +++ b/config/quickshell/settings/SettingsModel.qml @@ -34,6 +34,8 @@ Scope { property var displayUnsupportedProfiles: [] property string displayState: "idle" property string displayMessage: "" + property string displayBaseline: "" + property bool displayRefreshPending: false property int displayDpi: 96 property string displayDpiSource: "default" property int displayDpiPersisted: 0 @@ -91,9 +93,11 @@ Scope { return { "status": "restricted", "detail": "Persistent display controls are unavailable" }; } readonly property bool displayPersistenceAvailable: root.displayPersistenceCapability.status === "available" + readonly property bool displayHasPendingChanges: root.displayBaseline.length > 0 + && root.displayLayoutKey(root.displayOutputs) !== root.displayBaseline readonly property var sections: [ - { "id": "displays", "label": "Displays", "description": "Monitors, layouts, and profiles" }, + { "id": "displays", "label": "Displays", "description": "Resolution, refresh rate, and layouts" }, { "id": "input", "label": "Input", "description": "Keyboard, pointer, and touchpad" }, { "id": "network", "label": "Network", "description": "Connections and VPN providers" }, { "id": "bluetooth", "label": "Bluetooth", "description": "Adapters and devices" }, @@ -130,6 +134,13 @@ Scope { }); } + function capabilityById(id) { + for (const capability of root.capabilities) { + if (capability.id === id) return capability; + } + return { "status": "unavailable", "detail": "" }; + } + function watchOwnerArguments() { const stat = watchOwnerStat.text().trim(); const commandEnd = stat.lastIndexOf(") "); @@ -142,6 +153,7 @@ Scope { function activateSection(id) { displayWatchProcess.running = id === "displays" && root.visible; inputWatchProcess.running = id === "input" && root.visible; + notificationOwnerWatchProcess.running = id === "appearance" && root.visible; if (id !== "input") inputSettleTimer.stop(); if (root.networkModel) { const wantNetwork = id === "network" && root.visible; @@ -180,6 +192,7 @@ Scope { if (wantAppearance && !root.appearanceModel.settingsVisible) root.appearanceModel.openSettings(); else if (!wantAppearance && root.appearanceModel.settingsVisible) root.appearanceModel.closeSettings(); } + if (id === "appearance") root.refresh(); if (id === "appearance" && root.accessibilityModel) root.accessibilityModel.refresh(); if (id === "appearance" && root.panelSettingsModel) root.panelSettingsModel.refresh(); if (id === "displays") root.refreshDisplays(); @@ -242,6 +255,7 @@ Scope { } } root.displayOutputs = outputs; + root.displayBaseline = valid ? root.displayLayoutKey(outputs) : ""; root.displayModes = modes; root.displayProfiles = profiles; root.displayUnsupportedProfiles = unsupportedProfiles; @@ -292,16 +306,97 @@ Scope { } } root.displayOutputs = outputs; + root.displayMessage = root.displayLayoutKey(outputs) !== root.displayBaseline + ? "Display changes are ready to apply" : outputs.length + " connected outputs"; } - function cycleDisplayMode(index) { + function displayLayoutKey(outputs) { + return JSON.stringify(outputs.map(function(output) { + return [output.name, output.enabled, output.mode, output.rate, output.x, output.y, + output.rotation, output.primary]; + })); + } + + function displaySizeLabel(modeName) { + const match = /^(\d+)x(\d+)(.*)$/.exec(modeName); + if (!match) return modeName; + const scanVariant = /^([ip])/i.exec(match[3]); + return match[1] + " x " + match[2] + + (scanVariant ? scanVariant[1].toLowerCase() : ""); + } + + function displayResolutionChoices(index) { + const output = root.displayOutputs[index]; + if (!output) return []; + const choices = []; + for (const mode of root.displayModes) { + if (mode.output !== output.name) continue; + const label = root.displaySizeLabel(mode.mode); + if (label.length > 0 && choices.indexOf(label) < 0) choices.push(label); + } + return choices; + } + + function displayResolutionIndex(index) { const output = root.displayOutputs[index]; - const choices = root.displayModes.filter(function(mode) { return mode.output === output.name; }); + if (!output) return -1; + return root.displayResolutionChoices(index).indexOf(root.displaySizeLabel(output.mode)); + } + + function displayRefreshRateChoices(index) { + const output = root.displayOutputs[index]; + if (!output) return []; + const size = root.displaySizeLabel(output.mode); + const choices = []; + for (const mode of root.displayModes) { + if (mode.output !== output.name || root.displaySizeLabel(mode.mode) !== size) continue; + if (choices.indexOf(mode.rate) < 0) choices.push(mode.rate); + } + return choices; + } + + function displayRefreshRateIndex(index) { + const output = root.displayOutputs[index]; + if (!output) return -1; + return root.displayRefreshRateChoices(index).indexOf(output.rate); + } + + function updateDisplayMode(index, mode) { + if (!mode) return; + const outputs = root.displayOutputs.slice(); + const changed = Object.assign({}, outputs[index]); + changed.mode = mode.mode; + changed.rate = mode.rate; + outputs[index] = changed; + root.displayOutputs = outputs; + root.displayMessage = root.displayLayoutKey(outputs) !== root.displayBaseline + ? "Display changes are ready to apply" : outputs.length + " connected outputs"; + } + + function setDisplayResolution(index, size) { + const output = root.displayOutputs[index]; + if (!output) return; + const choices = root.displayModes.filter(function(mode) { + return mode.output === output.name && root.displaySizeLabel(mode.mode) === size; + }); + if (choices.length === 0) return; + const selected = choices.find(function(mode) { return mode.rate === output.rate; }) + || choices.find(function(mode) { return mode.preferred; }) || choices[0]; + root.updateDisplayMode(index, selected); + } + + function setDisplayRefreshRate(index, rate) { + const output = root.displayOutputs[index]; + if (!output) return; + const size = root.displaySizeLabel(output.mode); + const choices = root.displayModes.filter(function(mode) { + return mode.output === output.name && root.displaySizeLabel(mode.mode) === size + && mode.rate === rate; + }); if (choices.length === 0) return; - let selected = choices.findIndex(function(mode) { return mode.mode === output.mode && mode.rate === output.rate; }); - selected = (selected + 1) % choices.length; - root.updateDisplay(index, "mode", choices[selected].mode); - root.updateDisplay(index, "rate", choices[selected].rate); + const selected = choices.find(function(mode) { return mode.mode === output.mode; }) + || choices.find(function(mode) { return mode.preferred; }) || choices[0]; + root.updateDisplayMode(index, selected); } function cycleRotation(index) { @@ -351,8 +446,8 @@ Scope { root.runDisplay("preview-profile", [token, "15", name]); } - function keepPreview(name) { - if (root.previewKind === "display") root.runDisplay("keep", [root.previewToken].concat(name && !root.previewRollbackFailed ? [name] : [])); + function keepPreview() { + if (root.previewKind === "display") root.runDisplay("keep", [root.previewToken]); else if (root.previewKind === "input") root.runInput("keep", [root.previewToken]); } @@ -454,7 +549,12 @@ Scope { } function refreshDisplays() { - if (!root.visible || displayDiscoverProcess.running) return; + if (!root.visible) return; + if (displayDiscoverProcess.running) { + root.displayRefreshPending = true; + return; + } + root.displayRefreshPending = false; root.displayState = "loading"; displayDiscoverProcess.running = true; } @@ -589,10 +689,12 @@ Scope { } } providerProcess.running = false; + root.displayRefreshPending = false; displayDiscoverProcess.running = false; inputDiscoverProcess.running = false; displayWatchProcess.running = false; inputWatchProcess.running = false; + notificationOwnerWatchProcess.running = false; if (root.networkModel) root.networkModel.closeSettings(); if (root.bluetoothModel) root.bluetoothModel.closeSettings(); if (root.controlsModel) root.controlsModel.closeSettings(); @@ -643,6 +745,15 @@ Scope { running: false stdout: StdioCollector { onStreamFinished: root.parseDisplays(this.text) } stderr: StdioCollector { onStreamFinished: { const error = this.text.trim(); if (error) { root.displayState = "failure"; root.displayMessage = error; } } } + onRunningChanged: { + if (!running && root.displayRefreshPending && root.visible) { + root.displayRefreshPending = false; + Qt.callLater(function() { + if (root.visible && !displayDiscoverProcess.running) + root.refreshDisplays(); + }); + } + } } Process { @@ -667,6 +778,13 @@ Scope { stdout: SplitParser { onRead: inputSettleTimer.restart() } } + Process { + id: notificationOwnerWatchProcess + command: Commands.settingsProviderCommand("watch-notifications", []) + running: false + stdout: SplitParser { onRead: notificationOwnerSettleTimer.restart() } + } + Process { id: displayActionProcess running: false @@ -794,4 +912,13 @@ Scope { root.refreshInput(); } } + + Timer { + id: notificationOwnerSettleTimer + interval: 100 + onTriggered: { + if (root.visible && root.selectedSectionId === "appearance") + root.refresh(); + } + } } diff --git a/config/quickshell/settings/SettingsWindow.qml b/config/quickshell/settings/SettingsWindow.qml index 936fea9..ee91f36 100644 --- a/config/quickshell/settings/SettingsWindow.qml +++ b/config/quickshell/settings/SettingsWindow.qml @@ -18,6 +18,7 @@ FloatingWindow { required property var autostartModel required property var appearanceModel required property var accessibilityModel + required property var notificationModel required property var panelSettingsModel title: "dwm settings" @@ -379,6 +380,9 @@ FloatingWindow { visible: root.settingsModel.selectedSectionId === "appearance" appearanceModel: root.appearanceModel accessibilityModel: root.accessibilityModel + notificationModel: root.notificationModel + notificationCapability: root.settingsModel.capabilityById( + "accessibility-notifications") panelSettingsModel: root.panelSettingsModel capabilities: root.settingsModel.capabilitiesForSection("appearance") .filter(function(capability) { diff --git a/config/quickshell/shell.qml b/config/quickshell/shell.qml index 61c4145..12ea66f 100644 --- a/config/quickshell/shell.qml +++ b/config/quickshell/shell.qml @@ -450,6 +450,34 @@ ShellRoot { return notificationModel.historyLatestSummary(); } + function doNotDisturb(): bool { + return notificationModel.doNotDisturb; + } + + function popupTimeout(): int { + return notificationModel.popupTimeoutMs; + } + + function policyState(): string { + return notificationModel.policyState; + } + + function policyStatus(): string { + return notificationModel.policyStatus(); + } + + function resetPolicy(): void { + notificationModel.resetPolicy(); + } + + function setDoNotDisturb(enabled: bool): void { + notificationModel.setDoNotDisturb(enabled); + } + + function setPopupTimeout(timeoutMs: int): void { + notificationModel.setPopupTimeout(timeoutMs); + } + function openHistory(): void { notificationModel.openHistory(); } @@ -765,6 +793,10 @@ ShellRoot { return appearanceModel.mutationReady; } + function appearanceRefresh(): void { + appearanceModel.refreshAll(); + } + function appearancePreviewState(): string { return appearanceModel.previewState; } @@ -1030,6 +1062,7 @@ ShellRoot { autostartModel: autostartModel appearanceModel: appearanceModel accessibilityModel: accessibilityModel + notificationModel: notificationModel panelSettingsModel: panelSettingsModel } } diff --git a/docs/SYNC-P8-NOTIFICATIONS.md b/docs/SYNC-P8-NOTIFICATIONS.md deleted file mode 100644 index d35502a..0000000 --- a/docs/SYNC-P8-NOTIFICATIONS.md +++ /dev/null @@ -1,208 +0,0 @@ -# Sync Phase 8 — managed notification policy - -Upstream: [`#204`](https://github.com/ChrisTitusTech/dwm-titus/pull/204) -`feat: add managed notification policy` (`ad47c20`, +1239 / -86). -Depends on Phase 4 (accessibility capability records, done — see `CHANGELOG.md`). -Index: [`UPSTREAM-SYNC.md`](UPSTREAM-SYNC.md). - -Do-not-disturb, popup timeout, and per-urgency suppression — while preserving the -existing D-Bus notification owner. - -## Context - -Lyona's `config/quickshell/notifications/NotificationModel.qml` has **no policy layer -at all**: no do-not-disturb, no timeout control, no suppression. Popups fire -unconditionally. This phase is purely additive to that file. - -`TASKS.md:96` is the open checkbox: - -> - [ ] Add notification behavior controls that preserve the existing D-Bus owner, … - -The "preserve the existing D-Bus owner" clause is the constraint that shapes the -whole change. Quickshell claims `org.freedesktop.Notifications` at startup; a policy -that required re-registering would drop notifications during every save. Upstream's -design keeps the owner untouched and gates *display*, not *reception*. - -## Files - -| File | Upstream | Note | -| --- | --- | --- | -| `config/quickshell/notifications/NotificationModel.qml` | +185 | The policy layer | -| `config/quickshell/settings/AppearanceSettingsPane.qml` | +165 | New "Notifications" group | -| `config/quickshell/settings/SettingsModel.qml` | +19 | Owner watch + section wiring | -| `config/quickshell/settings/SettingsWindow.qml` | +4 | Pane wiring | -| `config/quickshell/shell.qml` | +29 | IPC probes for the xvfb tests | -| `scripts/dwm-settings-provider` | +118 | `watch-notifications`, owner capability | -| `scripts/autostart.sh` | +6 | Seed the policy directory before the shell starts | -| `tests/test-quickshell-notifications.sh` | +42 | Rewrite onto `tests/lib.sh` | -| `tests/test-autostart.sh` | +27 | Rewrite onto `tests/lib.sh` | -| `tests/test-quickshell-large-surfaces-xvfb.sh` | +254 | Rewrite onto `tests/lib.sh` | -| `docs/P5-NOTIFICATION-POLICY.md` | 78 new | Rewrite for Lyona paths | -| `docs/src/configuration.md`, `docs/src/settings.md` | +15 | mdBook paths, not `docs/src/content/` | - -## `config/quickshell/notifications/NotificationModel.qml` - -The new state, with the `configDir` adapted to Lyona: - -```qml - property bool doNotDisturb: false - property int popupTimeoutMs: 6000 - property string policyState: "loading" - property string policyDetail: "Loading notification policy" - property bool policySaving: false - property bool policyReloadPending: false - property bool confirmedDoNotDisturb: false - property int confirmedPopupTimeoutMs: 6000 - readonly property var popupTimeoutOptions: [4000, 6000, 10000] - readonly property bool popupSuppressed: root.policyState === "loading" - || root.policyState === "partial" - || root.policyState === "unavailable" - || root.policySaving || root.doNotDisturb - readonly property bool policyMutationReady: !root.policySaving - && (root.policyState === "available" || root.policyState === "defaults") - readonly property bool policyResetReady: !root.policySaving - && root.policyState !== "loading" - readonly property string homeDir: Quickshell.env("HOME") || "" - readonly property string configuredConfigHome: Quickshell.env("XDG_CONFIG_HOME") || "" - readonly property string configHome: root.configuredConfigHome.startsWith("/") - ? root.configuredConfigHome : root.homeDir + "/.config" - readonly property string configDir: root.configHome + "/lyona" - readonly property string policyPath: root.configDir + "/notification-settings.json" -``` - -> The only change from upstream is `"/lyona"` in place of `"/dwm-titus"`, matching -> `AppearanceModel.qml:165-187`. - -`popupSuppressed` is the fail-closed decision and the reason this is worth porting -rather than reimplementing: **`loading`, `partial` and `unavailable` all suppress**. -An unreadable or half-written policy file shows no popups rather than defaulting to -"show everything" — which is what a user who set do-not-disturb and then hit a -corrupt file would otherwise get. - -The behaviour functions: - -```qml - function validPopupTimeout(value) { - return root.popupTimeoutOptions.indexOf(value) >= 0; - } - - function usePolicyDefaults() { - root.doNotDisturb = false; - root.popupTimeoutMs = 6000; - } - - function dismissNonCriticalPopups() { - const current = root.notifications.slice(); - for (const item of current) { - if (item.urgencyName !== "critical") root.closeItem(item, false); - } - } - - function applyDoNotDisturb(enabled) { - root.doNotDisturb = enabled; - if (enabled) root.dismissNonCriticalPopups(); - // … persist, then confirm - } -``` - -`dismissNonCriticalPopups` copies with `.slice()` before iterating because -`closeItem` mutates `root.notifications`; iterating the live list skips every other -element. `urgencyName !== "critical"` is the exemption — a critical notification -survives do-not-disturb, which is the whole point of the urgency level. - -`popupTimeoutOptions` is a fixed whitelist, and `validPopupTimeout` gates every -write. An arbitrary integer from a hand-edited JSON file cannot become a popup -timeout. - -## `config/quickshell/settings/SettingsModel.qml` - -Owner-state watch, wired to the `appearance` section only — it runs while that pane -is open and stops when it closes: - -```diff - function activateSection(id) { - displayWatchProcess.running = id === "displays" && root.visible; - inputWatchProcess.running = id === "input" && root.visible; -+ notificationOwnerWatchProcess.running = id === "appearance" && root.visible; -``` - -```diff -+ if (id === "appearance") root.refreshCapabilities(); - if (id === "appearance" && root.accessibilityModel) root.accessibilityModel.refresh(); -``` - -```diff - displayWatchProcess.running = false; - inputWatchProcess.running = false; -+ notificationOwnerWatchProcess.running = false; -``` - -```diff -+ Process { -+ id: notificationOwnerWatchProcess -+ command: Commands.settingsProviderCommand("watch-notifications", []) -+ running: false -+ stdout: SplitParser { onRead: notificationOwnerSettleTimer.restart() } -+ } -``` - -> **Deviation from upstream (reuse).** Upstream adds this as a raw `Process` plus a -> settle `Timer`, matching the `displayWatchProcess` / `inputWatchProcess` pairs -> already in the file. Lyona has `core/WatchedProcess.qml` for exactly this shape. -> Two options, and the choice belongs to whoever implements the phase: -> -> - **Match the file** — port as written. `SettingsModel.qml` already has two -> hand-rolled watch pairs; a third is locally consistent and the diff stays -> reviewable against upstream. -> - **Convert all three** in a separate follow-up commit, so the conversion is -> reviewed as a refactor rather than smuggled into a feature. -> -> Do not do both in one commit. The recommendation is to port as written here and -> open the conversion separately. - -## `scripts/dwm-settings-provider` - -Adds `watch-notifications` (the D-Bus owner watch backing the `Process` above) and -folds notification-owner state into the `accessibility-notifications` capability -record Phase 4 added (done — see `CHANGELOG.md`) — flipping it from `partial` to -`available` once the owning process is confirmed to be the managed -`dwm-notifications` provider. Keep the field validation established there — -bounded lengths, whitelisted states, reject on any malformed row. - -## `scripts/autostart.sh` - -Six lines: create `~/.config/lyona` before the shell starts, so a first-run session -does not race `Component.onCompleted`'s `mkdir -p`. Lyona's `autostart.sh` already -seeds other config directories; extend that block rather than adding a new one. - -## Verification - -```bash -scripts/run-tests make check-quickshell-qml -scripts/run-tests make check-quickshell-notifications -scripts/run-tests make check-session-guards -scripts/run-tests make check-quickshell-large-surfaces-xvfb -scripts/run-tests make check-settings -``` - -Manual, in a live session: - -1. `notify-send "test"` — popup appears. -2. Enable do-not-disturb in Settings → Appearance → Notifications. `notify-send` - again: no popup, but the notification is still in history — the D-Bus owner never - stopped receiving. -3. `notify-send -u critical "urgent"` with do-not-disturb on — **must still appear**. -4. Enable do-not-disturb while three popups are on screen: the non-critical ones - dismiss, a critical one stays. -5. Change the popup timeout to 4000 ms and confirm the next popup dismisses sooner. -6. Corrupt `~/.config/lyona/notification-settings.json` (write `{`), reopen - Settings: the policy reports `partial`, popups are suppressed, and the pane - offers a reset that repairs the file. -7. Restart Quickshell and confirm the policy survives. -8. Closed-CPU baseline with Settings → Appearance open — the owner watch must not - spin. - -## Closes - -`TASKS.md:96` — *Add notification behavior controls that preserve the existing D-Bus -owner, …* diff --git a/docs/SYNC-P9-DISPLAY-APPLY.md b/docs/SYNC-P9-DISPLAY-APPLY.md deleted file mode 100644 index 6a972dc..0000000 --- a/docs/SYNC-P9-DISPLAY-APPLY.md +++ /dev/null @@ -1,202 +0,0 @@ -# Sync Phase 9 — display resolution and apply workflow - -Upstream: [`#198`](https://github.com/ChrisTitusTech/dwm-titus/pull/198) -`Settings: add display resolution dropdown` (`c9389ad`), -[`#200`](https://github.com/ChrisTitusTech/dwm-titus/pull/200) -`Settings: clarify display apply workflow` (`65fd1a6`), and `f558c77` -`Stabilize Settings preview countdown validation`. -Depends on Phase 0 and Phase 3, both done — see `CHANGELOG.md`. -Index: [`UPSTREAM-SYNC.md`](UPSTREAM-SYNC.md). - -Last of the Phase-5 parity work. Deliberately sequenced **after** the layout -compaction (Phase 3) so the resolution dropdown is added into the final geometry -rather than into the old 980×620 window and then re-laid-out. - -`f558c77` is test-only stabilisation of `#200`'s countdown. Fold it in rather than -porting it as a separate commit. - -## Files - -| File | Upstream | Note | -| --- | --- | --- | -| `config/quickshell/settings/SettingsModel.qml` | +82, then +39 | Mode grouping, then apply/revert | -| `config/quickshell/settings/DisplaySettingsPane.qml` | +102, then +55 | Dropdowns, then Apply UI | -| `config/quickshell/appearance/AppearanceModel.qml` | +42 | Preview lifecycle | -| `config/quickshell/core/ShellButton.qml` | +7 | Primary/pending states | -| `config/quickshell/settings/InputSettingsPane.qml` | +2 | Shared control tidy | -| `tests/test-settings.sh` | +83 | Rewrite onto `tests/lib.sh` | -| `tests/test-quickshell-settings-xvfb.sh` | +165 | Rewrite onto `tests/lib.sh` | -| `tests/test-quickshell-appearance-model.sh` | +10 | Rewrite onto `tests/lib.sh` | -| `docs/src/settings.md` | +21 / -9 | mdBook path, not `docs/src/content/` | - -## Part 1 — `#198` resolution dropdown - -Lyona's display card currently exposes only `cycleDisplayMode(index)` — a single -button that walks the whole mode list. On a monitor advertising thirty modes that is -unusable. `#198` replaces it with two dependent dropdowns: resolution, then the -refresh rates available *at* that resolution. - -The mode-grouping helpers in `config/quickshell/settings/SettingsModel.qml`: - -```qml - function displaySizeLabel(modeName) { - const match = /^(\d+)x(\d+)(.*)$/.exec(modeName); - if (!match) return modeName; - const scanVariant = /^([ip])/i.exec(match[3]); - return match[1] + " x " + match[2] - + (scanVariant ? scanVariant[1].toLowerCase() : ""); - } - - function displayResolutionChoices(index) { - const output = root.displayOutputs[index]; - if (!output) return []; - const choices = []; - for (const mode of root.displayModes) { - if (mode.output !== output.name) continue; - const label = root.displaySizeLabel(mode.mode); - if (label.length > 0 && choices.indexOf(label) < 0) choices.push(label); - } - return choices; - } - - function displayResolutionIndex(index) { - const output = root.displayOutputs[index]; - if (!output) return -1; - return root.displayResolutionChoices(index).indexOf(root.displaySizeLabel(output.mode)); - } - - function displayRefreshRateChoices(index) { - const output = root.displayOutputs[index]; - if (!output) return []; - const size = root.displaySizeLabel(output.mode); - const choices = []; - for (const mode of root.displayModes) { - if (mode.output !== output.name || root.displaySizeLabel(mode.mode) !== size) continue; - if (choices.indexOf(mode.rate) < 0) choices.push(mode.rate); - } - return choices; - } - - function displayRefreshRateIndex(index) { - const output = root.displayOutputs[index]; - if (!output) return -1; - return root.displayRefreshRateChoices(index).indexOf(output.rate); - } - - function updateDisplayMode(index, mode) { - if (!mode) return; - const outputs = root.displayOutputs.slice(); - const changed = Object.assign({}, outputs[index]); - changed.mode = mode.mode; - changed.rate = mode.rate; - outputs[index] = changed; - root.displayOutputs = outputs; - } -``` - -`displaySizeLabel` normalises `1920x1080i` and `1920x1080` to distinct labels -(`1920 x 1080i`, `1920 x 1080`) so an interlaced mode is never silently substituted -for a progressive one at the same size. - -`updateDisplayMode` copies the outputs array and the changed element with -`Object.assign` rather than mutating in place. That is not ceremony: QML property -bindings on `displayOutputs` only re-evaluate on assignment, so an in-place mutation -would change the data without repainting the card. - -> **Note on cost.** `displayResolutionChoices` and `displayRefreshRateChoices` are -> O(modes × choices) via `indexOf`. With a typical 20–40 modes per output that is a -> few hundred comparisons per repaint — fine as written, and worth leaving alone -> rather than introducing a `Set` for a list this size. Revisit only if a monitor -> with a pathological mode list shows up. - -`cycleDisplayMode` is removed; check for stale callers: - -```bash -grep -rn "cycleDisplayMode" config/quickshell tests -``` - -## Part 2 — `#200` apply workflow - -Replaces immediate-apply with explicit **Apply**, a countdown, and automatic revert -if the user does not confirm — the standard protection against a mode that leaves the -screen unreadable. - -`config/quickshell/core/ShellButton.qml` gains the primary/pending visual states the -Apply button needs. This is the same file Phase 6 touched (done — see -`CHANGELOG.md`); its `Accessible.*` block and `requestActivation()` are already in -place, so this lands as a clean additive hunk. - -`config/quickshell/appearance/AppearanceModel.qml` (+42) manages the preview -lifecycle — start, countdown, confirm, revert. - -## Lyona adaptation — the DPI interaction - -Upstream has no DPI hot reload, so its revert path only has to restore the mode. -Lyona's does: - -``` -dwm-settings-display → publish_dpi_state() → dpi.current - → dpiStateWatch → Theme.applyDisplayDpi → Theme.uiScale -``` - -`publish_dpi_state()` is at `scripts/dwm-settings-display:282`. **A reverted -resolution must also revert the published DPI**, or the shell is left scaled for a -resolution that is no longer active — every surface mis-sized, with no visible cause. - -Assert it in `tests/test-quickshell-settings-xvfb.sh`, building on Phase 0's -`themeDisplayDpi`/`themeUiScale` probes (already in `shell.qml`): - -```sh -# Apply a mode, let the countdown lapse without confirming, and require that -# both the mode and the published DPI return to where they started. -before_dpi=$(quickshell_ipc settings themeDisplayDpi) -before_scale=$(quickshell_ipc settings themeUiScale) - -quickshell_ipc settings applyDisplayPreview -# … wait out the countdown without confirming … - -[ "$(quickshell_ipc settings themeDisplayDpi)" = "$before_dpi" ] || - fail "auto-revert left the published DPI at the previewed value" -[ "$(quickshell_ipc settings themeUiScale)" = "$before_scale" ] || - fail "auto-revert left Theme.uiScale at the previewed value" -``` - -This is precisely why Phase 0 is a prerequisite: without `themeUiScale`, the revert -can only be checked by eye. - -## `f558c77` — countdown test stabilisation - -Upstream's countdown assertions raced the timer. The fix polls for the state -transition instead of sleeping a fixed interval. Carry that shape into the rewritten -test — Lyona's `tests/lib.sh` has the helpers for it, and a fixed `sleep` here is the -most likely source of a flaky suite. - -## Verification - -```bash -scripts/run-tests make check-quickshell-qml -scripts/run-tests make check-settings -scripts/run-tests make check-quickshell-appearance-model -scripts/run-tests make check-quickshell-settings-xvfb -``` - -Manual, on real hardware with at least two monitors: - -1. Open Settings → Displays. Each output shows a resolution dropdown listing distinct - sizes only, and a refresh-rate dropdown scoped to the selected size. -2. Pick a lower resolution, press Apply, and **let the countdown lapse**. The mode - reverts, and the shell's scale returns with it — no mis-sized panel. -3. Repeat and confirm within the countdown. The mode sticks and survives a shell - restart. -4. Pick a resolution whose DPI differs materially (e.g. 4K → 1080p on the same - panel), confirm, and check the whole shell rescales — the `Theme.uiScale` path. -5. On a monitor advertising an interlaced mode, confirm the progressive and - interlaced entries at the same size are separately selectable. -6. Unplug a monitor while the pane is open; the card disappears without leaving a - pending preview behind. - -## Closes - -The display half of `TASKS.md:133` — *Exercise reversible appearance and -accessibility changes on Arch*. The auto-revert path is what makes "reversible" -literal. diff --git a/docs/UPSTREAM-SYNC.md b/docs/UPSTREAM-SYNC.md index f222842..98273c8 100644 --- a/docs/UPSTREAM-SYNC.md +++ b/docs/UPSTREAM-SYNC.md @@ -105,26 +105,63 @@ Every new helper must be registered in **three** places or it will not ship: | 5 | **Done** — see `CHANGELOG.md`, `TASKS.md` | `#201` | 4 | | 6 | **Done** — see `CHANGELOG.md`, `TASKS.md` | `#202` | 4, 5 | | 7 | **Done** — see `CHANGELOG.md` | `#203` | 4 | -| 8 | [`SYNC-P8-NOTIFICATIONS.md`](SYNC-P8-NOTIFICATIONS.md) | `#204` | 4 | -| 9 | [`SYNC-P9-DISPLAY-APPLY.md`](SYNC-P9-DISPLAY-APPLY.md) | `#198`, `#200`, `f558c77` | 0, 3 | +| 8 | **Done** — see `CHANGELOG.md` | `#204` | 4 | +| 9 | **Done** — see `CHANGELOG.md` | `#198`, `#200`, `f558c77` | 0, 3 | | 10 | [`SYNC-P10-SYSTEM-MANAGEMENT.md`](SYNC-P10-SYSTEM-MANAGEMENT.md) | `#207`–`#253` | Lyona UPDATE-001…003 | | 11 | **Done** — see `CHANGELOG.md`, `TASKS.md` | — (Lyona's own audit) | — | File numbers above are **not** the recommended run order — see [Recommended execution order](#recommended-execution-order) below. -Phases 0, 1, 2, 3, 4, 5, 6, 7, and 11 are **done** — implemented, verified -(`make check-shell`, `make check-format`, `make check-quickshell-qml`, and -the relevant functional tests all pass), and their planning documents +Phases 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, and 11 are **done** — implemented, +verified (`make check-shell`, `make check-format`, `make check-quickshell-qml`, +and the relevant functional tests all pass), and their planning documents (`SYNC-P0-DPI-GATE.md`, `SYNC-P1-STANDALONE.md`, `P5-PANEL-WIDGETS-PORT.md`, `P5-SETTINGS-LAYOUT-PORT.md`, `SYNC-P4-A11Y-CAPABILITIES.md`, `SYNC-P11-SECURITY-HARDENING.md`, `SYNC-P5-CONTRAST-MOTION.md`, -`SYNC-P6-A11Y-CONTROLS.md`, `SYNC-P7-XKB-INPUT.md`) have been removed — the -record of what changed now lives in `CHANGELOG.md` (Phase 2, 4, 6, and 11's -`TASKS.md` checkboxes are also ticked, and Phase 5's `TASKS.md:93` checkbox -too; Phase 7 closed the same `TASKS.md:88`/`:91` items Phases 4 and 6 already -checked, so it ticks nothing new; Phases 0, 1, and 3 never had one) and git -history, not in a plan for work still to do. `SYNC-P7-XKB-INPUT.md`'s claim +`SYNC-P6-A11Y-CONTROLS.md`, `SYNC-P7-XKB-INPUT.md`, `SYNC-P8-NOTIFICATIONS.md`, +`SYNC-P9-DISPLAY-APPLY.md`) +have been removed — the record of what changed now lives in `CHANGELOG.md` +(Phase 2, 4, 6, and 11's `TASKS.md` checkboxes are also ticked, and Phase 5's +`TASKS.md:93` and Phase 8's `TASKS.md:96` checkboxes too; Phase 7 closed the +same `TASKS.md:88`/`:91` items Phases 4 and 6 already checked, so it ticks +nothing new; Phases 0, 1, 3, and 9 never had one) and git history, not in a plan +for work still to do. Phase 9's own doc pointed its "Closes" section at +`TASKS.md:133`, which by the time of implementation was a `SECURITY-001` +acceptance line, not the `P5-VALIDATE` "Exercise reversible appearance and +accessibility changes on Arch" item it meant — a line-number drift from an +earlier phase's insertion into this same file, not a content error. That +`P5-VALIDATE` item stays unchecked: it covers all of Phase 5's reversible +appearance/accessibility work, most of it needs real multi-monitor hardware, +and this phase's own manual-verification checklist (also in the removed doc) +was written for exactly that reason. Phase 9's "Lyona adaptation" section +also proposed a DPI-revert-on-abandoned-preview test +(`dwm-settings-display → publish_dpi_state() → dpi.current → Theme.uiScale` +reverting when a resolution preview's countdown lapses) that doesn't +correspond to real behavior: `scripts/dwm-settings-display`'s resolution +preview/revert/watchdog paths never call `publish_dpi_state()` at all — DPI +and resolution are entirely decoupled in the current implementation, so +there's nothing for a countdown lapse to leave stale. Confirmed against the +real upstream diff too: `65fd1a6`'s actual `tests/test-quickshell-settings-xvfb.sh` +hunk (+95/-21) has nothing to do with display resolution — it exercises +`AppearanceModel.qml`'s new `mutationReadinessPending` queueing (a `dwm-settings-theme +mutation-ready` fixture stub that stalls until released), which is real and +was ported (see the "validating queued theme readiness" stage in that file). +No DPI-revert wiring or test was added; documented here instead of forced in +speculatively. Phase 8's own doc undercounted the real scope — it +missed `tests/test-quickshell-settings-xvfb.sh` entirely (a +167/-13 hunk) +and its `scripts/autostart.sh` claim ("create `~/.config/lyona` before the +shell starts") didn't match the real commit at all, which only hardens +`QUICKSHELL_CONFIG`'s path against a relative `XDG_CONFIG_HOME` — ported the +real diff, not the doc's claim. Also **deliberately scoped out**: the +`test-quickshell-settings-xvfb.sh` restart-based persistence assertions +(load/reset/Do-Not-Disturb/malformed-JSON survive a fresh Quickshell +process, not just a live file change) — building a `restart_quickshell` +helper in an unfamiliar 2700-line file carried real risk for marginal +additional coverage, given the same policy behavior (including on-disk +persistence, verified by reading the actual JSON file) is already fully +exercised end-to-end by `test-quickshell-large-surfaces-xvfb.sh`, which +passes. `SYNC-P7-XKB-INPUT.md`'s claim that `xkbset` "is in the Arch extra repository, so no AUR handling is needed" was wrong — confirmed against a live `pacman -Ss`/AUR RPC query, it is AUR-only. Shipped as an `arch:desktop-optional` entry instead of @@ -174,12 +211,13 @@ as originally recommended. | ✅ | **Phase 5** — Contrast and motion policy | **Done.** See `CHANGELOG.md`, `TASKS.md`. Depends on Phase 4 (done). | | ✅ | **Phase 6** — Accessibility Settings controls | **Done.** See `CHANGELOG.md`, `TASKS.md`. Depends on Phases 4 and 5 (both done). | | ✅ | **Phase 7** — XKB input accessibility | **Done.** See `CHANGELOG.md`. Depends on Phase 4 (done) only; independent of 5/6. | -| 1 | **Phase 8** — Managed notification policy | Depends on Phase 4 (done) only; independent of 5/6/7. | -| 2 | **Phase 9** — Display resolution and apply workflow | Depends on Phase 0 (DPI interaction, done) and Phase 3 (final geometry, done) — also depends on Phase 6 (done), which touches the same `ShellButton.qml` this phase's Apply button extends — last by design. | +| ✅ | **Phase 8** — Managed notification policy | **Done.** See `CHANGELOG.md`. Depends on Phase 4 (done) only; independent of 5/6/7. | +| ✅ | **Phase 9** — Display resolution and apply workflow | **Done.** See `CHANGELOG.md`. Depended on Phase 0 (DPI interaction, done) and Phase 3 (final geometry, done); also touched the same `ShellButton.qml` Phase 6 extended, for the new Apply button's primary/pending states. | | — | **Phase 10** — System management | **Still deferred**, not part of the near-term order at all. Gated on Lyona's own `UPDATE-001…003` landing and on re-surveying upstream *again* immediately before starting — see that document's own "Re-survey before starting," which now has real teeth: 25 commits landed in the 33 hours between this plan's two surveys. | -**Net effect:** with Phases 0, 1, 2, 3, 4, 5, 6, 7, and 11 done, the only thing left is -Phase 8 and Phase 9. Nothing about that +**Net effect:** with Phases 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, and 11 done, the only +work left in this document is Phase 10, and that stays deliberately deferred. +Nothing about that remaining order changed from the original survey — the two pieces of new work found on 2026-09-06 both slotted in without disturbing it: Phase 11 because it shared no files with anything else, and the regional-services scope because it diff --git a/docs/src/settings.md b/docs/src/settings.md index 3bafe52..4379b12 100644 --- a/docs/src/settings.md +++ b/docs/src/settings.md @@ -12,14 +12,17 @@ dwm-settings open The Displays section discovers connected outputs and their advertised modes, lets you edit resolution, refresh rate, position, rotation, primary state, and -output enablement, and manages named profiles. Preview applies the complete -layout for 15 seconds. Choose Keep to accept it or Revert to restore the -captured layout; timeout or closing Settings also restores the prior layout. +output enablement, and manages named layouts. Choose **Apply changes** to test +the complete layout for 15 seconds, then **Keep changes** to accept it or +**Revert** to restore the captured layout. Timeout or closing Settings also +restores the prior layout. Saved layouts can be reused later; **Use at next +login** installs the selected layout for future X11 sessions after a separate +confirmation and administrator authorization. The machine-oriented `dwm-settings-display` helper exposes `discover` and `watch`, complete-layout `save` and `preview`, named `preview-profile`, timed `keep`, `revert`, and `preview-status`, plus authorized `install-profile` and -`rollback-system` actions. Named profiles live under the lyona XDG config -directory. Legacy incomplete profiles remain available to +`rollback-system` actions. Named layouts live under the lyona XDG config +directory. Legacy incomplete layouts remain available to `dwm-display-profile`, but Settings requires them to be resaved as complete layouts before preview or persistent installation. @@ -69,8 +72,10 @@ predates Settings, add this entry inside its `rules` array: Saving the file applies the rule through dwm's normal hot reload. A customized rule with the same title can be retained instead. -Persistent display installation writes only the managed +The **Use at next login** display action writes only the managed `90-lyona-display.conf` fragment after a separate confirmation and polkit authorization. The installed helper accepts validated display records only, -creates a backup, and offers a system rollback. Later phases add connectivity, -audio, power, defaults, personalization, and system-management operations. +backs up the previous managed next-login fragment, and offers **Restore login +backup**. It does not capture the live XRandR layout. Later phases add +connectivity, audio, power, defaults, personalization, and system-management +operations. diff --git a/scripts/autostart.sh b/scripts/autostart.sh index fe7d9b6..881a0d4 100755 --- a/scripts/autostart.sh +++ b/scripts/autostart.sh @@ -457,7 +457,11 @@ fi [ -z "$systemctl_import_pid" ] || wait "$systemctl_import_pid" [ -z "$dbus_import_pid" ] || wait "$dbus_import_pid" -QUICKSHELL_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/quickshell/shell.qml" +case ${XDG_CONFIG_HOME:-} in +/*) quickshell_config_home=$XDG_CONFIG_HOME ;; +*) quickshell_config_home=${HOME:?HOME is required for XDG_CONFIG_HOME fallback}/.config ;; +esac +QUICKSHELL_CONFIG=$quickshell_config_home/quickshell/shell.qml if [ -f "$QUICKSHELL_CONFIG" ]; then quickshell_check= quickshell_compatible=0 diff --git a/scripts/dwm-settings-provider b/scripts/dwm-settings-provider index 119d5b6..8d916eb 100755 --- a/scripts/dwm-settings-provider +++ b/scripts/dwm-settings-provider @@ -6,7 +6,7 @@ script_dir=$( cd -- "$(dirname -- "$0")" && pwd ) usage() { - printf 'usage: %s discover\n' "$0" >&2 + printf 'usage: %s discover|watch-notifications\n' "$0" >&2 } clean_field() { @@ -59,6 +59,103 @@ run_bounded_probe() { "$command_path" "$@" >/dev/null 2>&1 } +run_bounded_output() { + command_name=$1 + shift + command_path=$(provider_path "$command_name") || return 1 + timeout_path=$(command -v timeout 2>/dev/null) || return 1 + "$timeout_path" --signal=TERM --kill-after=1 2 \ + "$command_path" "$@" 2>/dev/null +} + +notification_owner_pid() { + owner_record=$(run_bounded_output busctl --user --json=short call \ + org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ + GetConnectionUnixProcessID s org.freedesktop.Notifications) || return 1 + case $owner_record in + '{"type":"u","data":['*']}') ;; + *) return 1 ;; + esac + owner_pid=${owner_record#'{"type":"u","data":['} + owner_pid=${owner_pid%??} + case $owner_pid in + '' | *[!0-9]* | 0) return 1 ;; + esac + printf '%s\n' "$owner_pid" +} + +notification_process_is_managed() { + notification_process_pid=$1 + case ${XDG_CONFIG_HOME:-} in + /*) config_home=$XDG_CONFIG_HOME ;; + *) config_home=${HOME:-}/.config ;; + esac + case $config_home in /*) ;; *) return 1 ;; esac + case $notification_process_pid in '' | *[!0-9]* | 0) return 1 ;; esac + process_executable=$(readlink "/proc/$notification_process_pid/exe" 2>/dev/null) || return 1 + case $process_executable in + *' (deleted)') process_executable=${process_executable%' (deleted)'} ;; + esac + [ "${process_executable##*/}" = quickshell ] || return 1 + [ -r "/proc/$notification_process_pid/cmdline" ] || return 1 + process_path_mode=$(tr '\0' '\n' <"/proc/$notification_process_pid/cmdline" | + awk -v expected_path="$config_home/quickshell/shell.qml" ' + $0 == "--path" || $0 == "-p" { + explicit = 1 + if (getline configured_path <= 0 || configured_path != expected_path) invalid = 1 + } + index($0, "--path=") == 1 { + explicit = 1 + if (substr($0, 8) != expected_path) invalid = 1 + } + $0 == "--config" || $0 == "-c" || $0 == "--manifest" || $0 == "-m" { + invalid = 1 + getline ignored + } + index($0, "--config=") == 1 || index($0, "--manifest=") == 1 { invalid = 1 } + END { + if (invalid) exit 1 + print explicit ? "explicit" : "default" + } + ') || return 1 + [ "$process_path_mode" = default ] || return 0 + [ -r "/proc/$notification_process_pid/environ" ] || return 1 + process_selector_mode=$(tr '\0' '\n' <"/proc/$notification_process_pid/environ" | + awk -F= -v expected_path="$config_home/quickshell/shell.qml" ' + $1 == "QS_CONFIG_PATH" { path = substr($0, index($0, "=") + 1) } + $1 == "QS_CONFIG_NAME" { name = substr($0, index($0, "=") + 1) } + $1 == "QS_MANIFEST" { manifest = substr($0, index($0, "=") + 1) } + END { + if (path != "") { + if (path == expected_path && name == "" && manifest == "") print "explicit" + else exit 1 + } else if ((name == "" || name == "default") && manifest == "") print "default" + else exit 1 + } + ') || return 1 + [ "$process_selector_mode" = default ] || return 0 + process_config_home=$(tr '\0' '\n' <"/proc/$notification_process_pid/environ" | + awk -F= '$1 == "XDG_CONFIG_HOME" { print substr($0, index($0, "=") + 1); exit }') + case $process_config_home in + /*) ;; + *) + process_home=$(tr '\0' '\n' <"/proc/$notification_process_pid/environ" | + awk -F= '$1 == "HOME" { print substr($0, index($0, "=") + 1); exit }') + case $process_home in /*) process_config_home=$process_home/.config ;; *) return 1 ;; esac + ;; + esac + [ "$process_config_home" = "$config_home" ] +} + +watch_notifications() { + monitor_path=$(provider_path busctl) || { + printf 'dwm-settings-provider: busctl is required\n' >&2 + return 127 + } + exec "$monitor_path" --user --no-pager monitor \ + "--match=type='signal',sender='org.freedesktop.DBus',path='/org/freedesktop/DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.freedesktop.Notifications'" +} + input_snapshot_valid() { command_path=$(provider_path dwm-settings-input) || return 1 timeout_path=$(command -v timeout 2>/dev/null) || return 1 @@ -312,9 +409,15 @@ emit_accessibility_capabilities() { emit_capability appearance accessibility-reduced-motion 'Reduced motion' unavailable user-session \ dwm-accessibility-settings 'Install the managed accessibility settings provider' fi - if run_bounded_probe busctl --user --quiet status org.freedesktop.Notifications; then - emit_capability appearance accessibility-notifications 'Notification policy' partial read-only \ - dbus 'A notification D-Bus owner is active; managed policy controls are not configured' + notification_pid=$(notification_owner_pid 2>/dev/null || true) + if [ -n "$notification_pid" ]; then + if notification_process_is_managed "$notification_pid"; then + emit_capability appearance accessibility-notifications 'Notification policy' available user-session \ + dwm-notifications 'Persistent Do Not Disturb and bounded popup duration controls are available' + else + emit_capability appearance accessibility-notifications 'Notification policy' partial read-only \ + dbus 'A notification owner is active, but it is not the managed policy provider' + fi else emit_capability appearance accessibility-notifications 'Notification policy' unavailable read-only \ dbus 'No notification D-Bus owner is observable in this session' @@ -604,6 +707,13 @@ discover) } discover ;; +watch-notifications) + [ "$#" -eq 1 ] || { + usage + exit 2 + } + watch_notifications + ;; -h | --help | help) usage ;; diff --git a/tests/test-autostart.sh b/tests/test-autostart.sh index 791b1bc..fb8e000 100755 --- a/tests/test-autostart.sh +++ b/tests/test-autostart.sh @@ -259,7 +259,11 @@ if [ "${1:-}" = --version ]; then printf '%s\n' 'quickshell 0.3.0' exit 0 fi -managed_config=${XDG_CONFIG_HOME:?}/quickshell/shell.qml +case ${XDG_CONFIG_HOME:-} in +/*) managed_config_home=$XDG_CONFIG_HOME ;; +*) managed_config_home=${HOME:?}/.config ;; +esac +managed_config=$managed_config_home/quickshell/shell.qml selected_path= selected_pid= previous= @@ -510,6 +514,26 @@ run_duplicate_case() { ' "$state/systemctl.log" } +run_relative_config_home_case() { + home="$work/relative-config-home/home" + state="$work/relative-config-home/state" + runtime="$work/relative-config-home/runtime" + mkdir -p "$home/Pictures/backgrounds" "$home/.config/quickshell" "$state" "$runtime" + chmod 700 "$runtime" + : >"$home/Pictures/backgrounds/wallpaper" + : >"$home/.config/quickshell/shell.qml" + : >"$state/polkit-mate-authentication-agent-1.running" + + DISPLAY=:198 HOME=$home QT_QPA_PLATFORM=wayland TEST_STATE=$state \ + PATH="$work/bin:/usr/bin:/bin" WAYLAND_DISPLAY=wayland-0 \ + XDG_CONFIG_HOME=relative XDG_RUNTIME_DIR="$runtime" \ + XDG_SESSION_TYPE=wayland DWM_AUTOSTART_NO_INPUT_WATCH=1 \ + DWM_AUTOSTART_NO_SETSID=1 sh "$repo/scripts/autostart.sh" + wait_for_marker "$state/quickshell.running" + grep -Fq -- "--path $home/.config/quickshell/shell.qml --no-duplicate" \ + "$state/quickshell.args" +} + run_wallpaper_recovery_with_existing_feh_case() { home="$work/wallpaper-recovery/home" state="$work/wallpaper-recovery/state" @@ -939,6 +963,7 @@ EOF run_duplicate_case display-manager run_duplicate_case startx +run_relative_config_home_case run_wallpaper_recovery_with_existing_feh_case run_status_display_scope_case run_status_launch_race_case diff --git a/tests/test-quickshell-appearance-model.sh b/tests/test-quickshell-appearance-model.sh index f184551..24e17c5 100755 --- a/tests/test-quickshell-appearance-model.sh +++ b/tests/test-quickshell-appearance-model.sh @@ -45,6 +45,16 @@ grep -Fq 'fields[1] === "none" && fields[2] === "unavailable"' "$model" grep -Fq '"mutable": root.validThemeName(fields[1])' "$model" grep -Fq 'Commands.checkedCommand(Commands.settingsThemeCommand(action, args))' "$model" grep -Fq 'Commands.settingsThemeCommand("mutation-ready", [])' "$model" +grep -Fq 'property bool mutationReadinessPending: false' "$model" +grep -Fq 'root.mutationReady = false;' "$model" +grep -Fq 'if (readinessProcess.running || actionProcess.running) {' "$model" +grep -Fq 'root.mutationReadinessPending = true;' "$model" +grep -Fq 'if (!running && root.mutationReadinessPending && !actionProcess.running) {' "$model" +grep -Fq 'onStreamFinished: root.mutationReady = !root.mutationReadinessPending' "$model" +for mutation_function in startPreview applyTheme resetTheme; do + sed -n "/function $mutation_function(/,/^ }/p" "$model" | + grep -Fq 'root.mutationReadinessPending' +done grep -Fq 'Theme.applyAppearanceColors(colors, darkMode)' "$model" grep -Fq 'watchChanges: true' "$model" test "$(grep -Fc 'watchChanges: true' "$model")" -eq 5 @@ -225,7 +235,7 @@ grep -Fq 'readonly property int panelIconFontSize: dp(13)' "$theme" grep -Fq 'font.pixelSize: Theme.panelIconFontSize + 1' "$icon_text" test "$(grep -Fc 'Theme.panelIconFontSize' "$panel")" -eq 5 grep -Fq 'Math.round(13 * fontScale * uiScale)' "$theme" -test "$(grep -Ec 'font\.pixelSize: Theme\.(bodyFontSize|inputFontSize)' "$display_pane")" -eq 9 +test "$(grep -Ec 'font\.pixelSize: Theme\.(bodyFontSize|inputFontSize)' "$display_pane")" -eq 13 test "$(grep -Ec 'font\.pixelSize: Theme\.(bodyFontSize|inputFontSize)' "$input_pane")" -eq 5 grep -Fq 'font.pixelSize: Theme.inputFontSize' "$network_pane" grep -Fq 'passwordInput.implicitHeight + 2 * Theme.spacingSm' "$network_pane" diff --git a/tests/test-quickshell-large-surfaces-xvfb.sh b/tests/test-quickshell-large-surfaces-xvfb.sh index bb7ef7a..e258bf0 100755 --- a/tests/test-quickshell-large-surfaces-xvfb.sh +++ b/tests/test-quickshell-large-surfaces-xvfb.sh @@ -12,6 +12,46 @@ for command_name in Xvfb quickshell xdotool xprop getconf; do fi done +if [ "$(id -u)" -eq 0 ] && [ "${DWM_LARGE_SURFACE_UNPRIVILEGED:-0}" != 1 ]; then + command -v setpriv >/dev/null 2>&1 || { + printf 'Quickshell large-surface Xvfb requires setpriv on a root runner\n' >&2 + exit 1 + } + unprivileged_uid=$(id -u nobody) + unprivileged_gid=$(id -g nobody) + root_runner_work=$(mktemp -d /var/tmp/dwm-large-surface-root.XXXXXX) + trap 'rm -rf -- "$root_runner_work"' EXIT + fixture_repo=$root_runner_work/repo + mkdir -p "$root_runner_work/cache" "$root_runner_work/config" \ + "$root_runner_work/data" "$root_runner_work/runtime" \ + "$root_runner_work/state" "$fixture_repo/tests" + cp -a "$repo/config" "$repo/scripts" "$fixture_repo/" + cp "$repo/tests/lib.sh" "$fixture_repo/tests/lib.sh" + mkdir -p "$fixture_repo/assets" + cp -a "$repo/assets/logo" "$fixture_repo/assets/logo" + cp "$repo/dwm" "$fixture_repo/dwm" + cp "$0" "$fixture_repo/tests/test-quickshell-large-surfaces-xvfb.sh" + chown -R "$unprivileged_uid:$unprivileged_gid" "$root_runner_work" + chmod 700 "$fixture_repo/dwm" "$root_runner_work/runtime" + if HOME="$root_runner_work" TMPDIR="$root_runner_work" \ + DWM_LARGE_SURFACE_REPO="$fixture_repo" \ + DWM_LARGE_SURFACE_UNPRIVILEGED=1 \ + XDG_CACHE_HOME="$root_runner_work/cache" \ + XDG_CONFIG_HOME="$root_runner_work/config" \ + XDG_DATA_HOME="$root_runner_work/data" \ + XDG_RUNTIME_DIR="$root_runner_work/runtime" \ + XDG_STATE_HOME="$root_runner_work/state" \ + setpriv --reuid "$unprivileged_uid" --regid "$unprivileged_gid" \ + --clear-groups "$fixture_repo/tests/test-quickshell-large-surfaces-xvfb.sh" "$@"; then + root_runner_status=0 + else + root_runner_status=$? + fi + rm -rf -- "$root_runner_work" + trap - EXIT + exit "$root_runner_status" +fi + if [ "${DWM_LARGE_SURFACE_DBUS_SESSION:-0}" != 1 ]; then if ! command -v dbus-run-session >/dev/null 2>&1; then printf 'SKIP: dbus-run-session is unavailable\n' @@ -154,12 +194,19 @@ capture_root() { } send_test_notification() { + urgency=${1:-normal} + summary=${2:-Nested notification} if [ "${DWM_LARGE_SURFACE_FORCE_DBUS_SEND:-0}" != 1 ] && command -v notify-send >/dev/null 2>&1; then DISPLAY=$display HOME=$home XDG_CACHE_HOME=$home/.cache XDG_RUNTIME_DIR=$runtime notify-send \ - --app-name='Large Surface Test' 'Nested notification' 'Pointer dismissal and history fixture' + --app-name='Large Surface Test' --urgency="$urgency" \ + "$summary" 'Pointer dismissal and history fixture' return fi + if [ "$urgency" = critical ]; then + printf 'SKIP: notify-send is required for critical notification validation\n' + exit 77 + fi if ! command -v dbus-send >/dev/null 2>&1; then printf 'SKIP: dbus-send is unavailable and notify-send cannot be used\n' @@ -168,7 +215,7 @@ send_test_notification() { DISPLAY=$display HOME=$home XDG_CACHE_HOME=$home/.cache XDG_RUNTIME_DIR=$runtime \ dbus-send --session --print-reply --dest=org.freedesktop.Notifications \ /org/freedesktop/Notifications org.freedesktop.Notifications.Notify \ - string:'Large Surface Test' uint32:0 string:'' string:'Nested notification' \ + string:'Large Surface Test' uint32:0 string:'' string:"$summary" \ string:'Pointer dismissal and history fixture' array:string: dict:string:variant: int32:6000 >/dev/null } @@ -245,6 +292,33 @@ else fi test_stage='validating notifications' +i=0 +while [ "$i" -lt 100 ]; do + policy_state=$(ipc notifications policyState) + case $policy_state in available | defaults | partial) break ;; esac + i=$((i + 1)) + sleep 0.05 +done +case $policy_state in +available | defaults | partial) ;; +*) + printf 'Notification policy did not load: %s\n' "$policy_state" >&2 + exit 1 + ;; +esac +ipc notifications resetPolicy >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +policy_state=$(ipc notifications policyState) +if [ "$policy_state" != available ]; then + printf 'Notification policy reset did not save: %s\n' "$policy_state" >&2 + exit 1 +fi +ipc notifications clearHistory >/dev/null send_test_notification i=0 while [ "$i" -lt 100 ]; do @@ -252,7 +326,38 @@ while [ "$i" -lt 100 ]; do i=$((i + 1)) sleep 0.05 done +ipc notifications setPopupTimeout 4000 >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + if [ "$(ipc notifications policyState)" = available ] && + [ "$(ipc notifications count)" -gt 0 ]; then + break + fi + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] [ "$(ipc notifications count)" -gt 0 ] +popup_timeout=$(ipc notifications popupTimeout) +if [ "$popup_timeout" != 4000 ]; then + printf 'Notification popup timeout did not update: %s\n' "$popup_timeout" >&2 + exit 1 +fi +if ! grep -Eq '"popupTimeoutMs"[[:space:]]*:[[:space:]]*4000' \ + "$config_home/lyona/notification-settings.json"; then + printf 'Notification popup timeout was not persisted:\n' >&2 + sed -n '1,20p' "$config_home/lyona/notification-settings.json" >&2 + exit 1 +fi +# FileView does not define whether saved or fileChanged arrives first. Sample a +# bounded post-save window so either ordering must keep the popup available. +i=0 +while [ "$i" -lt 20 ]; do + [ "$(ipc notifications count)" -gt 0 ] + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] capture_root notification-popup ipc notifications openHistory >/dev/null history_window=$(wait_window '^dwm notification history$') @@ -273,9 +378,157 @@ if DISPLAY=$display xdotool search --onlyvisible --name '^dwm notification histo printf 'Notification history did not close on Escape\n' >&2 exit 1 fi +ipc notifications setDoNotDisturb true >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications doNotDisturb)" = true ] +chmod 500 "$config_home/lyona" +ipc notifications setDoNotDisturb false >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = unavailable ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = unavailable ] +[ "$(ipc notifications doNotDisturb)" = true ] +[ "$(ipc notifications popupTimeout)" = 4000 ] +chmod 700 "$config_home/lyona" +ipc notifications resetPolicy >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] +[ "$(ipc notifications doNotDisturb)" = false ] +[ "$(ipc notifications popupTimeout)" = 6000 ] +ipc notifications setPopupTimeout 4000 >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] +ipc notifications setDoNotDisturb true >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] +[ "$(ipc notifications doNotDisturb)" = true ] +[ "$(ipc notifications popupTimeout)" = 4000 ] +chmod 000 "$config_home/lyona/notification-settings.json" +touch "$config_home/lyona/notification-settings.json" +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = unavailable ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = unavailable ] +[ "$(ipc notifications doNotDisturb)" = true ] +[ "$(ipc notifications popupTimeout)" = 4000 ] +send_test_notification normal 'Suppressed during policy read failure' +sleep 0.1 +[ "$(ipc notifications count)" = 0 ] +[ "$(ipc notifications historyLatestSummary)" = 'Suppressed during policy read failure' ] +chmod 600 "$config_home/lyona/notification-settings.json" +touch "$config_home/lyona/notification-settings.json" +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = available ] +ipc notifications resetPolicy >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications doNotDisturb)" = false ] +[ "$(ipc notifications popupTimeout)" = 6000 ] +printf '%s\n' '{"version":1,"doNotDisturb":true,"popupTimeoutMs":6000}' \ + >"$config_home/lyona/notification-settings.json" +i=0 +while [ "$i" -lt 100 ]; do + policy_dnd=$(ipc notifications doNotDisturb) + [ "$policy_dnd" = true ] && [ "$(ipc notifications count)" = 0 ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$policy_dnd" = true ] +[ "$(ipc notifications count)" = 0 ] +[ "$(ipc notifications historyCount)" -gt 0 ] +printf '%s\n' '{malformed' >"$config_home/lyona/notification-settings.json" +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = partial ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications policyState)" = partial ] +send_test_notification normal 'Suppressed with malformed policy' +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications historyLatestSummary)" = 'Suppressed with malformed policy' ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications historyLatestSummary)" = 'Suppressed with malformed policy' ] +[ "$(ipc notifications count)" = 0 ] +ipc notifications resetPolicy >/dev/null +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications policyState)" = available ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications doNotDisturb)" = false ] ipc notifications clear >/dev/null [ "$(ipc notifications count)" = 0 ] +test_stage='validating Do Not Disturb history and urgency' +history_before=$(ipc notifications historyCount) +ipc notifications setDoNotDisturb true >/dev/null +[ "$(ipc notifications doNotDisturb)" = true ] +send_test_notification normal 'Suppressed notification' +i=0 +while [ "$i" -lt 100 ]; do + history_after=$(ipc notifications historyCount) + latest_summary=$(ipc notifications historyLatestSummary) + [ "$history_after" -gt "$history_before" ] && + [ "$latest_summary" = 'Suppressed notification' ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$history_after" -gt "$history_before" ] +[ "$latest_summary" = 'Suppressed notification' ] +[ "$(ipc notifications count)" = 0 ] + +send_test_notification critical 'Critical notification' +i=0 +while [ "$i" -lt 100 ]; do + [ "$(ipc notifications count)" -gt 0 ] && break + i=$((i + 1)) + sleep 0.05 +done +[ "$(ipc notifications count)" -gt 0 ] +[ "$(ipc notifications historyLatestSummary)" = 'Critical notification' ] +ipc notifications clear >/dev/null +ipc notifications resetPolicy >/dev/null + test_stage='sampling closed-shell CPU usage' clock_ticks=$(getconf CLK_TCK) before=$(awk '{ print $14 + $15 }' "/proc/$quickshell_pid/stat") diff --git a/tests/test-quickshell-notifications.sh b/tests/test-quickshell-notifications.sh index 6ad35e9..8380c53 100755 --- a/tests/test-quickshell-notifications.sh +++ b/tests/test-quickshell-notifications.sh @@ -5,6 +5,7 @@ set -eu . "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/lib.sh" model=$repo/config/quickshell/notifications/NotificationModel.qml +pane=$repo/config/quickshell/settings/AppearanceSettingsPane.qml grep -Fq 'notification.closed.connect(() => root.remove(item.key));' "$model" grep -Fq 'const overflow = candidates.slice(root.maxVisible);' "$model" @@ -14,4 +15,46 @@ grep -Fq 'root.remove(item.key);' "$model" grep -Fq 'root.closeItem(root.notifications.find(n => n.key === key), false);' "$model" grep -Fq 'root.closeItem(root.notifications.find(n => n.key === key), true);' "$model" -printf 'Quickshell notification lifecycle: PASS\n' +# ── Phase 8: managed notification policy (done -- see CHANGELOG.md) ───── + +grep -Fq 'property bool doNotDisturb: false' "$model" +grep -Fq 'readonly property var popupTimeoutOptions: [4000, 6000, 10000]' "$model" +grep -Fq 'readonly property bool popupSuppressed: root.policyState === "loading"' "$model" +grep -Fq '|| root.policyState === "partial"' "$model" +grep -Fq '|| root.policyState === "unavailable"' "$model" +grep -Fq '|| root.policySaving || root.doNotDisturb' "$model" +grep -Fq 'root.applyDoNotDisturb(policy.doNotDisturb);' "$model" +grep -Fq 'if (root.popupSuppressed && item.urgencyName !== "critical") {' "$model" +grep -Fq 'notification.expire();' "$model" +grep -Fq 'root.addHistory(item);' "$model" +grep -Fq 'watchChanges: true' "$model" +grep -Fq 'atomicWrites: true' "$model" +grep -Fq 'policy = JSON.parse(policyFile.text());' "$model" +grep -Fq 'policyFile.setText(JSON.stringify({' "$model" +grep -Fq 'onSaveFailed: error =>' "$model" +grep -Fq 'root.applyDoNotDisturb(root.confirmedDoNotDisturb);' "$model" +grep -Fq 'root.popupTimeoutMs = root.confirmedPopupTimeoutMs;' "$model" +grep -Fq 'root.dismissNonCriticalPopups();' "$model" +grep -Fq 'root.policyReloadPending = true;' "$model" +grep -Fq 'else root.beginPolicyReload(false);' "$model" +grep -Fq 'readonly property bool policyResetReady: !root.policySaving' "$model" +if grep -A1 -F 'readonly property bool policyResetReady:' "$model" | + grep -Fq 'policyState !== "unavailable"'; then + printf 'Notification reset cannot recover from a transient save failure\n' >&2 + exit 1 +fi +if grep -Fq 'policySelfWriteExpected' "$model"; then + printf 'Notification policy still guesses FileView save signal ordering\n' >&2 + exit 1 +fi +if grep -Fq '} else if (root.policySaving)' "$model"; then + printf 'Notification policy reload still discards the next change as a self-write\n' >&2 + exit 1 +fi +grep -Fq 'error === FileViewError.FileNotFound' "$model" +grep -Fq 'root.policyState = "partial";' "$model" +policy_detail_line=$(grep -nF 'text: root.notificationModel.policyDetail' "$pane" | cut -d: -f1) +sed -n "$((policy_detail_line - 5)),$((policy_detail_line - 1))p" "$pane" | + grep -Fq 'visible: root.notificationCapability.status === "available"' + +printf 'Quickshell notification lifecycle and policy: PASS\n' diff --git a/tests/test-quickshell-settings-xvfb.sh b/tests/test-quickshell-settings-xvfb.sh index 0482152..af805bb 100755 --- a/tests/test-quickshell-settings-xvfb.sh +++ b/tests/test-quickshell-settings-xvfb.sh @@ -376,6 +376,17 @@ if [ "${1:-}" = preview-status ] && [ -f "$fixture" ]; then esac exit 0 fi +if [ "${1:-}" = mutation-ready ] && [ -f "$fixture.mutation-delay" ]; then + printf x >>"$fixture.mutation-calls" + if [ ! -f "$fixture.mutation-started" ]; then + : >"$fixture.mutation-started" + i=0 + while [ ! -f "$fixture.mutation-release" ] && [ "$i" -lt 500 ]; do + i=$((i + 1)) + sleep 0.01 + done + fi +fi exec "$(dirname -- "$0")/dwm-settings-theme.real" "$@" SH chmod +x "$data_home/lyona/scripts/dwm-settings-theme" @@ -476,6 +487,9 @@ cat >"$data_home/lyona/scripts/busctl" <<'SH' #!/bin/sh set -eu case $* in +'--user '*) + PATH=/usr/bin:/bin exec busctl "$@" + ;; '--system --json=short call org.bluez / org.freedesktop.DBus.ObjectManager GetManagedObjects') cat <<'JSON' {"type":"a{oa{sa{sv}}}","data":[{"/org/bluez/hci0":{"org.bluez.Adapter1":{"Address":{"type":"s","data":"00:11:22:33:44:55"},"Alias":{"type":"s","data":"Test Adapter"},"Powered":{"type":"b","data":true},"Discovering":{"type":"b","data":false},"Pairable":{"type":"b","data":true}}}}]} @@ -2206,6 +2220,48 @@ fi rm -f "$theme_status_fixture.started" "$theme_status_fixture.calls" printf '%s\n' none >"$theme_status_fixture" +test_stage='validating queued theme readiness' +rm -f "$theme_status_fixture.mutation-started" "$theme_status_fixture.mutation-release" +: >"$theme_status_fixture.mutation-calls" +: >"$theme_status_fixture.mutation-delay" +DISPLAY=$display HOME=$home XDG_CONFIG_HOME=$config_home XDG_DATA_HOME=$data_home \ + XDG_RUNTIME_DIR=$runtime quickshell ipc --path "$config" call settings appearanceRefresh >/dev/null +i=0 +while [ "$i" -lt 200 ]; do + [ -f "$theme_status_fixture.mutation-started" ] && break + i=$((i + 1)) + sleep 0.01 +done +if [ ! -f "$theme_status_fixture.mutation-started" ]; then + printf 'Delayed theme readiness probe did not start\n' >&2 + exit 1 +fi +DISPLAY=$display HOME=$home XDG_CONFIG_HOME=$config_home XDG_DATA_HOME=$data_home \ + XDG_RUNTIME_DIR=$runtime quickshell ipc --path "$config" call settings appearanceRefresh >/dev/null +readiness_ready=$(DISPLAY=$display HOME=$home XDG_CONFIG_HOME=$config_home XDG_DATA_HOME=$data_home \ + XDG_RUNTIME_DIR=$runtime quickshell ipc --path "$config" call settings appearanceMutationReady 2>/dev/null || true) +if [ "$readiness_ready" != false ]; then + printf 'Theme mutation remained ready while a refresh retry was pending\n' >&2 + exit 1 +fi +: >"$theme_status_fixture.mutation-release" +i=0 +while [ "$i" -lt 200 ]; do + readiness_calls=$(wc -c <"$theme_status_fixture.mutation-calls") + readiness_ready=$(DISPLAY=$display HOME=$home XDG_CONFIG_HOME=$config_home XDG_DATA_HOME=$data_home \ + XDG_RUNTIME_DIR=$runtime quickshell ipc --path "$config" call settings appearanceMutationReady 2>/dev/null || true) + [ "$readiness_calls" -ge 2 ] && [ "$readiness_ready" = true ] && break + i=$((i + 1)) + sleep 0.05 +done +if [ "$readiness_calls" -lt 2 ] || [ "$readiness_ready" != true ]; then + printf 'Queued theme readiness did not converge: calls=%s ready=%s\n' \ + "$readiness_calls" "$readiness_ready" >&2 + exit 1 +fi +rm -f "$theme_status_fixture.mutation-delay" "$theme_status_fixture.mutation-started" \ + "$theme_status_fixture.mutation-release" "$theme_status_fixture.mutation-calls" + mv "$config_home/lyona/themes.toml" "$work/named-themes.toml" mv "$data_home/lyona/config/themes.toml" "$work/managed-themes.toml" i=0 diff --git a/tests/test-settings.sh b/tests/test-settings.sh index 75d6db7..1fd00f0 100755 --- a/tests/test-settings.sh +++ b/tests/test-settings.sh @@ -31,6 +31,30 @@ make_failing_stub() { chmod +x "$path" } +make_notification_bus_stub() { + path=$1 + mkdir -p "${path%/*}" + printf '%s\n' '#!/bin/sh' 'printf '\''{"type":"u","data":[4242]}\n'\''' >"$path" + chmod +x "$path" +} + +make_live_notification_bus_stub() { + path=$1 + mkdir -p "${path%/*}" + printf '%s\n' '#!/bin/sh' \ + "printf '{\"type\":\"u\",\"data\":[%s]}\\n' \"\$PPID\"" >"$path" + chmod +x "$path" +} + +make_notification_monitor_stub() { + path=$1 + mkdir -p "${path%/*}" + printf '%s\n' '#!/bin/sh' \ + "printf '%s\\n' \"\$*\" >\"\${DWM_SETTINGS_MONITOR_LOG:?}\"" \ + "printf 'signal\\n'" >"$path" + chmod +x "$path" +} + make_appearance_stub() { path=$1 mkdir -p "${path%/*}" @@ -142,10 +166,16 @@ arch_bin=$work/arch-bin make_tools "$base_bin" dirname awk tr stat find grep timeout readlink cp -a "$base_bin" "$arch_bin" +grep -Fq "*' (deleted)') process_executable=\${process_executable%' (deleted)'} ;;" \ + "$provider" +grep -Fq "case \${XDG_CONFIG_HOME:-} in" "$provider" +grep -Fq "*) config_home=\${HOME:-}/.config ;;" "$provider" + for command_name in xrandr nmcli bluetoothctl pactl xset gsettings light-locker \ - xdg-settings xdg-mime xinput xkbset busctl; do + xdg-settings xdg-mime xinput xkbset; do make_stub "$arch_bin/$command_name" done +make_notification_bus_stub "$arch_bin/busctl" make_stub "$arch_bin/dwm-xdg-autostart" make_appearance_stub "$arch_bin/dwm-settings-appearance" make_font_stub "$arch_bin/dwm-settings-font" @@ -164,6 +194,15 @@ printf 'ID=arch\nPRETTY_NAME="Arch\tLinux"\n' \ arch_output=$(PATH="$arch_bin" XDG_CONFIG_HOME="$work/arch-config" \ DWM_SETTINGS_OS_RELEASE="$work/arch-os-release" "$provider" discover) +notification_monitor_bin=$work/notification-monitor-bin +cp -a "$arch_bin" "$notification_monitor_bin" +make_notification_monitor_stub "$notification_monitor_bin/busctl" +PATH="$notification_monitor_bin" DWM_SETTINGS_MONITOR_LOG="$work/notification-monitor.log" \ + "$provider" watch-notifications >"$work/notification-monitor.out" +grep -Fqx 'signal' "$work/notification-monitor.out" +grep -Fqx -- \ + "--user --no-pager monitor --match=type='signal',sender='org.freedesktop.DBus',path='/org/freedesktop/DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.freedesktop.Notifications'" \ + "$work/notification-monitor.log" printf '%s\n' "$arch_output" | grep -Fqx 'settings-protocol 1' printf '%s\n' "$arch_output" | grep -Fqx 'platform arch arch Arch Linux' printf '%s\n' "$arch_output" | grep -Fqx \ @@ -196,7 +235,7 @@ printf '%s\n' "$arch_output" | grep -Fqx \ printf '%s\n' "$arch_output" | grep -Fqx \ 'capability appearance accessibility-reduced-motion Reduced motion available user-session dwm-accessibility-settings Persistent managed-shell reduced-motion policy is available' printf '%s\n' "$arch_output" | grep -Fqx \ - 'capability appearance accessibility-notifications Notification policy partial read-only dbus A notification D-Bus owner is active; managed policy controls are not configured' + 'capability appearance accessibility-notifications Notification policy partial read-only dbus A notification owner is active, but it is not the managed policy provider' printf '%s\n' "$arch_output" | grep -Fqx \ 'capability appearance accessibility-input Keyboard and pointer access available user-session dwm-settings-input Persistent XKB accessibility controls are available' [ "$(printf '%s\n' "$arch_output" | @@ -429,6 +468,15 @@ no_notification_owner_output=$(PATH="$no_notification_owner_bin" XDG_CONFIG_HOME printf '%s\n' "$no_notification_owner_output" | grep -Fqx \ 'capability appearance accessibility-notifications Notification policy unavailable read-only dbus No notification D-Bus owner is observable in this session' +mismatched_notification_owner_bin=$work/mismatched-notification-owner-bin +cp -a "$arch_bin" "$mismatched_notification_owner_bin" +make_live_notification_bus_stub "$mismatched_notification_owner_bin/busctl" +mismatched_notification_owner_output=$(PATH="$mismatched_notification_owner_bin" \ + XDG_CONFIG_HOME="$work/arch-config" \ + DWM_SETTINGS_OS_RELEASE="$work/arch-os-release" "$provider" discover) +printf '%s\n' "$mismatched_notification_owner_output" | grep -Fqx \ + 'capability appearance accessibility-notifications Notification policy partial read-only dbus A notification owner is active, but it is not the managed policy provider' + unsafe_theme_bin=$work/unsafe-theme-bin cp -a "$arch_bin" "$unsafe_theme_bin" make_failing_stub "$unsafe_theme_bin/dwm-settings-theme" @@ -580,6 +628,17 @@ grep -Fq 'Commands.settingsDisplayCommand("watch", root.watchOwnerArguments())' "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'Commands.settingsInputCommand("watch", root.watchOwnerArguments())' \ "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'Commands.settingsProviderCommand("watch-notifications", [])' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'stdout: SplitParser { onRead: notificationOwnerSettleTimer.restart() }' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'notificationOwnerWatchProcess.running = id === "appearance" && root.visible;' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +if ! grep -Fq 'if (id === "appearance") root.refresh();' \ + "$repo/config/quickshell/settings/SettingsModel.qml"; then + printf 'Activating the appearance section must refresh capabilities, so the notification-owner capability updates promptly\n' >&2 + exit 1 +fi grep -Fq 'path: "/proc/" + Quickshell.processId.toString() + "/stat"' \ "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'root.runInput("preview-status", [])' "$repo/config/quickshell/settings/SettingsModel.qml" @@ -604,18 +663,101 @@ grep -Fq -- '--tearfree auto --force-full-composition-pipeline auto' \ "$repo/scripts/dwm-settings-display-root" grep -Fq 'fields.length >= 10 ? fields[9] : "unsupported"' \ "$repo/config/quickshell/settings/SettingsModel.qml" -grep -Fq 'NVIDIA full composition on persistent install' \ +grep -Fq 'NVIDIA anti-tearing available at next login' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'import QtQuick.Controls as Controls' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'component DisplayComboBox: Controls.ComboBox' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'Accessible.name: accessibleLabel' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'palette.window: Theme.popupBackground' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'highlighted: comboBox.highlightedIndex === index' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'accessibleLabel: "Resolution for " + outputCard.modelData.name' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'root.settingsModel.displayResolutionChoices(outputCard.index)' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'root.settingsModel.setDisplayResolution(outputCard.index, model[index])' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'root.settingsModel.displayRefreshRateChoices(outputCard.index)' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'root.settingsModel.setDisplayRefreshRate(outputCard.index, model[index])' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'function displayResolutionChoices(index)' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'const scanVariant = /^([ip])/i.exec(match[3]);' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq '(scanVariant ? scanVariant[1].toLowerCase() : "")' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'function displayRefreshRateChoices(index)' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +if grep -Fq 'cycleDisplayMode' "$repo/config/quickshell/settings/DisplaySettingsPane.qml"; then + printf 'Display resolution must not be presented as a mode-cycling button.\n' >&2 + exit 1 +fi +grep -Fq 'label: "Apply changes"' "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'enabled: root.settingsModel.displayState === "ready"' \ "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq '&& root.settingsModel.displayHasPendingChanges' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'readonly property bool displayHasPendingChanges:' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'property bool displayRefreshPending: false' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'root.displayRefreshPending = true;' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'if (!running && root.displayRefreshPending && root.visible) {' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq '? "Display changes are ready to apply" : outputs.length + " connected outputs";' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'label: root.settingsModel.previewRollbackFailed ? "Keep current" : "Keep changes"' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'onActivated: root.settingsModel.keepPreview()' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'onTextEdited: if (acceptableInput)' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +test "$(grep -Fc 'onTextEdited: if (acceptableInput)' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml")" -eq 2 +grep -Fq 'function keepPreview() {' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'root.runDisplay("keep", [root.previewToken]);' \ + "$repo/config/quickshell/settings/SettingsModel.qml" +if grep -Fq 'keepPreview(root.profileName' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml"; then + printf 'Keeping a display preview must not implicitly save a typed layout name.\n' >&2 + exit 1 +fi +grep -Fq 'label: "Use at next login"' "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'label: "Restore login backup"' "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +grep -Fq 'the previous next-login layout will be backed up' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml" +if grep -Fq 'root.profileName = modelData;' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml"; then + printf 'Trying a saved layout must not populate the save target.\n' >&2 + exit 1 +fi +if grep -Fq 'label: "Install persistent"' \ + "$repo/config/quickshell/settings/DisplaySettingsPane.qml"; then + printf 'Displays still exposes implementation-oriented persistence wording.\n' >&2 + exit 1 +fi grep -Fq 'migrate or remove it before installing a managed display profile' \ "$repo/scripts/dwm-settings-display-root" grep -Fq 'watch-apply' "$repo/scripts/autostart.sh" grep -Fq 'displayWatchProcess.running = false' "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'inputWatchProcess.running = false' "$repo/config/quickshell/settings/SettingsModel.qml" +grep -Fq 'notificationOwnerWatchProcess.running = false' \ + "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'stdout: SplitParser { onRead: inputSettleTimer.restart() }' \ "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'root.searchQuery = ""' "$repo/config/quickshell/settings/SettingsModel.qml" grep -Fq 'activeFocusOnTab: root.enabled' "$repo/config/quickshell/core/ShellButton.qml" grep -Fq 'event.key === Qt.Key_Return' "$repo/config/quickshell/core/ShellButton.qml" +grep -Fq 'property bool primary: false' "$repo/config/quickshell/core/ShellButton.qml" +grep -Fq ': root.danger ? (root.hovered ? Theme.controlHoverFill : Theme.controlNormalFill)' \ + "$repo/config/quickshell/core/ShellButton.qml" grep -Fq 'title: "dwm settings"' "$repo/config/quickshell/settings/SettingsWindow.qml" grep -Fq 'label: "Settings"' "$repo/config/quickshell/controlcenter/ControlCenterWindow.qml" grep -Fq 'root.settingsModel.openOnScreen(targetScreen)' "$repo/config/quickshell/controlcenter/ControlCenterWindow.qml"