Skip to content

Commit 0d358d0

Browse files
Merge pull request #1875 from contour-terminal/feature/osc99-desktop-notifications
Add OSC 99 desktop notification support
2 parents 1176737 + f2448bf commit 0d358d0

21 files changed

+1438
-40
lines changed

.github/actions/spelling/allow/allow.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
AABBGGRR
22
BBX
33
COLRv
4+
DESKTOPNOTIFY
45
DWIDTH
6+
Dbus
57
ENDCHAR
68
ENDFONT
79
ENDPROPERTIES
@@ -17,15 +19,19 @@ STARTPROPERTIES
1719
SWIDTH
1820
Unpremultiply
1921
UpperCamelCase
22+
Vsb
2023
XBase
2124
YBase
2225
aea
2326
appleclang
27+
appname
2428
bdf
2529
clangcl
30+
closeevent
2631
copy'n'paste
2732
copyable
2833
crossfade
34+
dbus
2935
gha
3036
ilammy
3137
lazygit
@@ -36,11 +42,17 @@ libzstd
3642
lowerCamelCase
3743
macOS
3844
midanimation
45+
nosemi
46+
notif
3947
nupkg
4048
nushell
49+
occ
4150
openxr
4251
ppem
4352
tablegen
53+
titlebody
4454
tparam
55+
unk
56+
urg
4557
vswhere
4658
zypper

metainfo.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
<li>Adds DECBKM (DEC mode 67, VT340) to toggle Backspace key between BS (0x08) and DEL (0x7F) via CSI ? 67 h / CSI ? 67 l</li>
131131
<li>Adds kitty's unscroll extension (CSI n + T) to restore scrolled-off content from scrollback history</li>
132132
<li>Adds natural momentum scrolling for touchpad gestures with configurable falloff via momentum_scrolling profile setting</li>
133+
<li>Adds Kitty OSC 99 desktop notification protocol with D-Bus backend on Linux, supporting structured metadata, chunked payloads, base64 encoding, urgency levels, display occasion filtering, bidirectional close/activation events, and query/alive responses</li>
133134
</ul>
134135
</description>
135136
</release>

src/contour/CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ NumberToHex(${PROJECT_VERSION_PATCH} HEX_PATCH)
1818
# {{{ Setup QT_COMPONENTS
1919
# QT_COMPONENTS is the list of Qt libraries Contour requires for building.
2020
# NB: Widgets is rquired for SystemTrayIcon's fallback implementation
21-
set(QT_COMPONENTS Core Gui Qml Quick QuickControls2 Network Multimedia Widgets OpenGL OpenGLWidgets)
21+
set(QT_COMPONENTS Core Gui Qml Quick QuickControls2 Network Multimedia Widgets OpenGL OpenGLWidgets DBus)
2222
# }}}
2323

2424
message(STATUS "Qt components: ${QT_COMPONENTS}")
@@ -57,6 +57,7 @@ if(CONTOUR_FRONTEND_GUI)
5757
Audio.h
5858
BlurBehind.h
5959
ContourGuiApp.h
60+
FreeDesktopNotifier.h
6061
TerminalSession.h
6162
TerminalSessionManager.h
6263
helper.h
@@ -65,6 +66,7 @@ if(CONTOUR_FRONTEND_GUI)
6566
Audio.cpp
6667
BlurBehind.cpp
6768
ContourGuiApp.cpp
69+
FreeDesktopNotifier.cpp
6870
TerminalSession.cpp
6971
TerminalSessionManager.cpp
7072
helper.cpp
@@ -189,6 +191,7 @@ if(CONTOUR_FRONTEND_GUI)
189191
PRIVATE
190192
vtrasterizer
191193
ContourTerminalDisplay
194+
Qt6::DBus
192195
Qt6::Multimedia
193196
Qt6::Network
194197
Qt6::OpenGL

src/contour/ContourGuiApp.cpp

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -588,35 +588,4 @@ void ContourGuiApp::newWindow()
588588
_qmlEngine->load(resolveResource("ui/main.qml"));
589589
}
590590

591-
void ContourGuiApp::showNotification(std::string_view title, std::string_view content)
592-
{
593-
// systrayIcon_->showMessage(
594-
// title,
595-
// content,
596-
// QSystemTrayIcon::MessageIcon::Information,
597-
// 10 * 1000
598-
// );
599-
600-
#if defined(__linux__)
601-
// XXX requires notify-send to be installed.
602-
QStringList args;
603-
args.append("--urgency=low");
604-
args.append("--expire-time=10000");
605-
args.append("--category=terminal");
606-
args.append(QString::fromStdString(string(title)));
607-
args.append(QString::fromStdString(string(content)));
608-
QProcess::execute(QString::fromLatin1("notify-send"), args);
609-
#elif defined(__APPLE__)
610-
// TODO: use Growl?
611-
(void) title;
612-
(void) content;
613-
#elif defined(_WIN32)
614-
// TODO: use Toast
615-
(void) title;
616-
(void) content;
617-
#else
618-
crispy::ignore_unused(title, content);
619-
#endif
620-
}
621-
622591
} // namespace contour

