From 876d8cf1b131263294ac7f342c1d4de4e3a8cca1 Mon Sep 17 00:00:00 2001 From: reionwong Date: Sun, 18 Oct 2026 01:50:27 +0800 Subject: [PATCH] feat(dm): keep each user's desktop background for the login screen --- CMakeLists.txt | 1 + daemon/backgrounds.cpp | 147 ++++++++++++++++++++++++++ daemon/backgrounds.h | 49 +++++++++ daemon/config.h | 1 + daemon/daemon.cpp | 59 +++++++++++ daemon/daemon.h | 8 ++ data/com.cutefish.DisplayManager.conf | 3 +- tests/CMakeLists.txt | 1 + tests/dm_test.cpp | 117 ++++++++++++++++++++ 9 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 daemon/backgrounds.cpp create mode 100644 daemon/backgrounds.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a86c117..2d3d6b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ add_library(cutefish-dm-common STATIC common/wire.cpp) target_link_libraries(cutefish-dm-common PUBLIC Cutefish::ToolkitBase) add_executable(cutefish-dm + daemon/backgrounds.cpp daemon/config.cpp daemon/daemon.cpp daemon/greeterserver.cpp diff --git a/daemon/backgrounds.cpp b/daemon/backgrounds.cpp new file mode 100644 index 0000000..61076be --- /dev/null +++ b/daemon/backgrounds.cpp @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2026 Reion Wong +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "backgrounds.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "base/file.h" +#include "base/settings.h" + +using namespace cutefish; + +namespace dm { + +static constexpr std::string_view kSettingsSuffix = ".conf"; +static constexpr std::string_view kPictureSuffix = ".picture"; + +static bool userExists(const std::string &user) +{ + return getpwnam(user.c_str()) != nullptr; +} + +static bool isColor(const std::string &color) +{ + if (color.size() != 4 && color.size() != 7 && color.size() != 9) + return false; + + if (color.front() != '#') + return false; + + return color.find_first_not_of("0123456789abcdefABCDEF", 1) == std::string::npos; +} + +// The whole file from its start, or nothing for anything but a regular file +// within the limit. The descriptor is the caller's, so its offset is left alone. +static std::optional readPicture(int fd) +{ + struct stat info = {}; + if (fstat(fd, &info) != 0 || !S_ISREG(info.st_mode) || info.st_size > Backgrounds::kMaximumPictureSize) + return std::nullopt; + + std::string bytes; + bytes.resize(size_t(info.st_size)); + size_t done = 0; + while (done < bytes.size()) { + const ssize_t count = pread(fd, bytes.data() + done, bytes.size() - done, off_t(done)); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return std::nullopt; + done += size_t(count); + } + + return bytes.empty() ? std::nullopt : std::optional(std::move(bytes)); +} + +Backgrounds::Backgrounds(std::string directory) + : m_directory(std::move(directory)) +{ +} + +std::string Backgrounds::settingsPath(const std::string &user) const +{ + return m_directory + '/' + user + std::string(kSettingsSuffix); +} + +std::string Backgrounds::picturePath(const std::string &user) const +{ + return m_directory + '/' + user + std::string(kPictureSuffix); +} + +void Backgrounds::removeStale() +{ + std::error_code error; + for (const auto &entry : std::filesystem::directory_iterator(m_directory, error)) { + const std::string name = entry.path().filename().string(); + std::string user; + if (name.ends_with(kSettingsSuffix)) + user = name.substr(0, name.size() - kSettingsSuffix.size()); + else if (name.ends_with(kPictureSuffix)) + user = name.substr(0, name.size() - kPictureSuffix.size()); + + if (user.empty() || !userExists(user)) + std::filesystem::remove(entry.path(), error); + } +} + +void Backgrounds::setPicture(const std::string &user, base::dbus::UnixFd file, std::function done) +{ + const uint64_t request = ++m_lastRequest; + m_latest[user] = request; + + const std::string pending = picturePath(user) + '.' + std::to_string(request); + m_copies[request] = base::ThreadPool::global().run( + [file, pending] { + const std::optional bytes = readPicture(file.fd()); + return bytes && base::file::writeAtomically(pending, *bytes); + }, + [this, user, request, pending, done = std::move(done)](bool copied) { + m_copies.erase(request); + if (!copied) { + unlink(pending.c_str()); + done(false); + return; + } + + // A later request took over; it has its own answer. + if (m_latest[user] != request) { + unlink(pending.c_str()); + done(true); + return; + } + + const std::string picture = picturePath(user); + if (rename(pending.c_str(), picture.c_str()) != 0) { + unlink(pending.c_str()); + done(false); + return; + } + + base::Settings settings(settingsPath(user)); + settings.setValue("Appearance/BackgroundType", 0); + settings.setValue("Appearance/Wallpaper", picture); + done(settings.sync()); + }); +} + +bool Backgrounds::setColor(const std::string &user, const std::string &color) +{ + if (!isColor(color)) + return false; + + m_latest[user] = ++m_lastRequest; + + base::Settings settings(settingsPath(user)); + settings.setValue("Appearance/BackgroundType", 1); + settings.setValue("Appearance/BackgroundColor", color); + return settings.sync(); +} + +} // namespace dm diff --git a/daemon/backgrounds.h b/daemon/backgrounds.h new file mode 100644 index 0000000..3c7634f --- /dev/null +++ b/daemon/backgrounds.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Reion Wong +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "base/dbus_types.h" +#include "base/thread_pool.h" + +namespace dm { + +// Each user's desktop background as the login screen shows it, kept where the +// greeter can read it: the user's home may be closed to it or not mounted yet. +// .conf has services.conf's [Appearance] keys, and .picture is a +// copy of the picture it names. +class Backgrounds +{ +public: + // The largest picture copied, in bytes. + static constexpr int64_t kMaximumPictureSize = 64 * 1024 * 1024; + + explicit Backgrounds(std::string directory); + + // Removes what belongs to no existing user, and copies left unfinished. + void removeStale(); + + // Copies the picture `file` reads, on a pool thread. done(false) for + // anything but a readable regular file within kMaximumPictureSize. Of two + // requests for one user, the later one wins whichever finishes first. + void setPicture(const std::string &user, cutefish::base::dbus::UnixFd file, std::function done); + // #RGB, #RRGGBB or #AARRGGBB. + bool setColor(const std::string &user, const std::string &color); + + std::string settingsPath(const std::string &user) const; + std::string picturePath(const std::string &user) const; + +private: + std::string m_directory; + uint64_t m_lastRequest = 0; + // The latest request for each user. + std::map m_latest; + std::map m_copies; +}; + +} // namespace dm diff --git a/daemon/config.h b/daemon/config.h index 057cd4b..fb6581a 100644 --- a/daemon/config.h +++ b/daemon/config.h @@ -16,6 +16,7 @@ struct Config { std::vector sessionDirectories = {"/usr/local/share/wayland-sessions", "/usr/share/wayland-sessions"}; std::string defaultSession = "cutefish-wayland"; std::string statePath = "/var/lib/cutefish-dm/state.conf"; + std::string backgroundDirectory = "/var/lib/cutefish-dm/backgrounds"; std::string runtimeDirectory = "/run/cutefish-dm"; // Defaults, overridden by whatever `path` sets. diff --git a/daemon/daemon.cpp b/daemon/daemon.cpp index c969613..6ab3276 100644 --- a/daemon/daemon.cpp +++ b/daemon/daemon.cpp @@ -58,6 +58,7 @@ static bool bootSplashRunning() Daemon::Daemon(Config config) : m_config(std::move(config)) , m_state(m_config.statePath) + , m_backgrounds(m_config.backgroundDirectory) { m_greeterRestart.setSingleShot(true); m_greeterRestart.onTimeout = [this] { startGreeter(); }; @@ -110,6 +111,10 @@ bool Daemon::start() "com.cutefish.DisplayManager"); m_object->addMethod("SwitchToGreeter", "", "", [this](base::dbus::MethodCall &call) { switchToGreeter(call); }); + m_object->addMethod("SetBackgroundPicture", "h", "", + [this](base::dbus::MethodCall &call) { setBackgroundPicture(call); }); + m_object->addMethod("SetBackgroundColor", "s", "", + [this](base::dbus::MethodCall &call) { setBackgroundColor(call); }); } if (!m_bus || !m_object->publish() || !m_bus->requestName("com.cutefish.DisplayManager")) CF_WARNING(base::LogCategory::defaultCategory()) @@ -120,6 +125,8 @@ bool Daemon::start() execute("plymouth", {"deactivate"}); } + m_backgrounds.removeStale(); + // A run that ended mid-login may have left it in KD_GRAPHICS with nobody on it. restoreTextMode(m_config.sessionVt); startGreeter(); @@ -254,6 +261,58 @@ void Daemon::switchToGreeter(base::dbus::MethodCall &call) returnToGreeter(); } +std::string Daemon::callerName(base::dbus::MethodCall &call) +{ + const base::dbus::Proxy driver(m_bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus"); + const std::optional caller = + driver.call("GetConnectionUnixUser", call.message().sender()).value(); + const passwd *entry = caller ? getpwuid(*caller) : nullptr; + if (!entry || !entry->pw_name || m_config.greeterUser == entry->pw_name) { + call.replyError("org.freedesktop.DBus.Error.AccessDenied", "The caller is not a user who logs in"); + return {}; + } + + return entry->pw_name; +} + +void Daemon::setBackgroundPicture(base::dbus::MethodCall &call) +{ + base::dbus::UnixFd file; + if (!call.read(file) || !file.isValid()) { + call.replyError("org.freedesktop.DBus.Error.InvalidArgs", "Expected the picture's file descriptor"); + return; + } + + const std::string user = callerName(call); + if (user.empty()) + return; + + m_backgrounds.setPicture(user, file, [call](bool copied) mutable { + if (copied) + call.reply(); + else + call.replyError("org.freedesktop.DBus.Error.InvalidArgs", "The picture cannot be copied"); + }); +} + +void Daemon::setBackgroundColor(base::dbus::MethodCall &call) +{ + std::string color; + if (!call.read(color)) { + call.replyError("org.freedesktop.DBus.Error.InvalidArgs", "Expected a colour"); + return; + } + + const std::string user = callerName(call); + if (user.empty()) + return; + + if (m_backgrounds.setColor(user, color)) + call.reply(); + else + call.replyError("org.freedesktop.DBus.Error.InvalidArgs", "Not a colour that can be stored"); +} + void Daemon::createSession(const std::string &username) { release(m_login); diff --git a/daemon/daemon.h b/daemon/daemon.h index 6f0b5ef..c70b31a 100644 --- a/daemon/daemon.h +++ b/daemon/daemon.h @@ -9,6 +9,7 @@ #include "base/dbus.h" #include "base/timer.h" +#include "backgrounds.h" #include "config.h" #include "greeterserver.h" #include "helperprocess.h" @@ -31,6 +32,10 @@ public: // com.cutefish.DisplayManager.SwitchToGreeter: called by the session // before it ends; replies once the login screen is up. void switchToGreeter(cutefish::base::dbus::MethodCall &call); + // com.cutefish.DisplayManager.SetBackgroundPicture and SetBackgroundColor: + // the caller's own desktop background, for the login screen to show. + void setBackgroundPicture(cutefish::base::dbus::MethodCall &call); + void setBackgroundColor(cutefish::base::dbus::MethodCall &call); private: struct PendingStart { @@ -44,6 +49,8 @@ private: void returnToGreeter(); void onGreeterFinished(); void replySwitchRequests(bool switched); + // The calling user's name; empty, with the call refused, when it cannot be told. + std::string callerName(cutefish::base::dbus::MethodCall &call); void createSession(const std::string &username); void postAuthMessageResponse(const Json &response); @@ -61,6 +68,7 @@ private: Config m_config; State m_state; + Backgrounds m_backgrounds; GreeterServer m_server; cutefish::base::dbus::Connection *m_bus = nullptr; std::unique_ptr m_object; diff --git a/data/com.cutefish.DisplayManager.conf b/data/com.cutefish.DisplayManager.conf index 98ba393..9885a35 100644 --- a/data/com.cutefish.DisplayManager.conf +++ b/data/com.cutefish.DisplayManager.conf @@ -4,7 +4,8 @@ - + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 39a7002..5a51cdb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,7 @@ find_package(GTest REQUIRED) add_executable(dm-test dm_test.cpp terminal_stub.cpp + ../daemon/backgrounds.cpp ../daemon/config.cpp ../daemon/daemon.cpp ../daemon/greeterserver.cpp diff --git a/tests/dm_test.cpp b/tests/dm_test.cpp index d39992b..6ebb1b7 100644 --- a/tests/dm_test.cpp +++ b/tests/dm_test.cpp @@ -4,7 +4,10 @@ #include #include #include +#include +#include #include +#include #include #include #include @@ -16,6 +19,7 @@ #include "base/settings.h" #include "common/wire.h" +#include "daemon/backgrounds.h" #include "daemon/config.h" #include "daemon/daemon.h" #include "daemon/greeterserver.h" @@ -301,6 +305,119 @@ TEST_F(DisplayManagerTest, ConfigFallsBackToDefaults) EXPECT_EQ(config.greeterUser, "cutefish-greeter"); } +static std::string currentUser() +{ + const passwd *entry = getpwuid(getuid()); + return entry && entry->pw_name ? entry->pw_name : ""; +} + +static base::dbus::UnixFd openForReading(const std::string &path) +{ + return base::dbus::UnixFd::adopt(open(path.c_str(), O_RDONLY | O_CLOEXEC)); +} + +// Runs the loop until the copy answers; its answer. +static std::optional copyPicture(dm::Backgrounds &backgrounds, const std::string &user, + base::dbus::UnixFd file) +{ + std::optional answer; + backgrounds.setPicture(user, std::move(file), [&answer](bool copied) { answer = copied; }); + runUntil([&answer] { return answer.has_value(); }); + return answer; +} + +TEST_F(DisplayManagerTest, BackgroundsKeepTheUsersPictureAndColour) +{ + const std::string user = currentUser(); + ASSERT_FALSE(user.empty()); + dm::Backgrounds backgrounds(directory.path() + "/backgrounds"); + + writeFile(directory.path() + "/sky.jpg", "picture bytes"); + base::dbus::UnixFd file = openForReading(directory.path() + "/sky.jpg"); + // The caller's offset is not where the copy starts. + ASSERT_EQ(lseek(file.fd(), 4, SEEK_SET), 4); + EXPECT_EQ(copyPicture(backgrounds, user, file), true); + EXPECT_EQ(lseek(file.fd(), 0, SEEK_CUR), 4); + EXPECT_EQ(base::file::read(backgrounds.picturePath(user)).value_or(""), "picture bytes"); + // The keys the login screen reads, as services.conf has them. + { + const base::Settings settings(backgrounds.settingsPath(user)); + EXPECT_EQ(settings.intValue("Appearance/BackgroundType", -1), 0); + EXPECT_EQ(settings.stringValue("Appearance/Wallpaper"), backgrounds.picturePath(user)); + } + // The login screen reads both as its own user. + struct stat info = {}; + ASSERT_EQ(stat(backgrounds.picturePath(user).c_str(), &info), 0); + EXPECT_TRUE(info.st_mode & S_IROTH); + ASSERT_EQ(stat(backgrounds.settingsPath(user).c_str(), &info), 0); + EXPECT_TRUE(info.st_mode & S_IROTH); + + EXPECT_TRUE(backgrounds.setColor(user, "#2B8ADA")); + { + const base::Settings settings(backgrounds.settingsPath(user)); + EXPECT_EQ(settings.intValue("Appearance/BackgroundType", -1), 1); + EXPECT_EQ(settings.stringValue("Appearance/BackgroundColor"), "#2B8ADA"); + } + EXPECT_FALSE(backgrounds.setColor(user, "red")); + EXPECT_FALSE(backgrounds.setColor(user, "#12345")); + EXPECT_FALSE(backgrounds.setColor(user, "#12x456")); +} + +TEST_F(DisplayManagerTest, BackgroundsRefuseWhatIsNotARegularFile) +{ + const std::string user = currentUser(); + dm::Backgrounds backgrounds(directory.path() + "/backgrounds"); + + EXPECT_EQ(copyPicture(backgrounds, user, openForReading(directory.path())), false); + int pipeFds[2]; + ASSERT_EQ(pipe(pipeFds), 0); + close(pipeFds[1]); + EXPECT_EQ(copyPicture(backgrounds, user, base::dbus::UnixFd::adopt(pipeFds[0])), false); + writeFile(directory.path() + "/empty.jpg", ""); + EXPECT_EQ(copyPicture(backgrounds, user, openForReading(directory.path() + "/empty.jpg")), false); + EXPECT_NE(access(backgrounds.settingsPath(user).c_str(), F_OK), 0); + EXPECT_NE(access(backgrounds.picturePath(user).c_str(), F_OK), 0); +} + +TEST_F(DisplayManagerTest, BackgroundsKeepTheLatestRequest) +{ + const std::string user = currentUser(); + dm::Backgrounds backgrounds(directory.path() + "/backgrounds"); + + // A colour chosen while a picture is still being copied wins. + writeFile(directory.path() + "/sky.jpg", "picture bytes"); + std::optional answer; + backgrounds.setPicture(user, openForReading(directory.path() + "/sky.jpg"), + [&answer](bool copied) { answer = copied; }); + EXPECT_TRUE(backgrounds.setColor(user, "#000")); + runUntil([&answer] { return answer.has_value(); }); + EXPECT_EQ(answer, true); + const base::Settings settings(backgrounds.settingsPath(user)); + EXPECT_EQ(settings.intValue("Appearance/BackgroundType", -1), 1); + EXPECT_NE(access(backgrounds.picturePath(user).c_str(), F_OK), 0); + // No copy is left behind. + std::error_code error; + for (const auto &entry : std::filesystem::directory_iterator(directory.path() + "/backgrounds", error)) + EXPECT_EQ(entry.path().filename().string(), user + ".conf"); +} + +TEST_F(DisplayManagerTest, BackgroundsOfRemovedUsersAreDropped) +{ + const std::string user = currentUser(); + const std::string path = directory.path() + "/backgrounds"; + dm::Backgrounds backgrounds(path); + EXPECT_TRUE(backgrounds.setColor(user, "#000")); + writeFile(path + "/no-such-user-dm-test.conf", ""); + writeFile(path + "/no-such-user-dm-test.picture", ""); + writeFile(path + "/" + user + ".picture.7", ""); + + backgrounds.removeStale(); + EXPECT_EQ(access(backgrounds.settingsPath(user).c_str(), F_OK), 0); + EXPECT_NE(access((path + "/no-such-user-dm-test.conf").c_str(), F_OK), 0); + EXPECT_NE(access((path + "/no-such-user-dm-test.picture").c_str(), F_OK), 0); + EXPECT_NE(access((path + "/" + user + ".picture.7").c_str(), F_OK), 0); +} + TEST(ConfigTest, CommandsSplitAsTheConfigurationAlwaysHas) { EXPECT_EQ(dm::splitCommand("a b\tc"), (std::vector {"a", "b", "c"}));