Add desktop notification confirmation fallback, update README for PyPI
When MCP elicitation is unavailable (most clients), fall back to org.freedesktop.Notifications with Approve/Deny action buttons. Opt-in via MCDBUS_NOTIFY_CONFIRM=1. Silence (timeout) is denial. Fixes signal race where NotificationClosed stomped ActionInvoked result in the same event loop iteration.
This commit is contained in:
parent
89419a36c2
commit
c8a1ce0fdb
112
README.md
112
README.md
@ -2,16 +2,122 @@
|
|||||||
|
|
||||||
D-Bus MCP server — bridge Linux IPC into the Model Context Protocol.
|
D-Bus MCP server — bridge Linux IPC into the Model Context Protocol.
|
||||||
|
|
||||||
Gives Claude native tool access to session and system D-Bus services through introspection-first discovery.
|
Gives Claude (or any MCP client) native tool access to session and system D-Bus services through introspection-first discovery. No hardcoded service knowledge — the server discovers what's available on your bus at runtime.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run mcdbus
|
pip install mcdbus
|
||||||
|
# or
|
||||||
|
uvx mcdbus
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requires Python 3.11+ and a running D-Bus daemon (standard on any Linux desktop).
|
||||||
|
|
||||||
## Add to Claude Code
|
## Add to Claude Code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add mcdbus -- uv run --directory /path/to/mcdbus mcdbus
|
claude mcp add mcdbus -- uvx mcdbus
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With the notification confirmation fallback enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add -e MCDBUS_NOTIFY_CONFIRM=1 mcdbus -- uvx mcdbus
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The server exposes D-Bus through a discovery workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
list_services → list_objects → introspect → call_method / get_property
|
||||||
|
```
|
||||||
|
|
||||||
|
Two buses are available:
|
||||||
|
|
||||||
|
- **session** — user desktop services (notifications, media players, KDE, portals)
|
||||||
|
- **system** — system services (systemd, NetworkManager, UDisks2, UPower, bluez)
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `list_services` | List well-known service names on a bus |
|
||||||
|
| `introspect` | Show interfaces, methods, properties, and signals for a D-Bus object |
|
||||||
|
| `list_objects` | Walk the object tree for a service (bounded BFS) |
|
||||||
|
|
||||||
|
### Interaction
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `call_method` | Call any D-Bus method and return the result |
|
||||||
|
| `get_property` | Read a single property value |
|
||||||
|
| `set_property` | Set a property value (requires confirmation) |
|
||||||
|
| `get_all_properties` | Read all properties on an interface |
|
||||||
|
|
||||||
|
### Shortcuts
|
||||||
|
|
||||||
|
Pre-wired tools for common operations — no introspection needed:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `send_notification` | Send a desktop notification |
|
||||||
|
| `list_systemd_units` | List systemd units, with optional glob filter |
|
||||||
|
| `media_player_control` | Control MPRIS2 media players (play, pause, next, stop) |
|
||||||
|
| `network_status` | NetworkManager connection status |
|
||||||
|
| `battery_status` | Battery percentage and charging state via UPower |
|
||||||
|
| `bluetooth_devices` | Paired and discovered devices via bluez |
|
||||||
|
| `kwin_windows` | Open windows from KDE KWin |
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
| URI | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `dbus://{bus}/services` | Live list of service names |
|
||||||
|
| `dbus://{bus}/{service}/objects` | Object tree (max 200 nodes) |
|
||||||
|
| `dbus://{bus}/{service}/{path}/interfaces` | Interfaces at an object path |
|
||||||
|
|
||||||
|
### Prompts
|
||||||
|
|
||||||
|
| Prompt | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `explore_service` | Guided walkthrough of a D-Bus service |
|
||||||
|
| `debug_service` | Diagnose issues with a service |
|
||||||
|
|
||||||
|
## Confirmation model
|
||||||
|
|
||||||
|
Sensitive operations require explicit user approval before executing:
|
||||||
|
|
||||||
|
- **System bus method calls** — confirmed before execution
|
||||||
|
- **Property mutations** (`set_property`) — confirmed on any bus
|
||||||
|
|
||||||
|
The server tries three confirmation methods in order:
|
||||||
|
|
||||||
|
1. **MCP elicitation** — native protocol support (if the client implements it)
|
||||||
|
2. **Desktop notification** — Approve/Deny buttons via `org.freedesktop.Notifications` (opt-in, see below)
|
||||||
|
3. **Proceed with warning** — logs to stderr and allows the operation
|
||||||
|
|
||||||
|
### Desktop notification fallback
|
||||||
|
|
||||||
|
When the MCP client doesn't support elicitation (most don't yet, including Claude Code), the server can fall back to desktop notifications with action buttons. The notification stays up for 60 seconds. No response is treated as a denial.
|
||||||
|
|
||||||
|
Enable with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MCDBUS_NOTIFY_CONFIRM=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MCDBUS_NOTIFY_CONFIRM` | unset | Enable desktop notification fallback for confirmation |
|
||||||
|
| `MCDBUS_REQUIRE_ELICITATION` | unset | Hard-fail if client doesn't support MCP elicitation (overrides notification fallback) |
|
||||||
|
| `MCDBUS_TIMEOUT` | `30` | D-Bus call timeout in seconds |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mcdbus"
|
name = "mcdbus"
|
||||||
version = "2026.03.05"
|
version = "2026.03.06"
|
||||||
description = "D-Bus MCP server — bridge Linux IPC into the Model Context Protocol"
|
description = "D-Bus MCP server — bridge Linux IPC into the Model Context Protocol"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@ -15,6 +15,9 @@ dependencies = [
|
|||||||
"dbus-fast>=3.1.2",
|
"dbus-fast>=3.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Source = "https://git.supported.systems/warehack.ing/mcdbus"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mcdbus = "mcdbus.server:main"
|
mcdbus = "mcdbus.server:main"
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from mcdbus._bus import (
|
|||||||
validate_object_path,
|
validate_object_path,
|
||||||
validate_signature,
|
validate_signature,
|
||||||
)
|
)
|
||||||
|
from mcdbus._notify_confirm import notify_confirm
|
||||||
from mcdbus._state import mcp
|
from mcdbus._state import mcp
|
||||||
|
|
||||||
|
|
||||||
@ -44,6 +45,28 @@ async def _confirm_or_abort(ctx: Context, message: str, operation: str) -> None:
|
|||||||
# Client doesn't support elicitation
|
# Client doesn't support elicitation
|
||||||
if os.environ.get("MCDBUS_REQUIRE_ELICITATION"):
|
if os.environ.get("MCDBUS_REQUIRE_ELICITATION"):
|
||||||
raise ToolError("Elicitation required but client does not support it")
|
raise ToolError("Elicitation required but client does not support it")
|
||||||
|
|
||||||
|
# D-Bus notification fallback (opt-in)
|
||||||
|
if os.environ.get("MCDBUS_NOTIFY_CONFIRM"):
|
||||||
|
try:
|
||||||
|
mgr = get_mgr(ctx)
|
||||||
|
bus = await mgr.get_bus("session")
|
||||||
|
approved = await notify_confirm(
|
||||||
|
bus,
|
||||||
|
summary=f"mcdbus: Confirm {operation}",
|
||||||
|
body=message,
|
||||||
|
)
|
||||||
|
if approved:
|
||||||
|
return
|
||||||
|
raise ToolError("Operation denied via desktop notification.")
|
||||||
|
except (RuntimeError, TimeoutError) as exc:
|
||||||
|
print(
|
||||||
|
f"mcdbus: notification fallback failed ({exc}), "
|
||||||
|
f"proceeding with {operation}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
print(f"mcdbus: elicitation unavailable, proceeding with {operation}", file=sys.stderr)
|
print(f"mcdbus: elicitation unavailable, proceeding with {operation}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
# User explicitly declined
|
# User explicitly declined
|
||||||
|
|||||||
137
src/mcdbus/_notify_confirm.py
Normal file
137
src/mcdbus/_notify_confirm.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"""D-Bus notification fallback for confirmation when MCP elicitation is unavailable."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from dbus_fast import MessageType
|
||||||
|
from dbus_fast.aio import MessageBus
|
||||||
|
from dbus_fast.signature import Variant
|
||||||
|
|
||||||
|
from mcdbus._bus import call_bus_method
|
||||||
|
|
||||||
|
_NOTIFY_DEST = "org.freedesktop.Notifications"
|
||||||
|
_NOTIFY_PATH = "/org/freedesktop/Notifications"
|
||||||
|
_NOTIFY_IFACE = "org.freedesktop.Notifications"
|
||||||
|
|
||||||
|
_DBUS_DEST = "org.freedesktop.DBus"
|
||||||
|
_DBUS_PATH = "/org/freedesktop/DBus"
|
||||||
|
_DBUS_IFACE = "org.freedesktop.DBus"
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_confirm(
|
||||||
|
bus: MessageBus,
|
||||||
|
summary: str,
|
||||||
|
body: str,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
) -> bool:
|
||||||
|
"""Send a desktop notification with Approve/Deny actions and wait for response.
|
||||||
|
|
||||||
|
Returns True if user clicked Approve, False for deny/dismiss/expire/timeout.
|
||||||
|
Raises RuntimeError if notification service is unreachable.
|
||||||
|
"""
|
||||||
|
event = asyncio.Event()
|
||||||
|
result_box: list[bool] = [False]
|
||||||
|
nid_box: list[int] = [0]
|
||||||
|
|
||||||
|
match_action = (
|
||||||
|
"type='signal',"
|
||||||
|
f"interface='{_NOTIFY_IFACE}',"
|
||||||
|
"member='ActionInvoked'"
|
||||||
|
)
|
||||||
|
match_closed = (
|
||||||
|
"type='signal',"
|
||||||
|
f"interface='{_NOTIFY_IFACE}',"
|
||||||
|
"member='NotificationClosed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(msg):
|
||||||
|
if event.is_set():
|
||||||
|
return # First signal wins — ignore NotificationClosed after ActionInvoked
|
||||||
|
if msg.message_type != MessageType.SIGNAL:
|
||||||
|
return
|
||||||
|
if msg.interface != _NOTIFY_IFACE:
|
||||||
|
return
|
||||||
|
if not nid_box[0] or not msg.body or msg.body[0] != nid_box[0]:
|
||||||
|
return
|
||||||
|
if msg.member == "ActionInvoked":
|
||||||
|
result_box[0] = msg.body[1] == "approve"
|
||||||
|
event.set()
|
||||||
|
elif msg.member == "NotificationClosed":
|
||||||
|
result_box[0] = False
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
# Register match rules before sending notification — no signal can be missed.
|
||||||
|
await call_bus_method(
|
||||||
|
bus,
|
||||||
|
destination=_DBUS_DEST,
|
||||||
|
path=_DBUS_PATH,
|
||||||
|
interface=_DBUS_IFACE,
|
||||||
|
member="AddMatch",
|
||||||
|
signature="s",
|
||||||
|
body=[match_action],
|
||||||
|
)
|
||||||
|
await call_bus_method(
|
||||||
|
bus,
|
||||||
|
destination=_DBUS_DEST,
|
||||||
|
path=_DBUS_PATH,
|
||||||
|
interface=_DBUS_IFACE,
|
||||||
|
member="AddMatch",
|
||||||
|
signature="s",
|
||||||
|
body=[match_closed],
|
||||||
|
)
|
||||||
|
|
||||||
|
bus.add_message_handler(handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = await call_bus_method(
|
||||||
|
bus,
|
||||||
|
destination=_NOTIFY_DEST,
|
||||||
|
path=_NOTIFY_PATH,
|
||||||
|
interface=_NOTIFY_IFACE,
|
||||||
|
member="Notify",
|
||||||
|
signature="susssasa{sv}i",
|
||||||
|
body=[
|
||||||
|
"mcdbus",
|
||||||
|
0,
|
||||||
|
"dialog-warning",
|
||||||
|
summary,
|
||||||
|
body,
|
||||||
|
["approve", "Approve", "deny", "Deny"],
|
||||||
|
{"urgency": Variant("y", 2)},
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
nid_box[0] = reply[0]
|
||||||
|
|
||||||
|
await asyncio.wait_for(event.wait(), timeout=timeout)
|
||||||
|
return result_box[0]
|
||||||
|
except TimeoutError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
bus.remove_message_handler(handler)
|
||||||
|
# Best-effort cleanup
|
||||||
|
if nid_box[0]:
|
||||||
|
try:
|
||||||
|
await call_bus_method(
|
||||||
|
bus,
|
||||||
|
destination=_NOTIFY_DEST,
|
||||||
|
path=_NOTIFY_PATH,
|
||||||
|
interface=_NOTIFY_IFACE,
|
||||||
|
member="CloseNotification",
|
||||||
|
signature="u",
|
||||||
|
body=[nid_box[0]],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for rule in (match_action, match_closed):
|
||||||
|
try:
|
||||||
|
await call_bus_method(
|
||||||
|
bus,
|
||||||
|
destination=_DBUS_DEST,
|
||||||
|
path=_DBUS_PATH,
|
||||||
|
interface=_DBUS_IFACE,
|
||||||
|
member="RemoveMatch",
|
||||||
|
signature="s",
|
||||||
|
body=[rule],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@ -63,6 +63,73 @@ class TestConfirmOrAbort:
|
|||||||
with pytest.raises(ToolError, match="Elicitation required"):
|
with pytest.raises(ToolError, match="Elicitation required"):
|
||||||
await _confirm_or_abort(ctx, "Test message", "test_op")
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
|
||||||
|
async def test_cancelled_notify_fallback_approved(self):
|
||||||
|
"""MCDBUS_NOTIFY_CONFIRM=1 + user approves → proceeds normally."""
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
|
||||||
|
mock_mgr = AsyncMock()
|
||||||
|
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
|
||||||
|
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
|
||||||
|
patch("mcdbus._interaction.notify_confirm", return_value=True),
|
||||||
|
):
|
||||||
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
|
||||||
|
async def test_cancelled_notify_fallback_denied(self):
|
||||||
|
"""MCDBUS_NOTIFY_CONFIRM=1 + user denies → ToolError."""
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
|
||||||
|
mock_mgr = AsyncMock()
|
||||||
|
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
|
||||||
|
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
|
||||||
|
patch("mcdbus._interaction.notify_confirm", return_value=False),
|
||||||
|
):
|
||||||
|
with pytest.raises(ToolError, match="denied via desktop notification"):
|
||||||
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
|
||||||
|
async def test_cancelled_notify_unavailable_proceeds(self):
|
||||||
|
"""Notification service failure → falls through with warning."""
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
|
||||||
|
mock_mgr = AsyncMock()
|
||||||
|
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
|
||||||
|
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
|
||||||
|
patch(
|
||||||
|
"mcdbus._interaction.notify_confirm",
|
||||||
|
side_effect=RuntimeError("No notification service"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Should proceed without raising
|
||||||
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
|
||||||
|
async def test_require_elicitation_overrides_notify(self):
|
||||||
|
"""MCDBUS_REQUIRE_ELICITATION takes precedence over MCDBUS_NOTIFY_CONFIRM."""
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {
|
||||||
|
"MCDBUS_REQUIRE_ELICITATION": "1",
|
||||||
|
"MCDBUS_NOTIFY_CONFIRM": "1",
|
||||||
|
}),
|
||||||
|
patch("mcdbus._interaction.notify_confirm") as mock_notify,
|
||||||
|
):
|
||||||
|
with pytest.raises(ToolError, match="Elicitation required"):
|
||||||
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
async def test_notify_not_attempted_without_env(self):
|
||||||
|
"""Without MCDBUS_NOTIFY_CONFIRM, notify_confirm is never called."""
|
||||||
|
ctx = AsyncMock()
|
||||||
|
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
|
||||||
|
with patch("mcdbus._interaction.notify_confirm") as mock_notify:
|
||||||
|
await _confirm_or_abort(ctx, "Test message", "test_op")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestCallMethodElicitation:
|
class TestCallMethodElicitation:
|
||||||
"""Verify call_method triggers elicitation on system bus but not session bus."""
|
"""Verify call_method triggers elicitation on system bus but not session bus."""
|
||||||
|
|||||||
143
tests/test_notify_confirm.py
Normal file
143
tests/test_notify_confirm.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""Tests for D-Bus notification confirmation fallback."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dbus_fast import MessageType
|
||||||
|
|
||||||
|
from mcdbus._notify_confirm import notify_confirm
|
||||||
|
|
||||||
|
NID = 42
|
||||||
|
IFACE = "org.freedesktop.Notifications"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_signal(interface, member, body):
|
||||||
|
"""Create a mock D-Bus signal message."""
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.message_type = MessageType.SIGNAL
|
||||||
|
msg.interface = interface
|
||||||
|
msg.member = member
|
||||||
|
msg.body = body
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bus():
|
||||||
|
"""Mock MessageBus with handler registration tracking."""
|
||||||
|
bus = MagicMock()
|
||||||
|
bus._handlers = []
|
||||||
|
|
||||||
|
def _add(h):
|
||||||
|
bus._handlers.append(h)
|
||||||
|
|
||||||
|
def _remove(h):
|
||||||
|
if h in bus._handlers:
|
||||||
|
bus._handlers.remove(h)
|
||||||
|
|
||||||
|
bus.add_message_handler = MagicMock(side_effect=_add)
|
||||||
|
bus.remove_message_handler = MagicMock(side_effect=_remove)
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_signal(mock_bus, interface, member, body, delay=0.01):
|
||||||
|
"""Schedule a signal delivery after a brief delay."""
|
||||||
|
async def _deliver():
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
msg = _make_signal(interface, member, body)
|
||||||
|
for handler in list(mock_bus._handlers):
|
||||||
|
handler(msg)
|
||||||
|
asyncio.create_task(_deliver())
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifyConfirm:
|
||||||
|
async def test_approve_returns_true(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
async def test_deny_returns_false(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"])
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
async def test_dismissed_returns_false(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
_fire_signal(mock_bus, IFACE, "NotificationClosed", [NID, 2])
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
async def test_timeout_returns_false(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=0.05)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
async def test_service_unavailable_raises(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
raise RuntimeError("org.freedesktop.DBus.Error.ServiceUnknown")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
with pytest.raises(RuntimeError, match="ServiceUnknown"):
|
||||||
|
await notify_confirm(mock_bus, "Test", "Confirm?")
|
||||||
|
|
||||||
|
async def test_handler_ignores_wrong_nid(self, mock_bus):
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
if member == "Notify":
|
||||||
|
# Wrong nid first, correct nid after
|
||||||
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [999, "approve"], delay=0.01)
|
||||||
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"], delay=0.03)
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=1.0)
|
||||||
|
# Wrong nid was ignored; correct nid with "deny" triggered
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
async def test_cleanup_called(self, mock_bus):
|
||||||
|
members_called = []
|
||||||
|
|
||||||
|
async def mock_call(bus, destination, path, interface, member,
|
||||||
|
signature="", body=None, timeout=30.0):
|
||||||
|
members_called.append(member)
|
||||||
|
if member == "Notify":
|
||||||
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
|
||||||
|
return [NID]
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
||||||
|
await notify_confirm(mock_bus, "Test", "Confirm?")
|
||||||
|
|
||||||
|
mock_bus.remove_message_handler.assert_called_once()
|
||||||
|
assert "CloseNotification" in members_called
|
||||||
|
assert members_called.count("RemoveMatch") == 2
|
||||||
Loading…
x
Reference in New Issue
Block a user