Repository navigation
Conversation
|
This has a one-time cost of causing users to "lose" their existing portal token. I considered adding a simple migration routine, but decided against doing it. Theoretically, if a DE doesn't correctly follow the spec and set This could be solved if we only migrated |
Meaning the |
Add detection for the active XDG Desktop Portal session active and assign
its name as a suffix to the portal_token file. This allows users to
seamlessly switch between desktop environments without having to
renegotiate tokens each time.
The WebUI's XDG troubleshooting button remains compatible with this
logic, and will only delete the restore token associated with the active
XDG session at the point of activation whilst leaving other tokens
untouched.
If for some reason XDG_SESSION_DESKTOP is not set correctly in the
environment, the legacy un-suffixed portal_token will be used.
Examples: portal_token.kde, portal_token.gnome, portal_token.wlr,
portal_token.hyprland.
75083bf to
c2f5ed1
Compare
I've added the migration change so that you can see. In summary:
|
|
ReenigneArcher
left a comment
There was a problem hiding this comment.
Please add coverage for the new session-specific token behavior, as detailed inline.
| * | ||
| * @return File path. | ||
| */ | ||
| static std::filesystem::path get_file_path() { |
There was a problem hiding this comment.
[P2] Add tests for the session-specific token resolver and migration
AGENTS.md requires tests for new or modified methods and targets 100% coverage on changed code, but this PR adds or updates no tests. The existing ConfigHttpTest fixture replaces the token-path provider with a fixed test_web_dir / "portal_token" path, so its reset tests bypass the new resolver and cannot detect selection or deletion of the wrong session's token. Please add focused gtests covering suffix normalization and validation, the .unknown fallback, legacy migration and its failure/existing-destination branches, and reset using the active session's token while preserving another desktop's token. Use isolated temporary files and restore any modified environment variables.
Edit: Clearly an AI generated response, but the point is valid that we're mocking the old file path. I'm having it generate a diff/patch if you don't want to waste your time on that part.
There was a problem hiding this comment.
Here is a ready-to-apply patch against c1ad982 for this finding. It adds 17 resolver cases, two HTTPS reset cases, and a fallback path test for builds without Portal support. The helper compiled under SUNSHINE_TESTS lets the tests supply a private directory and desktop value; production calls retain their existing defaults. The new test file is picked up by the existing CMake test-source glob.
Save the diff below as portal-token-tests.patch in your checkout, then run:
git apply --check portal-token-tests.patch
git apply portal-token-tests.patchWith a Linux build already configured with BUILD_TESTS=ON and SUNSHINE_ENABLE_PORTAL=ON, run the following, substituting your build directory for cmake-build-debug:
cmake --build cmake-build-debug --target test_sunshine
./cmake-build-debug/tests/test_sunshine --gtest_filter='*PortalToken*:*XdgRestoreTokenPath*'Validation: the patch applies cleanly to the current PR head; all 17 resolver cases passed in an isolated Linux harness using the production resolver; the Windows fallback case passed; the HTTPS additions passed a compile check with the project flags. The resolver harness exercised every resolver line, including lookup and rename failures. The full Linux Sunshine suite and the two HTTPS cases still need to run in the project build.
Copyable patch
diff --git a/src/platform/linux/misc.h b/src/platform/linux/misc.h
index b9a2efd..5d88fbb 100644
--- a/src/platform/linux/misc.h
+++ b/src/platform/linux/misc.h
@@ -71,6 +71,17 @@ namespace portal {
*/
std::filesystem::path get_saved_token_path();
+ #ifdef SUNSHINE_TESTS
+ /**
+ * @brief Resolve and migrate a saved token using isolated test inputs.
+ *
+ * @param directory Directory containing test tokens.
+ * @param desktop Session desktop used to select the token suffix.
+ * @return Path of the selected test token.
+ */
+ std::filesystem::path get_saved_token_path_for_testing(const std::filesystem::path &directory, const std::string &desktop);
+ #endif
+
/**
* @brief Check if the Portal service responds to a DBus Ping within 2 seconds.
*
diff --git a/src/platform/linux/portalgrab.cpp b/src/platform/linux/portalgrab.cpp
index 8454b05..b530ae6 100644
--- a/src/platform/linux/portalgrab.cpp
+++ b/src/platform/linux/portalgrab.cpp
@@ -129,12 +129,17 @@ namespace portal {
*
* The token filename is suffixed with the XDG session desktop when set in the environment.
*
+ * @param directory Directory containing saved tokens; defaults to the application data directory.
+ * @param desktop Session desktop; defaults to the XDG_SESSION_DESKTOP environment variable.
* @return File path.
*/
- static std::filesystem::path get_file_path() {
- const std::filesystem::path legacy_path = platf::appdata() / "portal_token";
+ static std::filesystem::path get_file_path(
+ const std::filesystem::path &directory = platf::appdata(),
+ const std::string &desktop = lizardbyte::common::get_env("XDG_SESSION_DESKTOP")
+ ) {
+ const std::filesystem::path legacy_path = directory / "portal_token";
- std::string suffix(lizardbyte::common::get_env("XDG_SESSION_DESKTOP"));
+ std::string suffix(desktop);
boost::algorithm::to_lower(suffix);
// Restrict the suffix to prevent path traversal and other invalid filename characters.
@@ -146,7 +151,7 @@ namespace portal {
suffix = "unknown";
}
- const std::filesystem::path suffixed_path = platf::appdata() / ("portal_token." + suffix);
+ const std::filesystem::path suffixed_path = directory / ("portal_token." + suffix);
// One-time migration of legacy portal_token.
std::error_code ec;
@@ -248,6 +253,12 @@ namespace portal {
return restore_token_t::get_file_path();
}
+#ifdef SUNSHINE_TESTS
+ std::filesystem::path get_saved_token_path_for_testing(const std::filesystem::path &directory, const std::string &desktop) {
+ return restore_token_t::get_file_path(directory, desktop);
+ }
+#endif
+
/**
* @brief PipeWire stream node and negotiated capture size.
*/
diff --git a/tests/unit/platform/test_portal_token.cpp b/tests/unit/platform/test_portal_token.cpp
new file mode 100644
index 0000000..8bb3a76
--- /dev/null
+++ b/tests/unit/platform/test_portal_token.cpp
@@ -0,0 +1,182 @@
+/**
+ * @file tests/unit/platform/test_portal_token.cpp
+ * @brief Tests for session-specific XDG Portal token paths and legacy migration.
+ */
+
+// test includes
+#include "../../tests_common.h"
+
+#ifndef SUNSHINE_BUILD_PORTAL
+TEST_F(BaseTest, XdgRestoreTokenPathUsesLegacyFilenameWithoutPortal) {
+ EXPECT_EQ(platf::get_xdg_restore_token_path(), platf::appdata() / "portal_token");
+}
+#else
+ // standard includes
+ #include <cstdlib>
+ #include <filesystem>
+ #include <fstream>
+ #include <string>
+
+ // local includes
+ #include "src/platform/linux/misc.h"
+
+/**
+ * @brief Desktop input and expected normalized token suffix.
+ */
+struct portal_desktop_suffix_t {
+ const char *desktop; ///< Desktop identifier supplied to the resolver.
+ const char *suffix; ///< Expected safe filename suffix.
+};
+
+namespace {
+ /**
+ * @brief Provide a private token directory without changing process environment variables.
+ */
+ class PortalTokenTest: public BaseTest {
+ protected:
+ /**
+ * @brief Create a unique writable directory for test tokens.
+ */
+ void SetUp() override {
+ BaseTest::SetUp();
+ if (HasFatalFailure() || IsSkipped()) {
+ return;
+ }
+ auto directory_template = (std::filesystem::temp_directory_path() / "sunshine-portal-token-XXXXXX").string(); // NOSONAR(cpp:S5443): mkdtemp creates a private unique directory.
+ const auto *directory = mkdtemp(directory_template.data());
+ ASSERT_NE(directory, nullptr);
+ token_directory_ = directory;
+ }
+
+ /**
+ * @brief Remove only the private directory created by this fixture.
+ */
+ void TearDown() override {
+ if (!token_directory_.empty()) {
+ std::error_code ec;
+ std::filesystem::remove_all(token_directory_, ec);
+ EXPECT_FALSE(ec) << ec.message();
+ }
+ BaseTest::TearDown();
+ }
+
+ /**
+ * @brief Resolve a token path through the production resolver.
+ * @param desktop Desktop identifier to normalize and validate.
+ * @return Selected path in the private token directory.
+ */
+ std::filesystem::path resolve(const std::string &desktop = "KDE") const {
+ return portal::get_saved_token_path_for_testing(token_directory_, desktop);
+ }
+
+ /**
+ * @brief Write a token in the private directory.
+ * @param name Token filename.
+ * @param value Token contents.
+ */
+ void write_token(const std::string &name, const std::string &value) const {
+ std::ofstream file(token_directory_ / name);
+ ASSERT_TRUE(file.is_open());
+ file << value;
+ ASSERT_TRUE(file.good());
+ }
+
+ /**
+ * @brief Read a token without changing its contents.
+ * @param name Token filename.
+ * @return First line of the saved token.
+ */
+ std::string read_token(const std::string &name) const {
+ std::ifstream file(token_directory_ / name);
+ std::string value;
+ std::getline(file, value);
+ return value;
+ }
+
+ std::filesystem::path token_directory_; ///< Private directory owned by this fixture.
+ };
+
+ /**
+ * @brief Parameterized suffix normalization and validation tests.
+ */
+ class PortalTokenSuffixTest: public PortalTokenTest, public testing::WithParamInterface<portal_desktop_suffix_t> {};
+} // namespace
+
+TEST_P(PortalTokenSuffixTest, SelectsSafeSessionFilename) {
+ const auto &value = GetParam();
+ EXPECT_EQ(resolve(value.desktop), token_directory_ / (std::string("portal_token.") + value.suffix));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ DesktopNames,
+ PortalTokenSuffixTest,
+ testing::Values(
+ portal_desktop_suffix_t {"KDE", "kde"},
+ portal_desktop_suffix_t {"GNOME", "gnome"},
+ portal_desktop_suffix_t {"Hyprland", "hyprland"},
+ portal_desktop_suffix_t {"Desktop_1-test", "desktop_1-test"},
+ portal_desktop_suffix_t {"", "unknown"},
+ portal_desktop_suffix_t {"../kde", "unknown"},
+ portal_desktop_suffix_t {"kde/gnome", "unknown"},
+ portal_desktop_suffix_t {"kde\\gnome", "unknown"},
+ portal_desktop_suffix_t {"kde:gnome", "unknown"},
+ portal_desktop_suffix_t {"kde.name", "unknown"}
+ )
+);
+
+TEST_F(PortalTokenTest, MigratesLegacyTokenAndPreservesContents) {
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token", "legacy-token"));
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_FALSE(std::filesystem::exists(token_directory_ / "portal_token"));
+ EXPECT_EQ(read_token("portal_token.kde"), "legacy-token");
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_EQ(read_token("portal_token.kde"), "legacy-token");
+}
+
+TEST_F(PortalTokenTest, MigratesLegacyTokenToUnknownWhenDesktopIsEmpty) {
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token", "legacy-token"));
+ EXPECT_EQ(resolve(""), token_directory_ / "portal_token.unknown");
+ EXPECT_FALSE(std::filesystem::exists(token_directory_ / "portal_token"));
+ EXPECT_EQ(read_token("portal_token.unknown"), "legacy-token");
+}
+
+TEST_F(PortalTokenTest, ExistingSessionTokenPreventsLegacyMigration) {
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token", "legacy-token"));
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token.kde", "current-token"));
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_EQ(read_token("portal_token"), "legacy-token");
+ EXPECT_EQ(read_token("portal_token.kde"), "current-token");
+}
+
+TEST_F(PortalTokenTest, MigrationPreservesOtherDesktopToken) {
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token", "legacy-token"));
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token.gnome", "gnome-token"));
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_EQ(read_token("portal_token.kde"), "legacy-token");
+ EXPECT_EQ(read_token("portal_token.gnome"), "gnome-token");
+}
+
+TEST_F(PortalTokenTest, SessionPathLookupErrorLeavesLegacyTokenUntouched) {
+ ASSERT_NO_FATAL_FAILURE(write_token("portal_token", "legacy-token"));
+ std::filesystem::create_symlink("portal_token.kde", token_directory_ / "portal_token.kde");
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_TRUE(std::filesystem::is_symlink(token_directory_ / "portal_token.kde"));
+ EXPECT_EQ(read_token("portal_token"), "legacy-token");
+}
+
+TEST_F(PortalTokenTest, LegacyPathLookupErrorDoesNotCreateSessionToken) {
+ std::filesystem::create_symlink("portal_token", token_directory_ / "portal_token");
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_TRUE(std::filesystem::is_symlink(token_directory_ / "portal_token"));
+ EXPECT_FALSE(std::filesystem::exists(token_directory_ / "portal_token.kde"));
+}
+
+TEST_F(PortalTokenTest, FailedRenamePreservesLegacyEntryAndSessionPath) {
+ ASSERT_TRUE(std::filesystem::create_directory(token_directory_ / "portal_token"));
+ std::filesystem::create_symlink("missing-token", token_directory_ / "portal_token.kde");
+ // POSIX cannot rename a directory over a symlink, even when its target is missing.
+ EXPECT_EQ(resolve(), token_directory_ / "portal_token.kde");
+ EXPECT_TRUE(std::filesystem::is_directory(token_directory_ / "portal_token"));
+ EXPECT_TRUE(std::filesystem::is_symlink(token_directory_ / "portal_token.kde"));
+}
+#endif
diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp
index 944c2e0..3d66881 100644
--- a/tests/unit/test_confighttp.cpp
+++ b/tests/unit/test_confighttp.cpp
@@ -34,6 +34,10 @@
#include <src/nvhttp.h>
#include <src/utility.h>
+#ifdef SUNSHINE_BUILD_PORTAL
+ #include <src/platform/linux/misc.h>
+#endif
+
using namespace std::literals;
namespace {
@@ -739,6 +743,53 @@ TEST_F(ConfigHttpTest, PortalTokenResetReportsDeletionFailure) {
EXPECT_TRUE(std::filesystem::exists(token_path));
}
+#ifdef SUNSHINE_BUILD_PORTAL
+TEST_F(ConfigHttpTest, PortalTokenResetUsesSessionResolverAndPreservesOtherDesktops) {
+ const auto active_token = test_web_dir / "portal_token.kde";
+ const auto other_token = test_web_dir / "portal_token.gnome";
+ std::ofstream(active_token) << "kde-token";
+ std::ofstream(other_token) << "gnome-token";
+ confighttp::set_portal_token_path_provider_for_testing([this]() {
+ return portal::get_saved_token_path_for_testing(test_web_dir, "KDE");
+ });
+
+ SimpleWeb::CaseInsensitiveMultimap headers;
+ headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
+ headers.emplace("Origin", std::format("https://localhost:{}", port));
+ const auto response = client->request("POST", "/portal-token-reset-test", "", headers);
+
+ ASSERT_EQ(response->status_code, "200 OK");
+ EXPECT_TRUE(nlohmann::json::parse(response->content.string()).at("status").get<bool>());
+ EXPECT_FALSE(std::filesystem::exists(active_token));
+ ASSERT_TRUE(std::filesystem::exists(other_token));
+ std::ifstream remaining_token(other_token);
+ std::string value;
+ std::getline(remaining_token, value);
+ EXPECT_EQ(value, "gnome-token");
+}
+
+TEST_F(ConfigHttpTest, PortalTokenResetRemovesMigratedLegacyToken) {
+ const auto legacy_token = test_web_dir / "portal_token";
+ const auto active_token = test_web_dir / "portal_token.kde";
+ std::ofstream(legacy_token) << "legacy-token";
+ confighttp::set_portal_token_path_provider_for_testing([this]() {
+ return portal::get_saved_token_path_for_testing(test_web_dir, "KDE");
+ });
+
+ SimpleWeb::CaseInsensitiveMultimap headers;
+ headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
+ headers.emplace("Origin", std::format("https://localhost:{}", port));
+ const auto response = client->request("POST", "/portal-token-reset-test", "", headers);
+
+ ASSERT_EQ(response->status_code, "200 OK");
+ EXPECT_TRUE(nlohmann::json::parse(response->content.string()).at("status").get<bool>());
+ EXPECT_FALSE(std::filesystem::exists(legacy_token));
+ EXPECT_FALSE(std::filesystem::exists(active_token));
+ EXPECT_EQ(portal::get_saved_token_path_for_testing(test_web_dir, "KDE"), active_token);
+ EXPECT_FALSE(std::filesystem::exists(active_token));
+}
+#endif
+
// Test: confighttp::authenticate() rejects requests without auth header
TEST_F(ConfigHttpTest, AuthenticateRejectsNoAuth) {
const auto response = client->request("GET", "/auth-test");


Description
Add detection for the active XDG Desktop Portal session and assign its name as a suffix to the portal_token file. This allows users to seamlessly switch between desktop environments without having to renegotiate tokens each time.
The WebUI's XDG troubleshooting button remains compatible with this logic, and will only delete the restore token associated with the active XDG session at the point of activation whilst leaving other tokens untouched.
If for some reason
XDG_SESSION_DESKTOPis not set correctly in the environment, the suffix.unknownwill be used.If a legacy
portal_tokenis detected, it will be automatically migrated to the suffixed version of the current session.Examples: portal_token.kde, portal_token.gnome, portal_token.wlr,
portal_token.hyprland.
Screenshot
Issues Fixed or Closed
Roadmap Issues
Type of Change
Checklist
AI Usage
See our AI usage policy.