feat: add the cutefish-dm display manager

main
reionwong 1 month ago
commit 6dd683619b

1
.gitignore vendored

@ -0,0 +1 @@
build/

@ -0,0 +1,62 @@
cmake_minimum_required(VERSION 3.22)
project(cutefish-dm
VERSION 0.1.0
DESCRIPTION "Cutefish display manager"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
include(GNUInstallDirs)
option(BUILD_TESTING "Build the test suite" OFF)
include(CTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core DBus Network)
find_library(PAM_LIBRARY pam)
if(NOT PAM_LIBRARY)
message(FATAL_ERROR "cutefish-dm needs libpam")
endif()
set(DM_HELPER_PATH ${CMAKE_INSTALL_FULL_LIBEXECDIR}/cutefish-dm-helper)
set(DM_SESSION_WRAPPER ${CMAKE_INSTALL_FULL_LIBEXECDIR}/cutefish-dm-session)
add_library(cutefish-dm-common STATIC common/wire.cpp)
target_link_libraries(cutefish-dm-common PUBLIC Qt6::Core)
add_executable(cutefish-dm
daemon/config.cpp
daemon/daemon.cpp
daemon/greeterserver.cpp
daemon/helperprocess.cpp
daemon/main.cpp
daemon/sessions.cpp
daemon/state.cpp
daemon/terminal.cpp
)
target_compile_definitions(cutefish-dm PRIVATE
DM_HELPER_PATH="${DM_HELPER_PATH}"
DM_SESSION_WRAPPER="${DM_SESSION_WRAPPER}"
)
target_link_libraries(cutefish-dm PRIVATE cutefish-dm-common Qt6::Core Qt6::DBus Qt6::Network)
# No Qt event loop and no threads: PAM modules and the session's fork run here.
add_executable(cutefish-dm-helper helper/main.cpp)
target_link_libraries(cutefish-dm-helper PRIVATE cutefish-dm-common ${PAM_LIBRARY})
install(TARGETS cutefish-dm DESTINATION ${CMAKE_INSTALL_BINDIR})
install(TARGETS cutefish-dm-helper DESTINATION ${CMAKE_INSTALL_LIBEXECDIR})
install(PROGRAMS data/cutefish-dm-session DESTINATION ${CMAKE_INSTALL_LIBEXECDIR})
install(FILES data/cutefish-dm.service DESTINATION /usr/lib/systemd/system)
install(FILES data/cutefish-dm.pam DESTINATION ${CMAKE_INSTALL_FULL_SYSCONFDIR}/pam.d
RENAME cutefish-dm)
install(FILES data/cutefish-dm-greeter.pam DESTINATION ${CMAKE_INSTALL_FULL_SYSCONFDIR}/pam.d
RENAME cutefish-dm-greeter)
install(FILES data/com.cutefish.DisplayManager.conf
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/dbus-1/system.d)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()

@ -0,0 +1,53 @@
# cutefish-dm
The Cutefish display manager. It runs the login screen
(`cutefish-compositor --login`) and the Wayland session the user picks, Cutefish
or any other `wayland-sessions` entry such as GNOME or Plasma, with no black
frame or console in between.
The login screen runs on VT1 and the session on VT2, so every handover is a switch
between two VTs that are already drawing. On a single VT the console would flash
whenever a compositor exits.
- `cutefish-dm` is the root daemon. The login screen talks to it over
`GREETD_SOCK` with greetd's protocol; `start_session` may name a
`wayland-sessions` id instead of a command.
- `cutefish-dm-helper` runs PAM and the session for one login, like greetd's
session worker and sddm-helper.
- `cutefish-session` calls `com.cutefish.DisplayManager.SwitchToGreeter` before
it logs out, so the login screen is already up when the session ends.
## Dependencies
Qt 6.8 (Core, DBus, Network) and PAM; `cutefish-compositor` at runtime.
## Build
```shell
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build
sudo cmake --install build
```
The Debian package creates the login screen's user; a source install needs it once:
```shell
sudo adduser --system --group --home /var/lib/cutefish-greeter cutefish-greeter
```
## Enable
```shell
sudo systemctl disable sddm
sudo systemctl enable cutefish-dm
```
Optional settings go in `/etc/cutefish/dm.conf`; see `daemon/config.cpp`.
## Tests
```shell
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
```

@ -0,0 +1,68 @@
#include "wire.h"
#include <QJsonDocument>
#include <cstring>
namespace dm {
QByteArray encodeFrame(const QJsonObject& message)
{
const QByteArray payload = QJsonDocument(message).toJson(QJsonDocument::Compact);
const quint32 length = quint32(payload.size());
QByteArray frame(reinterpret_cast<const char*>(&length), sizeof(length));
frame.append(payload);
return frame;
}
std::optional<QJsonObject> FrameReader::next()
{
if (m_failed || m_buffer.size() < qsizetype(sizeof(quint32)))
return std::nullopt;
quint32 length = 0;
std::memcpy(&length, m_buffer.constData(), sizeof(length));
if (length > kMaxFrameSize) {
m_failed = true;
return std::nullopt;
}
if (m_buffer.size() < qsizetype(sizeof(quint32) + length))
return std::nullopt;
QByteArray payload = m_buffer.mid(sizeof(quint32), length);
// Only the frame taken out: the bytes after it are the next one.
explicit_bzero(m_buffer.data(), sizeof(quint32) + length);
m_buffer.remove(0, sizeof(quint32) + length);
const auto document = QJsonDocument::fromJson(payload);
wipe(payload);
if (!document.isObject()) {
m_failed = true;
return std::nullopt;
}
return document.object();
}
QByteArray encodeLine(const QJsonObject& message)
{
return QJsonDocument(message).toJson(QJsonDocument::Compact) + '\n';
}
std::optional<QJsonObject> decodeLine(const QByteArray& line)
{
const auto document = QJsonDocument::fromJson(line.trimmed());
if (!document.isObject())
return std::nullopt;
return document.object();
}
void wipe(QByteArray& bytes)
{
if (!bytes.isEmpty())
explicit_bzero(bytes.data(), size_t(bytes.size()));
}
void wipe(QString& text)
{
if (!text.isEmpty())
explicit_bzero(text.data(), size_t(text.size()) * sizeof(QChar));
text.clear();
}
} // namespace dm

@ -0,0 +1,35 @@
#pragma once
#include <QByteArray>
#include <QJsonObject>
#include <optional>
namespace dm {
// greetd's framing (greetd_ipc::codec): native-endian u32 length, JSON object.
constexpr quint32 kMaxFrameSize = 64 * 1024;
QByteArray encodeFrame(const QJsonObject& message);
// An oversized frame or one that is not a JSON object fails it for good.
class FrameReader
{
public:
void append(const QByteArray& bytes) { m_buffer.append(bytes); }
std::optional<QJsonObject> next();
bool failed() const { return m_failed; }
private:
QByteArray m_buffer;
bool m_failed = false;
};
// The daemon and its helpers exchange one compact JSON object per line.
QByteArray encodeLine(const QJsonObject& message);
std::optional<QJsonObject> decodeLine(const QByteArray& line);
// Overwrites a buffer that held a password before it is released.
void wipe(QByteArray& bytes);
void wipe(QString& text);
} // namespace dm

@ -0,0 +1,40 @@
#include "config.h"
#include <QFileInfo>
#include <QProcess>
#include <QSettings>
namespace dm {
Config Config::load(const QString& path)
{
Config config;
if (!QFileInfo::exists(path))
return config;
QSettings settings(path, QSettings::IniFormat);
settings.beginGroup(QStringLiteral("Greeter"));
config.greeterVt = settings.value(QStringLiteral("Vt"), config.greeterVt).toInt();
config.greeterUser = settings.value(QStringLiteral("User"), config.greeterUser).toString();
if (settings.contains(QStringLiteral("Command")))
config.greeterCommand =
QProcess::splitCommand(settings.value(QStringLiteral("Command")).toString());
config.greeterService = settings.value(QStringLiteral("Service"), config.greeterService).toString();
settings.endGroup();
settings.beginGroup(QStringLiteral("Session"));
config.sessionVt = settings.value(QStringLiteral("Vt"), config.sessionVt).toInt();
config.sessionService = settings.value(QStringLiteral("Service"), config.sessionService).toString();
if (settings.contains(QStringLiteral("Directories")))
config.sessionDirectories =
settings.value(QStringLiteral("Directories")).toString().split(QLatin1Char(':'), Qt::SkipEmptyParts);
config.defaultSession =
settings.value(QStringLiteral("Default"), config.defaultSession).toString();
settings.endGroup();
settings.beginGroup(QStringLiteral("Daemon"));
config.runtimeDirectory =
settings.value(QStringLiteral("RuntimeDirectory"), config.runtimeDirectory).toString();
config.statePath = settings.value(QStringLiteral("StatePath"), config.statePath).toString();
settings.endGroup();
return config;
}
} // namespace dm

@ -0,0 +1,27 @@
#pragma once
#include <QString>
#include <QStringList>
namespace dm {
struct Config {
// Separate VTs keep a session's end off the screen; VT1 has the boot splash.
int greeterVt = 1;
int sessionVt = 2;
QString greeterUser = QStringLiteral("cutefish-greeter");
QStringList greeterCommand = {QStringLiteral("/usr/bin/cutefish-compositor"),
QStringLiteral("--login")};
QString greeterService = QStringLiteral("cutefish-dm-greeter");
QString sessionService = QStringLiteral("cutefish-dm");
QStringList sessionDirectories = {QStringLiteral("/usr/local/share/wayland-sessions"),
QStringLiteral("/usr/share/wayland-sessions")};
QString defaultSession = QStringLiteral("cutefish-wayland");
QString statePath = QStringLiteral("/var/lib/cutefish-dm/state.conf");
QString runtimeDirectory = QStringLiteral("/run/cutefish-dm");
// Defaults, overridden by whatever `path` sets.
static Config load(const QString& path = QStringLiteral("/etc/cutefish/dm.conf"));
};
} // namespace dm

@ -0,0 +1,372 @@
#include "daemon.h"
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusReply>
#include <QDir>
#include <QPointer>
#include <QProcess>
#include <QStandardPaths>
#include <pwd.h>
#include "sessions.h"
#include "terminal.h"
namespace dm {
namespace {
// How long the login screen or a session may take to open its display.
constexpr int kControlTimeoutMs = 10000;
constexpr int kControlPollMs = 20;
// How long the login screen outlives the switch to a session: the kernel takes
// its cursor down with it, and the session's replaces it once its displays settle.
constexpr int kGreeterLingerMs = 3000;
std::optional<uid_t> uidOf(const QString& user)
{
const passwd* entry = getpwnam(user.toLocal8Bit().constData());
if (!entry)
return std::nullopt;
return entry->pw_uid;
}
// As GDM does: plymouth gives up the display with its splash left on screen.
bool bootSplashRunning()
{
const QString plymouth = QStandardPaths::findExecutable(QStringLiteral("plymouth"));
return !plymouth.isEmpty() && QProcess::execute(plymouth, {QStringLiteral("--ping")}) == 0;
}
} // namespace
Daemon::Daemon(Config config, QObject* parent)
: QObject(parent)
, m_config(std::move(config))
, m_state(m_config.statePath)
{
m_greeterRestart.setSingleShot(true);
connect(&m_greeterRestart, &QTimer::timeout, this, &Daemon::startGreeter);
connect(&m_server, &GreeterServer::createSession, this, &Daemon::createSession);
connect(&m_server, &GreeterServer::postAuthMessageResponse, this,
&Daemon::postAuthMessageResponse);
connect(&m_server, &GreeterServer::startSession, this, &Daemon::startSession);
connect(&m_server, &GreeterServer::cancelSession, this, &Daemon::cancelSession);
connect(&m_server, &GreeterServer::disconnected, this, [this] {
if (m_login && !m_login->hasStarted())
release(m_login);
});
}
Daemon::~Daemon()
{
m_stopping = true;
replySwitchRequests(false);
m_login.reset();
m_session.reset();
m_greeter.reset();
restoreTextMode(m_config.sessionVt);
restoreTextMode(m_config.greeterVt);
}
bool Daemon::start()
{
const auto greeter = uidOf(m_config.greeterUser);
if (!greeter) {
qCritical() << "No login screen user" << m_config.greeterUser;
return false;
}
m_greeterUid = *greeter;
QDir().mkpath(m_config.runtimeDirectory);
const QString socket = m_config.runtimeDirectory + QStringLiteral("/greeter.sock");
if (!m_server.listen(socket, m_greeterUid)) {
qCritical() << "Cannot listen on" << socket;
return false;
}
auto bus = QDBusConnection::systemBus();
if (!bus.registerService(QStringLiteral("com.cutefish.DisplayManager"))
|| !bus.registerObject(QStringLiteral("/com/cutefish/DisplayManager"), this,
QDBusConnection::ExportScriptableSlots))
qWarning() << "Cannot register com.cutefish.DisplayManager; sessions will end on screen";
if (bootSplashRunning()) {
m_bootSplash = true;
QProcess::execute(QStringLiteral("plymouth"), {QStringLiteral("deactivate")});
}
// A run that ended mid-login may have left it in KD_GRAPHICS with nobody on it.
restoreTextMode(m_config.sessionVt);
startGreeter();
return true;
}
void Daemon::startGreeter()
{
if (m_greeter || m_stopping)
return;
// Until the compositor draws, VT1 keeps the previous frame, not the console.
setGraphicsMode(m_config.greeterVt);
m_greeterExpected = false;
m_greeterSpent = false;
m_greeter = std::make_unique<HelperProcess>();
connect(m_greeter.get(), &HelperProcess::authenticated, this, [this] {
m_greeter->start(m_config.greeterCommand,
greeterEnvironment(m_config.greeterVt, m_server.path()));
});
connect(m_greeter.get(), &HelperProcess::started, this, [this](const QString& id) {
m_greeterSessionId = id;
if (std::exchange(m_bootSplash, false))
QProcess::startDetached(QStringLiteral("plymouth"),
{QStringLiteral("quit"), QStringLiteral("--retain-splash")});
showGreeter();
});
connect(m_greeter.get(), &HelperProcess::failed, this, [](bool, const QString& message) {
qWarning() << "Login screen:" << message;
});
connect(m_greeter.get(), &HelperProcess::finished, this, &Daemon::onGreeterFinished);
m_greeter->login(m_config.greeterService, m_config.greeterUser, false, m_config.greeterVt);
}
void Daemon::showGreeter()
{
if (!m_greeter || !m_greeter->hasStarted())
return;
// Created on the VT in front, as at boot, the login screen is active already.
if (activeVt() == m_config.greeterVt) {
replySwitchRequests(true);
return;
}
HelperProcess* greeter = m_greeter.get();
whenControlled(greeter, m_config.greeterVt, [this, greeter](bool controlled) {
if (m_greeter.get() != greeter)
return;
if (controlled && activateSession(m_greeterSessionId)) {
replySwitchRequests(true);
return;
}
// Never switch to a VT nobody took; restart the login screen instead.
qWarning() << "Cannot switch to the login screen on VT" << m_config.greeterVt;
greeter->terminate();
});
}
void Daemon::stopGreeter()
{
m_greeterExpected = true;
if (m_greeter)
m_greeter->terminate();
}
void Daemon::onGreeterFinished()
{
release(m_greeter);
m_greeterSessionId.clear();
restoreTextMode(m_config.greeterVt);
if (m_stopping)
return;
if (std::exchange(m_restartGreeter, false)) {
startGreeter();
return;
}
if (m_greeterExpected) {
m_greeterFailures = 0;
return;
}
// Restart a login screen that died, with backoff, unless a session is up.
replySwitchRequests(false);
if (m_session)
return;
++m_greeterFailures;
m_greeterRestart.start(qMin(m_greeterFailures, 5) * 1000);
}
void Daemon::replySwitchRequests(bool switched)
{
auto bus = QDBusConnection::systemBus();
for (const QDBusMessage& request : std::exchange(m_switchRequests, {})) {
bus.send(switched ? request.createReply()
: request.createErrorReply(QDBusError::Failed,
QStringLiteral("The login screen is not up")));
}
}
void Daemon::SwitchToGreeter(const QDBusMessage& message)
{
auto bus = QDBusConnection::systemBus();
// A caller that is gone before it is looked up is refused, not taken for root.
const QDBusReply<uint> caller = bus.interface()->serviceUid(message.service());
const auto owner = m_session ? uidOf(m_session->user()) : std::nullopt;
if (!caller.isValid() || (caller.value() != 0 && (!owner || caller.value() != *owner))) {
bus.send(message.createErrorReply(QDBusError::AccessDenied,
QStringLiteral("Only the session's own user can end it")));
return;
}
message.setDelayedReply(true);
m_switchRequests.append(message);
// A login screen that already handed a login over is replaced, not shown.
if (m_greeter && m_greeterSpent) {
m_restartGreeter = true;
stopGreeter();
} else if (m_greeter) {
showGreeter();
} else {
startGreeter();
}
}
void Daemon::createSession(const QString& username)
{
if (m_login)
release(m_login);
m_login = std::make_unique<HelperProcess>();
auto* login = m_login.get();
connect(login, &HelperProcess::question, this, [this](const QString& style, const QString& text) {
m_server.respond(GreeterServer::authMessage(style, text));
});
connect(login, &HelperProcess::authenticated, this, [this] {
m_server.respond(GreeterServer::success());
});
connect(login, &HelperProcess::failed, this, [this, login](bool authentication,
const QString& message) {
m_server.respond(GreeterServer::error(authentication, message));
if (m_login.get() == login)
release(m_login);
});
connect(login, &HelperProcess::finished, this, [this, login] {
if (m_login.get() == login)
release(m_login);
});
login->login(m_config.sessionService, username, true, m_config.sessionVt);
}
void Daemon::postAuthMessageResponse(const QJsonValue& response)
{
if (!m_login)
m_server.respond(GreeterServer::error(false, QStringLiteral("No session is being created")));
else
m_login->answer(response);
}
void Daemon::startSession(const QStringList& command, const QStringList& environment,
const QString& session)
{
if (!m_login || !m_login->isAuthenticated()) {
m_server.respond(GreeterServer::error(false, QStringLiteral("Not authenticated")));
return;
}
PendingStart start;
// The client's variables first: the session's own, set after, win.
start.environment = environment;
if (!session.isEmpty()) {
const auto entry = findSession(m_config.sessionDirectories, session);
if (!entry) {
m_server.respond(GreeterServer::error(false, QStringLiteral("No such session")));
return;
}
start.command = QStringList {QStringLiteral(DM_SESSION_WRAPPER)} + entry->exec;
start.environment += sessionEnvironment(*entry, m_config.sessionVt);
m_state.remember(m_login->user(), session);
} else if (!command.isEmpty()) {
start.command = QStringList {QStringLiteral(DM_SESSION_WRAPPER)} + command;
start.environment += sessionEnvironment(SessionEntry {}, m_config.sessionVt);
} else {
m_server.respond(GreeterServer::error(false, QStringLiteral("Nothing to start")));
return;
}
m_server.respond(GreeterServer::success());
// The previous session is still ending; the next one follows on its VT.
if (m_session) {
m_pendingStart = std::move(start);
m_session->terminate();
return;
}
launchSession(std::move(start));
}
void Daemon::cancelSession()
{
if (m_login && !m_login->hasStarted())
release(m_login);
m_server.respond(GreeterServer::success());
}
void Daemon::launchSession(PendingStart start)
{
if (!m_login)
return;
setGraphicsMode(m_config.sessionVt);
m_greeterSpent = true;
m_session = std::move(m_login);
HelperProcess* session = m_session.get();
connect(session, &HelperProcess::started, this, [this, session](const QString& id) {
whenControlled(session, m_config.sessionVt, [this, session, id](bool controlled) {
if (m_session.get() != session)
return;
// Never switch to a VT nobody took; end the session instead.
if (!controlled || !activateSession(id)) {
qWarning() << "Cannot switch to the session on VT" << m_config.sessionVt
<< "- ending it";
session->terminate();
return;
}
QTimer::singleShot(kGreeterLingerMs, this,
[this, greeter = QPointer<HelperProcess>(m_greeter.get())] {
if (greeter && greeter == m_greeter.get())
stopGreeter();
});
});
});
connect(m_session.get(), &HelperProcess::finished, this, &Daemon::onSessionFinished);
m_session->start(start.command, start.environment);
}
void Daemon::onSessionFinished()
{
release(m_session);
restoreTextMode(m_config.sessionVt);
if (m_stopping)
return;
// A login screen that went away meanwhile took its login along.
if (auto pending = std::exchange(m_pendingStart, std::nullopt); pending && m_login) {
launchSession(std::move(*pending));
return;
}
// A session that failed before it was shown leaves a spent login screen.
if (m_greeter && m_greeterSpent) {
m_restartGreeter = true;
stopGreeter();
return;
}
// A crash, or another desktop's own logout: the login screen comes back.
if (m_greeter)
showGreeter();
else
startGreeter();
}
void Daemon::whenControlled(HelperProcess* helper, int vt, std::function<void(bool)> then)
{
// The helper's child, so the wait ends with it.
auto* timer = new QTimer(helper);
auto waited = std::make_shared<int>(0);
connect(timer, &QTimer::timeout, this, [timer, waited, vt, then = std::move(then)] {
*waited += kControlPollMs;
const bool controlled = isControlled(vt);
if (!controlled && *waited < kControlTimeoutMs)
return;
timer->stop();
timer->deleteLater();
then(controlled);
});
timer->start(kControlPollMs);
}
void Daemon::release(std::unique_ptr<HelperProcess>& helper)
{
if (!helper)
return;
helper->disconnect();
helper.release()->deleteLater();
}
} // namespace dm

@ -0,0 +1,80 @@
#pragma once
#include <QDBusMessage>
#include <QList>
#include <QObject>
#include <QTimer>
#include <functional>
#include <memory>
#include <optional>
#include <sys/types.h>
#include "config.h"
#include "greeterserver.h"
#include "helperprocess.h"
#include "state.h"
namespace dm {
// Runs the login screen and the session on VTs of their own; see README.md.
class Daemon : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "com.cutefish.DisplayManager")
public:
explicit Daemon(Config config, QObject* parent = nullptr);
~Daemon() override;
bool start();
public Q_SLOTS:
// Called by the session before it ends; replies once the login screen is up.
Q_SCRIPTABLE void SwitchToGreeter(const QDBusMessage& message);
private:
struct PendingStart {
QStringList command;
QStringList environment;
};
void startGreeter();
void showGreeter();
void stopGreeter();
void onGreeterFinished();
void replySwitchRequests(bool switched);
void createSession(const QString& username);
void postAuthMessageResponse(const QJsonValue& response);
void startSession(const QStringList& command, const QStringList& environment,
const QString& session);
void cancelSession();
void launchSession(PendingStart start);
void onSessionFinished();
// then(true) once logind has taken `vt` for the helper's session, then(false)
// after a timeout; nothing runs once the helper is released.
void whenControlled(HelperProcess* helper, int vt, std::function<void(bool)> then);
// HelperProcess objects end in their own signals; they are released later.
static void release(std::unique_ptr<HelperProcess>& helper);
Config m_config;
State m_state;
GreeterServer m_server;
uid_t m_greeterUid = 0;
std::unique_ptr<HelperProcess> m_greeter;
std::unique_ptr<HelperProcess> m_login;
std::unique_ptr<HelperProcess> m_session;
QString m_greeterSessionId;
std::optional<PendingStart> m_pendingStart;
QList<QDBusMessage> m_switchRequests;
QTimer m_greeterRestart;
int m_greeterFailures = 0;
bool m_greeterExpected = false;
// The login screen has handed a login over and cannot take another.
bool m_greeterSpent = false;
bool m_restartGreeter = false;
bool m_bootSplash = false;
bool m_stopping = false;
};
} // namespace dm

