Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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
Expand Down
2 changes: 1 addition & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 35 additions & 7 deletions config/quickshell/appearance/AppearanceModel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -625,6 +631,7 @@ Scope {
root.wallpaperStatusPending = false;
root.inventoryPending = false;
root.inventoryPendingAllowUnwatched = false;
root.mutationReadinessPending = false;
}

function nextPreviewToken() {
Expand All @@ -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", [], "", "");
}

Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion config/quickshell/core/ShellButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
185 changes: 181 additions & 4 deletions config/quickshell/notifications/NotificationModel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -61,8 +184,6 @@ Scope {
for (const overflowItem of overflow) {
root.closeItem(overflowItem, false);
}

root.addHistory(item);
}

function remove(key) {
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading