#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN using System; using System.Diagnostics; using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using UnityEngine; using UnityTcp.Editor.Helpers; namespace Cn.Tuanjie.Codely.Editor { /// /// Shared static utilities used by both and . /// Centralises SessionState keys, port management, lock-file resolution, and executable discovery. /// internal static class TauriUtils { #region SessionState Keys & Port Constants /// SessionState key for the running Tauri process PID. internal const string SESSION_STATE_TAURI_PID_KEY = "Codely_TauriPID"; /// SessionState key for the running Tauri HTTP server port. internal const string SESSION_STATE_TAURI_PORT_KEY = "Codely_TauriPort"; internal const int DEFAULT_TAURI_PORT = 31415; internal const int MAX_PORT_ATTEMPTS = 100; internal const string TAURI_ICONS_BASE_PATH = "Packages/cn.tuanjie.codely.bridge/Editor/Tauri/Icons/"; #endregion #region Automation / Headless Detection /// /// True when this is not the main interactive Editor — a worker/secondary process, batch /// mode, or a null graphics device (CI / QA automation harnesses). Auto-open windows stay /// closed in these environments: creating an forces native window + /// graphics-surface creation, which fails with a SUCCEEDED(hr) native assertion that /// flips otherwise-passing automated test runs to an overall FAIL. /// internal static bool IsAutomatedEditorRun() => EditorProcessGuard.ShouldSkipEditorUiAutoStart(); #endregion #region Workspace & Lock-File Utilities /// Returns the Unity project root directory (one level above Application.dataPath). internal static string GetWorkspaceDirectory() => Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); /// /// Returns the per-project lock directory: ~/.codely/projects/<sha256_16hex>. /// The hash algorithm matches the Rust lock_dir_for_workspace function: /// SHA256 of the project path with forward slashes, first 16 hex chars. /// internal static string GetLockDirForWorkspace(string workspaceDir) { try { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrEmpty(home)) return null; // Forward-slash normalisation is required so the hash is identical across platforms. string projectPath = Path.GetFullPath(workspaceDir).Replace('\\', '/'); using (var sha256 = SHA256.Create()) { byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(projectPath)); string hashHex = BitConverter.ToString(hashBytes).Replace("-", "").Substring(0, 16).ToLowerInvariant(); return Path.Combine(home, ".codely", "projects", hashHex); } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: GetLockDirForWorkspace failed: {ex.Message}"); return null; } } /// /// Returns the global app meta file path: ~/.codely/app.lock.meta.json. /// Matches the Rust write_global_app_meta location. The file is /// written by any running single-instance APP so external processes /// can discover the APP's HTTP port. /// internal static string GetGlobalAppMetaPath() { try { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrEmpty(home)) return null; return Path.Combine(home, ".codely", "app.lock.meta.json"); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: GetGlobalAppMetaPath failed: {ex.Message}"); return null; } } /// /// Reads ~/.codely/data.json and returns whether the drill walkthrough /// has been completed. Missing file or unparseable JSON returns false /// (treat as not-yet-completed so the walkthrough can open); read errors return /// true so we fall back to the main window instead of looping on a broken file. /// internal static bool IsDrillCompleted() { try { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string dataPath = Path.Combine(home, ".codely", "data.json"); if (!File.Exists(dataPath)) return false; string json = File.ReadAllText(dataPath); var match = Regex.Match(json, "\"drillCompleted\"\\s*:\\s*(true|false)"); if (match.Success) { return match.Groups[1].Value == "true"; } return false; } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to read drill status from data.json: {ex.Message}"); return true; } } #endregion #region Executable Discovery /// /// Resolves the cowork / cowork.exe runnable path. /// Reads CODELY_APP_HOME from the process environment or OS-level store, then /// falls back to the default installation location. Returns null if not found. /// internal static string GetCodelyRunnable() { // Read CODELY_APP_HOME from the OS-level store (registry / login shell) every call // so the Editor picks up installer updates without a restart. string home = ReadOsEnvVar("CODELY_APP_HOME"); #if UNITY_EDITOR_WIN if (!string.IsNullOrEmpty(home) && !File.Exists(Path.Combine(home, "cowork.exe"))) { CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{home}' does not contain cowork.exe, falling back to default install location."); home = null; } if (string.IsNullOrEmpty(home)) { string tuanjieHome = Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Tuanjie Cowork"); string codelyHome = Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Codely Cowork"); home = File.Exists(Path.Combine(tuanjieHome, "cowork.exe")) ? tuanjieHome : codelyHome; } string exePath = Path.Combine(home, "cowork.exe"); #elif UNITY_EDITOR_OSX if (!string.IsNullOrEmpty(home) && !File.Exists(Path.Combine(home, "cowork"))) { CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{home}' does not contain cowork, falling back to default install location."); home = null; } if (string.IsNullOrEmpty(home)) { const string tuanjieHome = "/Applications/Tuanjie Cowork.app/Contents/MacOS"; const string codelyHome = "/Applications/Codely Cowork.app/Contents/MacOS"; home = File.Exists(Path.Combine(tuanjieHome, "cowork")) ? tuanjieHome : codelyHome; } string exePath = Path.Combine(home, "cowork"); #else if (string.IsNullOrEmpty(home)) return null; string exePath = Path.Combine(home, "cowork"); #endif if (!File.Exists(exePath)) { CodelyLogger.LogError($"Codely: cowork runnable not found at '{exePath}'. Make sure cowork is installed."); return null; } return Path.GetFullPath(exePath); } /// /// Resolves the codely / codely.exe CLI path. Mirrors /// 's OS-level env-var reading so installer updates take /// effect without restarting the Editor: /// /// CODELY_HOME: look directly for codely[.exe] at that root. /// CODELY_APP_HOME: look under cli\bin\win32-x64 (Windows) or /// cli/bin/darwin-arm64 / cli/bin/darwin-x64 (macOS). /// Fallback to the default Tuanjie Cowork / Codely Cowork install dir with the /// same cli subpath. /// /// Returns null if none of these resolve. /// internal static string GetCodelyCliRunnable() { #if UNITY_EDITOR_WIN const string cliExe = "codely.exe"; string[] cliSubPaths = { @"cli\bin\win32-x64" }; string[] fallbackRoots = { Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Tuanjie Cowork"), Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Codely Cowork"), }; #elif UNITY_EDITOR_OSX const string cliExe = "codely"; // Apple Silicon first, then x64 — try both since the Editor's process arch is not // always a reliable indicator of which CLI binary the user has installed. string[] cliSubPaths = { "cli/bin/darwin-arm64", "cli/bin/darwin-x64" }; string[] fallbackRoots = { "/Applications/Tuanjie Cowork.app/Contents/MacOS", "/Applications/Codely Cowork.app/Contents/MacOS", }; #else return null; #endif // 1) CODELY_HOME — direct codely[.exe] at the root. string codelyHome = ReadOsEnvVar("CODELY_HOME"); if (!string.IsNullOrEmpty(codelyHome)) { string direct = Path.Combine(codelyHome, cliExe); if (File.Exists(direct)) return Path.GetFullPath(direct); CodelyLogger.LogWarning($"Codely: CODELY_HOME='{codelyHome}' does not contain {cliExe}, falling back to CODELY_APP_HOME."); } // 2) CODELY_APP_HOME + cli subpath. string appHome = ReadOsEnvVar("CODELY_APP_HOME"); if (!string.IsNullOrEmpty(appHome)) { string found = TryFindCli(appHome, cliSubPaths, cliExe); if (!string.IsNullOrEmpty(found)) return found; CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{appHome}' does not contain {cliExe} under the expected cli/bin path, falling back to default install location."); } // 3) Fallback: default install locations (Tuanjie Cowork preferred, then Codely Cowork). foreach (var root in fallbackRoots) { string found = TryFindCli(root, cliSubPaths, cliExe); if (!string.IsNullOrEmpty(found)) return found; } CodelyLogger.LogError("Codely: codely CLI runnable not found. Make sure Codely is installed."); return null; } private static string TryFindCli(string root, string[] subPaths, string exeName) { if (string.IsNullOrEmpty(root)) return null; foreach (var sub in subPaths) { string candidate = Path.Combine(root, sub, exeName); if (File.Exists(candidate)) return Path.GetFullPath(candidate); } return null; } /// /// Reads an environment variable from the OS-level store (Windows registry HKCU/HKLM /// Environment, or macOS login shell), bypassing the Unity Editor process's cached env. /// Returns null if not set or unavailable. Windows values are /// -expanded before return. /// private static string ReadOsEnvVar(string name) { if (string.IsNullOrEmpty(name)) return null; #if UNITY_EDITOR_WIN var registryLocations = new (Microsoft.Win32.RegistryKey root, string subKey)[] { (Microsoft.Win32.Registry.CurrentUser, @"Environment"), (Microsoft.Win32.Registry.LocalMachine, @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), }; foreach (var (root, subKey) in registryLocations) { try { using (var key = root.OpenSubKey(subKey, writable: false)) { string value = key?.GetValue(name, null, Microsoft.Win32.RegistryValueOptions.DoNotExpandEnvironmentNames) as string; if (!string.IsNullOrEmpty(value)) return Environment.ExpandEnvironmentVariables(value); } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to read {name} from registry ({subKey}): {ex.Message}"); } } return null; #elif UNITY_EDITOR_OSX try { string shell = Environment.GetEnvironmentVariable("SHELL"); if (string.IsNullOrEmpty(shell)) shell = "/bin/zsh"; for (int pass = 0; pass < 2; pass++) { string flags = pass == 0 ? "-l" : "-i -l"; var psi = new ProcessStartInfo(shell, $"{flags} -c \"echo ${name}\"") { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true, }; using (var proc = Process.Start(psi)) { string value = proc?.StandardOutput.ReadLine()?.Trim(); proc?.WaitForExit(3000); if (!string.IsNullOrEmpty(value)) return value; } } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to read {name} from login shell: {ex.Message}"); } return null; #else return null; #endif } /// /// Returns "tuanjie" if the project uses the Tuanjie engine, otherwise "unity". /// internal static string GetEditorType() { try { string versionTxt = Path.GetFullPath(Path.Combine( UnityEngine.Application.dataPath, "..", "ProjectSettings", "ProjectVersion.txt")); if (!File.Exists(versionTxt)) return "unity"; string content = File.ReadAllText(versionTxt); // m_TuanjieEditorVersion is the primary signal; m_EditorVersion with a // 't' suffix (e.g. 2022.3.62t1) is the fallback for Tuanjie projects // that don't yet write the dedicated field. if (content.Contains("m_TuanjieEditorVersion")) return "tuanjie"; var match = Regex.Match(content, @"m_EditorVersion:\s*\d+\.\d+\.\d+(\w+)"); if (match.Success && match.Groups[1].Value.Contains("t")) return "tuanjie"; } catch (Exception ex) { CodelyLogger.LogError($"Codely: Error reading ProjectVersion.txt: {ex.Message}"); } return "unity"; } #endregion #region Port Utilities internal static bool IsPortAvailable(int port) { try { var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port); listener.Start(); listener.Stop(); return true; } catch { return false; } } internal static int FindAvailablePort() { for (int port = DEFAULT_TAURI_PORT; port < DEFAULT_TAURI_PORT + MAX_PORT_ATTEMPTS; port++) { if (IsPortAvailable(port)) return port; } return -1; } /// /// Removes inherited pkg bootstrap variables before launching cowork/Tauri. /// If these variables leak from the editor process into cowork/core, core /// startup can fail inside the editor because pkg mis-detects its entrypoint. /// internal static void SanitizePkgEnvironment(ProcessStartInfo startInfo) { if (startInfo == null || startInfo.UseShellExecute) { return; } startInfo.EnvironmentVariables.Remove("PKG_EXECPATH"); startInfo.EnvironmentVariables.Remove("PKG_INVOKE_NODEJS"); startInfo.EnvironmentVariables.Remove("PKG_INVOKE_ENTRYPOINT"); } #endregion #region File & JSON Utilities /// /// Reads a text file with retry on . /// Returns null on permanent failure or if the file does not exist. /// internal static string TryReadFileWithRetry(string filePath, int retryCount, int retryDelayMs) { for (int attempt = 1; attempt <= retryCount; attempt++) { try { if (!File.Exists(filePath)) return null; return File.ReadAllText(filePath); } catch (IOException ex) { if (attempt >= retryCount) { CodelyLogger.LogWarning($"Codely: Failed to read file after {retryCount} attempts. path={filePath}, error={ex.Message}"); return null; } Thread.Sleep(retryDelayMs); } catch (UnauthorizedAccessException ex) { CodelyLogger.LogWarning($"Codely: No permission to read file. path={filePath}, error={ex.Message}"); return null; } catch { return null; } } return null; } /// /// Extracts an integer field from a simple flat JSON string using regex. /// Returns -1 if the field is absent or cannot be parsed. /// internal static int ExtractIntFromJson(string json, string fieldName) { if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(fieldName)) return -1; var match = Regex.Match(json, $"\"{Regex.Escape(fieldName)}\"\\s*:\\s*(\\d+)"); return match.Success && int.TryParse(match.Groups[1].Value, out int v) ? v : -1; } #endregion } } #endif