@ -0,0 +1,145 @@
#include "greeterserver.h"
#include <QFile>
#include <QJsonArray>
#include <QLocalSocket>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
namespace dm {
GreeterServer::GreeterServer(QObject* parent)
: QObject(parent)
{
connect(&m_server, &QLocalServer::newConnection, this, &GreeterServer::accept);
}
GreeterServer::~GreeterServer()
{
// m_server owns the socket; deleted with it, it would signal a dead object.
if (m_client) {
m_client->disconnect(this);
delete m_client;
}
m_server.close();
}
bool GreeterServer::listen(const QString& path, uid_t owner)
{
m_owner = owner;
m_path = path;
QLocalServer::removeServer(path);
m_server.setSocketOptions(QLocalServer::UserAccessOption);
if (!m_server.listen(path))
return false;
const QByteArray native = QFile::encodeName(path);
return ::chown(native.constData(), owner, gid_t(-1)) == 0 && ::chmod(native.constData(), 0600) == 0;
}
void GreeterServer::accept()
{
while (QLocalSocket* socket = m_server.nextPendingConnection()) {
ucred peer {};
socklen_t length = sizeof(peer);
const bool trusted =
getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &peer, &length) == 0
&& (peer.uid == m_owner || peer.uid == 0);
if (!trusted) {
socket->abort();
socket->deleteLater();
continue;
}
// A new login screen replaces whatever was connected before.
if (m_client) {
m_client->disconnect(this);
m_client->abort();
m_client->deleteLater();
}
m_client = socket;
m_reader = FrameReader();
m_queued.clear();
m_waiting = false;
connect(socket, &QLocalSocket::readyRead, this, &GreeterServer::readRequests);
connect(socket, &QLocalSocket::disconnected, this, [this, socket] {
if (socket != m_client)
return;
m_client = nullptr;
socket->deleteLater();
emit disconnected();
});
}
}
void GreeterServer::readRequests()
{
if (!m_client)
return;
QByteArray bytes = m_client->readAll();
m_reader.append(bytes);
wipe(bytes);
while (auto request = m_reader.next())
m_queued.append(*request);
if (m_reader.failed()) {
m_client->abort();
return;
}
if (!m_waiting && !m_queued.isEmpty())
dispatch(m_queued.takeFirst());
}
void GreeterServer::dispatch(const QJsonObject& request)
{
m_waiting = true;
const QString type = request.value(QStringLiteral("type")).toString();
if (type == QLatin1String("create_session")) {
emit createSession(request.value(QStringLiteral("username")).toString());
} else if (type == QLatin1String("post_auth_message_response")) {
emit postAuthMessageResponse(request.value(QStringLiteral("response")));
} else if (type == QLatin1String("start_session")) {
QStringList command, environment;
for (const auto& value : request.value(QStringLiteral("cmd")).toArray())
command.append(value.toString());
for (const auto& value : request.value(QStringLiteral("env")).toArray())
environment.append(value.toString());
emit startSession(command, environment, request.value(QStringLiteral("session")).toString());
} else if (type == QLatin1String("cancel_session")) {
emit cancelSession();
} else {
respond(error(false, QStringLiteral("Unknown request")));
}
}
void GreeterServer::respond(const QJsonObject& response)
{
if (!m_waiting)
return;
m_waiting = false;
if (m_client)
m_client->write(encodeFrame(response));
if (!m_queued.isEmpty())
dispatch(m_queued.takeFirst());
}
QJsonObject GreeterServer::success()
{
return {{QStringLiteral("type"), QStringLiteral("success")}};
}
QJsonObject GreeterServer::error(bool authentication, const QString& description)
{
return {{QStringLiteral("type"), QStringLiteral("error")},
{QStringLiteral("error_type"),
authentication ? QStringLiteral("auth_error") : QStringLiteral("error")},
{QStringLiteral("description"), description}};
}
QJsonObject GreeterServer::authMessage(const QString& style, const QString& text)
{
return {{QStringLiteral("type"), QStringLiteral("auth_message")},
{QStringLiteral("auth_message_type"), style},
{QStringLiteral("auth_message"), text}};
}
} // namespace dm

