Skip to content

feat(linux/portal): Support multiple XDG Portal session implementations - #5856

Open
psyke83 wants to merge 6 commits into
LizardByte:masterfrom
psyke83:portal_multitoken
Open

psyke83 wants to merge 6 commits into
LizardByte:masterfrom
psyke83:portal_multitoken

Conversation

@psyke83

@psyke83 psyke83 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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_DESKTOP is not set correctly in the environment, the suffix .unknown will be used.

If a legacy portal_token is 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

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

See our AI usage policy.

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

@psyke83

psyke83 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

@ReenigneArcher

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 XDG_SESSION_DESKTOP in their environment, it will use the same original portal_token name. If you then swap from this DE to (say) KDE or GNOME that has the env set correctly, it will erroneously migrate the token from an unknown session to another session.

This could be solved if we only migrated portal_token but used an .unknown suffix for unrecognized sessions moving forwards. Let me know what you prefer, happy to make the changes.

Comment thread src/platform/linux/portalgrab.cpp Outdated
@ReenigneArcher

Copy link
Copy Markdown
Member

This could be solved if we only migrated portal_token but used an .unknown suffix for unrecognized sessions moving forwards.

Meaning the .unknown suffix would just be for sessions created before they used this version? If that's the case, that seems fine to me. If it's .unknown we can have a warning that suggests they reset the token using the troubleshooting tab?

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.
@psyke83
psyke83 force-pushed the portal_multitoken branch from 75083bf to c2f5ed1 Compare October 4, 2026 14:31
@psyke83

psyke83 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

This could be solved if we only migrated portal_token but used an .unknown suffix for unrecognized sessions moving forwards.

Meaning the .unknown suffix would just be for sessions created before they used this version? If that's the case, that seems fine to me. If it's .unknown we can have a warning that suggests they reset the token using the troubleshooting tab?

I've added the migration change so that you can see. In summary:

  • portal_token.(suffix) is the name of the token moving forward. If the session cannot be determined, the suffix will be .unknown.
  • portal_token is the legacy name. If found it will be migrated via rename to the current session's suffix.

@sonarqubecloud

sonarqubecloud Bot commented Oct 4, 2026

Copy link
Copy Markdown

@ReenigneArcher ReenigneArcher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add coverage for the new session-specific token behavior, as detailed inline.

*
* @return File path.
*/
static std::filesystem::path get_file_path() {

@ReenigneArcher ReenigneArcher Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.patch

With 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");

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants