From 72e49ac918d378c2d075fadec852e23af8a49064 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:53:28 +0200 Subject: [PATCH] Fix host-owned notification compatibility and history tabs --- docs/step1d-notifications-validation.md | 71 ++++ hancore.shibumi.status/BarWidget.qml | 8 +- .../NotificationAdapter.qml | 395 ++++++++++++++++++ hancore.shibumi.status/NotificationPanel.qml | 138 +++++- hancore.shibumi.status/Service.qml | 12 +- scripts/check-production-boundary | 13 +- tests/contract-regression.sh | 21 +- tests/notification-adapter-regression.sh | 47 +++ tests/notification-adapter-smoke.qml | 316 ++++++++++++++ tests/status-plugin-regression.sh | 43 +- tests/status-plugin-smoke.qml | 7 +- 11 files changed, 1043 insertions(+), 28 deletions(-) create mode 100644 docs/step1d-notifications-validation.md create mode 100644 hancore.shibumi.status/NotificationAdapter.qml create mode 100755 tests/notification-adapter-regression.sh create mode 100644 tests/notification-adapter-smoke.qml diff --git a/docs/step1d-notifications-validation.md b/docs/step1d-notifications-validation.md new file mode 100644 index 0000000..fb499e3 --- /dev/null +++ b/docs/step1d-notifications-validation.md @@ -0,0 +1,71 @@ +# Step 1D Notifications compatibility validation + +Status: implementation in progress on the Step 1D worktree. This slice keeps +Omarchy as the sole Notifications owner and adds only a Shibumi primitive-row +adapter; it does not create a second notification daemon or server. + +## Reproduced failure + +The current Omarchy Notifications service exposes the live `popupModel` plus +host actions such as `dismissPopup`, `clearPopups`, `focusApp`, and +`showRecentHistory`. Shibumi Status still expected the older +`pendingModel`/`pastModel` shape. A real notification produced an Omarchy toast, +but the Shibumi panel showed `No notifications`. + +The failure was reproduced with a uniquely named critical notification on the +local DP-1 output before the adapter was deployed. + +## Implementation + +- `hancore.shibumi.status/NotificationAdapter.qml` copies host rows into + Shibumi-owned primitive `pendingModel` and `pastModel` models. +- Current hosts use `popupModel`; legacy hosts with `pendingModel` and + `pastModel` remain supported and take precedence when present. +- DND, dismiss, clear, focus, and history actions resolve against the current + host service at call time. +- Host absence and host replacement clear and rebuild the adapter models. +- The raw host reference remains private to an internal adapter object and does + not escape to the bar or panel views. +- The current host has no public recent-history model. The panel therefore + provides explicit `Live` and `Recent` tabs; selecting Recent asks the host to + replay its host-owned history. Rows are labeled `LIVE`/`RECENT`, with + `color03` for Live and `color04` for Recent. The adapter buffers notifications + that arrive during the host replay and suppresses stale replay results after + Clear all. The Clear all control and per-row dismiss actions remain + available on both tabs; current-host replay dismissals are filtered from the + current view even though the host has no persistent per-entry history delete. + The adapter never reads private host state files. Legacy `pastModel` history + remains supported. + +## Validation + +Passed: + +- notification adapter smoke and regression, including current and legacy host + shapes, host replacement, unavailable state, DND, dismiss, focus, clear, + history replay, negative-ID empty-history sentinels, replay races, and + post-replay live notifications; +- production-boundary regression; +- documentation regression; +- unpinned status-plugin smoke against the installed Omarchy host; +- local live probe: active row appeared in the Shibumi panel and authoritative + host dismissal removed it; +- Machine 2 (`192.168.2.128`) live probe on `eDP-1`: active row appeared in the + Shibumi panel and authoritative host dismissal succeeded; +- both live probes retained one production Quickshell process and + `omarchy-shell shell ping: ok`; +- final local installation from this worktree was updated with the history + panel, and the live probe visibly rendered the explicit `Live`/`Recent` tabs; + a separate batch of 10 normal notifications rendered with `LIVE` labels and + a batch of 10 normal-urgency notifications was sent while DND was enabled + for the Recent replay check. A second live batch of 10 normal and 5 DND + notifications was generated after the final tab-order deployment; the Live + tab showed all 10 normal rows and the 5 DND rows remain available through + Recent. + +The pinned status/contract gate remains blocked by the known installed Omarchy +shell-content baseline drift. This is an environment validation limitation, +not a runtime error in this slice. + +Step 1D remains open until the complete applicable contract gate, independent +review, and final user visual/interaction acceptance are recorded. diff --git a/hancore.shibumi.status/BarWidget.qml b/hancore.shibumi.status/BarWidget.qml index 32d97b0..6d0116d 100644 --- a/hancore.shibumi.status/BarWidget.qml +++ b/hancore.shibumi.status/BarWidget.qml @@ -58,9 +58,11 @@ Item { : bar ? bar.background : Commons.Color.background readonly property var updateWidget: updateLoader.item readonly property var trayWidget: trayLoader.item - readonly property var notificationService: bar && bar.shell - && typeof bar.shell.firstPartyServiceFor === "function" - ? bar.shell.firstPartyServiceFor("omarchy.notifications") : null + readonly property var statusService: bar && bar.shell + && typeof bar.shell.serviceFor === "function" + ? bar.shell.serviceFor("hancore.shibumi.status") : null + readonly property var notificationService: statusService + ? statusService.notificationService : null readonly property var trayDrawerItem: trayDrawerLoader.item readonly property var trayAppMenuPanelItem: trayAppMenuLoader.item readonly property var notificationPanelItem: notificationPanelLoader.item diff --git a/hancore.shibumi.status/NotificationAdapter.qml b/hancore.shibumi.status/NotificationAdapter.qml new file mode 100644 index 0000000..96fa9b0 --- /dev/null +++ b/hancore.shibumi.status/NotificationAdapter.qml @@ -0,0 +1,395 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +// Compatibility adapter for the host-owned Omarchy Notifications service. +// The host service owns the notification daemon and all notification objects; +// this object copies only primitive rows into Shibumi-owned models. +Item { + id: root + + width: 0 + height: 0 + visible: false + + // The host reference is deliberately kept in a private child object. The + // public notificationService façade exposes only primitive models and + // typed methods to Shibumi consumers. + QtObject { + id: state + property var hostService: null + property bool historyReplayActive: false + property var liveKeys: [] + property var lateLiveRows: [] + property var dismissedHistoryKeys: [] + property bool suppressReplay: false + property double historyReplayCutoff: 0 + property double suppressionCutoff: 0 + } + + readonly property bool available: state.hostService !== null + readonly property bool doNotDisturb: available + && state.hostService.doNotDisturb === true + readonly property int pendingCount: pendingRows.count + readonly property int recentCount: pastRows.count + readonly property bool historyAvailable: available + && (historySourceModel() !== null + || typeof state.hostService.showRecentHistory === "function" + || typeof state.hostService.showHistory === "function") + readonly property bool pastDismissAvailable: available + && (typeof state.hostService.dismissPast === "function" + || typeof state.hostService.dismissPopup === "function") + property alias pendingModel: pendingRows + property alias pastModel: pastRows + + ListModel { id: pendingRows } + ListModel { id: pastRows } + + function attachShell(shellValue) { + const service = shellValue + && typeof shellValue.firstPartyServiceFor === "function" + ? shellValue.firstPartyServiceFor("omarchy.notifications") : null + state.hostService = service || null + state.historyReplayActive = false + state.liveKeys = [] + state.lateLiveRows = [] + state.dismissedHistoryKeys = [] + state.suppressReplay = false + state.historyReplayCutoff = 0 + state.suppressionCutoff = 0 + syncModels() + } + + function sourceModel() { + const service = state.hostService + if (!service) return null + // pendingModel is the legacy contract when present. popupModel is the + // current Quattro contract and is used only when pendingModel is absent. + return service.pendingModel || service.popupModel || null + } + + function historySourceModel() { + const service = state.hostService + if (!service) return null + const model = service.pastModel + return model && model !== sourceModel() ? model : null + } + + function primitiveEntry(entry) { + const value = entry || ({}) + return { + id: Number(value.id || value.originalId || 0), + originalId: Number(value.originalId || value.id || 0), + app: String(value.app || value.appName || ""), + appIcon: String(value.appIcon || ""), + summary: String(value.summary || ""), + body: String(value.body || ""), + image: String(value.image || ""), + glyph: String(value.glyph || ""), + exec: String(value.exec || ""), + urgency: Number(value.urgency || 0), + expireTimeout: Number(value.expireTimeout || 0), + timestamp: Number(value.timestamp || 0) + } + } + + function entryKey(entry) { + const value = entry || ({}) + return String(Number(value.timestamp || 0)) + ":" + + String(Number(value.originalId || value.id || 0)) + } + + function rebuild(target, model) { + target.clear() + if (!model || typeof model.get !== "function") return + for (let index = 0; index < model.count; index++) { + const entry = model.get(index) + if (!entry || Number(entry.originalId || entry.id || 0) < 0) + continue + target.append(primitiveEntry(entry)) + } + } + + function rememberLateLiveRows(model) { + if (!state.historyReplayActive || !model + || typeof model.get !== "function") return + for (let index = 0; index < model.count; index++) { + const entry = model.get(index) + if (!entry || Number(entry.originalId || entry.id || 0) < 0) continue + const key = entryKey(entry) + const timestamp = Number(entry.timestamp || 0) + if (state.liveKeys.indexOf(key) >= 0 + || state.historyReplayCutoff <= 0 + || timestamp < state.historyReplayCutoff + || state.lateLiveRows.some(row => entryKey(row) === key)) continue + state.lateLiveRows.push(primitiveEntry(entry)) + } + } + + function rebuildReplayModels(model) { + pendingRows.clear() + pastRows.clear() + const lateKeys = [] + for (const entry of state.lateLiveRows) { + lateKeys.push(entryKey(entry)) + pendingRows.append(entry) + } + if (!model || typeof model.get !== "function") return + for (let index = 0; index < model.count; index++) { + const entry = model.get(index) + if (!entry || Number(entry.originalId || entry.id || 0) < 0 + || lateKeys.indexOf(entryKey(entry)) >= 0 + || state.dismissedHistoryKeys.indexOf(entryKey(entry)) >= 0) + continue + const timestamp = Number(entry.timestamp || 0) + const isNewLive = state.historyReplayCutoff > 0 + && timestamp >= state.historyReplayCutoff + const target = state.liveKeys.indexOf(entryKey(entry)) >= 0 || isNewLive + ? pendingRows : pastRows + target.append(primitiveEntry(entry)) + } + } + + function rebuildSuppressedModels(model) { + pendingRows.clear() + pastRows.clear() + if (!model || typeof model.get !== "function") return + for (let index = 0; index < model.count; index++) { + const entry = model.get(index) + if (!entry || Number(entry.originalId || entry.id || 0) < 0) continue + if (Number(entry.timestamp || 0) >= state.suppressionCutoff) + pendingRows.append(primitiveEntry(entry)) + } + } + + function syncModels() { + const current = sourceModel() + const archived = historySourceModel() + rememberLateLiveRows(current) + if (state.suppressReplay && !archived) + rebuildSuppressedModels(current) + else if (state.historyReplayActive && !archived) + rebuildReplayModels(current) + else { + rebuild(pendingRows, current) + // The current host intentionally exposes only live popupModel rows. Its + // history is available through showRecentHistory(), not as a public + // model; keep the recent model empty until that action is requested. + rebuild(pastRows, archived) + } + } + + function removeBufferedEntry(entry) { + const key = entryKey(entry) + let removed = false + for (let index = state.lateLiveRows.length - 1; index >= 0; index--) { + if (entryKey(state.lateLiveRows[index]) !== key) continue + state.lateLiveRows.splice(index, 1) + removed = true + } + for (let index = state.liveKeys.length - 1; index >= 0; index--) { + if (state.liveKeys[index] !== key) continue + state.liveKeys.splice(index, 1) + removed = true + } + return removed + } + + function sourceIndex(entry, model) { + if (!entry || !model || typeof model.get !== "function") return -1 + const timestamp = Number(entry.timestamp || 0) + const originalId = Number(entry.originalId || entry.id || 0) + for (let index = 0; index < model.count; index++) { + const candidate = model.get(index) + if (!candidate) continue + if (Number(candidate.timestamp || 0) === timestamp + && Number(candidate.originalId || candidate.id || 0) + === originalId) + return index + } + return -1 + } + + function setDoNotDisturb(value) { + const service = state.hostService + if (!service) return false + if (typeof service.setDoNotDisturb === "function") { + service.setDoNotDisturb(value === true) + return true + } + if (typeof service.setDnd === "function") { + service.setDnd(value === true) + return true + } + return false + } + + function toggleDoNotDisturb() { + return setDoNotDisturb(!doNotDisturb) + } + + function dismissPending(index) { + const service = state.hostService + if (!service || index < 0 || index >= pendingRows.count) return false + if (typeof service.dismissPending === "function") { + service.dismissPending(index) + return true + } + const entry = pendingRows.get(index) + const buffered = removeBufferedEntry(entry) + const source = sourceIndex(entry, sourceModel()) + if (source < 0) { + if (!buffered) return false + syncModels() + return true + } + if (typeof service.dismissPopup !== "function") return false + service.dismissPopup(source) + return true + } + + function dismissPast(index) { + const service = state.hostService + if (!service || index < 0 || index >= pastRows.count) return false + const entry = pastRows.get(index) + const key = entryKey(entry) + if (typeof service.dismissPast === "function") { + service.dismissPast(index) + return true + } + const source = sourceIndex(entry, sourceModel()) + if (source < 0 || typeof service.dismissPopup !== "function") return false + state.dismissedHistoryKeys = state.dismissedHistoryKeys.concat([key]) + service.dismissPopup(source) + return true + } + + function clearPending() { + const service = state.hostService + if (!service) return false + if (typeof service.clearPending === "function") { + service.clearPending() + return true + } + if (typeof service.markAllSeen === "function") { + service.markAllSeen() + return true + } + if (typeof service.clearPopups === "function") { + service.clearPopups() + state.liveKeys = [] + state.lateLiveRows = [] + state.dismissedHistoryKeys = [] + state.historyReplayActive = false + state.suppressReplay = true + state.historyReplayCutoff = 0 + state.suppressionCutoff = Date.now() + syncModels() + return true + } + return false + } + + function clearPast() { + const service = state.hostService + if (!service) return false + if (typeof service.clearPast === "function") { + service.clearPast() + return true + } + if (typeof service.clearHistory === "function") { + service.clearHistory() + return true + } + return false + } + + function markAllSeen() { + const service = state.hostService + if (!service) return false + if (typeof service.markAllSeen === "function") { + service.markAllSeen() + return true + } + return clearPending() + } + + function focusApp(entry) { + const service = state.hostService + if (!service || !entry) return false + if (typeof service.focusApp === "function") { + service.focusApp(entry) + return true + } + const source = sourceIndex(entry, sourceModel()) + if (source < 0 || typeof service.invokePopupDefault !== "function") + return false + service.invokePopupDefault(source) + return true + } + + function showHistory() { + const service = state.hostService + if (!service) return false + const archived = historySourceModel() + if (archived) { + syncModels() + return true + } + + const current = sourceModel() + state.liveKeys = [] + state.lateLiveRows = [] + state.dismissedHistoryKeys = [] + if (state.historyReplayActive) { + for (let index = 0; index < pendingRows.count; index++) + state.liveKeys.push(entryKey(pendingRows.get(index))) + } else if (current && typeof current.get === "function") { + for (let index = 0; index < current.count; index++) + state.liveKeys.push(entryKey(current.get(index))) + } + state.suppressReplay = false + state.suppressionCutoff = 0 + state.historyReplayCutoff = Date.now() + state.historyReplayActive = true + syncModels() + if (typeof service.showRecentHistory === "function") { + service.showRecentHistory() + return true + } + if (typeof service.showHistory === "function") { + service.showHistory() + return true + } + state.historyReplayActive = false + syncModels() + return false + } + + Connections { + target: root.sourceModel() + ignoreUnknownSignals: true + function onRowsInserted() { root.syncModels() } + function onRowsRemoved() { root.syncModels() } + function onDataChanged() { root.syncModels() } + function onModelReset() { root.syncModels() } + } + + Connections { + target: root.historySourceModel() + ignoreUnknownSignals: true + function onRowsInserted() { root.syncModels() } + function onRowsRemoved() { root.syncModels() } + function onDataChanged() { root.syncModels() } + function onModelReset() { root.syncModels() } + } + + Connections { + target: state.hostService + ignoreUnknownSignals: true + function onPopupModelChanged() { root.syncModels() } + function onPendingModelChanged() { root.syncModels() } + function onPastModelChanged() { root.syncModels() } + function onDoNotDisturbChanged() { root.syncModels() } + } +} diff --git a/hancore.shibumi.status/NotificationPanel.qml b/hancore.shibumi.status/NotificationPanel.qml index dc17393..dd62150 100644 --- a/hancore.shibumi.status/NotificationPanel.qml +++ b/hancore.shibumi.status/NotificationPanel.qml @@ -9,6 +9,21 @@ ShibumiPanel { required property var ownerWidget required property var notificationService + property bool showingRecent: false + readonly property bool historyAvailable: notificationService + && notificationService.historyAvailable === true + function paletteColor(id, fallback) { + const shell = panel.bar && panel.bar.shell + const state = shell && typeof shell.serviceFor === "function" + ? shell.serviceFor("hancore.shibumi.state") : null + return state && typeof state.paletteColor === "function" + ? state.paletteColor(id) : fallback + } + + readonly property color liveHighlight: paletteColor("color03", + panel.controlAccent) + readonly property color recentHighlight: paletteColor("color04", + panel.controlAccent) readonly property int pendingCount: notificationService && notificationService.pendingModel ? notificationService.pendingModel.count : 0 @@ -16,6 +31,12 @@ ShibumiPanel { && notificationService.pastModel ? notificationService.pastModel.count : 0 readonly property int activeCount: pendingCount + recentCount + readonly property int displayedCount: showingRecent + ? recentCount : pendingCount + // The current host contract exposes live popupModel rows but no public + // recent-history model. The Recent tab asks the host to replay its + // host-owned history; the adapter then exposes that replay as recent rows. + // History is never reconstructed from private host files. readonly property var activeRows: { const rows = [] function append(model, bucket) { @@ -33,10 +54,10 @@ ShibumiPanel { }) } } - append(notificationService ? notificationService.pendingModel : null, - "pending") - append(notificationService ? notificationService.pastModel : null, - "past") + const model = showingRecent + ? (notificationService ? notificationService.pastModel : null) + : (notificationService ? notificationService.pendingModel : null) + append(model, showingRecent ? "past" : "pending") return rows } @@ -52,6 +73,22 @@ ShibumiPanel { ownerWidget.closeNotificationPanel() } + function selectTab(tab) { + const recent = String(tab || "") === "recent" + if (recent) { + if (!historyAvailable || !showHistory()) return false + } + showingRecent = recent + return true + } + + function showHistory() { + if (!notificationService || !historyAvailable + || typeof notificationService.showHistory !== "function") + return false + return notificationService.showHistory() !== false + } + function setDnd(value) { if (notificationService && typeof notificationService.setDoNotDisturb === "function") @@ -133,8 +170,13 @@ ShibumiPanel { ? closeText.left : closeAction.left anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter - text: panel.activeCount > 0 - ? "Notifications · " + panel.activeCount : "Notifications" + text: panel.showingRecent + ? (panel.displayedCount > 0 + ? "Recent notifications · " + panel.displayedCount + : "Recent notifications") + : panel.displayedCount > 0 + ? "Live notifications · " + panel.displayedCount + : "Live notifications" color: panel.controlForeground font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family @@ -185,11 +227,78 @@ ShibumiPanel { color: panel.dividerColor } + Row { + id: tabRow + width: parent.width + height: 28 + spacing: 6 + + Rectangle { + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: panel.controlRadius + color: panel.showingRecent + ? Commons.Util.alpha(panel.recentHighlight, 0.16) + : panel.controlFillColor + border.width: panel.controlBorderWidth + border.color: panel.showingRecent + ? panel.recentHighlight : panel.controlBorderColor + + Text { + anchors.centerIn: parent + text: "Recent" + color: panel.recentHighlight + font.family: panel.bar ? panel.bar.fontFamily + : Commons.Style.font.family + font.pixelSize: 11 + font.weight: Font.Medium + renderType: Text.NativeRendering + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: panel.selectTab("recent") + } + } + + Rectangle { + width: (parent.width - parent.spacing) / 2 + height: parent.height + radius: panel.controlRadius + color: !panel.showingRecent + ? Commons.Util.alpha(panel.liveHighlight, 0.16) + : panel.controlFillColor + border.width: panel.controlBorderWidth + border.color: !panel.showingRecent + ? panel.liveHighlight : panel.controlBorderColor + + Text { + anchors.centerIn: parent + text: "Live" + color: panel.liveHighlight + font.family: panel.bar ? panel.bar.fontFamily + : Commons.Style.font.family + font.pixelSize: 11 + font.weight: Font.Medium + renderType: Text.NativeRendering + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: panel.selectTab("live") + } + } + } + Item { id: listViewport width: parent.width height: notificationList.count > 0 - ? Math.min(notificationList.contentHeight, 420) + ? Math.min(notificationList.contentHeight, 384) : emptyLabel.implicitHeight ListView { @@ -241,6 +350,19 @@ ShibumiPanel { anchors.rightMargin: 26 spacing: 3 + Text { + width: parent.width + text: notificationRow.bucket === "past" ? "RECENT" : "LIVE" + color: notificationRow.bucket === "past" + ? panel.recentHighlight : panel.liveHighlight + font.family: panel.bar ? panel.bar.fontFamily + : Commons.Style.font.family + font.pixelSize: 9 + font.letterSpacing: 1.2 + font.weight: Font.Medium + renderType: Text.NativeRendering + } + Text { width: parent.width text: notificationRow.app || "App" @@ -297,7 +419,7 @@ ShibumiPanel { : Commons.Util.alpha(panel.controlForeground, 0.45) font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family - font.pixelSize: 10 + font.pixelSize: 12 renderType: Text.NativeRendering } diff --git a/hancore.shibumi.status/Service.qml b/hancore.shibumi.status/Service.qml index 3f92311..2b1bc9f 100644 --- a/hancore.shibumi.status/Service.qml +++ b/hancore.shibumi.status/Service.qml @@ -17,14 +17,20 @@ Item { readonly property var idleService: shell && typeof shell.firstPartyServiceFor === "function" ? shell.firstPartyServiceFor("omarchy.idle") : null - readonly property var notificationService: shell - && typeof shell.firstPartyServiceFor === "function" - ? shell.firstPartyServiceFor("omarchy.notifications") : null + readonly property var notificationService: notificationAdapter.available + ? notificationAdapter : null readonly property bool stayAwake: idleService ? idleService.stayAwake === true : false readonly property bool notificationsSilenced: notificationService ? notificationService.doNotDisturb === true : false + NotificationAdapter { + id: notificationAdapter + } + + onShellChanged: notificationAdapter.attachShell(shell) + Component.onCompleted: notificationAdapter.attachShell(shell) + property string recordingPid: "" property int recordingElapsed: 0 property int recordingBaseElapsed: 0 diff --git a/scripts/check-production-boundary b/scripts/check-production-boundary index 539a767..1b37246 100755 --- a/scripts/check-production-boundary +++ b/scripts/check-production-boundary @@ -78,14 +78,17 @@ TOKEN_ALLOWANCES = { }), "hancore.shibumi.status/BarWidget.qml": Counter({ "entryPointUrl": 2, - "firstPartyServiceFor": 5, + "firstPartyServiceFor": 3, "registeredComponent": 3, "registeredSource": 3, "registeredWidgetComponent": 2, "registeredWidgetSource": 2, }), + "hancore.shibumi.status/NotificationAdapter.qml": Counter({ + "firstPartyServiceFor": 2, + }), "hancore.shibumi.status/Service.qml": Counter({ - "firstPartyServiceFor": 4, + "firstPartyServiceFor": 2, }), } @@ -108,9 +111,11 @@ FIRST_PARTY_ALLOWANCES = { "hancore.shibumi.media/BarWidget.qml": {"omarchy.media"}, "hancore.shibumi.media/Service.qml": {"omarchy.media"}, "hancore.shibumi.reactor/ReactorService.qml": {"omarchy.media"}, - "hancore.shibumi.status/BarWidget.qml": {"omarchy.notifications"}, + "hancore.shibumi.status/NotificationAdapter.qml": { + "omarchy.notifications", + }, "hancore.shibumi.status/Service.qml": { - "omarchy.idle", "omarchy.notifications", + "omarchy.idle", }, } diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index 82212ee..9a3bbb3 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -1099,6 +1099,7 @@ OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/state-service-regression.sh" OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/workspaces-plugin-regression.sh" "$repo_root/tests/update-center-regression.sh" OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/status-plugin-regression.sh" + OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/notification-adapter-regression.sh" OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/center-plugin-regression.sh" OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/network-plugin-regression.sh" OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/brightness-plugin-regression.sh" @@ -1177,11 +1178,21 @@ OMARCHY_PATH="$OMARCHY_PATH" "$repo_root/tests/state-service-regression.sh" rg -q "$status_contract" "$official_tray_widget" \ || fail "official tray widget contract changed: $status_contract" done - for status_contract in pendingModel pastModel doNotDisturb setDoNotDisturb \ - markAllSeen dismissPending dismissPast clearPast; do - rg -q "$status_contract" "$official_notification_service" \ - || fail "official notification widget contract changed: $status_contract" - done + if rg -q 'popupModel' "$official_notification_service"; then + for status_contract in popupModel doNotDisturb setDoNotDisturb \ + dismissPopup clearPopups focusApp showRecentHistory; do + rg -q "$status_contract" "$official_notification_service" \ + || fail "official current notification contract changed: $status_contract" + done + elif rg -q 'pendingModel' "$official_notification_service"; then + for status_contract in pendingModel pastModel doNotDisturb setDoNotDisturb \ + markAllSeen dismissPending dismissPast clearPast; do + rg -q "$status_contract" "$official_notification_service" \ + || fail "official legacy notification contract changed: $status_contract" + done + else + fail "official notification service exposes neither current nor legacy model contract" + fi host_has_module() { find "${OMARCHY_PATH}/shell/plugins" -type f \ diff --git a/tests/notification-adapter-regression.sh b/tests/notification-adapter-regression.sh new file mode 100755 index 0000000..519e445 --- /dev/null +++ b/tests/notification-adapter-regression.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +omarchy_path=${OMARCHY_PATH:-/usr/share/omarchy} +quickshell_bin=${QUICKSHELL_BIN:-/usr/bin/quickshell} +tmpdir=$(mktemp -d /tmp/shibumi-notification-adapter.XXXXXX) +trap 'rm -rf -- "$tmpdir"' EXIT + +fail() { + printf 'notification adapter regression failed: %s\n' "$*" >&2 + exit 1 +} + +[[ -d $omarchy_path/shell/Commons ]] || fail "Omarchy Commons not found" +[[ -d $omarchy_path/shell/Ui ]] || fail "Omarchy Ui not found" +[[ -x $quickshell_bin ]] || fail "Quickshell not found" + +mkdir -p "$tmpdir/runtime" "$tmpdir/status" +chmod 700 "$tmpdir/runtime" +cp -a -- "$repo_root/hancore.shibumi.status/." "$tmpdir/status/" +cp -a -- "$omarchy_path/shell/Commons" "$tmpdir/Commons" +cp -a -- "$omarchy_path/shell/Ui" "$tmpdir/Ui" +cp -- "$repo_root/tests/notification-adapter-smoke.qml" "$tmpdir/shell.qml" + +set +e +output=$(timeout 8 env \ + QT_QPA_PLATFORM=offscreen \ + WAYLAND_DISPLAY= \ + XDG_RUNTIME_DIR="$tmpdir/runtime" \ + QML_IMPORT_PATH="$omarchy_path/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ + QML2_IMPORT_PATH="$omarchy_path/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ + "$quickshell_bin" -p "$tmpdir" 2>&1) +rc=$? +set -e +printf '%s\n' "$output" + +[[ $rc -eq 0 ]] || fail "smoke exited $rc" +grep -Fq 'notification adapter smoke passed' <<<"$output" \ + || fail 'success marker missing' +if grep -Eq 'Binding loop|TypeError|ReferenceError|is not a type|failed to load' \ + <<<"$output"; then + fail 'runtime log contains a composition error' +fi + +printf 'notification adapter regression passed\n' diff --git a/tests/notification-adapter-smoke.qml b/tests/notification-adapter-smoke.qml new file mode 100644 index 0000000..534779e --- /dev/null +++ b/tests/notification-adapter-smoke.qml @@ -0,0 +1,316 @@ +import QtQuick +import Quickshell +import "status" as Status + +ShellRoot { + id: root + + property int phase: 0 + property int ticks: 0 + property bool focused: false + + function fail(message) { + console.error("notification-adapter-smoke:", message) + Qt.exit(1) + } + + ListModel { + id: hostPopupModel + + ListElement { + originalId: 7 + app: "Adapter fixture" + appIcon: "" + summary: "Current notification" + body: "Primitive host row" + image: "" + glyph: "" + exec: "" + urgency: 1 + expireTimeout: 8000 + timestamp: 100 + } + } + + ListModel { + id: legacyPendingModel + + ListElement { + originalId: 17 + app: "Legacy fixture" + appIcon: "" + summary: "Legacy pending" + body: "Legacy model row" + image: "" + urgency: 1 + expireTimeout: 8000 + timestamp: 200 + } + } + + ListModel { + id: legacyPastModel + + ListElement { + originalId: 18 + app: "Legacy fixture" + appIcon: "" + summary: "Legacy recent" + body: "Legacy history row" + image: "" + urgency: 1 + expireTimeout: 0 + timestamp: 201 + } + } + + QtObject { + id: fakeHost + + property var popupModel: hostPopupModel + property bool doNotDisturb: false + property int dismissCount: 0 + property int clearCount: 0 + property bool delayHistory: false + property bool historyQueued: false + property double replayTimestamp: 0 + property string focusedSummary: "" + + function setDoNotDisturb(value) { + doNotDisturb = value === true + } + + function dismissPopup(index) { + if (index < 0 || index >= popupModel.count) return + dismissCount++ + popupModel.remove(index) + } + + function clearPopups() { + clearCount++ + popupModel.clear() + } + + function replayHistory() { + // Simulate a notification arriving after replay was requested but before + // the host clears and rebuilds its popup model. + popupModel.append({ + id: 21, originalId: 21, app: "Adapter fixture", appIcon: "", + summary: "Late live notification", body: "Arrived during replay", + image: "", glyph: "", exec: "", urgency: 1, + expireTimeout: 8000, + timestamp: replayTimestamp || Date.now() + }) + popupModel.clear() + popupModel.append({ + id: 19, originalId: 19, app: "Adapter fixture", appIcon: "", + summary: "History notification", body: "Saved during DND", + image: "", glyph: "", exec: "", urgency: 1, + expireTimeout: 0, timestamp: 300 + }) + } + + function showRecentHistory() { + replayTimestamp = Date.now() + if (delayHistory) { + historyQueued = true + return + } + replayHistory() + } + + function finishHistoryReplay() { + if (!historyQueued) return + historyQueued = false + replayHistory() + } + + function focusApp(entry) { + focusedSummary = String(entry && entry.summary || "") + } + } + + QtObject { + id: fakeShell + + function firstPartyServiceFor(_id) { + return fakeHost + } + } + + Status.NotificationAdapter { + id: adapter + } + + Status.NotificationAdapter { + id: unavailableAdapter + } + + QtObject { + id: legacyHost + + property var popupModel: hostPopupModel + property var pendingModel: legacyPendingModel + property var pastModel: legacyPastModel + } + + QtObject { + id: legacyShell + + function firstPartyServiceFor(_id) { + return legacyHost + } + } + + Status.NotificationAdapter { + id: legacyAdapter + } + + Component.onCompleted: { + adapter.attachShell(fakeShell) + legacyAdapter.attachShell(legacyShell) + unavailableAdapter.attachShell(null) + } + + Timer { + interval: 20 + repeat: true + running: true + + onTriggered: { + root.ticks++ + if (root.phase === 0) { + if (root.ticks < 3 || adapter.pendingModel.count !== 1) return + const row = adapter.pendingModel.get(0) + if (!adapter.available || !adapter.historyAvailable + || adapter.pastModel.count !== 0 + || row.summary !== "Current notification" + || row.body !== "Primitive host row" + || adapter.pendingCount !== 1 + || legacyAdapter.pendingModel.count !== 1 + || legacyAdapter.pendingModel.get(0).summary + !== "Legacy pending" + || legacyAdapter.pastModel.count !== 1 + || legacyAdapter.pastModel.get(0).summary + !== "Legacy recent" + || unavailableAdapter.available + || unavailableAdapter.pendingModel.count !== 0) + return root.fail("current host popup was not normalized") + hostPopupModel.append({ + id: -1, originalId: -1, app: "omarchy-action", appIcon: "", + summary: "No recent notifications", body: "", image: "", + urgency: 0, expireTimeout: 0, timestamp: Date.now() + }) + if (adapter.pendingModel.count !== 1) + return root.fail("host history sentinel leaked into live rows") + hostPopupModel.remove(hostPopupModel.count - 1) + legacyPastModel.append({ + originalId: 20, app: "Legacy fixture", appIcon: "", + summary: "Legacy appended", body: "Updated history", image: "", + urgency: 1, expireTimeout: 0, timestamp: 202 + }) + adapter.attachShell(null) + root.phase = 1 + root.ticks = 0 + } else if (root.phase === 1) { + if (adapter.available || adapter.pendingModel.count !== 0 + || legacyAdapter.pastModel.count !== 2) return + adapter.attachShell(fakeShell) + root.phase = 2 + root.ticks = 0 + } else if (root.phase === 2) { + if (adapter.pendingModel.count !== 1) return + hostPopupModel.append({ + id: 8, originalId: 8, app: "Adapter fixture", appIcon: "", + summary: "Second notification", body: "Updated model", + image: "", glyph: "", exec: "", urgency: 1, + expireTimeout: 8000, timestamp: 101 + }) + root.phase = 3 + root.ticks = 0 + } else if (root.phase === 3) { + if (adapter.pendingModel.count !== 2) return + if (!adapter.setDoNotDisturb(true) || !adapter.doNotDisturb + || !adapter.dismissPending(0) || fakeHost.dismissCount !== 1) { + return root.fail("DND or pending dismiss bypassed the host adapter") + } + root.phase = 4 + root.ticks = 0 + } else if (root.phase === 4) { + if (adapter.pendingModel.count !== 1) return + if (!adapter.focusApp(adapter.pendingModel.get(0)) + || fakeHost.focusedSummary !== "Second notification" + || !adapter.clearPending() || fakeHost.clearCount !== 1) { + return root.fail("focus or clear action bypassed the host adapter") + } + root.phase = 5 + root.ticks = 0 + } else if (root.phase === 5) { + if (adapter.pendingModel.count !== 0) return + if (!adapter.showHistory()) + return root.fail("host history action was not exposed") + root.phase = 6 + root.ticks = 0 + } else if (root.phase === 6) { + if (adapter.pastModel.count !== 1) return + if (adapter.pastModel.get(0).summary !== "History notification" + || adapter.pendingModel.count !== 1 + || adapter.pendingModel.get(0).summary + !== "Late live notification") + return root.fail("late live row was lost during history replay") + hostPopupModel.append({ + id: 20, originalId: 20, app: "Adapter fixture", appIcon: "", + summary: "New live notification", body: "Arrived after history", + image: "", glyph: "", exec: "", urgency: 1, + expireTimeout: 8000, timestamp: Date.now() + }) + root.phase = 7 + root.ticks = 0 + } else if (root.phase === 7) { + if (adapter.pendingModel.count !== 2 + || adapter.pendingModel.get(1).summary + !== "New live notification" + || adapter.pastModel.count !== 1) return + if (!adapter.dismissPending(0) + || adapter.pendingModel.count !== 1 + || adapter.pendingModel.get(0).summary + !== "New live notification") + return root.fail("late live row was resurrected after dismiss") + if (!adapter.pastDismissAvailable || !adapter.dismissPast(0) + || adapter.pastModel.count !== 0) + return root.fail("current-host history dismiss was not reflected") + if (!adapter.clearPending()) + return root.fail("current-host replay rows could not be cleared") + root.phase = 8 + root.ticks = 0 + } else if (root.phase === 8) { + if (adapter.pendingModel.count !== 0 || adapter.pastModel.count !== 0) + return + fakeHost.delayHistory = true + if (!adapter.showHistory() || !adapter.clearPending()) + return root.fail("in-flight history clear was not accepted") + fakeHost.finishHistoryReplay() + root.phase = 9 + root.ticks = 0 + } else if (root.phase === 9) { + if (adapter.pendingModel.count !== 0 || adapter.pastModel.count !== 0) + return + hostPopupModel.append({ + id: 22, originalId: 22, app: "Adapter fixture", appIcon: "", + summary: "Post-clear live", body: "New after clear", + image: "", glyph: "", exec: "", urgency: 1, + expireTimeout: 8000, timestamp: Date.now() + }) + root.phase = 10 + root.ticks = 0 + } else if (root.phase === 10) { + if (adapter.pendingModel.count !== 1 + || adapter.pendingModel.get(0).summary !== "Post-clear live" + || adapter.pastModel.count !== 0) return + console.log("notification adapter smoke passed") + Qt.exit(0) + } + if (root.ticks > 100) root.fail("adapter smoke timed out") + } + } +} diff --git a/tests/status-plugin-regression.sh b/tests/status-plugin-regression.sh index c06272b..e2fce54 100755 --- a/tests/status-plugin-regression.sh +++ b/tests/status-plugin-regression.sh @@ -58,8 +58,11 @@ rg -q 'registered(Source|Component)\("omarchy\.tray"\)' "$status_widget" \ rg -q 'registered(Source|Component)\("hancore\.shibumi\.update-center"\)' \ "$status_widget" \ || fail "status view does not resolve the Shibumi update center" -rg -q 'firstPartyServiceFor\("omarchy\.notifications"\)' "$status_widget" \ - || fail "status view does not resolve the official notification service" +rg -q 'serviceFor\("hancore\.shibumi\.status"\)' "$status_widget" \ + || fail "status view does not resolve the Shibumi notification adapter" +if rg -q 'firstPartyServiceFor\("omarchy\.notifications"\)' "$status_widget"; then + fail "status view bypasses the Shibumi notification adapter" +fi if rg -q 'notification(Source|Component|Loader|Widget)' "$status_widget"; then fail "status view retained the removed Quattro notification bar widget" fi @@ -171,14 +174,46 @@ rg -Fq 'notificationService.dismissPast(index)' \ rg -Fq 'notificationService.clearPast()' \ "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ || fail "V1 notification panel cannot clear recent notifications" +rg -Fq 'notificationService.showHistory()' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification panel cannot open host-owned notification history" +rg -Fq 'text: "Recent"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification panel has no recent-history action" +rg -Fq 'notificationRow.bucket === "past" ? "RECENT" : "LIVE"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification rows do not label live versus recent entries" +rg -Fq 'paletteColor("color03"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification live highlight does not use color03" +rg -Fq 'paletteColor("color04"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification recent highlight does not use color04" +rg -Fq 'text: "Live"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification panel has no live tab" +rg -Fq 'function selectTab(tab)' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" \ + || fail "V1 notification panel has no live/recent tab selection" +recent_tab_line=$(rg -n 'text: "Recent"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" | head -1 | cut -d: -f1) +live_tab_line=$(rg -n 'text: "Live"' \ + "$repo_root/hancore.shibumi.status/NotificationPanel.qml" | head -1 | cut -d: -f1) +(( recent_tab_line < live_tab_line )) \ + || fail "V1 notification tabs are not ordered Recent then Live" if rg -q 'PopupCard|Quickshell\.Services\.Notifications|makoctl' \ "$repo_root/hancore.shibumi.status/NotificationPanel.qml"; then fail "V1 notification presentation duplicates stock chrome or backend ownership" fi rg -q 'firstPartyServiceFor\("omarchy\.idle"\)' "$status_service" \ || fail "status service bypasses the official idle service" -rg -q 'firstPartyServiceFor\("omarchy\.notifications"\)' "$status_service" \ - || fail "status service bypasses the official notification service" +rg -q 'firstPartyServiceFor\("omarchy\.notifications"\)' \ + "$repo_root/hancore.shibumi.status/NotificationAdapter.qml" \ + || fail "notification adapter does not resolve the official service" +rg -Fq 'NotificationAdapter' "$status_service" \ + || fail "status service does not expose the notification adapter" +rg -Fq 'popupModel' "$repo_root/hancore.shibumi.status/NotificationAdapter.qml" \ + || fail "notification adapter does not support the current host model" if rg -U -q 'onLoaded: \{[^}]*root\.scheduleChildSync' "$status_widget"; then fail "loaded status children recursively reschedule their own loaders" fi diff --git a/tests/status-plugin-smoke.qml b/tests/status-plugin-smoke.qml index 1829caf..222cd65 100644 --- a/tests/status-plugin-smoke.qml +++ b/tests/status-plugin-smoke.qml @@ -102,8 +102,11 @@ ShellRoot { QtObject { id: fakeShell + property var statusFacade: null function serviceFor(id) { - return id === "hancore.shibumi.state" ? fakeState : null + if (id === "hancore.shibumi.state") return fakeState + if (id === "hancore.shibumi.status") return statusFacade + return null } function firstPartyServiceFor(id) { if (id === "omarchy.idle") return fakeIdle @@ -128,6 +131,8 @@ ShellRoot { runtimeProbesEnabled: false } + Component.onCompleted: fakeShell.statusFacade = statusService + QtObject { id: fakeBar property bool vertical: false