src/contour/ContourGuiApp.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ class ContourGuiApp: public QObject, public ContourApp
4040
[[nodiscard]] crispy::cli::command parameterDefinition() const override;
4141

4242
void newWindow();
43-
static void showNotification(std::string_view title, std::string_view content);
4443

4544
[[nodiscard]] std::string profileName() const;
4645

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#if defined(__linux__)
3+
4+
#include <contour/FreeDesktopNotifier.h>
5+
6+
#include <crispy/logstore.h>
7+
8+
#include <QtDBus/QDBusConnection>
9+
#include <QtDBus/QDBusReply>
10+
11+
namespace contour
12+
{
13+
14+
namespace
15+
{
16+
auto const notifierLog = logstore::category("gui.notifier", "Desktop notification backend");
17+
18+
/// Converts NotificationUrgency to the D-Bus urgency byte value.
19+
uint8_t toDBusUrgency(vtbackend::NotificationUrgency urgency)
20+
{
21+
switch (urgency)
22+
{
23+
case vtbackend::NotificationUrgency::Low: return 0;
24+
case vtbackend::NotificationUrgency::Normal: return 1;
25+
case vtbackend::NotificationUrgency::Critical: return 2;
26+
}
27+
return 1;
28+
}
29+
} // namespace
30+
31+
FreeDesktopNotifier::FreeDesktopNotifier(QObject* parent): QObject(parent)
32+
{
33+
_interface = std::make_unique<QDBusInterface>("org.freedesktop.Notifications",
34+
"/org/freedesktop/Notifications",
35+
"org.freedesktop.Notifications",
36+
QDBusConnection::sessionBus(),
37+
this);
38+
39+
if (!_interface->isValid())
40+
{
41+
notifierLog()("Failed to connect to org.freedesktop.Notifications D-Bus interface: {}",
42+
_interface->lastError().message().toStdString());
43+
return;
44+
}
45+
46+
// Connect to the NotificationClosed and ActionInvoked signals from the notification server.
47+
auto bus = QDBusConnection::sessionBus();
48+
bus.connect("org.freedesktop.Notifications",
49+
"/org/freedesktop/Notifications",
50+
"org.freedesktop.Notifications",
51+
"NotificationClosed",
52+
this,
53+
SLOT(onNotificationClosed(uint, uint)));
54+
55+
bus.connect("org.freedesktop.Notifications",
56+
"/org/freedesktop/Notifications",
57+
"org.freedesktop.Notifications",
58+
"ActionInvoked",
59+
this,
60+
SLOT(onActionInvoked(uint, QString)));
61+
}
62+
63+
void FreeDesktopNotifier::notify(vtbackend::DesktopNotification const& notification)
64+
{
65+
if (!_interface || !_interface->isValid())
66+
return;
67+
68+
auto const appName = notification.applicationName.empty()
69+
? QStringLiteral("contour")
70+
: QString::fromStdString(notification.applicationName);
71+
auto const title = QString::fromStdString(notification.title);
72+
auto const body = QString::fromStdString(notification.body);
73+
74+
// Build hints map with urgency level.
75+
QVariantMap hints;
76+
hints["urgency"] = QVariant::fromValue(toDBusUrgency(notification.urgency));
77+
78+
// Check if we're replacing an existing notification.
79+
uint32_t replacesId = 0;
80+
if (auto it = _oscToDbus.find(notification.identifier); it != _oscToDbus.end())
81+
replacesId = it->second;
82+
83+
// Build actions list. The default action is triggered on click.
84+
QStringList actions;
85+
actions << QStringLiteral("default") << QStringLiteral("Activate");
86+
87+
// org.freedesktop.Notifications.Notify parameters:
88+
// STRING app_name, UINT32 replaces_id, STRING app_icon, STRING summary,
89+
// STRING body, ARRAY actions, DICT hints, INT32 expire_timeout
90+
QDBusReply<uint> reply = _interface->call("Notify",
91+
appName,
92+
replacesId,
93+
QStringLiteral(""), // app_icon (empty)
94+
title,
95+
body,
96+
actions,
97+
hints,
98+
notification.timeout);
99+
100+
if (reply.isValid())
101+
{
102+
auto const dbusId = reply.value();
103+
notifierLog()("Notification sent: id='{}' -> dbus_id={}", notification.identifier, dbusId);
104+
105+
// Remove stale reverse mapping when replacing a notification.
106+
if (replacesId != 0)
107+
_dbusToOsc.erase(replacesId);
108+
109+
// Update the bidirectional ID mapping.
110+
_dbusToOsc[dbusId] = notification.identifier;
111+
_oscToDbus[notification.identifier] = dbusId;
112+
}
113+
else
114+
{
115+
notifierLog()("Failed to send notification: {}", reply.error().message().toStdString());
116+
}
117+
}
118+
119+
void FreeDesktopNotifier::close(std::string const& identifier)
120+
{
121+
if (!_interface || !_interface->isValid())
122+
return;
123+
124+
auto it = _oscToDbus.find(identifier);
125+
if (it == _oscToDbus.end())
126+
return;
127+
128+
auto const dbusId = it->second;
129+
_interface->call("CloseNotification", dbusId);
130+
131+
// Clean up mappings.
132+
_dbusToOsc.erase(dbusId);
133+
_oscToDbus.erase(it);
134+
}
135+
136+
void FreeDesktopNotifier::onNotificationClosed(uint id, uint reason)
137+
{
138+
auto it = _dbusToOsc.find(id);
139+
if (it == _dbusToOsc.end())
140+
return;
141+
142+
auto const identifier = QString::fromStdString(it->second);
143+
notifierLog()("Notification closed: dbus_id={} reason={}", id, reason);
144+
145+
// Clean up mappings.
146+
auto const oscId = it->second;
147+
_dbusToOsc.erase(it);
148+
_oscToDbus.erase(oscId);
149+
150+
emit notificationClosed(identifier, reason);
151+
}
152+
153+
void FreeDesktopNotifier::onActionInvoked(uint id, QString const& actionKey)
154+
{
155+
(void) actionKey; // We only register "default" action.
156+
157+
auto it = _dbusToOsc.find(id);
158+
if (it == _dbusToOsc.end())
159+
return;
160+
161+
auto const identifier = QString::fromStdString(it->second);
162+
notifierLog()("Notification activated: dbus_id={}", id);
163+
164+
emit actionInvoked(identifier);
165+
}
166+
167+
} // namespace contour
168+
169+
#endif // defined(__linux__)