@ -0,0 +1,57 @@
#pragma once
#include <QJsonObject>
#include <QList>
#include <QLocalServer>
#include <QPointer>
#include <sys/types.h>
#include "../common/wire.h"
class QLocalSocket;
namespace dm {
// GREETD_SOCK with greetd's protocol; one request is answered at a time.
class GreeterServer : public QObject
{
Q_OBJECT
public:
explicit GreeterServer(QObject* parent = nullptr);
~GreeterServer() override;
// Only `owner` may connect; the socket is chowned to it with mode 0600.
bool listen(const QString& path, uid_t owner);
QString path() const { return m_path; }
// Answers the request in flight.
void respond(const QJsonObject& response);
static QJsonObject success();
static QJsonObject error(bool authentication, const QString& description);
static QJsonObject authMessage(const QString& style, const QString& text);
signals:
void createSession(const QString& username);
void postAuthMessageResponse(const QJsonValue& response);
// `session`, a wayland-sessions id, is our extension; greetd greeters send cmd.
void startSession(const QStringList& command, const QStringList& environment,
const QString& session);
void cancelSession();
// The client went away; a new greeter starts from scratch.
void disconnected();
private:
void accept();
void readRequests();
void dispatch(const QJsonObject& request);
QLocalServer m_server;
QPointer<QLocalSocket> m_client;
FrameReader m_reader;
QList<QJsonObject> m_queued;
QString m_path;
uid_t m_owner = 0;
bool m_waiting = false;
};
} // namespace dm

