feat: add whyoolw/sharednd — auto DND while screen sharing (niri)

Headless service that follows niri msg -j event-stream and enables
notification Do Not Disturb via the host IPC (notification-dnd-set)
while at least one screencast is active, restoring it afterwards.

Side effects: spawns a persistent shell retry loop around
`niri msg -j event-stream`, runs `niri msg -j casts` /
`niri msg version` and `noctalia msg notification-dnd-*` on demand.
No network access, no filesystem writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wioletowa
2026-07-14 01:28:53 +03:00
co-authored by Claude Fable 5
parent 74dc5fcd81
commit 704fa538b0
5 changed files with 235 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# ShareDND
Automatic Do Not Disturb while the screen is being shared.
When the first screencast starts (any app sharing via the portal — Discord,
OBS, browsers, `niri msg action set-dynamic-cast-*`, ...), notification DND is
enabled. When the last screencast stops, notifications come back.
## How it works
- A headless service follows `niri msg -j event-stream` and reacts to
`CastsChanged` / `CastStartedOrChanged` / `CastStopped` events — no polling.
- On every cast event it re-queries `niri msg -j casts` as the authoritative
state, so missed or reordered events cannot desync it. The stream is wrapped
in a shell retry loop and resends full state on reconnect.
- DND is toggled through the host IPC (`noctalia msg notification-dnd-set`),
so the usual OSD feedback appears.
## Ownership rules
- If DND was **already on** before sharing started, the plugin leaves it on
after sharing ends (it never took ownership). The
"Always disable DND after sharing" setting overrides this.
- If DND was enabled manually *during* sharing while the plugin owned it, it
will still be turned off when sharing ends (the plugin cannot tell the
difference).
## Settings
- **Only active streams** — count only casts with `is_active: true`. Off by
default: any open screencast session (even paused or showing nothing) keeps
DND on.
- **Always disable DND after sharing** — force DND off when sharing ends,
regardless of its state before sharing started.
## Requirements & limitations
- Requires **niri** (detection is niri IPC; on other compositors the plugin
is inert and shows a warning at startup).
- There is no plugin-disable hook in the host: if you disable the plugin
mid-share while it owns DND, DND stays on — toggle it manually.
+39
View File
@@ -0,0 +1,39 @@
# ShareDND — automatic Do Not Disturb while the screen is being shared.
#
# A headless [[service]] follows `niri msg -j event-stream` and re-queries
# `niri msg -j casts` on every Cast* event. When the first screencast
# appears, notification DND is enabled through the host IPC
# (notification-dnd-set); when the last one disappears, DND is restored.
# If DND was already on before sharing started, the plugin does not take
# ownership and leaves it on afterwards (configurable).
#
# External requirements: niri (screencast events come from niri IPC).
id = "whyoolw/sharednd"
name = "ShareDND"
version = "1.0.0"
min_noctalia = "5.0.0"
author = "whyoolw"
license = "MIT"
dependencies = ["niri"]
tags = ["notifications", "privacy", "niri"]
icon = "screen-share"
description = "Enables notification Do Not Disturb while the screen is shared (niri screencast) and turns notifications back on when sharing stops. Requires niri."
[[setting]]
key = "only_active"
type = "bool"
label_key = "settings.only_active.label"
description_key = "settings.only_active.description"
default = false
[[setting]]
key = "always_off_after"
type = "bool"
label_key = "settings.always_off_after.label"
description_key = "settings.always_off_after.description"
default = false
[[service]]
id = "service"
entry = "service.luau"
+146
View File
@@ -0,0 +1,146 @@
-- ShareDND service: watches niri screencasts and toggles notification
-- Do Not Disturb while the screen is being shared.
--
-- Detection is event-driven: a persistent `niri msg -j event-stream` runs
-- under runStream, wrapped in a shell retry loop because the stream API has
-- no exit notification. Any Cast* event triggers a re-query of
-- `niri msg -j casts`, which is the authoritative state, so no per-event
-- bookkeeping is needed and reconnects re-sync for free (the stream sends
-- the full current state, including CastsChanged, on connect).
local function cfg(key)
return noctalia.getConfig(key)
end
-- true while at least one screencast is believed to be running
local sharingActive = false
-- true when this plugin enabled DND (and therefore owns turning it off)
local dndSetByUs = false
local queryInFlight = false
local queryDirty = false
local function countCasts(json)
local needle = cfg("only_active") and '"is_active":true' or '"session_id":'
local n = 0
local pos = 1
while true do
local s, e = json:find(needle, pos, true)
if not s then
break
end
n = n + 1
pos = e + 1
end
return n
end
local function setDnd(on, cb)
noctalia.runAsync("noctalia msg notification-dnd-set " .. (on and "on" or "off"), function(res)
local ok = res ~= nil and res.exitCode == 0
if not ok then
noctalia.log("sharednd: notification-dnd-set failed")
end
if cb then
cb(ok)
end
end)
end
local function onSharingStarted()
noctalia.runAsync("noctalia msg notification-dnd-status", function(res)
if not sharingActive then
return -- sharing already ended while the status query was in flight
end
if res == nil or res.exitCode ~= 0 then
noctalia.log("sharednd: notification-dnd-status failed")
return
end
if (res.stdout or ""):match("^%s*on") then
-- DND was already on (enabled manually) — don't take ownership.
dndSetByUs = false
noctalia.log("sharednd: sharing started, DND already on")
else
setDnd(true, function(ok)
dndSetByUs = ok
if ok then
noctalia.log("sharednd: sharing started, DND enabled")
if not sharingActive then
-- sharing ended while the set was in flight — undo
setDnd(false)
dndSetByUs = false
end
end
end)
end
end)
end
local function onSharingStopped()
if dndSetByUs or cfg("always_off_after") then
setDnd(false)
noctalia.log("sharednd: sharing stopped, DND disabled")
else
noctalia.log("sharednd: sharing stopped, DND left as-is")
end
dndSetByUs = false
end
local function applyState(activeCount)
local active = activeCount > 0
if active == sharingActive then
return
end
sharingActive = active
if active then
onSharingStarted()
else
onSharingStopped()
end
end
local function queryCasts()
if queryInFlight then
queryDirty = true
return
end
queryInFlight = true
noctalia.runAsync("niri msg -j casts", function(res)
queryInFlight = false
if res ~= nil and res.exitCode == 0 and res.stdout ~= nil then
applyState(countCasts(res.stdout))
end
if queryDirty then
queryDirty = false
queryCasts()
end
end)
end
local function onEventLine(line)
-- Matches CastsChanged / CastStartedOrChanged / CastStopped. The casts
-- query is authoritative, so a rare false positive (a window title
-- containing '"Cast') only costs one extra query.
if line:find('"Cast', 1, true) then
queryCasts()
end
end
-- Boot. The niri check is a user-facing warning only; the stream loop below
-- retries forever anyway, which also covers niri's socket coming up after
-- noctalia during session start.
noctalia.runAsync("niri msg version", function(res)
if res == nil or res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("notify.no_niri_title"), noctalia.tr("notify.no_niri_body"))
noctalia.log("sharednd: niri IPC not reachable at startup")
end
end)
-- The process group is killed on plugin disable, taking the shell loop and
-- the stream down with it.
if not noctalia.runStream("while :; do niri msg -j event-stream 2>/dev/null; sleep 3; done", onEventLine) then
noctalia.log("sharednd: failed to start niri event-stream")
end
-- Safety net in case the initial CastsChanged is ever missed.
queryCasts()
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+9
View File
@@ -0,0 +1,9 @@
{
"title": "ShareDND",
"settings.only_active.label": "Only active streams",
"settings.only_active.description": "Count only screencasts that are actively streaming. When off, any open screencast session (even one paused or showing nothing) keeps Do Not Disturb enabled.",
"settings.always_off_after.label": "Always disable DND after sharing",
"settings.always_off_after.description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
"notify.no_niri_title": "ShareDND: niri not found",
"notify.no_niri_body": "Screen sharing detection uses niri IPC (niri msg). The plugin will keep retrying, but it only works under niri."
}