Merge branch 'main' into feat/device-pixel-alignment
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 3m19s
C++ / clang-tidy (pull_request) Failing after 3m17s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 32s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Failing after 1m20s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 57s

This commit is contained in:
2026-11-01 12:50:58 +01:00
11 changed files with 1664 additions and 279 deletions
+15 -261
View File
@@ -4,14 +4,11 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import Quickshell.Hyprland import ZShell.Services
import QtQuick
import ZShell
import qs.Components import qs.Components
import qs.Modules
import qs.Helpers import qs.Helpers
import qs.Services
import qs.Paths import qs.Paths
import qs.Services
import ZShell.Config import ZShell.Config
Singleton { Singleton {
@@ -19,11 +16,9 @@ Singleton {
readonly property var appCooldownMap: new Map() readonly property var appCooldownMap: new Map()
property alias dnd: props.dnd property alias dnd: props.dnd
property list<Notif> list: [] property alias list: store.rows
property bool loaded readonly property list<NotificationRow> notClosed: store.rows.filter(n => !n.closed)
readonly property list<Notif> notClosed: list.filter(n => !n.closed) readonly property list<NotificationRow> popups: store.rows.filter(n => n.popup)
readonly property list<Notif> popups: list.filter(n => n.popup)
property alias server: server
function shouldThrottle(appName: string): bool { function shouldThrottle(appName: string): bool {
if (props.dnd || ShellState.anySidebarOpen()) if (props.dnd || ShellState.anySidebarOpen())
@@ -46,33 +41,6 @@ Singleton {
return true; return true;
} }
onListChanged: {
if (loaded) {
saveTimer.restart();
}
}
Timer {
id: saveTimer
interval: 1000
onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({
time: n.time,
id: n.id,
summary: n.summary,
body: n.body,
appIcon: n.appIcon,
appName: n.appName,
image: n.image,
expireTimeout: n.expireTimeout,
urgency: n.urgency,
resident: n.resident,
hasActionIcons: n.hasActionIcons,
actions: n.actions
}))))
}
PersistentProperties { PersistentProperties {
id: props id: props
@@ -81,6 +49,12 @@ Singleton {
reloadableId: "notifs" reloadableId: "notifs"
} }
NotificationStore {
id: store
path: `${Paths.state}/notifs.sqlite`
}
NotificationServer { NotificationServer {
id: server id: server
@@ -89,40 +63,12 @@ Singleton {
bodyImagesSupported: true bodyImagesSupported: true
bodyMarkupSupported: true bodyMarkupSupported: true
imageSupported: true imageSupported: true
keepOnReload: false keepOnReload: true
persistenceSupported: true persistenceSupported: true
onNotification: notif => { onNotification: notif => {
notif.tracked = true; notif.tracked = true;
store.insert(notif, root.shouldThrottle(notif.appName));
const is_popup = root.shouldThrottle(notif.appName);
const comp = notifComp.createObject(root, {
popup: is_popup,
notification: notif
});
root.list = [comp, ...root.list];
}
}
FileView {
id: storage
path: `${Paths.state}/notifs.json`
printErrors: false
onLoadFailed: err => {
if (err === FileViewError.FileNotFound) {
root.loaded = true;
Qt.callLater(() => setText("[]"));
}
}
onLoaded: {
const data = JSON.parse(text());
for (const notif of data)
root.list.push(notifComp.createObject(root, notif));
root.list.sort((a, b) => b.time - a.time);
root.loaded = true;
} }
} }
@@ -130,16 +76,12 @@ Singleton {
description: "Clear all notifications" description: "Clear all notifications"
name: "clearnotifs" name: "clearnotifs"
onPressed: { onPressed: store.clear()
for (const notif of root.list.slice())
notif.close();
}
} }
IpcHandler { IpcHandler {
function clear(): void { function clear(): void {
for (const notif of root.list.slice()) store.clear();
notif.close();
} }
function disableDnd(): void { function disableDnd(): void {
@@ -160,192 +102,4 @@ Singleton {
target: "notifs" target: "notifs"
} }
Component {
id: notifComp
Notif {
}
}
component Notif: QtObject {
id: notif
property list<var> actions
property string appIcon
property string appName
property string body
property string cachedImageSource: ""
property bool cachingImage: false
property bool closed
readonly property Connections conn: Connections {
function onActionsChanged(): void {
notif.actions = notif.notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
function onAppIconChanged(): void {
notif.appIcon = notif.notification.appIcon;
}
function onAppNameChanged(): void {
notif.appName = notif.notification.appName;
}
function onBodyChanged(): void {
notif.body = notif.notification.body;
}
function onClosed(): void {
notif.close();
}
function onExpireTimeoutChanged(): void {
notif.expireTimeout = notif.notification.expireTimeout;
}
function onHasActionIconsChanged(): void {
notif.hasActionIcons = notif.notification.hasActionIcons;
}
function onHintsChanged(): void {
notif.hints = notif.notification.hints;
}
function onImageChanged(): void {
notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource;
notif.cacheImageIfNeeded();
}
function onResidentChanged(): void {
notif.resident = notif.notification.resident;
}
function onSummaryChanged(): void {
notif.summary = notif.notification.summary;
}
function onUrgencyChanged(): void {
notif.urgency = notif.notification.urgency;
}
target: notif.notification
}
property real expireTimeout: 5
property bool hasActionIcons
property var hints
property string image
property string imageSource
property var locks: new Set()
property string notifId
property Notification notification
property bool popup
property bool resident
property string summary
property date time: new Date()
readonly property string timeStr: {
const diff = Time.date.getTime() - time.getTime();
const m = Math.floor(diff / 60000);
if (m < 1)
return qsTr("now");
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0)
return `${d}d`;
if (h > 0)
return `${h}h`;
return `${m}m`;
}
readonly property Timer timer: Timer {
property bool paused: false
property int remainingTime: totalTime
property int totalTime: Config.notifs.defaultExpireTimeout
interval: 50
repeat: true
running: !paused
onTriggered: {
remainingTime -= interval;
if (remainingTime <= 0) {
remainingTime = 0;
notif.popup = false;
stop();
}
}
}
property int urgency: NotificationUrgency.Normal
function cacheImageIfNeeded(): void {
const source = imageSource;
if (!source || cachingImage)
return;
if (cachedImageSource === source)
return;
cachingImage = true;
ZShellIo.cacheImage(Qt.resolvedUrl(source), Paths.notifimagecache, (path, url) => {
cachedImageSource = source;
image = path;
cachingImage = false;
}, () => {
cachingImage = false;
});
}
function close(): void {
closed = true;
if (locks.size === 0 && root.list.includes(this)) {
root.list = root.list.filter(n => n !== this);
notification?.dismiss();
destroy();
}
}
function lock(item: Item): void {
locks.add(item);
}
function unlock(item: Item): void {
locks.delete(item);
if (closed)
close();
}
Component.onCompleted: {
if (!notification)
return;
notifId = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
imageSource = notification.image || "";
image = imageSource;
hints = notification.hints;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
hasActionIcons = notification.hasActionIcons;
actions = notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
cacheImageIfNeeded();
}
}
} }
+1 -1
View File
@@ -286,7 +286,7 @@ CustomRect {
component NotifLine: CustomText { component NotifLine: CustomText {
id: notifLine id: notifLine
required property NotifServer.Notif modelData required property NotificationRow modelData
Layout.fillWidth: true Layout.fillWidth: true
color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface
+2 -1
View File
@@ -5,6 +5,7 @@ import Quickshell.Widgets
import QtQuick import QtQuick
import ZShell.Config import ZShell.Config
import ZShell.Components import ZShell.Components
import ZShell.Services
import qs.Components import qs.Components
import qs.Daemons import qs.Daemons
import qs.Helpers import qs.Helpers
@@ -97,7 +98,7 @@ Item {
property int idx property int idx
required property int index required property int index
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property alias nonAnimHeight: notif.nonAnimHeight readonly property alias nonAnimHeight: notif.nonAnimHeight
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small) implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small)
+13 -8
View File
@@ -5,7 +5,7 @@ import QtQuick.Shapes
import Quickshell import Quickshell
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import ZShell.Components import ZShell.Components
import qs.Daemons import ZShell.Services
import qs.Helpers import qs.Helpers
import ZShell.Config import ZShell.Config
import qs.Components import qs.Components
@@ -17,7 +17,7 @@ CustomRect {
property bool expanded: Config.notifs.openExpanded property bool expanded: Config.notifs.openExpanded
readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property bool hasImage: modelData.image.length > 0 readonly property bool hasImage: modelData.image.length > 0
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer
@@ -56,10 +56,10 @@ CustomRect {
if (actions.length === 1) if (actions.length === 1)
actions[0].invoke(); actions[0].invoke();
} }
onEntered: root.modelData.timer.stop() onEntered: root.modelData.pausePopup()
onExited: { onExited: {
if (!pressed) if (!pressed)
root.modelData.timer.start(); root.modelData.resumePopup();
} }
onPositionChanged: event => { onPositionChanged: event => {
if (pressed) { if (pressed) {
@@ -69,14 +69,14 @@ CustomRect {
} }
} }
onPressed: event => { onPressed: event => {
root.modelData.timer.stop(); root.modelData.pausePopup();
startY = event.y; startY = event.y;
if (event.button === Qt.MiddleButton) if (event.button === Qt.MiddleButton)
root.modelData.close(); root.modelData.close();
} }
onReleased: event => { onReleased: event => {
if (!containsMouse) if (!containsMouse)
root.modelData.timer.start(); root.modelData.resumePopup();
if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold)
root.x = 0; root.x = 0;
@@ -200,7 +200,7 @@ CustomRect {
radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2 radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2
radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2 radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2
startAngle: -90 startAngle: -90
sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 sweepAngle: (root.modelData.progress / 100) * 360
Behavior on sweepAngle { Behavior on sweepAngle {
Anim { Anim {
@@ -477,7 +477,12 @@ CustomRect {
shapeMorph: true shapeMorph: true
text: modelData.text text: modelData.text
onClicked: modelData.invoke() onClicked: {
if (modelData.invokable)
modelData.invoke();
else if (!root.modelData.resident)
root.modelData.close();
}
} }
} }
+3 -1
View File
@@ -4,6 +4,8 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
import ZShell.Components
import ZShell.Services
import qs.Components import qs.Components
import qs.Services import qs.Services
import qs.Daemons import qs.Daemons
@@ -14,7 +16,7 @@ CustomRect {
readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null
required property bool expanded required property bool expanded
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height
required property ScreenState screenState required property ScreenState screenState
+2 -3
View File
@@ -9,12 +9,11 @@ import ZShell.Components
import qs.Components import qs.Components
import qs.Effects import qs.Effects
import qs.Services import qs.Services
import qs.Daemons
Item { Item {
id: root id: root
required property NotifServer.Notif notif required property NotificationRow notif
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: actionList.implicitHeight implicitHeight: actionList.implicitHeight
@@ -30,7 +29,7 @@ Item {
{ {
isClose: true isClose: true
}, },
...(root.notif?.actions ?? []), ...Array.from(root.notif?.actions ?? []),
{ {
isCopy: true isCopy: true
} }
+1 -2
View File
@@ -81,9 +81,8 @@ Item {
Behavior on opacity { Behavior on opacity {
Anim {} Anim {}
} }
sourceComponent: SnapLayout { sourceComponent: ColumnLayout {
spacing: 20 spacing: 20
orientation: Qt.Vertical
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
+2 -2
View File
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell.Components import ZShell.Components
import ZShell.Services
import ZShell.Config import ZShell.Config
import qs.Daemons
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -35,7 +35,7 @@ LazyListView {
id: notif id: notif
required property int index required property int index
required property NotifServer.Notif modelData required property NotificationRow modelData
property int startY property int startY
LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight
+3
View File
@@ -23,11 +23,14 @@ qml_module(ZShell-services
lyriccandidate.hpp lyriccandidate.cpp lyriccandidate.hpp lyriccandidate.cpp
lyrics.hpp lyrics.cpp lyrics.hpp lyrics.cpp
dropimporter.hpp dropimporter.cpp dropimporter.hpp dropimporter.cpp
notificationstore.hpp notificationstore.cpp
LIBRARIES LIBRARIES
Qt6::Core Qt6::Core
Qt6::Qml Qt6::Qml
Qt6::Quick
Qt6::Concurrent Qt6::Concurrent
Qt6::Network Qt6::Network
Qt6::Sql
PkgConfig::Pipewire PkgConfig::Pipewire
PkgConfig::Aubio PkgConfig::Aubio
PkgConfig::Cava PkgConfig::Cava
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
#pragma once
#include <QDateTime>
#include <QHash>
#include <QJSValue>
#include <QList>
#include <QObject>
#include <QPointer>
#include <qqmlintegration.h>
#include <QQmlListProperty>
#include <QSet>
#include <QString>
#include <QTimer>
namespace ZShell::config {
class Notifs;
}
namespace ZShell::Services {
class NotificationStore;
class NotifImageProvider;
class NotificationActionRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE(
"NotificationActionRow instances come from a NotificationStore")
Q_PROPERTY(bool invokable READ invokable NOTIFY invokableChanged)
Q_PROPERTY(QString identifier READ identifier CONSTANT)
Q_PROPERTY(QString text READ text CONSTANT)
public:
NotificationActionRow(
QObject* action,
const QString& identifier,
const QString& text,
QObject* parent = nullptr);
[[nodiscard]] bool invokable() const;
[[nodiscard]] QString identifier() const;
[[nodiscard]] QString text() const;
Q_INVOKABLE void invoke();
signals:
void invokableChanged();
private:
QPointer<QObject> m_action;
QString m_identifier;
QString m_text;
};
class NotificationRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("NotificationRow instances come from a NotificationStore")
Q_PROPERTY(QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY actionsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged)
Q_PROPERTY(QString body READ body NOTIFY bodyChanged)
Q_PROPERTY(bool closed READ closed NOTIFY closedChanged)
Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(int notificationId READ notificationId CONSTANT)
Q_PROPERTY(int progress READ progress NOTIFY progressChanged)
Q_PROPERTY(bool popup READ popup WRITE setPopup NOTIFY popupChanged)
Q_PROPERTY(bool resident READ resident NOTIFY residentChanged)
Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged)
Q_PROPERTY(QDateTime time READ time CONSTANT)
Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged)
Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged)
public:
explicit NotificationRow(QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<NotificationActionRow> actions();
[[nodiscard]] QString appIcon() const;
[[nodiscard]] QString appName() const;
[[nodiscard]] QString body() const;
[[nodiscard]] bool closed() const;
[[nodiscard]] bool hasActionIcons() const;
[[nodiscard]] QString image() const;
[[nodiscard]] QString imageSource() const;
[[nodiscard]] int notificationId() const;
[[nodiscard]] int progress() const;
[[nodiscard]] bool popup() const;
void setPopup(bool popup);
[[nodiscard]] bool resident() const;
[[nodiscard]] QString summary() const;
[[nodiscard]] QDateTime time() const;
[[nodiscard]] QString timeStr() const;
[[nodiscard]] int urgency() const;
Q_INVOKABLE void close();
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void pausePopup();
Q_INVOKABLE void resumePopup();
Q_INVOKABLE void unlock(QObject* item);
signals:
void actionsChanged();
void appIconChanged();
void appNameChanged();
void bodyChanged();
void closedChanged();
void hasActionIconsChanged();
void imageChanged();
void imageSourceChanged();
void popupChanged();
void progressChanged();
void residentChanged();
void summaryChanged();
void timeStrChanged();
void urgencyChanged();
private:
friend NotificationStore;
[[nodiscard]] bool expired(qint64 now) const;
void refreshTimeStr();
QList<NotificationActionRow*> m_actions;
NotificationStore* m_store = nullptr;
QString m_appIcon;
QString m_appName;
QString m_body;
QString m_digest;
QString m_image;
QString m_imageSource;
QString m_summary;
QString m_timeStr;
qint64 m_deadline = 0;
qint64 m_rowId = -1;
qint64 m_timeMs = 0;
QPointer<QObject> m_notification;
QSet<QObject*> m_locks;
QJSValue m_jsNotification;
bool m_closed = false;
bool m_hasActionIcons = false;
bool m_liveConnected = false;
bool m_popup = false;
bool m_popupPaused = false;
bool m_resident = false;
int m_notificationId = 0;
int m_progress = 0;
int m_urgency = 1;
};
class NotificationStore : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY autoExpireChanged)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY
popupTimeoutChanged)
Q_PROPERTY(QQmlListProperty<NotificationRow> rows READ rows NOTIFY rowsChanged)
public:
explicit NotificationStore(QObject* parent = nullptr);
~NotificationStore() override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
[[nodiscard]] bool autoExpire() const;
void setAutoExpire(bool autoExpire);
[[nodiscard]] int popupTimeout() const;
void setPopupTimeout(int timeout);
[[nodiscard]] QQmlListProperty<NotificationRow> rows();
Q_INVOKABLE NotificationRow* insert(const QJSValue& notification, bool popup);
Q_INVOKABLE void clear();
signals:
void autoExpireChanged();
void pathChanged();
void popupTimeoutChanged();
void rowsChanged();
private slots:
void handleLiveUpdate();
private:
friend NotificationRow;
void attachNotification(
NotificationRow* row, QObject* notification, const QJSValue& js);
void applyNotifConfig(const config::Notifs* notifs);
void closeDb();
void copyFields(NotificationRow* row, QObject* notification);
void handleClosed(NotificationRow* row);
void insertDbRow(NotificationRow* row);
void loadRows();
void migrateLegacyJson();
[[nodiscard]] bool openDb();
void orphanCleanup();
void persistPopup(const NotificationRow* row);
void prune();
void registerProvider();
void removeRow(NotificationRow* row);
// Coalesced rowsChanged: a burst of inserts/removes triggers one QML
// rebind on the next event loop turn instead of one per notification.
void scheduleRowsChanged();
[[nodiscard]] NotificationRow* rowForNotificationId(int id) const;
void startImageCache(NotificationRow* row);
void storeImageBlob(
const QPointer<NotificationRow>& row,
const QString& digest,
const QByteArray& png);
void tick();
void trimRows();
void updateDbRow(NotificationRow* row);
static QList<NotificationActionRow*> parseActions(
const QJSValue& actions, NotificationRow* row);
int m_popupTimeout = 5000;
qint64 m_nextPrune = 0;
qint64 m_nextTimeStrRefresh = 0;
bool m_autoExpire = true;
bool m_clearing = false;
bool m_rowsSignalPending = false;
QString m_uuid;
QString m_path;
QList<NotificationRow*> m_rows;
QHash<QObject*, NotificationRow*> m_liveRows;
NotifImageProvider* m_provider = nullptr;
QTimer* m_tick = nullptr;
};
} // namespace ZShell::Services