diff --git a/sharednd/README.md b/sharednd/README.md new file mode 100644 index 0000000..5180bf2 --- /dev/null +++ b/sharednd/README.md @@ -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. diff --git a/sharednd/plugin.toml b/sharednd/plugin.toml new file mode 100644 index 0000000..cb70aea --- /dev/null +++ b/sharednd/plugin.toml @@ -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" diff --git a/sharednd/service.luau b/sharednd/service.luau new file mode 100644 index 0000000..5ce4500 --- /dev/null +++ b/sharednd/service.luau @@ -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() diff --git a/sharednd/thumbnail.webp b/sharednd/thumbnail.webp new file mode 100644 index 0000000..6314898 Binary files /dev/null and b/sharednd/thumbnail.webp differ diff --git a/sharednd/translations/en.json b/sharednd/translations/en.json new file mode 100644 index 0000000..30a12c2 --- /dev/null +++ b/sharednd/translations/en.json @@ -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." +}