Merge pull request #5 from wioletowa/add-sharednd
feat: add whyoolw/sharednd — auto DND while screen sharing
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
- Stop transitions are debounced by ~1 second: switching what is being shared
|
||||
produces a stop/start event pair, which the debounce collapses — DND does
|
||||
not flap, and the stop/start race cannot drop ownership.
|
||||
- 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). Detection only starts inside a
|
||||
niri session (`NIRI_SOCKET` set and the `niri` binary in `PATH`); on other
|
||||
compositors the service is inert and spawns no processes.
|
||||
- Disabling or reloading the plugin mid-share while it owns DND turns DND
|
||||
back off (`onExit` cleanup).
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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). Detection only
|
||||
# starts inside a niri session (NIRI_SOCKET + niri binary present); on
|
||||
# other compositors the service is inert and spawns nothing.
|
||||
#
|
||||
# External requirements: niri (screencast events come from niri IPC).
|
||||
|
||||
id = "whyoolw/sharednd"
|
||||
name = "ShareDND"
|
||||
version = "1.1.0"
|
||||
min_noctalia = "5.0.0"
|
||||
author = "whyoolw"
|
||||
license = "MIT"
|
||||
dependencies = ["niri"]
|
||||
tags = ["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"
|
||||
@@ -0,0 +1,188 @@
|
||||
-- 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).
|
||||
--
|
||||
-- Stop transitions are debounced by STOP_DEBOUNCE seconds. Switching what is
|
||||
-- being shared surfaces as a stop/start event pair; acting on the stop
|
||||
-- immediately would race the follow-up status query (the async `off` can
|
||||
-- land after the query already read DND as on), dropping ownership and
|
||||
-- leaving DND off for the rest of the new sharing session. The debounce
|
||||
-- collapses the pair into no transition, and also keeps DND from flapping.
|
||||
|
||||
local STOP_DEBOUNCE = 1.0
|
||||
|
||||
local function cfg(key)
|
||||
return noctalia.getConfig(key)
|
||||
end
|
||||
|
||||
-- debounced sharing state that the DND logic acts on
|
||||
local sharingActive = false
|
||||
-- true when this plugin enabled DND (and therefore owns turning it off)
|
||||
local dndSetByUs = false
|
||||
|
||||
-- raw state from the last casts query + pending debounced stop deadline
|
||||
local rawActive = false
|
||||
local stopDeadline = nil -- os.clock() timestamp, nil when no stop is pending
|
||||
|
||||
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)
|
||||
rawActive = activeCount > 0
|
||||
if rawActive then
|
||||
-- A restart within the debounce window cancels the pending stop, so a
|
||||
-- stop/start pair from switching casts is no transition at all.
|
||||
stopDeadline = nil
|
||||
if not sharingActive then
|
||||
sharingActive = true
|
||||
onSharingStarted()
|
||||
end
|
||||
elseif sharingActive and stopDeadline == nil then
|
||||
stopDeadline = os.clock() + STOP_DEBOUNCE
|
||||
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
|
||||
|
||||
-- Host-driven tick (~250 ms): commits a pending stop once the debounce
|
||||
-- window has passed without sharing resuming.
|
||||
function update()
|
||||
if stopDeadline ~= nil and os.clock() >= stopDeadline then
|
||||
stopDeadline = nil
|
||||
if sharingActive and not rawActive then
|
||||
sharingActive = false
|
||||
onSharingStopped()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Called by the host on plugin disable/reload. The callback-less runAsync
|
||||
-- spawns a detached process, so it survives the VM teardown.
|
||||
function onExit()
|
||||
if dndSetByUs then
|
||||
noctalia.runAsync("noctalia msg notification-dnd-set off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot. `niri msg` needs the niri binary and NIRI_SOCKET (set only inside a
|
||||
-- niri session), so gate detection on both instead of spawning a retry loop
|
||||
-- that can never succeed on other compositors.
|
||||
local niriSocket = noctalia.getenv("NIRI_SOCKET")
|
||||
if niriSocket == nil or niriSocket == "" then
|
||||
-- Expected on other compositors: stay silent and spawn nothing.
|
||||
noctalia.log("sharednd: no NIRI_SOCKET, not a niri session — detection disabled")
|
||||
elseif not noctalia.commandExists("niri") then
|
||||
-- Inside a niri session but the binary is unreachable — worth a warning.
|
||||
noctalia.notifyError(noctalia.tr("notify.no_niri_title"), noctalia.tr("notify.no_niri_body"))
|
||||
noctalia.log("sharednd: NIRI_SOCKET set but niri not in PATH — detection disabled")
|
||||
else
|
||||
-- The process group is killed on plugin disable, taking the shell loop and
|
||||
-- the stream down with it. That cleanup does not run when the host dies
|
||||
-- hard (crash, session logout), so the loop also exits on its own once the
|
||||
-- parent is gone or the niri socket disappears — otherwise orphaned loops
|
||||
-- would keep respawning `niri msg` every 3 s across sessions.
|
||||
local loop = 'P=$PPID; while kill -0 "$P" 2>/dev/null && [ -S "$NIRI_SOCKET" ]; do'
|
||||
.. " niri msg -j event-stream 2>/dev/null; sleep 3; done"
|
||||
if not noctalia.runStream(loop, onEventLine) then
|
||||
noctalia.log("sharednd: failed to start niri event-stream")
|
||||
end
|
||||
-- Safety net in case the initial CastsChanged is ever missed.
|
||||
queryCasts()
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected."
|
||||
}
|
||||
Reference in New Issue
Block a user