From 6dd683619b7b1d49c9784330965986a485c4769a Mon Sep 17 00:00:00 2001 From: reionwong Date: Sat, 26 Sep 2026 00:32:02 +0800 Subject: [PATCH] feat: add the cutefish-dm display manager --- .gitignore | 1 + CMakeLists.txt | 62 +++++ README.md | 53 ++++ common/wire.cpp | 68 +++++ common/wire.h | 35 +++ daemon/config.cpp | 40 +++ daemon/config.h | 27 ++ daemon/daemon.cpp | 372 ++++++++++++++++++++++++++ daemon/daemon.h | 80 ++++++ daemon/greeterserver.cpp | 145 ++++++++++ daemon/greeterserver.h | 57 ++++ daemon/helperprocess.cpp | 123 +++++++++ daemon/helperprocess.h | 53 ++++ daemon/main.cpp | 52 ++++ daemon/sessions.cpp | 164 ++++++++++++ daemon/sessions.h | 32 +++ daemon/state.cpp | 37 +++ daemon/state.h | 22 ++ daemon/terminal.cpp | 80 ++++++ daemon/terminal.h | 23 ++ data/com.cutefish.DisplayManager.conf | 14 + data/cutefish-dm-greeter.pam | 18 ++ data/cutefish-dm-session | 18 ++ data/cutefish-dm.pam | 26 ++ data/cutefish-dm.service | 19 ++ debian/changelog | 5 + debian/compat | 1 + debian/control | 21 ++ debian/copyright | 3 + debian/postinst | 30 +++ debian/rules | 4 + debian/source/format | 1 + helper/main.cpp | 336 +++++++++++++++++++++++ tests/CMakeLists.txt | 17 ++ tests/dm_test.cpp | 346 ++++++++++++++++++++++++ 35 files changed, 2385 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 common/wire.cpp create mode 100644 common/wire.h create mode 100644 daemon/config.cpp create mode 100644 daemon/config.h create mode 100644 daemon/daemon.cpp create mode 100644 daemon/daemon.h create mode 100644 daemon/greeterserver.cpp create mode 100644 daemon/greeterserver.h create mode 100644 daemon/helperprocess.cpp create mode 100644 daemon/helperprocess.h create mode 100644 daemon/main.cpp create mode 100644 daemon/sessions.cpp create mode 100644 daemon/sessions.h create mode 100644 daemon/state.cpp create mode 100644 daemon/state.h create mode 100644 daemon/terminal.cpp create mode 100644 daemon/terminal.h create mode 100644 data/com.cutefish.DisplayManager.conf create mode 100644 data/cutefish-dm-greeter.pam create mode 100755 data/cutefish-dm-session create mode 100644 data/cutefish-dm.pam create mode 100644 data/cutefish-dm.service create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/postinst create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 helper/main.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/dm_test.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..415964e --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1a3051 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/common/wire.cpp b/common/wire.cpp new file mode 100644 index 0000000..e683ded --- /dev/null +++ b/common/wire.cpp @@ -0,0 +1,68 @@ +#include "wire.h" + +#include +#include + +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(&length), sizeof(length)); + frame.append(payload); + return frame; +} + +std::optional 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 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 diff --git a/common/wire.h b/common/wire.h new file mode 100644 index 0000000..ffa78b2 --- /dev/null +++ b/common/wire.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +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 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 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 diff --git a/daemon/config.cpp b/daemon/config.cpp new file mode 100644 index 0000000..0ca971c --- /dev/null +++ b/daemon/config.cpp @@ -0,0 +1,40 @@ +#include "config.h" + +#include +#include +#include + +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 diff --git a/daemon/config.h b/daemon/config.h new file mode 100644 index 0000000..20b6195 --- /dev/null +++ b/daemon/config.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +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 diff --git a/daemon/daemon.cpp b/daemon/daemon.cpp new file mode 100644 index 0000000..cf78de6 --- /dev/null +++ b/daemon/daemon.cpp @@ -0,0 +1,372 @@ +#include "daemon.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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 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(); + 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 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(); + 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(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 then) +{ + // The helper's child, so the wait ends with it. + auto* timer = new QTimer(helper); + auto waited = std::make_shared(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& helper) +{ + if (!helper) + return; + helper->disconnect(); + helper.release()->deleteLater(); +} + +} // namespace dm diff --git a/daemon/daemon.h b/daemon/daemon.h new file mode 100644 index 0000000..6f5e99c --- /dev/null +++ b/daemon/daemon.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 then); + // HelperProcess objects end in their own signals; they are released later. + static void release(std::unique_ptr& helper); + + Config m_config; + State m_state; + GreeterServer m_server; + uid_t m_greeterUid = 0; + std::unique_ptr m_greeter; + std::unique_ptr m_login; + std::unique_ptr m_session; + QString m_greeterSessionId; + std::optional m_pendingStart; + QList 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 diff --git a/daemon/greeterserver.cpp b/daemon/greeterserver.cpp new file mode 100644 index 0000000..b5db3e7 --- /dev/null +++ b/daemon/greeterserver.cpp @@ -0,0 +1,145 @@ +#include "greeterserver.h" + +#include +#include +#include + +#include +#include +#include + +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 diff --git a/daemon/greeterserver.h b/daemon/greeterserver.h new file mode 100644 index 0000000..70809ff --- /dev/null +++ b/daemon/greeterserver.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 m_client; + FrameReader m_reader; + QList m_queued; + QString m_path; + uid_t m_owner = 0; + bool m_waiting = false; +}; + +} // namespace dm diff --git a/daemon/helperprocess.cpp b/daemon/helperprocess.cpp new file mode 100644 index 0000000..1232eb2 --- /dev/null +++ b/daemon/helperprocess.cpp @@ -0,0 +1,123 @@ +#include "helperprocess.h" + +#include +#include + +#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 diff --git a/daemon/helperprocess.h b/daemon/helperprocess.h new file mode 100644 index 0000000..92d493e --- /dev/null +++ b/daemon/helperprocess.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +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 diff --git a/daemon/main.cpp b/daemon/main.cpp new file mode 100644 index 0000000..2922d50 --- /dev/null +++ b/daemon/main.cpp @@ -0,0 +1,52 @@ +#include +#include +#include + +#include +#include +#include + +#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(); +} diff --git a/daemon/sessions.cpp b/daemon/sessions.cpp new file mode 100644 index 0000000..53546e8 --- /dev/null +++ b/daemon/sessions.cpp @@ -0,0 +1,164 @@ +#include "sessions.h" + +#include +#include +#include +#include +#include +#include + +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 readSessionFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return std::nullopt; + + QHash 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 findSessions(const QStringList& directories) +{ + QList sessions; + QSet 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 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 diff --git a/daemon/sessions.h b/daemon/sessions.h new file mode 100644 index 0000000..19595c4 --- /dev/null +++ b/daemon/sessions.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +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 readSessionFile(const QString& path); + +// An id in an earlier directory hides the same id in later ones. +QList findSessions(const QStringList& directories); + +std::optional 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 diff --git a/daemon/state.cpp b/daemon/state.cpp new file mode 100644 index 0000000..2a8ba4c --- /dev/null +++ b/daemon/state.cpp @@ -0,0 +1,37 @@ +#include "state.h" + +#include +#include +#include + +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 diff --git a/daemon/state.h b/daemon/state.h new file mode 100644 index 0000000..764d728 --- /dev/null +++ b/daemon/state.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +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 diff --git a/daemon/terminal.cpp b/daemon/terminal.cpp new file mode 100644 index 0000000..fc037dd --- /dev/null +++ b/daemon/terminal.cpp @@ -0,0 +1,80 @@ +#include "terminal.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 reply = manager.call(QStringLiteral("ActivateSession"), sessionId); + if (!reply.isValid()) + qWarning() << "Cannot activate session" << sessionId << reply.error().message(); + return reply.isValid(); +} + +} // namespace dm diff --git a/daemon/terminal.h b/daemon/terminal.h new file mode 100644 index 0000000..acb9a6b --- /dev/null +++ b/daemon/terminal.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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 diff --git a/data/com.cutefish.DisplayManager.conf b/data/com.cutefish.DisplayManager.conf new file mode 100644 index 0000000..98ba393 --- /dev/null +++ b/data/com.cutefish.DisplayManager.conf @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/data/cutefish-dm-greeter.pam b/data/cutefish-dm-greeter.pam new file mode 100644 index 0000000..ddf9cb0 --- /dev/null +++ b/data/cutefish-dm-greeter.pam @@ -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 diff --git a/data/cutefish-dm-session b/data/cutefish-dm-session new file mode 100755 index 0000000..35c16e5 --- /dev/null +++ b/data/cutefish-dm-session @@ -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 diff --git a/data/cutefish-dm.pam b/data/cutefish-dm.pam new file mode 100644 index 0000000..37b9959 --- /dev/null +++ b/data/cutefish-dm.pam @@ -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 diff --git a/data/cutefish-dm.service b/data/cutefish-dm.service new file mode 100644 index 0000000..fd85713 --- /dev/null +++ b/data/cutefish-dm.service @@ -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 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..1f609f8 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +cutefish-dm (0.1.0) UNRELEASED; urgency=medium + + * Initial release. + + -- Reion Wong Fri, 25 Sep 2026 22:34:44 +0800 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..59c3261 --- /dev/null +++ b/debian/control @@ -0,0 +1,21 @@ +Source: cutefish-dm +Section: x11 +Priority: optional +Maintainer: Reion Wong +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. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..018d4e7 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,3 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: cutefish-dm +Source: cutefishos.com diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..ce8774a --- /dev/null +++ b/debian/postinst @@ -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 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..ce2eb52 --- /dev/null +++ b/debian/rules @@ -0,0 +1,4 @@ +#!/usr/bin/make -f + +%: + dh $@ --parallel diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/helper/main.cpp b/helper/main.cpp new file mode 100644 index 0000000..ed904bc --- /dev/null +++ b/helper/main.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#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 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(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(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(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 strings(const QJsonArray& array) +{ + std::vector 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 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 arguments; + for (const std::string& argument : command) + arguments.push_back(const_cast(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; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..84239c8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/dm_test.cpp b/tests/dm_test.cpp new file mode 100644 index 0000000..20d8c71 --- /dev/null +++ b/tests/dm_test.cpp @@ -0,0 +1,346 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#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(&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(&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 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"