From e30080b45aca43525404146e19b04b745f216017 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sat, 1 Aug 2026 00:18:24 +0200 Subject: [PATCH 1/3] fix(playerprefs): use ArgumentList for defaults read on macOS (#143) --- CHANGELOG.md | 1 + Editor/MCPServerMethods.PlayerPrefs.cs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba314e5..a4776fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable public changes to Nexus Unity are documented here. - Consolidated duplicate internal Ollama-review and serialized-property write helpers. ### Fixed +- `ListPlayerPrefs` now uses `ProcessStartInfo.ArgumentList` for macOS `defaults read` execution, preventing command argument parsing issues from unescaped company or product names in `PlayerSettings` (#143). - Persist authentication tokens across Unity Editor process restarts in Library token file, preventing persistent HTTP 401 Unauthorized errors for external MCP CLI integrations after restarting Unity (#166). - `get_scene_dependencies` now uses `enterChildren = false` after the initial property iteration step, preventing deep recursive traversal into child properties and eliminating duplicated dependency references (#71). - `find_objects` now safely constructs name search regexes with a 100ms match timeout and falls back gracefully to literal substring search on invalid regex patterns or match timeouts (#119). diff --git a/Editor/MCPServerMethods.PlayerPrefs.cs b/Editor/MCPServerMethods.PlayerPrefs.cs index 18929ba..0a8ccab 100644 --- a/Editor/MCPServerMethods.PlayerPrefs.cs +++ b/Editor/MCPServerMethods.PlayerPrefs.cs @@ -89,7 +89,9 @@ private static JToken ListPlayerPrefs(JToken p) foreach (var domain in domainsToCheck) { - ProcessStartInfo psi = new ProcessStartInfo("defaults", $"read \"{domain}\""); + ProcessStartInfo psi = new ProcessStartInfo("defaults"); + psi.ArgumentList.Add("read"); + psi.ArgumentList.Add(domain); psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; psi.UseShellExecute = false; From cdd860290a0d216eba75ce81b06e6cdc8b5af9e5 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sat, 1 Aug 2026 00:24:35 +0200 Subject: [PATCH 2/3] refactor(playerprefs): split ListPlayerPrefs helper methods to satisfy 50-line readability limit (#143) --- Editor/MCPServerMethods.PlayerPrefs.cs | 187 +++++++++++++------------ 1 file changed, 97 insertions(+), 90 deletions(-) diff --git a/Editor/MCPServerMethods.PlayerPrefs.cs b/Editor/MCPServerMethods.PlayerPrefs.cs index 0a8ccab..6f7a7b4 100644 --- a/Editor/MCPServerMethods.PlayerPrefs.cs +++ b/Editor/MCPServerMethods.PlayerPrefs.cs @@ -68,116 +68,123 @@ private static JToken DeletePlayerPref(JToken p) return "Success"; } - private static JToken ListPlayerPrefs(JToken p) + private static List CollectPlayerPrefKeys() { - var result = new JObject(); var keys = new List(); - - // Note: Unity doesn't provide a native way to list all PlayerPrefs keys. - // We use platform-specific logic to find where they are stored. - try { #if UNITY_EDITOR_OSX - string bundleId = PlayerSettings.applicationIdentifier; - if (string.IsNullOrEmpty(bundleId)) bundleId = $"com.{PlayerSettings.companyName}.{PlayerSettings.productName}"; - - // On macOS, Unity Editor stores PlayerPrefs in unity...plist - string editorPlist = $"unity.{PlayerSettings.companyName}.{PlayerSettings.productName}"; - - var domainsToCheck = new List { editorPlist, bundleId }; - - foreach (var domain in domainsToCheck) - { - ProcessStartInfo psi = new ProcessStartInfo("defaults"); - psi.ArgumentList.Add("read"); - psi.ArgumentList.Add(domain); - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - psi.UseShellExecute = false; - psi.CreateNoWindow = true; - - using (Process process = Process.Start(psi)) - { - string output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode == 0) - { - var lines = output.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - var trimmed = line.Trim(); - if (trimmed.StartsWith("{") || trimmed.EndsWith("}") || string.IsNullOrEmpty(trimmed) || trimmed.EndsWith("(")) continue; - - int equalIndex = trimmed.IndexOf('='); - if (equalIndex > 0) - { - string key = trimmed.Substring(0, equalIndex).Trim().Trim('"'); - keys.Add(key); - } - } - } - } - } + CollectMacOsPlayerPrefKeys(keys); #elif UNITY_EDITOR_WIN - string registryPath = $@"Software\Unity\UnityEditor\{PlayerSettings.companyName}\{PlayerSettings.productName}"; - using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryPath)) - { - if (key != null) - { - foreach (var valueName in key.GetValueNames()) - { - // Unity appends a hash to the end of the key name in the registry - // e.g. "MyKey_h123456789" - int lastUnderscore = valueName.LastIndexOf('_'); - if (lastUnderscore > 0) - { - keys.Add(valueName.Substring(0, lastUnderscore)); - } - else - { - keys.Add(valueName); - } - } - } - } + CollectWindowsPlayerPrefKeys(keys); #endif } catch (Exception ex) { NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to list PlayerPrefs: {ex.Message}"); } + return keys; + } - // Now populate the result with values using the standard Unity API - foreach (var key in keys.Distinct()) +#if UNITY_EDITOR_OSX + private static void CollectMacOsPlayerPrefKeys(List keys) + { + string bundleId = PlayerSettings.applicationIdentifier; + if (string.IsNullOrEmpty(bundleId)) bundleId = $"com.{PlayerSettings.companyName}.{PlayerSettings.productName}"; + string editorPlist = $"unity.{PlayerSettings.companyName}.{PlayerSettings.productName}"; + + var domainsToCheck = new List { editorPlist, bundleId }; + foreach (var domain in domainsToCheck) { - if (PlayerPrefs.HasKey(key)) + ReadMacOsPlistKeys(domain, keys); + } + } + + private static void ReadMacOsPlistKeys(string domain, List keys) + { + ProcessStartInfo psi = new ProcessStartInfo("defaults"); + psi.ArgumentList.Add("read"); + psi.ArgumentList.Add(domain); + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + + using (Process process = Process.Start(psi)) + { + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode == 0) { - // Best effort to get the value. - // If it's a string, GetString works. - // If it's an int, GetString might return empty, but GetInt will work. - // Since we can't be sure, we'll try to get it as string. - // If it's empty, we try int and float. - string sVal = PlayerPrefs.GetString(key, null); - if (sVal != null) - { - result[key] = sVal; - } - else + ParseMacOsDefaultsOutput(output, keys); + } + } + } + + private static void ParseMacOsDefaultsOutput(string output, List keys) + { + var lines = output.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("{") || trimmed.EndsWith("}") || string.IsNullOrEmpty(trimmed) || trimmed.EndsWith("(")) continue; + + int equalIndex = trimmed.IndexOf('='); + if (equalIndex > 0) + { + string key = trimmed.Substring(0, equalIndex).Trim().Trim('"'); + keys.Add(key); + } + } + } +#elif UNITY_EDITOR_WIN + private static void CollectWindowsPlayerPrefKeys(List keys) + { + string registryPath = $@"Software\Unity\UnityEditor\{PlayerSettings.companyName}\{PlayerSettings.productName}"; + using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryPath)) + { + if (key != null) + { + foreach (var valueName in key.GetValueNames()) { - // Try int - int iVal = PlayerPrefs.GetInt(key, int.MinValue); - if (iVal != int.MinValue) result[key] = iVal; + int lastUnderscore = valueName.LastIndexOf('_'); + if (lastUnderscore > 0) + keys.Add(valueName.Substring(0, lastUnderscore)); else - { - float fVal = PlayerPrefs.GetFloat(key, float.NaN); - if (!float.IsNaN(fVal)) result[key] = fVal; - else result[key] = "[Unknown Type]"; - } + keys.Add(valueName); } } } + } +#endif + + private static JToken ReadPlayerPrefValue(string key) + { + string sVal = PlayerPrefs.GetString(key, null); + if (sVal != null) return sVal; + + int iVal = PlayerPrefs.GetInt(key, int.MinValue); + if (iVal != int.MinValue) return iVal; + + float fVal = PlayerPrefs.GetFloat(key, float.NaN); + if (!float.IsNaN(fVal)) return fVal; + + return "[Unknown Type]"; + } + + private static JToken ListPlayerPrefs(JToken p) + { + var result = new JObject(); + var keys = CollectPlayerPrefKeys(); + + foreach (var key in keys.Distinct()) + { + if (PlayerPrefs.HasKey(key)) + { + result[key] = ReadPlayerPrefValue(key); + } + } return new JObject { ["prefs"] = result, From 344de456811f5739a20121a15990d6bfdced1def Mon Sep 17 00:00:00 2001 From: Daliys Date: Sat, 1 Aug 2026 01:28:29 +0200 Subject: [PATCH 3/3] fix(playerprefs): resolve deadlock, Windows registry hash truncation, double-default type parsing, and add Linux support (#143) --- CHANGELOG.md | 2 +- Editor/MCPServerMethods.PlayerPrefs.cs | 183 +++++++++++++++++++------ Tests~/Editor/PlayerPrefsTests.cs | 97 +++++++++++++ 3 files changed, 236 insertions(+), 46 deletions(-) create mode 100644 Tests~/Editor/PlayerPrefsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a4776fa..8accefb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable public changes to Nexus Unity are documented here. - Consolidated duplicate internal Ollama-review and serialized-property write helpers. ### Fixed -- `ListPlayerPrefs` now uses `ProcessStartInfo.ArgumentList` for macOS `defaults read` execution, preventing command argument parsing issues from unescaped company or product names in `PlayerSettings` (#143). +- `ListPlayerPrefs` now uses `ProcessStartInfo.ArgumentList` and timeout guards for macOS `defaults read` execution, preventing process hangs, alongside regex unhashing for Windows registry keys, Linux XML prefs support, and double-default type verification (#143). - Persist authentication tokens across Unity Editor process restarts in Library token file, preventing persistent HTTP 401 Unauthorized errors for external MCP CLI integrations after restarting Unity (#166). - `get_scene_dependencies` now uses `enterChildren = false` after the initial property iteration step, preventing deep recursive traversal into child properties and eliminating duplicated dependency references (#71). - `find_objects` now safely constructs name search regexes with a 100ms match timeout and falls back gracefully to literal substring search on invalid regex patterns or match timeouts (#119). diff --git a/Editor/MCPServerMethods.PlayerPrefs.cs b/Editor/MCPServerMethods.PlayerPrefs.cs index 6f7a7b4..9ce8ce3 100644 --- a/Editor/MCPServerMethods.PlayerPrefs.cs +++ b/Editor/MCPServerMethods.PlayerPrefs.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; using Newtonsoft.Json.Linq; @@ -70,105 +71,195 @@ private static JToken DeletePlayerPref(JToken p) private static List CollectPlayerPrefKeys() { - var keys = new List(); try { #if UNITY_EDITOR_OSX - CollectMacOsPlayerPrefKeys(keys); + return CollectMacOsPlayerPrefKeys(); #elif UNITY_EDITOR_WIN - CollectWindowsPlayerPrefKeys(keys); + return CollectWindowsPlayerPrefKeys(); +#elif UNITY_EDITOR_LINUX + return CollectLinuxPlayerPrefKeys(); +#else + return new List(); #endif } catch (Exception ex) { NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to list PlayerPrefs: {ex.Message}"); + return new List(); } - return keys; + } + + private static string GetSafeCompanyName() + { + string company = PlayerSettings.companyName; + return string.IsNullOrEmpty(company) ? "DefaultCompany" : company; + } + + private static string GetSafeProductName() + { + string product = PlayerSettings.productName; + return string.IsNullOrEmpty(product) ? "DefaultProduct" : product; } #if UNITY_EDITOR_OSX - private static void CollectMacOsPlayerPrefKeys(List keys) + private static List CollectMacOsPlayerPrefKeys() { + var keys = new List(); + string company = GetSafeCompanyName(); + string product = GetSafeProductName(); string bundleId = PlayerSettings.applicationIdentifier; - if (string.IsNullOrEmpty(bundleId)) bundleId = $"com.{PlayerSettings.companyName}.{PlayerSettings.productName}"; - string editorPlist = $"unity.{PlayerSettings.companyName}.{PlayerSettings.productName}"; + if (string.IsNullOrEmpty(bundleId)) bundleId = $"com.{company}.{product}"; + string editorPlist = $"unity.{company}.{product}"; var domainsToCheck = new List { editorPlist, bundleId }; foreach (var domain in domainsToCheck) { - ReadMacOsPlistKeys(domain, keys); + keys.AddRange(ReadMacOsPlistKeys(domain)); } + return keys; } - private static void ReadMacOsPlistKeys(string domain, List keys) + private static List ReadMacOsPlistKeys(string domain) { + var keys = new List(); ProcessStartInfo psi = new ProcessStartInfo("defaults"); psi.ArgumentList.Add("read"); psi.ArgumentList.Add(domain); psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; + psi.RedirectStandardError = false; psi.UseShellExecute = false; psi.CreateNoWindow = true; - using (Process process = Process.Start(psi)) + try { - string output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode == 0) + using (Process process = Process.Start(psi)) { - ParseMacOsDefaultsOutput(output, keys); + if (process == null) return keys; + string output = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(5000)) + { + try { process.Kill(); } catch { } + NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] 'defaults read {domain}' timed out after 5000ms."); + return keys; + } + + if (process.ExitCode == 0) + { + keys.AddRange(ParseMacOsDefaultsOutput(output)); + } } } - } - - private static void ParseMacOsDefaultsOutput(string output, List keys) - { - var lines = output.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + catch (Exception ex) { - var trimmed = line.Trim(); - if (trimmed.StartsWith("{") || trimmed.EndsWith("}") || string.IsNullOrEmpty(trimmed) || trimmed.EndsWith("(")) continue; - - int equalIndex = trimmed.IndexOf('='); - if (equalIndex > 0) - { - string key = trimmed.Substring(0, equalIndex).Trim().Trim('"'); - keys.Add(key); - } + NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed reading macOS plist for domain '{domain}': {ex.Message}"); } + return keys; } #elif UNITY_EDITOR_WIN - private static void CollectWindowsPlayerPrefKeys(List keys) + private static List CollectWindowsPlayerPrefKeys() { - string registryPath = $@"Software\Unity\UnityEditor\{PlayerSettings.companyName}\{PlayerSettings.productName}"; + var keys = new List(); + string company = GetSafeCompanyName(); + string product = GetSafeProductName(); + string registryPath = $@"Software\Unity\UnityEditor\{company}\{product}"; using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryPath)) { if (key != null) { foreach (var valueName in key.GetValueNames()) { - int lastUnderscore = valueName.LastIndexOf('_'); - if (lastUnderscore > 0) - keys.Add(valueName.Substring(0, lastUnderscore)); - else - keys.Add(valueName); + keys.Add(UnescapeWindowsRegistryKeyName(valueName)); } } } + return keys; + } +#elif UNITY_EDITOR_LINUX + private static List CollectLinuxPlayerPrefKeys() + { + var keys = new List(); + string company = GetSafeCompanyName(); + string product = GetSafeProductName(); + string configDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string prefsPath = Path.Combine(configDir, ".config", "unity3d", company, product, "prefs"); + + if (File.Exists(prefsPath)) + { + try + { + var doc = System.Xml.Linq.XDocument.Load(prefsPath); + foreach (var elem in doc.Descendants("pref")) + { + var nameAttr = elem.Attribute("name"); + if (nameAttr != null && !string.IsNullOrEmpty(nameAttr.Value)) + { + keys.Add(nameAttr.Value); + } + } + } + catch (Exception ex) + { + NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to parse Linux PlayerPrefs XML: {ex.Message}"); + } + } + return keys; } #endif - private static JToken ReadPlayerPrefValue(string key) + internal static List ParseMacOsDefaultsOutput(string output) { - string sVal = PlayerPrefs.GetString(key, null); - if (sVal != null) return sVal; + var keys = new List(); + if (string.IsNullOrEmpty(output)) return keys; - int iVal = PlayerPrefs.GetInt(key, int.MinValue); - if (iVal != int.MinValue) return iVal; + var lineRegex = new Regex(@"^\s*(?:""((?:[^""\\]|\\.)+)""|([^\s=]+))\s*=", RegexOptions.Multiline); + var matches = lineRegex.Matches(output); + foreach (Match match in matches) + { + string rawKey = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + if (!string.IsNullOrEmpty(rawKey)) + { + string unescaped = rawKey.Replace("\\\"", "\"").Replace("\\\\", "\\"); + keys.Add(unescaped); + } + } + return keys; + } - float fVal = PlayerPrefs.GetFloat(key, float.NaN); - if (!float.IsNaN(fVal)) return fVal; + internal static string UnescapeWindowsRegistryKeyName(string valueName) + { + if (string.IsNullOrEmpty(valueName)) return valueName; + int lastUnderscore = valueName.LastIndexOf('_'); + if (lastUnderscore > 0 && lastUnderscore < valueName.Length - 1 && valueName[lastUnderscore + 1] == 'h') + { + string hashPart = valueName.Substring(lastUnderscore + 2); + if (hashPart.Length > 0 && hashPart.All(char.IsDigit)) + { + return valueName.Substring(0, lastUnderscore); + } + } + return valueName; + } + + internal static JToken ReadPlayerPrefValue(string key) + { + const string def1 = "__NEXUS_PLPREF_DEF_1__"; + const string def2 = "__NEXUS_PLPREF_DEF_2__"; + + string sVal1 = PlayerPrefs.GetString(key, def1); + string sVal2 = PlayerPrefs.GetString(key, def2); + if (sVal1 == sVal2 && sVal1 != def1) + { + return sVal1; + } + + int iVal1 = PlayerPrefs.GetInt(key, 0); + int iVal2 = PlayerPrefs.GetInt(key, 1); + if (iVal1 == iVal2) return iVal1; + + float fVal1 = PlayerPrefs.GetFloat(key, 0f); + float fVal2 = PlayerPrefs.GetFloat(key, 1f); + if (Mathf.Approximately(fVal1, fVal2)) return fVal1; return "[Unknown Type]"; } @@ -193,3 +284,5 @@ private static JToken ListPlayerPrefs(JToken p) } } } + + diff --git a/Tests~/Editor/PlayerPrefsTests.cs b/Tests~/Editor/PlayerPrefsTests.cs new file mode 100644 index 0000000..e2247e1 --- /dev/null +++ b/Tests~/Editor/PlayerPrefsTests.cs @@ -0,0 +1,97 @@ +using NUnit.Framework; +using UnityEngine; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace UnityMCP.Editor.Tests +{ + public class PlayerPrefsTests + { + [Test] + public void UnescapeWindowsRegistryKeyName_StripsHashSuffix_WhenValidHashPresent() + { + string input = "my_setting_key_h1234567890"; + string result = MCPServerMethods.UnescapeWindowsRegistryKeyName(input); + Assert.AreEqual("my_setting_key", result); + } + + [Test] + public void UnescapeWindowsRegistryKeyName_PreservesKey_WhenNoHashSuffix() + { + string input = "user_profile_volume_level"; + string result = MCPServerMethods.UnescapeWindowsRegistryKeyName(input); + Assert.AreEqual("user_profile_volume_level", result); + } + + [Test] + public void ParseMacOsDefaultsOutput_ExtractsQuotedAndUnquotedKeys() + { + string mockOutput = @"{ + ""player_session_count"" = 5; + music_volume = 0.8; + ""custom.key.name"" = ""some_value""; + ""escaped\\\""quote"" = ""val""; +}"; + List keys = MCPServerMethods.ParseMacOsDefaultsOutput(mockOutput); + Assert.Contains("player_session_count", keys); + Assert.Contains("music_volume", keys); + Assert.Contains("custom.key.name", keys); + Assert.Contains("escaped\"quote", keys); + } + + [Test] + public void ReadPlayerPrefValue_CorrectlyIdentifiesIntMinValue() + { + string testKey = "NexusTest_IntMin_" + System.Guid.NewGuid().ToString("N"); + try + { + PlayerPrefs.SetInt(testKey, int.MinValue); + PlayerPrefs.Save(); + + JToken val = MCPServerMethods.ReadPlayerPrefValue(testKey); + Assert.AreEqual(int.MinValue, val.Value()); + } + finally + { + PlayerPrefs.DeleteKey(testKey); + } + } + + [Test] + public void ReadPlayerPrefValue_CorrectlyIdentifiesString() + { + string testKey = "NexusTest_String_" + System.Guid.NewGuid().ToString("N"); + try + { + PlayerPrefs.SetString(testKey, "hello_world"); + PlayerPrefs.Save(); + + JToken val = MCPServerMethods.ReadPlayerPrefValue(testKey); + Assert.AreEqual("hello_world", val.ToString()); + } + finally + { + PlayerPrefs.DeleteKey(testKey); + } + } + + [Test] + public void ReadPlayerPrefValue_DoesNotConfuseIntForString() + { + string testKey = "NexusTest_IntForStr_" + System.Guid.NewGuid().ToString("N"); + try + { + PlayerPrefs.SetInt(testKey, 42); + PlayerPrefs.Save(); + + JToken val = MCPServerMethods.ReadPlayerPrefValue(testKey); + Assert.AreEqual(JTokenType.Integer, val.Type); + Assert.AreEqual(42, val.Value()); + } + finally + { + PlayerPrefs.DeleteKey(testKey); + } + } + } +}