diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 80c309ca052..09a0aaeae66 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -588,7 +588,7 @@ jobs:
           echo "publish=${PUBLISH}" >> $GITHUB_OUTPUT
 
       - name: Validate and Publish Homebrew Formula
-        uses: LizardByte/homebrew-release-action@v2024.311.172824
+        uses: LizardByte/homebrew-release-action@v2024.314.134529
         with:
           formula_file: ${{ github.workspace }}/homebrew/sunshine.rb
           git_email: ${{ secrets.GH_BOT_EMAIL }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1c67c68c3f..d4fbff3a4ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## [0.22.2] - 2024-03-15
+**Fixed**
+- (Tray/Windows) Fix broken system tray icon on some systems
+- (Linux) Fix crash when XDG_CONFIG_HOME or CONFIGURATION_DIRECTORY are set
+- (Linux) Fix config migration across filesystems and with non-existent parent directories
+
 ## [0.22.1] - 2024-03-13
 **Breaking**
 - (ArchLinux) Drop support for standalone PKGBUILD files. Use the binary Arch package or install via AUR instead.
@@ -759,3 +765,4 @@ settings. In v0.17.0, games now run under your user account without elevated pri
 [0.21.0]: https://github.com/LizardByte/Sunshine/releases/tag/v0.21.0
 [0.22.0]: https://github.com/LizardByte/Sunshine/releases/tag/v0.22.0
 [0.22.1]: https://github.com/LizardByte/Sunshine/releases/tag/v0.22.1
+[0.22.2]: https://github.com/LizardByte/Sunshine/releases/tag/v0.22.2
diff --git a/CMakeLists.txt b/CMakeLists.txt
index fce20cd2bb7..ebff395abbc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.18)
 # todo - set this conditionally
 
 # todo - set version to 0.0.0 once confident in automated versioning
