forked from quickshell/quickshell
feat(slock): add user facing SessionLock and SessionLockSurface
This commit is contained in:
parent
1fa87b7c5a
commit
48bdcf4db2
|
@ -134,8 +134,10 @@ void ProxyWindowBase::setScreen(QuickshellScreenInfo* screen) {
|
|||
QObject::connect(qscreen, &QObject::destroyed, this, &ProxyWindowBase::onScreenDestroyed);
|
||||
}
|
||||
|
||||
if (this->window == nullptr) this->mScreen = qscreen;
|
||||
else this->window->setScreen(qscreen);
|
||||
if (this->window == nullptr) {
|
||||
this->mScreen = qscreen;
|
||||
emit this->screenChanged();
|
||||
} else this->window->setScreen(qscreen);
|
||||
}
|
||||
|
||||
void ProxyWindowBase::onScreenDestroyed() { this->mScreen = nullptr; }
|
||||
|
|
|
@ -46,6 +46,7 @@ endfunction()
|
|||
|
||||
qt_add_library(quickshell-wayland STATIC
|
||||
wlr_layershell.cpp
|
||||
session_lock.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(quickshell-wayland URI Quickshell.Wayland)
|
||||
|
|
|
@ -3,5 +3,6 @@ description = "Wayland specific Quickshell types"
|
|||
headers = [
|
||||
"wlr_layershell/window.hpp",
|
||||
"wlr_layershell.hpp",
|
||||
"session_lock.hpp",
|
||||
]
|
||||
-----
|
||||
|
|
284
src/wayland/session_lock.cpp
Normal file
284
src/wayland/session_lock.cpp
Normal file
|
@ -0,0 +1,284 @@
|
|||
#include "session_lock.hpp"
|
||||
|
||||
#include <qcolor.h>
|
||||
#include <qguiapplication.h>
|
||||
#include <qlogging.h>
|
||||
#include <qobject.h>
|
||||
#include <qqmlcomponent.h>
|
||||
#include <qqmlengine.h>
|
||||
#include <qquickitem.h>
|
||||
#include <qquickwindow.h>
|
||||
#include <qscreen.h>
|
||||
#include <qtmetamacros.h>
|
||||
#include <qtypes.h>
|
||||
|
||||
#include "session_lock/session_lock.hpp"
|
||||
|
||||
void SessionLock::onReload(QObject* oldInstance) {
|
||||
auto* old = qobject_cast<SessionLock*>(oldInstance);
|
||||
|
||||
if (old != nullptr) {
|
||||
QObject::disconnect(old->manager, nullptr, old, nullptr);
|
||||
this->manager = old->manager;
|
||||
this->manager->setParent(this);
|
||||
} else {
|
||||
this->manager = new SessionLockManager(this);
|
||||
}
|
||||
|
||||
// clang-format off
|
||||
QObject::connect(this->manager, &SessionLockManager::locked, this, &SessionLock::secureStateChanged);
|
||||
QObject::connect(this->manager, &SessionLockManager::unlocked, this, &SessionLock::secureStateChanged);
|
||||
|
||||
QObject::connect(this->manager, &SessionLockManager::unlocked, this, &SessionLock::unlock);
|
||||
|
||||
auto* app = QCoreApplication::instance();
|
||||
auto* guiApp = qobject_cast<QGuiApplication*>(app);
|
||||
|
||||
if (guiApp != nullptr) {
|
||||
QObject::connect(guiApp, &QGuiApplication::primaryScreenChanged, this, &SessionLock::onScreensChanged);
|
||||
QObject::connect(guiApp, &QGuiApplication::screenAdded, this, &SessionLock::onScreensChanged);
|
||||
QObject::connect(guiApp, &QGuiApplication::screenRemoved, this, &SessionLock::onScreensChanged);
|
||||
}
|
||||
// clang-format on
|
||||
|
||||
if (this->lockTarget) {
|
||||
this->manager->lock();
|
||||
this->updateSurfaces(old);
|
||||
} else {
|
||||
this->setLocked(false);
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::updateSurfaces(SessionLock* old) {
|
||||
if (this->manager->isLocked()) {
|
||||
auto screens = QGuiApplication::screens();
|
||||
|
||||
auto map = this->surfaces.toStdMap();
|
||||
for (auto& [screen, surface]: map) {
|
||||
if (!screens.contains(screen)) {
|
||||
this->surfaces.remove(screen);
|
||||
surface->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
if (this->mSurfaceComponent == nullptr) {
|
||||
qWarning() << "SessionLock.surface is null. Aborting lock.";
|
||||
this->unlock();
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto* screen: screens) {
|
||||
if (!this->surfaces.contains(screen)) {
|
||||
auto* instanceObj = this->mSurfaceComponent->create(QQmlEngine::contextForObject(this));
|
||||
auto* instance = qobject_cast<SessionLockSurface*>(instanceObj);
|
||||
|
||||
if (instance == nullptr) {
|
||||
qWarning() << "SessionLock.surface does not create a SessionLockSurface. Aborting lock.";
|
||||
this->unlock();
|
||||
return;
|
||||
}
|
||||
|
||||
instance->setParent(this);
|
||||
instance->setScreen(screen);
|
||||
|
||||
auto* oldInstance = old == nullptr ? nullptr : old->surfaces.value(screen, nullptr);
|
||||
instance->onReload(oldInstance);
|
||||
|
||||
this->surfaces[screen] = instance;
|
||||
instance->show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::unlock() {
|
||||
if (this->isLocked()) {
|
||||
this->lockTarget = false;
|
||||
this->manager->unlock();
|
||||
|
||||
for (auto* surface: this->surfaces) {
|
||||
surface->deleteLater();
|
||||
}
|
||||
|
||||
this->surfaces.clear();
|
||||
|
||||
emit this->lockStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::onScreensChanged() { this->updateSurfaces(); }
|
||||
|
||||
bool SessionLock::isLocked() const {
|
||||
return this->manager == nullptr ? this->lockTarget : this->manager->isLocked();
|
||||
}
|
||||
|
||||
bool SessionLock::isSecure() const {
|
||||
return this->manager != nullptr && SessionLockManager::sessionLocked();
|
||||
}
|
||||
|
||||
void SessionLock::setLocked(bool locked) {
|
||||
if (this->isLocked() == locked) return;
|
||||
this->lockTarget = locked;
|
||||
|
||||
if (this->manager == nullptr) {
|
||||
emit this->lockStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
this->manager->lock();
|
||||
this->updateSurfaces();
|
||||
if (this->lockTarget) emit this->lockStateChanged();
|
||||
} else {
|
||||
this->unlock(); // emits lockStateChanged
|
||||
}
|
||||
}
|
||||
|
||||
QQmlComponent* SessionLock::surfaceComponent() const { return this->mSurfaceComponent; }
|
||||
|
||||
void SessionLock::rip() {
|
||||
if (this->isLocked()) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLock::setSurfaceComponent(QQmlComponent* surfaceComponent) {
|
||||
if (this->mSurfaceComponent != nullptr) this->mSurfaceComponent->deleteLater();
|
||||
if (surfaceComponent != nullptr) surfaceComponent->setParent(this);
|
||||
|
||||
this->mSurfaceComponent = surfaceComponent;
|
||||
emit this->surfaceComponentChanged();
|
||||
}
|
||||
|
||||
SessionLockSurface::SessionLockSurface(QObject* parent)
|
||||
: Reloadable(parent)
|
||||
, mContentItem(new QQuickItem())
|
||||
, ext(new LockWindowExtension(this)) {
|
||||
this->mContentItem->setParent(this);
|
||||
|
||||
// clang-format off
|
||||
QObject::connect(this, &SessionLockSurface::widthChanged, this, &SessionLockSurface::onWidthChanged);
|
||||
QObject::connect(this, &SessionLockSurface::heightChanged, this, &SessionLockSurface::onHeightChanged);
|
||||
// clang-format on
|
||||
}
|
||||
|
||||
SessionLockSurface::~SessionLockSurface() {
|
||||
if (this->window != nullptr) {
|
||||
this->window->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
void SessionLockSurface::onReload(QObject* oldInstance) {
|
||||
if (auto* old = qobject_cast<SessionLockSurface*>(oldInstance)) {
|
||||
this->window = old->disownWindow();
|
||||
}
|
||||
|
||||
if (this->window == nullptr) {
|
||||
this->window = new QQuickWindow();
|
||||
}
|
||||
|
||||
this->mContentItem->setParentItem(this->window->contentItem());
|
||||
|
||||
this->mContentItem->setWidth(this->width());
|
||||
this->mContentItem->setHeight(this->height());
|
||||
|
||||
if (this->mScreen != nullptr) this->window->setScreen(this->mScreen);
|
||||
this->window->setColor(this->mColor);
|
||||
|
||||
// clang-format off
|
||||
QObject::connect(this->window, &QWindow::visibilityChanged, this, &SessionLockSurface::visibleChanged);
|
||||
QObject::connect(this->window, &QWindow::widthChanged, this, &SessionLockSurface::widthChanged);
|
||||
QObject::connect(this->window, &QWindow::heightChanged, this, &SessionLockSurface::heightChanged);
|
||||
QObject::connect(this->window, &QWindow::screenChanged, this, &SessionLockSurface::screenChanged);
|
||||
QObject::connect(this->window, &QQuickWindow::colorChanged, this, &SessionLockSurface::colorChanged);
|
||||
// clang-format on
|
||||
|
||||
if (auto* parent = qobject_cast<SessionLock*>(this->parent())) {
|
||||
if (!this->ext->attach(window, parent->manager)) {
|
||||
qWarning(
|
||||
) << "Failed to attach LockWindowExtension to window. Surface will not behave correctly.";
|
||||
}
|
||||
} else {
|
||||
qWarning(
|
||||
) << "SessionLockSurface parent is not a SessionLock. Surface will not behave correctly.";
|
||||
}
|
||||
|
||||
// without this the dangling screen pointer wont be updated to a real screen
|
||||
emit this->screenChanged();
|
||||
}
|
||||
|
||||
QQuickWindow* SessionLockSurface::disownWindow() {
|
||||
QObject::disconnect(this->window, nullptr, this, nullptr);
|
||||
this->mContentItem->setParentItem(nullptr);
|
||||
|
||||
auto* window = this->window;
|
||||
this->window = nullptr;
|
||||
return window;
|
||||
}
|
||||
|
||||
void SessionLockSurface::show() { this->ext->setVisible(); }
|
||||
|
||||
QQuickItem* SessionLockSurface::contentItem() const { return this->mContentItem; }
|
||||
|
||||
bool SessionLockSurface::isVisible() const { return this->window->isVisible(); }
|
||||
|
||||
qint32 SessionLockSurface::width() const {
|
||||
if (this->window == nullptr) return 0;
|
||||
else return this->window->width();
|
||||
}
|
||||
|
||||
qint32 SessionLockSurface::height() const {
|
||||
if (this->window == nullptr) return 0;
|
||||
else return this->window->height();
|
||||
}
|
||||
|
||||
QuickshellScreenInfo* SessionLockSurface::screen() const {
|
||||
QScreen* qscreen = nullptr;
|
||||
|
||||
if (this->window == nullptr) {
|
||||
if (this->mScreen != nullptr) qscreen = this->mScreen;
|
||||
} else {
|
||||
qscreen = this->window->screen();
|
||||
}
|
||||
|
||||
return new QuickshellScreenInfo(
|
||||
const_cast<SessionLockSurface*>(this), // NOLINT
|
||||
qscreen
|
||||
);
|
||||
}
|
||||
|
||||
void SessionLockSurface::setScreen(QScreen* qscreen) {
|
||||
if (this->mScreen != nullptr) {
|
||||
QObject::disconnect(this->mScreen, nullptr, this, nullptr);
|
||||
}
|
||||
|
||||
if (qscreen != nullptr) {
|
||||
QObject::connect(qscreen, &QObject::destroyed, this, &SessionLockSurface::onScreenDestroyed);
|
||||
}
|
||||
|
||||
if (this->window == nullptr) {
|
||||
this->mScreen = qscreen;
|
||||
emit this->screenChanged();
|
||||
} else this->window->setScreen(qscreen);
|
||||
}
|
||||
|
||||
void SessionLockSurface::onScreenDestroyed() { this->mScreen = nullptr; }
|
||||
|
||||
QColor SessionLockSurface::color() const {
|
||||
if (this->window == nullptr) return this->mColor;
|
||||
else return this->window->color();
|
||||
}
|
||||
|
||||
void SessionLockSurface::setColor(QColor color) {
|
||||
if (this->window == nullptr) {
|
||||
this->mColor = color;
|
||||
emit this->colorChanged();
|
||||
} else this->window->setColor(color);
|
||||
}
|
||||
|
||||
QQmlListProperty<QObject> SessionLockSurface::data() {
|
||||
return this->mContentItem->property("data").value<QQmlListProperty<QObject>>();
|
||||
}
|
||||
|
||||
void SessionLockSurface::onWidthChanged() { this->mContentItem->setWidth(this->width()); }
|
||||
void SessionLockSurface::onHeightChanged() { this->mContentItem->setHeight(this->height()); }
|
151
src/wayland/session_lock.hpp
Normal file
151
src/wayland/session_lock.hpp
Normal file
|
@ -0,0 +1,151 @@
|
|||
#pragma once
|
||||
|
||||
#include <qcolor.h>
|
||||
#include <qcontainerfwd.h>
|
||||
#include <qguiapplication.h>
|
||||
#include <qmap.h>
|
||||
#include <qnamespace.h>
|
||||
#include <qobject.h>
|
||||
#include <qqmlcomponent.h>
|
||||
#include <qqmlintegration.h>
|
||||
#include <qquickitem.h>
|
||||
#include <qquickwindow.h>
|
||||
#include <qscreen.h>
|
||||
#include <qtclasshelpermacros.h>
|
||||
#include <qtmetamacros.h>
|
||||
#include <qtypes.h>
|
||||
|
||||
#include "../core/doc.hpp"
|
||||
#include "../core/qmlscreen.hpp"
|
||||
#include "../core/reload.hpp"
|
||||
#include "session_lock/session_lock.hpp"
|
||||
|
||||
class SessionLockSurface;
|
||||
|
||||
/// Note: Very untested. Do anything outside of the obvious use cases and you WILL red screen.
|
||||
class SessionLock: public Reloadable {
|
||||
Q_OBJECT;
|
||||
// clang-format off
|
||||
/// Note: only one SessionLock may be locked at a time.
|
||||
Q_PROPERTY(bool locked READ isLocked WRITE setLocked NOTIFY lockStateChanged);
|
||||
/// Returns the *compositor* lock state, which will only be set to true after all surfaces are in place.
|
||||
Q_PROPERTY(bool secure READ isSecure NOTIFY secureStateChanged);
|
||||
/// Component that will be instantiated for each screen. Must be a `SessionLockSurface`.
|
||||
Q_PROPERTY(QQmlComponent* surface READ surfaceComponent WRITE setSurfaceComponent NOTIFY surfaceComponentChanged);
|
||||
// clang-format on
|
||||
QML_ELEMENT;
|
||||
Q_CLASSINFO("DefaultProperty", "surface");
|
||||
|
||||
public:
|
||||
explicit SessionLock(QObject* parent = nullptr): Reloadable(parent) {}
|
||||
|
||||
void onReload(QObject* oldInstance) override;
|
||||
|
||||
[[nodiscard]] bool isLocked() const;
|
||||
void setLocked(bool locked);
|
||||
|
||||
[[nodiscard]] bool isSecure() const;
|
||||
|
||||
[[nodiscard]] QQmlComponent* surfaceComponent() const;
|
||||
void setSurfaceComponent(QQmlComponent* surfaceComponent);
|
||||
|
||||
QSDOC_HIDE Q_INVOKABLE void rip();
|
||||
|
||||
signals:
|
||||
void lockStateChanged();
|
||||
void secureStateChanged();
|
||||
void surfaceComponentChanged();
|
||||
|
||||
private slots:
|
||||
void unlock();
|
||||
void onScreensChanged();
|
||||
|
||||
private:
|
||||
void updateSurfaces(SessionLock* old = nullptr);
|
||||
|
||||
SessionLockManager* manager = nullptr;
|
||||
QQmlComponent* mSurfaceComponent = nullptr;
|
||||
QMap<QScreen*, SessionLockSurface*> surfaces;
|
||||
bool lockTarget = false;
|
||||
|
||||
friend class SessionLockSurface;
|
||||
};
|
||||
|
||||
class SessionLockSurface: public Reloadable {
|
||||
Q_OBJECT;
|
||||
// clang-format off
|
||||
Q_PROPERTY(QQuickItem* contentItem READ contentItem);
|
||||
/// If the window has been presented yet.
|
||||
Q_PROPERTY(bool visible READ isVisible NOTIFY visibleChanged);
|
||||
Q_PROPERTY(qint32 width READ width NOTIFY widthChanged);
|
||||
Q_PROPERTY(qint32 height READ height NOTIFY heightChanged);
|
||||
/// The screen that the window currently occupies.
|
||||
///
|
||||
/// > [!INFO] This cannot be changed after windowConnected.
|
||||
Q_PROPERTY(QuickshellScreenInfo* screen READ screen NOTIFY screenChanged);
|
||||
/// The background color of the window. Defaults to white.
|
||||
///
|
||||
/// > [!WARNING] This seems to behave weirdly when using transparent colors on some systems.
|
||||
/// > Using a colored content item over a transparent window is the recommended way to work around this:
|
||||
/// > ```qml
|
||||
/// > ProxyWindow {
|
||||
/// > Rectangle {
|
||||
/// > anchors.fill: parent
|
||||
/// > color: "#20ffffff"
|
||||
/// >
|
||||
/// > // your content here
|
||||
/// > }
|
||||
/// > }
|
||||
/// > ```
|
||||
/// > ... but you probably shouldn't make a transparent lock,
|
||||
/// > and most compositors will ignore an attempt to do so.
|
||||
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged);
|
||||
Q_PROPERTY(QQmlListProperty<QObject> data READ data);
|
||||
// clang-format on
|
||||
QML_ELEMENT;
|
||||
Q_CLASSINFO("DefaultProperty", "data");
|
||||
|
||||
public:
|
||||
explicit SessionLockSurface(QObject* parent = nullptr);
|
||||
~SessionLockSurface() override;
|
||||
Q_DISABLE_COPY_MOVE(SessionLockSurface);
|
||||
|
||||
void onReload(QObject* oldInstance) override;
|
||||
QQuickWindow* disownWindow();
|
||||
|
||||
void show();
|
||||
|
||||
[[nodiscard]] QQuickItem* contentItem() const;
|
||||
|
||||
[[nodiscard]] bool isVisible() const;
|
||||
|
||||
[[nodiscard]] qint32 width() const;
|
||||
[[nodiscard]] qint32 height() const;
|
||||
|
||||
[[nodiscard]] QuickshellScreenInfo* screen() const;
|
||||
void setScreen(QScreen* qscreen);
|
||||
|
||||
[[nodiscard]] QColor color() const;
|
||||
void setColor(QColor color);
|
||||
|
||||
[[nodiscard]] QQmlListProperty<QObject> data();
|
||||
|
||||
signals:
|
||||
void visibleChanged();
|
||||
void widthChanged();
|
||||
void heightChanged();
|
||||
void screenChanged();
|
||||
void colorChanged();
|
||||
|
||||
private slots:
|
||||
void onScreenDestroyed();
|
||||
void onWidthChanged();
|
||||
void onHeightChanged();
|
||||
|
||||
private:
|
||||
QQuickWindow* window = nullptr;
|
||||
QQuickItem* mContentItem;
|
||||
QScreen* mScreen = nullptr;
|
||||
QColor mColor = Qt::white;
|
||||
LockWindowExtension* ext;
|
||||
};
|
|
@ -17,10 +17,10 @@ QSWaylandSessionLock::~QSWaylandSessionLock() { this->unlock(); }
|
|||
|
||||
void QSWaylandSessionLock::unlock() {
|
||||
if (this->isInitialized()) {
|
||||
if (this->locked) this->unlock_and_destroy();
|
||||
else this->destroy();
|
||||
if (this->finished) this->destroy();
|
||||
else this->unlock_and_destroy();
|
||||
|
||||
this->locked = false;
|
||||
this->secure = false;
|
||||
this->manager->active = nullptr;
|
||||
|
||||
emit this->unlocked();
|
||||
|
@ -29,14 +29,15 @@ void QSWaylandSessionLock::unlock() {
|
|||
|
||||
bool QSWaylandSessionLock::active() const { return this->isInitialized(); }
|
||||
|
||||
bool QSWaylandSessionLock::hasCompositorLock() const { return this->locked; }
|
||||
bool QSWaylandSessionLock::hasCompositorLock() const { return this->secure; }
|
||||
|
||||
void QSWaylandSessionLock::ext_session_lock_v1_locked() {
|
||||
this->locked = true;
|
||||
this->secure = true;
|
||||
emit this->compositorLocked();
|
||||
}
|
||||
|
||||
void QSWaylandSessionLock::ext_session_lock_v1_finished() {
|
||||
this->locked = false;
|
||||
this->secure = false;
|
||||
this->finished = true;
|
||||
this->unlock();
|
||||
}
|
||||
|
|
|
@ -34,5 +34,6 @@ private:
|
|||
QSWaylandSessionLockManager* manager; // static and not dealloc'd
|
||||
|
||||
// true when the compositor determines the session is locked
|
||||
bool locked = false;
|
||||
bool secure = false;
|
||||
bool finished = false;
|
||||
};
|
||||
|
|
|
@ -21,7 +21,7 @@ static QSWaylandSessionLockManager* manager() {
|
|||
}
|
||||
|
||||
bool SessionLockManager::lock() {
|
||||
if (SessionLockManager::sessionLocked()) return false;
|
||||
if (this->isLocked() || SessionLockManager::sessionLocked()) return false;
|
||||
this->mLock = manager()->acquireLock();
|
||||
this->mLock->setParent(this);
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ class SessionLockManager: public QObject {
|
|||
Q_OBJECT;
|
||||
|
||||
public:
|
||||
SessionLockManager(QObject* parent = nullptr): QObject(parent) {}
|
||||
explicit SessionLockManager(QObject* parent = nullptr): QObject(parent) {}
|
||||
Q_DISABLE_COPY_MOVE(SessionLockManager);
|
||||
|
||||
// Returns true if a lock was acquired.
|
||||
|
@ -57,7 +57,7 @@ class LockWindowExtension: public QObject {
|
|||
Q_OBJECT;
|
||||
|
||||
public:
|
||||
LockWindowExtension(QObject* parent = nullptr): QObject(parent) {}
|
||||
explicit LockWindowExtension(QObject* parent = nullptr): QObject(parent) {}
|
||||
~LockWindowExtension() override;
|
||||
|
||||
// Attach this lock extension to the given window.
|
||||
|
|
|
@ -40,7 +40,10 @@ QSWaylandSessionLockSurface::QSWaylandSessionLockSurface(QtWaylandClient::QWayla
|
|||
this->init(this->ext->lock->get_lock_surface(window->waylandSurface()->object(), output));
|
||||
}
|
||||
|
||||
QSWaylandSessionLockSurface::~QSWaylandSessionLockSurface() { this->destroy(); }
|
||||
QSWaylandSessionLockSurface::~QSWaylandSessionLockSurface() {
|
||||
if (this->ext != nullptr) this->ext->surface = nullptr;
|
||||
this->destroy();
|
||||
}
|
||||
|
||||
bool QSWaylandSessionLockSurface::isExposed() const { return this->configured; }
|
||||
|
||||
|
@ -60,7 +63,7 @@ bool QSWaylandSessionLockSurface::handleExpose(const QRegion& region) {
|
|||
|
||||
void QSWaylandSessionLockSurface::setExtension(LockWindowExtension* ext) {
|
||||
if (ext == nullptr) {
|
||||
this->window()->window()->close();
|
||||
if (this->window() != nullptr) this->window()->window()->close();
|
||||
} else {
|
||||
if (this->ext != nullptr) {
|
||||
this->ext->surface = nullptr;
|
||||
|
|
Loading…
Reference in a new issue