@ -0,0 +1,123 @@
#include "helperprocess.h"
#include <QJsonArray>
#include <QJsonObject>
#include "../common/wire.h"
namespace dm {
namespace {
QString helperPath()
{
const QString overridden = qEnvironmentVariable("CUTEFISH_DM_HELPER");
return overridden.isEmpty() ? QStringLiteral(DM_HELPER_PATH) : overridden;
}
} // namespace
HelperProcess::HelperProcess(QObject* parent)
: QObject(parent)
{
m_process.setProgram(helperPath());
// The session's output shares the helper's stderr: the daemon's journal.
m_process.setProcessChannelMode(QProcess::ForwardedErrorChannel);
connect(&m_process, &QProcess::readyReadStandardOutput, this, &HelperProcess::readMessages);
connect(&m_process, &QProcess::finished, this, [this] {
readMessages();
if (!m_authenticated && !m_failed) {
m_failed = true;
emit failed(false, QStringLiteral("The login helper exited"));
}
emit finished();
});
connect(&m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
if (error == QProcess::FailedToStart) {
m_failed = true;
emit failed(false, QStringLiteral("The login helper did not start"));
emit finished();
}
});
}
HelperProcess::~HelperProcess()
{
disconnect(&m_process, nullptr, this, nullptr);
if (m_process.state() != QProcess::NotRunning) {
terminate();
if (!m_process.waitForFinished(3000))
m_process.kill();
m_process.waitForFinished(1000);
}
}
void HelperProcess::login(const QString& service, const QString& user, bool authenticate, int vt)
{
m_user = user;
m_process.start();
send({{QStringLiteral("type"), QStringLiteral("login")},
{QStringLiteral("service"), service},
{QStringLiteral("user"), user},
{QStringLiteral("authenticate"), authenticate},
{QStringLiteral("vt"), vt}});
}
void HelperProcess::answer(const QJsonValue& response)
{
send({{QStringLiteral("type"), QStringLiteral("answer")},
{QStringLiteral("response"), response}});
}
void HelperProcess::start(const QStringList& command, const QStringList& environment)
{
send({{QStringLiteral("type"), QStringLiteral("start")},
{QStringLiteral("cmd"), QJsonArray::fromStringList(command)},
{QStringLiteral("env"), QJsonArray::fromStringList(environment)}});
}
void HelperProcess::terminate()
{
// Never the session's pid itself: once reaped, another process may have it.
if (m_process.state() != QProcess::NotRunning)
m_process.terminate();
}
void HelperProcess::send(const QJsonObject& message)
{
QByteArray line = encodeLine(message);
m_process.write(line);
wipe(line);
}
void HelperProcess::readMessages()
{
m_buffer.append(m_process.readAllStandardOutput());
qsizetype end = 0;
while ((end = m_buffer.indexOf('\n')) >= 0) {
const auto message = decodeLine(m_buffer.left(end));
m_buffer.remove(0, end + 1);
if (!message)
continue;
const QString type = message->value(QStringLiteral("type")).toString();
if (type == QLatin1String("question")) {
emit question(message->value(QStringLiteral("style")).toString(),
message->value(QStringLiteral("text")).toString());
} else if (type == QLatin1String("authenticated")) {
m_authenticated = true;
emit authenticated();
} else if (type == QLatin1String("started")) {
m_sessionPid = message->value(QStringLiteral("pid")).toInteger();
m_sessionId = message->value(QStringLiteral("session")).toString();
emit started(m_sessionId);
} else if (type == QLatin1String("error")) {
m_failed = true;
emit failed(message->value(QStringLiteral("auth")).toBool(),
message->value(QStringLiteral("message")).toString());
} else if (type == QLatin1String("finished")) {
m_sessionPid = 0;
}
}
}
} // namespace dm

@ -0,0 +1,53 @@
#pragma once
#include <QJsonValue>
#include <QObject>
#include <QProcess>
#include <QStringList>
namespace dm {
// The daemon's side of one cutefish-dm-helper: a login, then its session.
class HelperProcess : public QObject
{
Q_OBJECT
public:
explicit HelperProcess(QObject* parent = nullptr);
~HelperProcess() override;
void login(const QString& service, const QString& user, bool authenticate, int vt);
// A string for a prompt, null for an informational message.
void answer(const QJsonValue& response);
void start(const QStringList& command, const QStringList& environment);
// SIGTERM to the helper, which passes it on to its session, or stops the
// login while none runs yet.
void terminate();
QString user() const { return m_user; }
bool isAuthenticated() const { return m_authenticated; }
bool hasStarted() const { return m_sessionPid > 0; }
QString sessionId() const { return m_sessionId; }
signals:
void question(const QString& style, const QString& text);
void authenticated();
void started(const QString& sessionId);
// `authentication`: the credentials were wrong, rather than something broke.
void failed(bool authentication, const QString& message);
// The helper exited; after `started`, the session ended and PAM closed it.
void finished();
private:
void send(const QJsonObject& message);
void readMessages();
QProcess m_process;
QByteArray m_buffer;
QString m_user;
QString m_sessionId;
qint64 m_sessionPid = 0;
bool m_authenticated = false;
bool m_failed = false;
};
} // namespace dm

@ -0,0 +1,52 @@
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QSocketNotifier>
#include <csignal>
#include <sys/signalfd.h>
#include <unistd.h>
#include "config.h"
#include "daemon.h"
int main(int argc, char* argv[])
{
// Through signalfd, so SIGTERM runs the daemon's teardown.
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, nullptr);
const int signalFd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName(QStringLiteral("cutefish-dm"));
QCommandLineParser parser;
parser.addHelpOption();
const QCommandLineOption configOption(QStringLiteral("config"),
QStringLiteral("Configuration file to read."),
QStringLiteral("path"),
QStringLiteral("/etc/cutefish/dm.conf"));
parser.addOption(configOption);
parser.process(app);
if (geteuid() != 0) {
qCritical("cutefish-dm has to run as root");
return 1;
}
dm::Daemon daemon(dm::Config::load(parser.value(configOption)));
if (signalFd >= 0) {
auto* notifier = new QSocketNotifier(signalFd, QSocketNotifier::Read, &app);
QObject::connect(notifier, &QSocketNotifier::activated, &app, [signalFd] {
signalfd_siginfo info;
while (read(signalFd, &info, sizeof(info)) == sizeof(info)) {
}
QCoreApplication::quit();
});
}
if (!daemon.start())
return 1;
return app.exec();
}

@ -0,0 +1,164 @@
#include "sessions.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QSet>
#include <QStandardPaths>
namespace dm {
namespace {
// The escapes the Desktop Entry specification defines for string values.
QString unescape(const QString& value)
{
QString result;
result.reserve(value.size());
for (qsizetype i = 0; i < value.size(); ++i) {
const QChar c = value.at(i);
if (c != QLatin1Char('\\') || i + 1 == value.size()) {
result.append(c);
continue;
}
const QChar next = value.at(++i);
switch (next.unicode()) {
case 's': result.append(QLatin1Char(' ')); break;
case 'n': result.append(QLatin1Char('\n')); break;
case 't': result.append(QLatin1Char('\t')); break;
case 'r': result.append(QLatin1Char('\r')); break;
default: result.append(next); break;
}
}
return result;
}
bool isTrue(const QString& value)
{
return value.trimmed() == QLatin1String("true");
}
bool executableExists(const QString& program)
{
if (QFileInfo(program).isAbsolute())
return QFileInfo(program).isExecutable();
return !QStandardPaths::findExecutable(program).isEmpty();
}
} // namespace
std::optional<SessionEntry> readSessionFile(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return std::nullopt;
QHash<QString, QString> keys;
bool inEntry = false;
while (!file.atEnd()) {
const QString line = QString::fromUtf8(file.readLine()).trimmed();
if (line.isEmpty() || line.startsWith(QLatin1Char('#')))
continue;
if (line.startsWith(QLatin1Char('['))) {
inEntry = line == QLatin1String("[Desktop Entry]");
continue;
}
const qsizetype equals = line.indexOf(QLatin1Char('='));
if (!inEntry || equals <= 0)
continue;
const QString key = line.left(equals).trimmed();
if (!keys.contains(key))
keys.insert(key, line.mid(equals + 1).trimmed());
}
if (isTrue(keys.value(QStringLiteral("Hidden")))
|| isTrue(keys.value(QStringLiteral("NoDisplay"))))
return std::nullopt;
const QString tryExec = unescape(keys.value(QStringLiteral("TryExec")));
if (!tryExec.isEmpty() && !executableExists(tryExec))
return std::nullopt;
SessionEntry entry;
entry.id = QFileInfo(path).completeBaseName();
entry.name = unescape(keys.value(QStringLiteral("Name"), entry.id));
entry.comment = unescape(keys.value(QStringLiteral("Comment")));
// Field codes have no meaning for a session and are dropped.
for (const QString& argument : QProcess::splitCommand(unescape(keys.value(QStringLiteral("Exec"))))) {
if (!(argument.size() == 2 && argument.startsWith(QLatin1Char('%'))))
entry.exec.append(argument);
}
for (const QString& name : keys.value(QStringLiteral("DesktopNames")).split(QLatin1Char(';'))) {
if (!name.trimmed().isEmpty())
entry.desktopNames.append(unescape(name.trimmed()));
}
if (entry.exec.isEmpty())
return std::nullopt;
return entry;
}
QList<SessionEntry> findSessions(const QStringList& directories)
{
QList<SessionEntry> sessions;
QSet<QString> seen;
for (const QString& directory : directories) {
const auto files = QDir(directory).entryInfoList({QStringLiteral("*.desktop")}, QDir::Files,
QDir::Name);
for (const QFileInfo& file : files) {
const QString id = file.completeBaseName();
if (seen.contains(id))
continue;
// A hidden entry masks the same id further down, as with autostart.
seen.insert(id);
if (auto entry = readSessionFile(file.absoluteFilePath()))
sessions.append(*entry);
}
}
return sessions;
}
std::optional<SessionEntry> findSession(const QStringList& directories, const QString& id)
{
// An id comes from the greeter; it must name a file, not a path.
if (id.isEmpty() || id.contains(QLatin1Char('/')) || id.startsWith(QLatin1Char('.')))
return std::nullopt;
for (const SessionEntry& session : findSessions(directories)) {
if (session.id == id)
return session;
}
return std::nullopt;
}
QStringList sessionEnvironment(const SessionEntry& session, int vt)
{
QStringList environment {
QStringLiteral("XDG_SESSION_TYPE=wayland"),
QStringLiteral("XDG_SESSION_CLASS=user"),
QStringLiteral("XDG_SEAT=seat0"),
QStringLiteral("XDG_VTNR=%1").arg(vt),
};
// A bare command, as a greetd greeter sends, names no session.
if (!session.id.isEmpty()) {
environment.append(QStringLiteral("DESKTOP_SESSION=%1").arg(session.id));
environment.append(QStringLiteral("XDG_SESSION_DESKTOP=%1")
.arg(session.desktopNames.isEmpty() ? session.id
: session.desktopNames.first()));
}
if (!session.desktopNames.isEmpty())
environment.append(
QStringLiteral("XDG_CURRENT_DESKTOP=%1").arg(session.desktopNames.join(QLatin1Char(':'))));
return environment;
}
QStringList greeterEnvironment(int vt, const QString& socket)
{
return {
QStringLiteral("XDG_SESSION_TYPE=wayland"),
QStringLiteral("XDG_SESSION_CLASS=greeter"),
QStringLiteral("XDG_SEAT=seat0"),
QStringLiteral("XDG_VTNR=%1").arg(vt),
QStringLiteral("GREETD_SOCK=%1").arg(socket),
};
}
} // namespace dm