src/contour/FreeDesktopNotifier.h

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#pragma once
3+
4+
#if defined(__linux__)
5+
6+
#include <vtbackend/DesktopNotification.h>
7+
8+
#include <QtCore/QObject>
9+
#include <QtDBus/QDBusInterface>
10+
11+
#include <cstdint>
12+
#include <string>
13+
#include <unordered_map>
14+
15+
namespace contour
16+
{
17+
18+
/// D-Bus backend for the Kitty OSC 99 desktop notification protocol on Linux.
19+
///
20+
/// Uses the org.freedesktop.Notifications interface for:
21+
/// - Sending notifications (Notify)
22+
/// - Closing notifications (CloseNotification)
23+
/// - Receiving close events (NotificationClosed signal)
24+
/// - Receiving activation events (ActionInvoked signal)
25+
class FreeDesktopNotifier: public QObject
26+
{
27+
Q_OBJECT
28+
29+
public:
30+
explicit FreeDesktopNotifier(QObject* parent = nullptr);
31+
~FreeDesktopNotifier() override = default;
32+
33+
/// Sends a desktop notification via D-Bus.
34+
///
35+
/// @param notification the parsed OSC 99 notification data.
36+
void notify(vtbackend::DesktopNotification const& notification);
37+
38+
/// Requests the desktop to close a notification.
39+
///
40+
/// @param identifier the OSC 99 notification identifier.
41+
void close(std::string const& identifier);
42+
43+
signals:
44+
/// Emitted when a notification is closed by the desktop environment.
45+
///
46+
/// @param identifier the OSC 99 identifier of the closed notification.
47+
/// @param reason the D-Bus close reason code (1=expired, 2=dismissed, 3=closed, 4=undefined).
48+
void notificationClosed(QString identifier, uint reason);
49+
50+
/// Emitted when the user interacts with a notification.
51+
///
52+
/// @param identifier the OSC 99 identifier of the activated notification.
53+
void actionInvoked(QString identifier);
54+
55+
private slots:
56+
/// Handles the NotificationClosed D-Bus signal.
57+
void onNotificationClosed(uint id, uint reason);
58+
59+
/// Handles the ActionInvoked D-Bus signal.
60+
void onActionInvoked(uint id, QString const& actionKey);
61+
62+
private:
63+
/// Maps D-Bus uint32_t notification IDs to OSC 99 string identifiers.
64+
std::unordered_map<uint32_t, std::string> _dbusToOsc;
65+
66+
/// Maps OSC 99 string identifiers to D-Bus uint32_t notification IDs.
67+
std::unordered_map<std::string, uint32_t> _oscToDbus;
68+
69+
/// The D-Bus interface proxy for org.freedesktop.Notifications.
70+
std::unique_ptr<QDBusInterface> _interface;
71+
};
72+
73+
} // namespace contour
74+
75+
#endif // defined(__linux__)

0 commit comments

Comments
 (0)