-project(Sunshine VERSION 0.22.1
+project(Sunshine VERSION 0.22.2
         DESCRIPTION "Self-hosted game stream host for Moonlight"
         HOMEPAGE_URL "https://app.lizardbyte.dev/Sunshine")
 
diff --git a/src/platform/linux/misc.cpp b/src/platform/linux/misc.cpp
index 884c0e9047a..980c0804858 100644
--- a/src/platform/linux/misc.cpp
+++ b/src/platform/linux/misc.cpp
@@ -10,6 +10,7 @@
 
 // standard includes
 #include <fstream>
+#include <iostream>
 
 // lib includes
 #include <arpa/inet.h>
@@ -98,54 +99,87 @@ namespace platf {
     return ifaddr_t { p };
   }
 
+  /**
+   * @brief Performs migration if necessary, then returns the appdata directory.
+   * @details This is used for the log directory, so it cannot invoke Boost logging!
+   * @return The path of the appdata directory that should be used.
+   */
   fs::path
   appdata() {
-    bool found = false;
-    bool migrate_config = true;
-    const char *dir;
-    const char *homedir;
-    fs::path config_path;
-
-    // Get the home directory
-    if ((homedir = getenv("HOME")) == nullptr || strlen(homedir) == 0) {
-      // If HOME is empty or not set, use the current user's home directory
-      homedir = getpwuid(geteuid())->pw_dir;
-    }
-
-    // May be set if running under a systemd service with the ConfigurationDirectory= option set.
-    if ((dir = getenv("CONFIGURATION_DIRECTORY")) != nullptr && strlen(dir) > 0) {
-      found = true;
-      config_path = fs::path(dir) / "sunshine"sv;
-    }
-    // Otherwise, follow the XDG base directory specification:
-    // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
-    if (!found && (dir = getenv("XDG_CONFIG_HOME")) != nullptr && strlen(dir) > 0) {
-      found = true;
-      config_path = fs::path(dir) / "sunshine"sv;
-    }
-    // As a last resort, use the home directory
-    if (!found) {
-      migrate_config = false;
-      config_path = fs::path(homedir) / ".config/sunshine"sv;
-    }
-
-    // migrate from the old config location if necessary
-    if (migrate_config && found && getenv("SUNSHINE_MIGRATE_CONFIG") == "1"sv) {
-      fs::path old_config_path = fs::path(homedir) / ".config/sunshine"sv;
-      if (old_config_path != config_path && fs::exists(old_config_path)) {
-        if (!fs::exists(config_path)) {
-          BOOST_LOG(info) << "Migrating config from "sv << old_config_path << " to "sv << config_path;
-          std::error_code ec;
-          fs::rename(old_config_path, config_path, ec);
-          if (ec) {
-            return old_config_path;
+    static std::once_flag migration_flag;
+    static fs::path config_path;
+
+    // Ensure migration is only attempted once
+    std::call_once(migration_flag, []() {
+      bool found = false;
+      bool migrate_config = true;
+      const char *dir;
+      const char *homedir;
+      const char *migrate_envvar;
+
+      // Get the home directory
+      if ((homedir = getenv("HOME")) == nullptr || strlen(homedir) == 0) {
+        // If HOME is empty or not set, use the current user's home directory
+        homedir = getpwuid(geteuid())->pw_dir;
+      }
+
+      // May be set if running under a systemd service with the ConfigurationDirectory= option set.
+      if ((dir = getenv("CONFIGURATION_DIRECTORY")) != nullptr && strlen(dir) > 0) {
+        found = true;
+        config_path = fs::path(dir) / "sunshine"sv;
+      }
+      // Otherwise, follow the XDG base directory specification:
+      // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+      if (!found && (dir = getenv("XDG_CONFIG_HOME")) != nullptr && strlen(dir) > 0) {
+        found = true;
+        config_path = fs::path(dir) / "sunshine"sv;
+      }
+      // As a last resort, use the home directory
+      if (!found) {
+        migrate_config = false;
+        config_path = fs::path(homedir) / ".config/sunshine"sv;
+      }
+
+      // migrate from the old config location if necessary
+      migrate_envvar = getenv("SUNSHINE_MIGRATE_CONFIG");
+      if (migrate_config && found && migrate_envvar && strcmp(migrate_envvar, "1") == 0) {
+        std::error_code ec;
+        fs::path old_config_path = fs::path(homedir) / ".config/sunshine"sv;
+        if (old_config_path != config_path && fs::exists(old_config_path, ec)) {
+          if (!fs::exists(config_path, ec)) {
+            std::cout << "Migrating config from "sv << old_config_path << " to "sv << config_path << std::endl;
+            if (!ec) {
+              // Create the new directory tree if it doesn't already exist
+              fs::create_directories(config_path, ec);
+            }
+            if (!ec) {
+              // Copy the old directory into the new location
+              // NB: We use a copy instead of a move so that cross-volume migrations work
+              fs::copy(old_config_path, config_path, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec);
+            }
+            if (!ec) {
+              // If the copy was successful, delete the original directory
+              fs::remove_all(old_config_path, ec);
+              if (ec) {
+                std::cerr << "Failed to clean up old config directory: " << ec.message() << std::endl;
+
+                // This is not fatal. Next time we start, we'll warn the user to delete the old one.
+                ec.clear();
+              }
+            }
+            if (ec) {
+              std::cerr << "Migration failed: " << ec.message() << std::endl;
+              config_path = old_config_path;
+            }
+          }
+          else {
+            // We cannot use Boost logging because it hasn't been initialized yet!
+            std::cerr << "Config exists in both "sv << old_config_path << " and "sv << config_path << ". Using "sv << config_path << " for config" << std::endl;
+            std::cerr << "It is recommended to remove "sv << old_config_path << std::endl;
           }
-        }
-        else {
-          BOOST_LOG(warning) << "Config exists in both "sv << old_config_path << " and "sv << config_path << ", using "sv << config_path << "... it is recommended to remove "sv << old_config_path;
         }
       }
-    }
+    });
 
     return config_path;
   }
diff --git a/third-party/tray b/third-party/tray
index a08c1025c3f..4d8b798cafd 160000
--- a/third-party/tray
+++ b/third-party/tray
@@ -1 +1 @@
-Subproject commit a08c1025c3f158d6b6c4b9bcf0ab770291d26896
+Subproject commit 4d8b798cafdd11285af9409c16b5f792968e0045