@ -0,0 +1,32 @@
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
#include <optional>
namespace dm {
// One entry of wayland-sessions, as sddm and GDM offer it.
struct SessionEntry {
// The file name without ".desktop", e.g. "cutefish-wayland".
QString id;
QString name;
QString comment;
QStringList exec;
QStringList desktopNames;
};
// Hidden, NoDisplay, a missing TryExec or no Exec: not a session.
std::optional<SessionEntry> readSessionFile(const QString& path);
// An id in an earlier directory hides the same id in later ones.
QList<SessionEntry> findSessions(const QStringList& directories);
std::optional<SessionEntry> findSession(const QStringList& directories, const QString& id);
// For pam_systemd, set before pam_open_session (sddm's Display::startAuth).
QStringList sessionEnvironment(const SessionEntry& session, int vt);
QStringList greeterEnvironment(int vt, const QString& socket);
} // namespace dm

@ -0,0 +1,37 @@
#include "state.h"
#include <QDir>
#include <QFileInfo>
#include <QSettings>
namespace dm {
State::State(QString path)
: m_path(std::move(path))
{
}
QString State::lastUser() const
{
return QSettings(m_path, QSettings::IniFormat).value(QStringLiteral("Last/User")).toString();
}
QString State::lastSession(const QString& user) const
{
QSettings settings(m_path, QSettings::IniFormat);
settings.beginGroup(QStringLiteral("Sessions"));
return settings.value(user).toString();
}
void State::remember(const QString& user, const QString& session)
{
QDir().mkpath(QFileInfo(m_path).absolutePath());
QSettings settings(m_path, QSettings::IniFormat);
settings.setValue(QStringLiteral("Last/User"), user);
settings.beginGroup(QStringLiteral("Sessions"));
settings.setValue(user, session);
settings.endGroup();
settings.sync();
}
} // namespace dm

@ -0,0 +1,22 @@
#pragma once
#include <QString>
namespace dm {
// Who logged in last and with which session, kept for the login screen to
// preselect. The file is world-readable: the greeter reads it as its own user.
class State
{
public:
explicit State(QString path);
QString lastUser() const;
QString lastSession(const QString& user) const;
void remember(const QString& user, const QString& session);
private:
QString m_path;
};
} // namespace dm

@ -0,0 +1,80 @@
#include "terminal.h"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusReply>
#include <QFile>
#include <fcntl.h>
#include <linux/kd.h>
#include <linux/vt.h>
#include <sys/ioctl.h>
#include <unistd.h>
namespace dm {
namespace {
int openVt(int vt)
{
return ::open(QStringLiteral("/dev/tty%1").arg(vt).toLocal8Bit().constData(),
O_RDWR | O_NOCTTY | O_CLOEXEC);
}
} // namespace
bool setGraphicsMode(int vt)
{
const int fd = openVt(vt);
if (fd < 0)
return false;
const bool set = ioctl(fd, KDSETMODE, KD_GRAPHICS) == 0;
::close(fd);
return set;
}
void restoreTextMode(int vt)
{
const int fd = openVt(vt);
if (fd < 0)
return;
vt_mode mode {};
int kdMode = KD_TEXT;
if (ioctl(fd, VT_GETMODE, &mode) == 0 && mode.mode == VT_AUTO
&& ioctl(fd, KDGETMODE, &kdMode) == 0 && kdMode == KD_GRAPHICS)
ioctl(fd, KDSETMODE, KD_TEXT);
::close(fd);
}
bool isControlled(int vt)
{
const int fd = openVt(vt);
if (fd < 0)
return false;
vt_mode mode {};
const bool controlled = ioctl(fd, VT_GETMODE, &mode) == 0 && mode.mode == VT_PROCESS;
::close(fd);
return controlled;
}
int activeVt()
{
QFile file(QStringLiteral("/sys/class/tty/tty0/active"));
if (!file.open(QIODevice::ReadOnly))
return 0;
return QString::fromLatin1(file.readAll()).trimmed().mid(3).toInt();
}
bool activateSession(const QString& sessionId)
{
QDBusInterface manager(QStringLiteral("org.freedesktop.login1"),
QStringLiteral("/org/freedesktop/login1"),
QStringLiteral("org.freedesktop.login1.Manager"),
QDBusConnection::systemBus());
const QDBusReply<void> reply = manager.call(QStringLiteral("ActivateSession"), sessionId);
if (!reply.isValid())
qWarning() << "Cannot activate session" << sessionId << reply.error().message();
return reply.isValid();
}
} // namespace dm

@ -0,0 +1,23 @@
#pragma once
#include <QString>
namespace dm {
// A VT in KD_GRAPHICS that comes to the front keeps the previous master's
// frame instead of showing the console.
bool setGraphicsMode(int vt);
// Undoes setGraphicsMode when nobody took the VT: in front, KD_GRAPHICS with
// VT_AUTO makes the kernel ignore every VT switch (change_console).
void restoreTextMode(int vt);
// logind has taken the VT for a session's controller (VT_PROCESS).
bool isControlled(int vt);
int activeVt();
// logind's ActivateSession: the VT switch plus the DRM and input handover.
bool activateSession(const QString& sessionId);
} // namespace dm

@ -0,0 +1,14 @@
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy user="root">
<allow own="com.cutefish.DisplayManager"/>
</policy>
<!-- The daemon checks that the caller owns the session it is asked to end. -->
<policy context="default">
<allow send_destination="com.cutefish.DisplayManager"
send_interface="com.cutefish.DisplayManager"/>
<allow send_destination="com.cutefish.DisplayManager"
send_interface="org.freedesktop.DBus.Introspectable"/>
</policy>
</busconfig>

@ -0,0 +1,18 @@
#%PAM-1.0
# The login screen runs without authentication, as its own system user.
auth required pam_permit.so
@include common-account
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
session optional pam_keyinit.so force revoke
session required pam_limits.so
session required pam_loginuid.so
@include common-session
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
password required pam_deny.so
session required pam_env.so
session required pam_env.so envfile=/etc/default/locale

@ -0,0 +1,18 @@
#!/bin/sh
# Runs the session through the login shell so profiles apply (sddm's wayland-session).
case $SHELL in
*/bash|*/zsh)
exec "$SHELL" --login -c 'exec "$@"' - "$@"
;;
*/fish)
[ -f /etc/profile ] && . /etc/profile
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
exec "$SHELL" --login -c 'exec $argv' "$@"
;;
*)
[ -f /etc/profile ] && . /etc/profile
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
exec "$@"
;;
esac

@ -0,0 +1,26 @@
#%PAM-1.0
# Block login if they are globally disabled
auth requisite pam_nologin.so
auth required pam_succeed_if.so user != root quiet_success
@include common-auth
-auth optional pam_gnome_keyring.so
-auth optional pam_kwallet5.so
@include common-account
# SELinux needs to be the first session rule, and to intervene last.
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
session optional pam_keyinit.so force revoke
session required pam_limits.so
session required pam_loginuid.so
@include common-session
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
-session optional pam_gnome_keyring.so auto_start
-session optional pam_kwallet5.so auto_start
@include common-password
# pam_env goes last: what it sets can affect the modules above.
session required pam_env.so
session required pam_env.so envfile=/etc/default/locale user_readenv=1

@ -0,0 +1,19 @@
[Unit]
Description=Cutefish Display Manager
Conflicts=getty@tty1.service
After=getty@tty1.service systemd-user-sessions.service systemd-logind.service
# The login screen takes over plymouth's splash, as GDM does.
Conflicts=plymouth-quit.service
After=plymouth-quit.service plymouth-start.service
OnFailure=plymouth-quit.service
[Service]
ExecStart=/usr/bin/cutefish-dm
# The daemon ends the login screen and the session itself.
KillMode=mixed
Restart=always
RestartSec=1s
EnvironmentFile=-/etc/default/locale
[Install]
Alias=display-manager.service

5
debian/changelog vendored

@ -0,0 +1,5 @@
cutefish-dm (0.1.0) UNRELEASED; urgency=medium
* Initial release.
-- Reion Wong <reionwong@gmail.com> Fri, 25 Sep 2026 22:34:44 +0800

1
debian/compat vendored

@ -0,0 +1 @@
9

21
debian/control vendored

