Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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).
Expand Down
264 changes: 183 additions & 81 deletions Editor/MCPServerMethods.PlayerPrefs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,112 +69,211 @@ private static JToken DeletePlayerPref(JToken p)
return "Success";
}

private static JToken ListPlayerPrefs(JToken p)
private static List<string> CollectPlayerPrefKeys()
{
try
{
#if UNITY_EDITOR_OSX
return CollectMacOsPlayerPrefKeys();
#elif UNITY_EDITOR_WIN
return CollectWindowsPlayerPrefKeys();
#elif UNITY_EDITOR_LINUX
return CollectLinuxPlayerPrefKeys();
#else
return new List<string>();
#endif
}
catch (Exception ex)
{
NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to list PlayerPrefs: {ex.Message}");
return new List<string>();
}
}

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 List<string> CollectMacOsPlayerPrefKeys()
{
var result = new JObject();
var keys = new List<string>();
string company = GetSafeCompanyName();
string product = GetSafeProductName();
string bundleId = PlayerSettings.applicationIdentifier;
if (string.IsNullOrEmpty(bundleId)) bundleId = $"com.{company}.{product}";
string editorPlist = $"unity.{company}.{product}";

var domainsToCheck = new List<string> { editorPlist, bundleId };
foreach (var domain in domainsToCheck)
{
keys.AddRange(ReadMacOsPlistKeys(domain));
}
return keys;
}

private static List<string> ReadMacOsPlistKeys(string domain)
{
var keys = new List<string>();
ProcessStartInfo psi = new ProcessStartInfo("defaults");
psi.ArgumentList.Add("read");
psi.ArgumentList.Add(domain);
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = false;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;

// 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.<Company>.<Product>.plist
string editorPlist = $"unity.{PlayerSettings.companyName}.{PlayerSettings.productName}";

var domainsToCheck = new List<string> { editorPlist, bundleId };

foreach (var domain in domainsToCheck)
using (Process process = Process.Start(psi))
{
ProcessStartInfo psi = new ProcessStartInfo("defaults", $"read \"{domain}\"");
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;

using (Process process = Process.Start(psi))
if (process == null) return keys;
string output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(5000))
{
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);
}
}
}
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));
}
}
}
catch (Exception ex)
{
NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed reading macOS plist for domain '{domain}': {ex.Message}");
}
return keys;
}
#elif UNITY_EDITOR_WIN
string registryPath = $@"Software\Unity\UnityEditor\{PlayerSettings.companyName}\{PlayerSettings.productName}";
using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(registryPath))
private static List<string> CollectWindowsPlayerPrefKeys()
{
var keys = new List<string>();
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())
{
keys.Add(UnescapeWindowsRegistryKeyName(valueName));
}
}
}
return keys;
}
#elif UNITY_EDITOR_LINUX
private static List<string> CollectLinuxPlayerPrefKeys()
{
var keys = new List<string>();
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
{
if (key != null)
var doc = System.Xml.Linq.XDocument.Load(prefsPath);
foreach (var elem in doc.Descendants("pref"))
{
foreach (var valueName in key.GetValueNames())
var nameAttr = elem.Attribute("name");
if (nameAttr != null && !string.IsNullOrEmpty(nameAttr.Value))
{
// 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);
}
keys.Add(nameAttr.Value);
}
}
}
catch (Exception ex)
{
NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to parse Linux PlayerPrefs XML: {ex.Message}");
}
}
return keys;
}
#endif

internal static List<string> ParseMacOsDefaultsOutput(string output)
{
var keys = new List<string>();
if (string.IsNullOrEmpty(output)) return keys;

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);
}
}
catch (Exception ex)
return keys;
}

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')
{
NexusEditorLog.Warning(NexusLogCategory.Api, $"[MCP] Failed to list PlayerPrefs: {ex.Message}");
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;
}

// Now populate the result with values using the standard Unity API
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]";
}

private static JToken ListPlayerPrefs(JToken p)
{
var result = new JObject();
var keys = CollectPlayerPrefKeys();

foreach (var key in keys.Distinct())
{
if (PlayerPrefs.HasKey(key))
{
// 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
{
// Try int
int iVal = PlayerPrefs.GetInt(key, int.MinValue);
if (iVal != int.MinValue) result[key] = iVal;
else
{
float fVal = PlayerPrefs.GetFloat(key, float.NaN);
if (!float.IsNaN(fVal)) result[key] = fVal;
else result[key] = "[Unknown Type]";
}
}
result[key] = ReadPlayerPrefValue(key);
}
}

Expand All @@ -184,3 +284,5 @@ private static JToken ListPlayerPrefs(JToken p)
}
}
}


Loading
Loading