@ -0,0 +1,21 @@
Source: cutefish-dm
Section: x11
Priority: optional
Maintainer: Reion Wong <reionwong@gmail.com>
Build-Depends: cmake (>= 3.22),
debhelper (>= 9),
qt6-base-dev (>= 6.8),
libpam0g-dev
Standards-Version: 4.5.0
Homepage: https://github.com/cutefishos
Package: cutefish-dm
Architecture: any
Depends: adduser,
cutefish-compositor,
${misc:Depends},
${shlibs:Depends}
Description: CutefishOS display manager
Runs the Cutefish login screen and the Wayland session a user logs in to,
Cutefish or any other desktop in wayland-sessions, and hands the display
between them without a black frame or the console in between.

3
debian/copyright vendored

@ -0,0 +1,3 @@
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cutefish-dm
Source: cutefishos.com

30
debian/postinst vendored

@ -0,0 +1,30 @@
#! /bin/sh
# see: dh_installdeb(1)
set -e
case "$1" in
configure)
# The login screen's own user; it owns nothing but its home.
if ! getent passwd cutefish-greeter >/dev/null; then
adduser --system --group --home /var/lib/cutefish-greeter \
--gecos "Cutefish login screen" --quiet cutefish-greeter
fi
mkdir -p /var/lib/cutefish-dm
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
exit 0

4
debian/rules vendored

@ -0,0 +1,4 @@
#!/usr/bin/make -f
%:
dh $@ --parallel

@ -0,0 +1 @@
3.0 (quilt)

@ -0,0 +1,336 @@
// One login, PAM to session end, outside the threaded daemon (greetd's session
// worker, sddm-helper). stdout carries only replies; the session writes stderr.
#include <security/pam_appl.h>
#include <cerrno>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <grp.h>
#include <optional>
#include <pwd.h>
#include <string>
#include <sys/prctl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "../common/wire.h"
namespace {
constexpr size_t kMaxLine = 64 * 1024;
// After authentication nobody answers; a session module's motd would hang.
bool g_conversing = true;
// The session's pid until it is reaped: unreaped, it cannot belong to anyone else.
volatile sig_atomic_t g_session = 0;
// SIGTERM ends the session, which this process then closes, or the login before one.
void onTerminate(int)
{
if (g_session > 0)
kill(pid_t(g_session), SIGTERM);
else
_exit(128 + SIGTERM);
}
std::optional<QJsonObject> receive()
{
std::string line;
char c = 0;
ssize_t count = 0;
while ((count = read(STDIN_FILENO, &c, 1)) == 1 || (count < 0 && errno == EINTR)) {
if (count < 0)
continue;
if (c == '\n')
break;
line.push_back(c);
if (line.size() > kMaxLine)
return std::nullopt;
}
if (line.empty())
return std::nullopt;
const auto message = dm::decodeLine(QByteArray::fromStdString(line));
explicit_bzero(line.data(), line.size());
return message;
}
void send(const QJsonObject& message)
{
const QByteArray bytes = dm::encodeLine(message);
qsizetype written = 0;
while (written < bytes.size()) {
const ssize_t count = write(STDOUT_FILENO, bytes.constData() + written,
size_t(bytes.size() - written));
if (count < 0 && errno == EINTR)
continue;
if (count <= 0)
return;
written += count;
}
}
void fail(bool authentication, const QString& message)
{
send({{QStringLiteral("type"), QStringLiteral("error")},
{QStringLiteral("auth"), authentication},
{QStringLiteral("message"), message}});
}
void freeResponses(pam_response* responses, int count)
{
for (int i = 0; i < count; ++i) {
if (responses[i].resp) {
explicit_bzero(responses[i].resp, strlen(responses[i].resp));
free(responses[i].resp);
}
}
free(responses);
}
// Every PAM message goes to the greeter and waits for its answer, as in greetd.
int converse(int count, const pam_message** messages, pam_response** result, void*)
{
if (count <= 0 || count > PAM_MAX_NUM_MSG)
return PAM_CONV_ERR;
auto* responses = static_cast<pam_response*>(calloc(size_t(count), sizeof(pam_response)));
if (!responses)
return PAM_BUF_ERR;
for (int i = 0; i < count; ++i) {
const int kind = messages[i]->msg_style;
if (!g_conversing) {
if (kind == PAM_PROMPT_ECHO_OFF || kind == PAM_PROMPT_ECHO_ON) {
freeResponses(responses, count);
return PAM_CONV_ERR;
}
fprintf(stderr, "PAM: %s\n", messages[i]->msg);
continue;
}
QString style;
switch (messages[i]->msg_style) {
case PAM_PROMPT_ECHO_OFF: style = QStringLiteral("secret"); break;
case PAM_PROMPT_ECHO_ON: style = QStringLiteral("visible"); break;
case PAM_ERROR_MSG: style = QStringLiteral("error"); break;
case PAM_TEXT_INFO: style = QStringLiteral("info"); break;
default:
freeResponses(responses, count);
return PAM_CONV_ERR;
}
send({{QStringLiteral("type"), QStringLiteral("question")},
{QStringLiteral("style"), style},
{QStringLiteral("text"), QString::fromUtf8(messages[i]->msg)}});
auto answer = receive();
if (!answer || answer->value(QStringLiteral("type")).toString() != QLatin1String("answer")) {
freeResponses(responses, count);
return PAM_CONV_ERR;
}
const int prompt = messages[i]->msg_style;
if (prompt == PAM_PROMPT_ECHO_OFF || prompt == PAM_PROMPT_ECHO_ON) {
QString text = answer->value(QStringLiteral("response")).toString();
QByteArray bytes = text.toUtf8();
responses[i].resp = strdup(bytes.constData());
dm::wipe(bytes);
dm::wipe(text);
if (!responses[i].resp) {
freeResponses(responses, count);
return PAM_BUF_ERR;
}
}
(*answer)[QStringLiteral("response")] = QJsonValue();
}
*result = responses;
return PAM_SUCCESS;
}
struct Account {
std::string name;
std::string home;
std::string shell;
uid_t uid = 0;
gid_t gid = 0;
};
std::optional<Account> account(pam_handle_t* pam)
{
// PAM_USER, since a module may have changed it.
const void* item = nullptr;
if (pam_get_item(pam, PAM_USER, &item) != PAM_SUCCESS || !item)
return std::nullopt;
const passwd* entry = getpwnam(static_cast<const char*>(item));
if (!entry)
return std::nullopt;
return Account {entry->pw_name, entry->pw_dir, entry->pw_shell, entry->pw_uid, entry->pw_gid};
}
std::vector<std::string> strings(const QJsonArray& array)
{
std::vector<std::string> result;
for (const auto& value : array)
result.push_back(value.toString().toStdString());
return result;
}
} // namespace
int main()
{
// The daemon's signalfd mask is inherited; without this SIGTERM never lands.
sigset_t none;
sigemptyset(&none);
sigprocmask(SIG_SETMASK, &none, nullptr);
struct sigaction action = {};
action.sa_handler = onTerminate;
sigemptyset(&action.sa_mask);
sigaction(SIGTERM, &action, nullptr);
// The daemon's death ends the login like its SIGTERM; no password goes to a core file.
prctl(PR_SET_PDEATHSIG, SIGTERM);
prctl(PR_SET_DUMPABLE, 0);
signal(SIGPIPE, SIG_IGN);
const auto login = receive();
if (!login || login->value(QStringLiteral("type")).toString() != QLatin1String("login")) {
fail(false, QStringLiteral("Expected a login request"));
return 1;
}
const std::string service = login->value(QStringLiteral("service")).toString().toStdString();
const std::string user = login->value(QStringLiteral("user")).toString().toStdString();
const bool authenticate = login->value(QStringLiteral("authenticate")).toBool();
const int vt = login->value(QStringLiteral("vt")).toInt();
if (service.empty() || user.empty() || vt <= 0) {
fail(false, QStringLiteral("Incomplete login request"));
return 1;
}
const pam_conv conversation {converse, nullptr};
pam_handle_t* pam = nullptr;
int status = pam_start(service.c_str(), user.c_str(), &conversation, &pam);
if (status != PAM_SUCCESS) {
fail(false, QString::fromUtf8(pam_strerror(pam, status)));
return 1;
}
const auto finish = [&](int result) {
pam_end(pam, result);
return 1;
};
if (authenticate) {
status = pam_authenticate(pam, 0);
if (status != PAM_SUCCESS) {
fail(true, QString::fromUtf8(pam_strerror(pam, status)));
return finish(status);
}
}
status = pam_acct_mgmt(pam, 0);
if (status == PAM_NEW_AUTHTOK_REQD)
status = pam_chauthtok(pam, PAM_CHANGE_EXPIRED_AUTHTOK);
if (status != PAM_SUCCESS) {
fail(true, QString::fromUtf8(pam_strerror(pam, status)));
return finish(status);
}
status = pam_setcred(pam, PAM_ESTABLISH_CRED);
if (status != PAM_SUCCESS) {
fail(false, QString::fromUtf8(pam_strerror(pam, status)));
return finish(status);
}
g_conversing = false;
send({{QStringLiteral("type"), QStringLiteral("authenticated")}});
const auto request = receive();
if (!request || request->value(QStringLiteral("type")).toString() != QLatin1String("start")) {
pam_setcred(pam, PAM_DELETE_CRED);
pam_end(pam, PAM_SUCCESS);
return 0;
}
const std::vector<std::string> command = strings(request->value(QStringLiteral("cmd")).toArray());
const auto owner = account(pam);
if (command.empty() || !owner) {
fail(false, QStringLiteral("No command or no account to start"));
pam_setcred(pam, PAM_DELETE_CRED);
return finish(PAM_SESSION_ERR);
}
// pam_systemd takes the seat, VT, type and class from here.
const std::string tty = "tty" + std::to_string(vt);
pam_set_item(pam, PAM_TTY, tty.c_str());
for (const std::string& variable : strings(request->value(QStringLiteral("env")).toArray()))
pam_putenv(pam, variable.c_str());
const std::string home = "HOME=" + owner->home;
const std::string name = "USER=" + owner->name;
const std::string logname = "LOGNAME=" + owner->name;
// The session wrapper picks the login shell from it.
const std::string shell = "SHELL=" + owner->shell;
pam_putenv(pam, home.c_str());
pam_putenv(pam, name.c_str());
pam_putenv(pam, logname.c_str());
pam_putenv(pam, shell.c_str());
if (!pam_getenv(pam, "PATH"))
pam_putenv(pam, "PATH=/usr/local/bin:/usr/bin:/bin");
status = pam_open_session(pam, 0);
if (status != PAM_SUCCESS) {
fail(false, QString::fromUtf8(pam_strerror(pam, status)));
pam_setcred(pam, PAM_DELETE_CRED);
return finish(status);
}
const char* sessionId = pam_getenv(pam, "XDG_SESSION_ID");
char** environment = pam_getenvlist(pam);
std::vector<char*> arguments;
for (const std::string& argument : command)
arguments.push_back(const_cast<char*>(argument.c_str()));
arguments.push_back(nullptr);
// The session runs in a child; this process stays to close the PAM session.
const pid_t child = fork();
if (child == 0) {
setsid();
const int null = open("/dev/null", O_RDONLY | O_CLOEXEC);
if (null >= 0)
dup2(null, STDIN_FILENO);
dup2(STDERR_FILENO, STDOUT_FILENO);
// Not the session's: fds PAM modules left open, the ignored SIGPIPE.
close_range(3, ~0U, 0);
signal(SIGPIPE, SIG_DFL);
if (initgroups(owner->name.c_str(), owner->gid) != 0 || setgid(owner->gid) != 0
|| setuid(owner->uid) != 0)
_exit(127);
// setuid resets it.
prctl(PR_SET_PDEATHSIG, SIGTERM);
if (chdir(owner->home.c_str()) != 0 && chdir("/") != 0)
_exit(127);
execve(arguments.front(), arguments.data(), environment);
_exit(127);
}
if (child < 0) {
fail(false, QString::fromUtf8(strerror(errno)));
pam_close_session(pam, 0);
pam_setcred(pam, PAM_DELETE_CRED);
return finish(PAM_SESSION_ERR);
}
g_session = child;
send({{QStringLiteral("type"), QStringLiteral("started")},
{QStringLiteral("pid"), qint64(child)},
{QStringLiteral("session"), QString::fromUtf8(sessionId ? sessionId : "")}});
int wait = 0;
while (waitpid(child, &wait, 0) < 0 && errno == EINTR) {
}
// The session is over; closing it is not to be cut short.
signal(SIGTERM, SIG_IGN);
// Closing the PAM session needs root.
pam_close_session(pam, 0);
pam_setcred(pam, PAM_DELETE_CRED);
pam_end(pam, PAM_SUCCESS);
send({{QStringLiteral("type"), QStringLiteral("finished")},
{QStringLiteral("status"),
WIFEXITED(wait) ? WEXITSTATUS(wait) : 128 + (WIFSIGNALED(wait) ? WTERMSIG(wait) : 0)}});
return 0;
}

@ -0,0 +1,17 @@
find_package(Qt6 6.8 REQUIRED COMPONENTS Test)
add_executable(dm-test
dm_test.cpp
../daemon/config.cpp
../daemon/greeterserver.cpp
../daemon/helperprocess.cpp
../daemon/sessions.cpp
../daemon/state.cpp
)
target_compile_definitions(dm-test PRIVATE
DM_HELPER_PATH="/nonexistent/cutefish-dm-helper"
DM_SESSION_WRAPPER="/nonexistent/cutefish-dm-session"
)
target_include_directories(dm-test PRIVATE ${PROJECT_SOURCE_DIR})
target_link_libraries(dm-test PRIVATE cutefish-dm-common Qt6::Core Qt6::Network Qt6::Test)
add_test(NAME dm COMMAND dm-test)

@ -0,0 +1,346 @@
#include <QCoreApplication>
#include <QFile>
#include <QJsonArray>
#include <QLocalSocket>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
#include <csignal>
#include <unistd.h>
#include "common/wire.h"
#include "daemon/config.h"
#include "daemon/greeterserver.h"
#include "daemon/helperprocess.h"
#include "daemon/sessions.h"
#include "daemon/state.h"
namespace {
// When set, the test binary is the helper: it accepts "right" and its session
// runs until terminated.
constexpr char kFakeHelper[] = "DM_TEST_FAKE_HELPER";
QByteArray readLine()
{
QByteArray line;
char c = 0;
while (read(STDIN_FILENO, &c, 1) == 1 && c != '\n')
line.append(c);
return line;
}
void writeLine(const QJsonObject& message)
{
const QByteArray bytes = dm::encodeLine(message);
if (write(STDOUT_FILENO, bytes.constData(), size_t(bytes.size())) != bytes.size())
_exit(3);
}
int fakeHelper()
{
const auto login = dm::decodeLine(readLine());
if (!login || login->value(QStringLiteral("user")).toString() != QLatin1String("alice"))
return 2;
writeLine({{QStringLiteral("type"), QStringLiteral("question")},
{QStringLiteral("style"), QStringLiteral("secret")},
{QStringLiteral("text"), QStringLiteral("Password:")}});
const auto answer = dm::decodeLine(readLine());
if (!answer || answer->value(QStringLiteral("response")).toString() != QLatin1String("right")) {
writeLine({{QStringLiteral("type"), QStringLiteral("error")},
{QStringLiteral("auth"), true},
{QStringLiteral("message"), QStringLiteral("Authentication failure")}});
return 1;
}
writeLine({{QStringLiteral("type"), QStringLiteral("authenticated")}});
const auto start = dm::decodeLine(readLine());
if (!start || start->value(QStringLiteral("cmd")).toArray().isEmpty())
return 0;
writeLine({{QStringLiteral("type"), QStringLiteral("started")},
{QStringLiteral("pid"), qint64(getpid())},
{QStringLiteral("session"), QStringLiteral("42")}});
for (;;)
pause();
}
QByteArray frame(const QJsonObject& message)
{
return dm::encodeFrame(message);
}
void writeFile(const QString& path, const QByteArray& contents)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(contents);
}
} // namespace
class DisplayManagerTest : public QObject
{
Q_OBJECT
private slots:
void framesSurviveSplitsAndBatches()
{
const QByteArray first = frame({{QStringLiteral("type"), QStringLiteral("success")}});
const QByteArray second = frame({{QStringLiteral("type"), QStringLiteral("cancel_session")}});
dm::FrameReader reader;
reader.append(first.left(3));
QVERIFY(!reader.next());
reader.append(first.mid(3) + second);
const auto a = reader.next();
const auto b = reader.next();
QVERIFY(a && b);
QCOMPARE(a->value(QStringLiteral("type")).toString(), QStringLiteral("success"));
QCOMPARE(b->value(QStringLiteral("type")).toString(), QStringLiteral("cancel_session"));
QVERIFY(!reader.next());
QVERIFY(!reader.failed());
}
void oversizedOrMalformedFramesFailTheReader()
{
dm::FrameReader oversized;
const quint32 length = dm::kMaxFrameSize + 1;
oversized.append(QByteArray(reinterpret_cast<const char*>(&length), sizeof(length)));
QVERIFY(!oversized.next());
QVERIFY(oversized.failed());
dm::FrameReader malformed;
const QByteArray payload("[1,2]");
const quint32 size = quint32(payload.size());
malformed.append(QByteArray(reinterpret_cast<const char*>(&size), sizeof(size)) + payload);
QVERIFY(!malformed.next());
QVERIFY(malformed.failed());
}
void linesRoundTrip()
{
const QJsonObject message {{QStringLiteral("type"), QStringLiteral("answer")},
{QStringLiteral("response"), QJsonValue()}};
const QByteArray line = dm::encodeLine(message);
QVERIFY(line.endsWith('\n'));
QCOMPARE(line.count('\n'), 1);
QCOMPARE(*dm::decodeLine(line), message);
QVERIFY(!dm::decodeLine("not json"));
}
void sessionFilesParse()
{
QTemporaryDir directory;
writeFile(directory.filePath(QStringLiteral("plasma.desktop")),
"[Desktop Entry]\n"
"# a comment\n"
"Name=Plasma (Wayland)\n"
"Comment=Plasma by KDE\n"
"Exec=/usr/bin/startplasma-wayland --arg \"two words\" %f\n"
"DesktopNames=KDE;Plasma;\n"
"[Other Group]\n"
"Exec=ignored\n");
const auto entry = dm::readSessionFile(directory.filePath(QStringLiteral("plasma.desktop")));
QVERIFY(entry);
QCOMPARE(entry->id, QStringLiteral("plasma"));
QCOMPARE(entry->name, QStringLiteral("Plasma (Wayland)"));
QCOMPARE(entry->exec, (QStringList {QStringLiteral("/usr/bin/startplasma-wayland"),
QStringLiteral("--arg"), QStringLiteral("two words")}));
QCOMPARE(entry->desktopNames, (QStringList {QStringLiteral("KDE"), QStringLiteral("Plasma")}));
}
void hiddenMissingOrCommandlessSessionsAreNotOffered()
{
QTemporaryDir directory;
writeFile(directory.filePath(QStringLiteral("hidden.desktop")),
"[Desktop Entry]\nName=Hidden\nExec=/bin/true\nHidden=true\n");
writeFile(directory.filePath(QStringLiteral("nodisplay.desktop")),
"[Desktop Entry]\nName=NoDisplay\nExec=/bin/true\nNoDisplay=true\n");
writeFile(directory.filePath(QStringLiteral("missing.desktop")),
"[Desktop Entry]\nName=Missing\nExec=/bin/true\nTryExec=/nonexistent/program\n");
writeFile(directory.filePath(QStringLiteral("noexec.desktop")),
"[Desktop Entry]\nName=NoExec\n");
writeFile(directory.filePath(QStringLiteral("good.desktop")),
"[Desktop Entry]\nName=Good\nExec=/bin/true\nTryExec=true\n");
const auto sessions = dm::findSessions({directory.path()});
QCOMPARE(sessions.size(), 1);
QCOMPARE(sessions.first().id, QStringLiteral("good"));
}
void earlierDirectoriesWinAndIdsNameFilesOnly()
{
QTemporaryDir local, system;
writeFile(local.filePath(QStringLiteral("cutefish.desktop")),
"[Desktop Entry]\nName=Local\nExec=/bin/true\n");
writeFile(system.filePath(QStringLiteral("cutefish.desktop")),
"[Desktop Entry]\nName=System\nExec=/bin/false\n");
writeFile(local.filePath(QStringLiteral("gnome.desktop")),
"[Desktop Entry]\nName=GNOME\nExec=/bin/true\nHidden=true\n");
writeFile(system.filePath(QStringLiteral("gnome.desktop")),
"[Desktop Entry]\nName=GNOME\nExec=/bin/true\n");
const QStringList directories {local.path(), system.path()};
QCOMPARE(dm::findSession(directories, QStringLiteral("cutefish"))->name,
QStringLiteral("Local"));
// A hidden local entry masks the system one.
QVERIFY(!dm::findSession(directories, QStringLiteral("gnome")));
QVERIFY(!dm::findSession(directories, QStringLiteral("../cutefish")));
QVERIFY(!dm::findSession(directories, QStringLiteral(".hidden")));
QVERIFY(!dm::findSession(directories, QString()));
}
void environmentsCarryWhatPamSystemdReads()
{
dm::SessionEntry gnome;
gnome.id = QStringLiteral("gnome");
gnome.desktopNames = {QStringLiteral("GNOME")};
const QStringList session = dm::sessionEnvironment(gnome, 2);
QVERIFY(session.contains(QStringLiteral("XDG_SESSION_TYPE=wayland")));
QVERIFY(session.contains(QStringLiteral("XDG_SESSION_CLASS=user")));
QVERIFY(session.contains(QStringLiteral("XDG_SEAT=seat0")));
QVERIFY(session.contains(QStringLiteral("XDG_VTNR=2")));
QVERIFY(session.contains(QStringLiteral("DESKTOP_SESSION=gnome")));
QVERIFY(session.contains(QStringLiteral("XDG_SESSION_DESKTOP=GNOME")));
QVERIFY(session.contains(QStringLiteral("XDG_CURRENT_DESKTOP=GNOME")));
const QStringList bare = dm::sessionEnvironment(dm::SessionEntry {}, 2);
QVERIFY(bare.contains(QStringLiteral("XDG_SESSION_TYPE=wayland")));
for (const QString& variable : bare)
QVERIFY(!variable.startsWith(QLatin1String("DESKTOP_SESSION=")));
const QStringList greeter = dm::greeterEnvironment(1, QStringLiteral("/run/x.sock"));
QVERIFY(greeter.contains(QStringLiteral("XDG_SESSION_CLASS=greeter")));
QVERIFY(greeter.contains(QStringLiteral("XDG_VTNR=1")));
QVERIFY(greeter.contains(QStringLiteral("GREETD_SOCK=/run/x.sock")));
}
void stateRemembersTheLastLogin()
{
QTemporaryDir directory;
dm::State state(directory.filePath(QStringLiteral("state/state.conf")));
QVERIFY(state.lastUser().isEmpty());
state.remember(QStringLiteral("alice"), QStringLiteral("gnome"));
state.remember(QStringLiteral("bob"), QStringLiteral("cutefish-wayland"));
dm::State reread(directory.filePath(QStringLiteral("state/state.conf")));
QCOMPARE(reread.lastUser(), QStringLiteral("bob"));
QCOMPARE(reread.lastSession(QStringLiteral("alice")), QStringLiteral("gnome"));
QCOMPARE(reread.lastSession(QStringLiteral("bob")), QStringLiteral("cutefish-wayland"));
}
void configFallsBackToDefaults()
{
const dm::Config defaults = dm::Config::load(QStringLiteral("/nonexistent/dm.conf"));
QCOMPARE(defaults.greeterVt, 1);
QCOMPARE(defaults.sessionVt, 2);
QCOMPARE(defaults.greeterUser, QStringLiteral("cutefish-greeter"));
QTemporaryDir directory;
writeFile(directory.filePath(QStringLiteral("dm.conf")),
"[Greeter]\nVt=8\nCommand=/usr/bin/cutefish-compositor --login --no-xwayland\n"
"[Session]\nVt=9\n[Daemon]\nRuntimeDirectory=/run/test\n");
const dm::Config config = dm::Config::load(directory.filePath(QStringLiteral("dm.conf")));
QCOMPARE(config.greeterVt, 8);
QCOMPARE(config.sessionVt, 9);
QCOMPARE(config.greeterCommand.last(), QStringLiteral("--no-xwayland"));
QCOMPARE(config.runtimeDirectory, QStringLiteral("/run/test"));
QCOMPARE(config.greeterUser, QStringLiteral("cutefish-greeter"));
}
void greeterServerSpeaksGreetd()
{
QTemporaryDir directory;
const QString path = directory.filePath(QStringLiteral("greeter.sock"));
dm::GreeterServer server;
QVERIFY(server.listen(path, getuid()));
QCOMPARE(QFile::permissions(path) & (QFile::ReadGroup | QFile::ReadOther), QFileDevice::Permissions());
QSignalSpy created(&server, &dm::GreeterServer::createSession);
QSignalSpy started(&server, &dm::GreeterServer::startSession);
QLocalSocket client;
client.connectToServer(path);
QVERIFY(client.waitForConnected(1000));
// Two requests at once: the second waits for the first's answer.
client.write(frame({{QStringLiteral("type"), QStringLiteral("create_session")},
{QStringLiteral("username"), QStringLiteral("alice")}}));
client.write(frame({{QStringLiteral("type"), QStringLiteral("start_session")},
{QStringLiteral("cmd"), QJsonArray {}},
{QStringLiteral("env"), QJsonArray {QStringLiteral("LANG=C")}},
{QStringLiteral("session"), QStringLiteral("gnome")}}));
client.flush();
QTRY_COMPARE(created.count(), 1);
QCOMPARE(created.first().first().toString(), QStringLiteral("alice"));
QTest::qWait(50);
QCOMPARE(started.count(), 0);
server.respond(dm::GreeterServer::authMessage(QStringLiteral("secret"), QStringLiteral("Password:")));
QTRY_COMPARE(started.count(), 1);
QCOMPARE(started.first().at(1).toStringList(), QStringList {QStringLiteral("LANG=C")});
QCOMPARE(started.first().at(2).toString(), QStringLiteral("gnome"));
server.respond(dm::GreeterServer::success());
dm::FrameReader reader;
QList<QJsonObject> responses;
QTRY_VERIFY([&] {
reader.append(client.readAll());
while (auto response = reader.next())
responses.append(*response);
return responses.size() == 2;
}());
QCOMPARE(responses[0].value(QStringLiteral("type")).toString(), QStringLiteral("auth_message"));
QCOMPARE(responses[0].value(QStringLiteral("auth_message_type")).toString(),
QStringLiteral("secret"));
QCOMPARE(responses[1].value(QStringLiteral("type")).toString(), QStringLiteral("success"));
}
void helperProtocolRunsALogin()
{
qputenv("CUTEFISH_DM_HELPER", QCoreApplication::applicationFilePath().toLocal8Bit());
qputenv(kFakeHelper, "1");
dm::HelperProcess helper;
QSignalSpy questions(&helper, &dm::HelperProcess::question);
QSignalSpy authenticated(&helper, &dm::HelperProcess::authenticated);
QSignalSpy started(&helper, &dm::HelperProcess::started);
QSignalSpy finished(&helper, &dm::HelperProcess::finished);
helper.login(QStringLiteral("cutefish-dm"), QStringLiteral("alice"), true, 2);
QTRY_COMPARE(questions.count(), 1);
QCOMPARE(questions.first().first().toString(), QStringLiteral("secret"));
helper.answer(QStringLiteral("right"));
QTRY_COMPARE(authenticated.count(), 1);
QVERIFY(helper.isAuthenticated());
helper.start({QStringLiteral("/bin/true")}, {QStringLiteral("XDG_VTNR=2")});
QTRY_COMPARE(started.count(), 1);
QCOMPARE(helper.sessionId(), QStringLiteral("42"));
QVERIFY(helper.hasStarted());
helper.terminate();
QTRY_COMPARE(finished.count(), 1);
qunsetenv(kFakeHelper);
}
void helperReportsAWrongPassword()
{
qputenv("CUTEFISH_DM_HELPER", QCoreApplication::applicationFilePath().toLocal8Bit());
qputenv(kFakeHelper, "1");
dm::HelperProcess helper;
QSignalSpy failed(&helper, &dm::HelperProcess::failed);
QSignalSpy finished(&helper, &dm::HelperProcess::finished);
QSignalSpy questions(&helper, &dm::HelperProcess::question);
helper.login(QStringLiteral("cutefish-dm"), QStringLiteral("alice"), true, 2);
QTRY_COMPARE(questions.count(), 1);
helper.answer(QStringLiteral("wrong"));
QTRY_COMPARE(finished.count(), 1);
QCOMPARE(failed.count(), 1);
QVERIFY(failed.first().first().toBool());
QVERIFY(!helper.isAuthenticated());
qunsetenv(kFakeHelper);
}
};
int main(int argc, char* argv[])
{
if (qEnvironmentVariableIsSet(kFakeHelper))
return fakeHelper();
QCoreApplication app(argc, argv);
DisplayManagerTest test;
return QTest::qExec(&test, argc, argv);
}
#include "dm_test.moc"
Loading…
Cancel
Save