diff --git a/README.md b/README.md index 1b59fc6..8f50f00 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ with CI/CD pipelines. | `version-usage` | Lists installed Unity versions and indicates which are used. | | `create [version]` | Creates a new empty Unity project in the directory. | | `upm-git-url [path]` | Generates a package git URL for Unity Package Manager from a project. | +| `logs [path]` | Opens Editor, package manager, and player logs for a project. | | `hub` | Executes Unity Hub interactively or with additional CLI arguments. | | `completion [shell]` | Generates shell completion scripts (supports ZSH). | diff --git a/src/ucll.tests/UnityLogPathsTests.cs b/src/ucll.tests/UnityLogPathsTests.cs new file mode 100644 index 0000000..1392b6e --- /dev/null +++ b/src/ucll.tests/UnityLogPathsTests.cs @@ -0,0 +1,56 @@ +public class UnityLogPathsTests +{ + [Theory] + [InlineData("-batchmode -logFile /tmp/custom-editor.log", "/tmp/custom-editor.log")] + [InlineData("-batchmode -logFile \"C:\\Logs\\Unity Editor.log\"", "C:\\Logs\\Unity Editor.log")] + [InlineData("-batchmode -logfile ./Editor.log", "./Editor.log")] + public void GetCustomEditorLogPathParsesLogFileArgument(string args, string expectedPath) + { + string? path = UnityLogPaths.GetCustomEditorLogPath(args); + + Assert.Equal(expectedPath, path); + } + + [Fact] + public void GetCustomEditorLogPathIgnoresStdout() + { + string? path = UnityLogPaths.GetCustomEditorLogPath("-batchmode -logFile -"); + + Assert.Null(path); + } + + [Fact] + public void ReadPlayerLogNamesUsesProjectSettings() + { + CreateTempProject(""" + %YAML 1.1 + PlayerSettings: + companyName: Example Studio + productName: Space Tool + """, tempDir => + { + var names = UnityLogPaths.ReadPlayerLogNames(tempDir); + + Assert.Equal("Example Studio", names.CompanyName); + Assert.Equal("Space Tool", names.ProductName); + }); + } + + private static void CreateTempProject(string projectSettings, Action action) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + string projectSettingsDir = Path.Combine(tempDir, "ProjectSettings"); + Directory.CreateDirectory(projectSettingsDir); + File.WriteAllText(Path.Combine(projectSettingsDir, "ProjectVersion.txt"), "m_EditorVersion: 6000.0.0f1"); + File.WriteAllText(Path.Combine(projectSettingsDir, "ProjectSettings.asset"), projectSettings); + + try + { + action.Invoke(tempDir); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/src/ucll/AppConfiguration.cs b/src/ucll/AppConfiguration.cs index 7a01a0d..4b243ab 100644 --- a/src/ucll/AppConfiguration.cs +++ b/src/ucll/AppConfiguration.cs @@ -109,6 +109,13 @@ public static void Build(IConfigurator config) .WithExample("upm-git-url", "--favorite") .WithExample("upm-git-url", "searchPath"); + config.AddCommand("logs") + .WithDescription("Open Unity Editor, package manager, and player logs for a project") + .WithExample("logs") + .WithExample("logs", "searchPath") + .WithExample("logs", "searchPath", "--type", "editor") + .WithExample("logs", "searchPath", "--path-only"); + config.AddCommand("hub") .WithDescription("Execute Unity Hub interactively or with additional CLI arguments") .WithExample("hub") @@ -119,4 +126,4 @@ public static void Build(IConfigurator config) .WithExample("completion") .WithExample("completion", "zsh"); } -} \ No newline at end of file +} diff --git a/src/ucll/Commands/Logs/LogsCommand.cs b/src/ucll/Commands/Logs/LogsCommand.cs new file mode 100644 index 0000000..520ff15 --- /dev/null +++ b/src/ucll/Commands/Logs/LogsCommand.cs @@ -0,0 +1,41 @@ +internal class LogsCommand(PlatformSupport platformSupport, UnityHub unityHub) + : SearchPathCommand(unityHub) +{ + protected override int ExecuteImpl(LogsSettings settings) + { + string searchPath = ResolveSearchPath(settings.SearchPath, settings.Favorite); + string projectPath = Project.FindProjectPath(searchPath); + + var paths = UnityLogPaths.Resolve(projectPath, UnityHub.GetProjectArgs(projectPath), platformSupport) + .Where(p => settings.Type.Equals("all", StringComparison.OrdinalIgnoreCase) + || p.Type.Equals(settings.Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var path in paths) + { + Console.WriteLine($"{path.Type}: {path.Path}"); + + if (!settings.PathOnly) + OpenLogPath(path.Path, settings.MutatingProcess); + } + + return 0; + } + + private void OpenLogPath(string logPath, IProcessRunner processRunner) + { + bool logFileExists = File.Exists(logPath); + string? pathToOpen = logFileExists + ? logPath + : Path.GetDirectoryName(logPath); + + if (pathToOpen == null || (!logFileExists && !Directory.Exists(pathToOpen))) + { + AnsiConsole.MarkupLine($"[yellow]Log path does not exist yet: {Markup.Escape(logPath)}[/]"); + return; + } + + var process = processRunner.Run(platformSupport.OpenFile(pathToOpen)); + process.WaitForExit(); + } +} diff --git a/src/ucll/Commands/Logs/LogsSettings.cs b/src/ucll/Commands/Logs/LogsSettings.cs new file mode 100644 index 0000000..3e7f21d --- /dev/null +++ b/src/ucll/Commands/Logs/LogsSettings.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; + +internal class LogsSettings : MutatingSettings +{ + [CommandArgument(0, "[searchPath]")] + [Description(Descriptions.SearchPath)] + public string? SearchPath { get; init; } + + [CommandOption("-f|--favorite|--favorites")] + [Description(Descriptions.Favorite)] + public bool Favorite { get; init; } + + [CommandOption("-t|--type")] + [Description("Log type to open: all, editor, upm, or player.")] + public string Type { get; init; } = "all"; + + [CommandOption("-p|--path-only")] + [Description("Print log paths without opening them.")] + public bool PathOnly { get; init; } + + public override ValidationResult Validate() + { + string[] supportedTypes = ["all", "editor", "upm", "player"]; + if (!supportedTypes.Contains(Type, StringComparer.OrdinalIgnoreCase)) + return ValidationResult.Error("Log type must be one of: all, editor, upm, player."); + + return base.Validate(); + } +} diff --git a/src/ucll/Commands/Logs/UnityLogPaths.cs b/src/ucll/Commands/Logs/UnityLogPaths.cs new file mode 100644 index 0000000..bd93a3b --- /dev/null +++ b/src/ucll/Commands/Logs/UnityLogPaths.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; + +internal static partial class UnityLogPaths +{ + public static IReadOnlyList Resolve( + string projectPath, + string projectArgs, + PlatformSupport platformSupport) + { + (string companyName, string productName) = ReadPlayerLogNames(projectPath); + string editorLogPath = GetCustomEditorLogPath(projectArgs) ?? platformSupport.DefaultEditorLogPath; + + return + [ + new("editor", editorLogPath), + new("upm", platformSupport.DefaultUpmLogPath), + new("player", platformSupport.GetPlayerLogPath(companyName, productName)), + ]; + } + + internal static string? GetCustomEditorLogPath(string projectArgs) + { + string[] args = SplitCommandLine(projectArgs); + + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i].Equals("-logFile", StringComparison.OrdinalIgnoreCase) + && args[i + 1] != "-") + { + return args[i + 1]; + } + } + + return null; + } + + internal static (string CompanyName, string ProductName) ReadPlayerLogNames(string projectPath) + { + string projectSettingsPath = Path.Combine(projectPath, "ProjectSettings", "ProjectSettings.asset"); + if (!File.Exists(projectSettingsPath)) + return ("CompanyName", "ProductName"); + + string companyName = "CompanyName"; + string productName = "ProductName"; + + foreach (string line in File.ReadLines(projectSettingsPath)) + { + Match companyMatch = CompanyNameRegex().Match(line); + if (companyMatch.Success) + companyName = companyMatch.Groups[1].Value.Trim(); + + Match productMatch = ProductNameRegex().Match(line); + if (productMatch.Success) + productName = productMatch.Groups[1].Value.Trim(); + } + + return (companyName, productName); + } + + private static string[] SplitCommandLine(string commandLine) + { + var args = new List(); + MatchCollection matches = CommandLineArgRegex().Matches(commandLine); + + foreach (Match match in matches) + { + string value = match.Groups["quoted"].Success + ? match.Groups["quoted"].Value + : match.Groups["bare"].Value; + + if (!string.IsNullOrWhiteSpace(value)) + args.Add(value); + } + + return args.ToArray(); + } + + [GeneratedRegex(@"^\s*companyName:\s*(.+)\s*$")] + private static partial Regex CompanyNameRegex(); + + [GeneratedRegex(@"^\s*productName:\s*(.+)\s*$")] + private static partial Regex ProductNameRegex(); + + [GeneratedRegex("""(?:"(?[^"]*)"|(?\S+))""")] + private static partial Regex CommandLineArgRegex(); +} + +internal record UnityLogPath(string Type, string Path); diff --git a/src/ucll/Shared/Platform/LinuxSupport.cs b/src/ucll/Shared/Platform/LinuxSupport.cs index f182a6b..ce1bc22 100644 --- a/src/ucll/Shared/Platform/LinuxSupport.cs +++ b/src/ucll/Shared/Platform/LinuxSupport.cs @@ -18,6 +18,13 @@ public override ProcessStartInfo OpenFileWithApp(string filePath, string applica public override string UnityHubConfigDirectory => Path.Combine(UserHome, ".config/UnityHub"); + public override string DefaultEditorLogPath => Path.Combine(UserHome, ".config/unity3d/Editor.log"); + + public override string DefaultUpmLogPath => Path.Combine(UserHome, ".config/unity3d/upm.log"); + + public override string GetPlayerLogPath(string companyName, string productName) => + Path.Combine(UserHome, ".config/unity3d", companyName, productName, "Player.log"); + public override ProcessStartInfo GetUnityProjectSearchProcess() { // Presumably requires manual database update (at least on macOS it's not up-to-date by default). @@ -44,4 +51,4 @@ public override ProcessStartInfo GetUnityProjectSearchProcess() .Where(parts => parts.Length == 2) .Select(parts => parts[1].Trim()).FirstOrDefault(); } -} \ No newline at end of file +} diff --git a/src/ucll/Shared/Platform/MacSupport.cs b/src/ucll/Shared/Platform/MacSupport.cs index 05064a9..ac2872c 100644 --- a/src/ucll/Shared/Platform/MacSupport.cs +++ b/src/ucll/Shared/Platform/MacSupport.cs @@ -20,6 +20,13 @@ public override string GetUnityExecutablePath(string path) public override string UnityHubConfigDirectory => Path.Combine(UserHome, "Library/Application Support/UnityHub"); + public override string DefaultEditorLogPath => Path.Combine(UserHome, "Library/Logs/Unity/Editor.log"); + + public override string DefaultUpmLogPath => Path.Combine(UserHome, "Library/Logs/Unity/upm.log"); + + public override string GetPlayerLogPath(string companyName, string productName) => + Path.Combine(UserHome, "Library/Logs", companyName, productName, "Player.log"); + public override ProcessStartInfo GetUnityProjectSearchProcess() { // Automatically indexed Spotlight search. @@ -64,4 +71,4 @@ public override ProcessStartInfo GetUnityProjectSearchProcess() return Path.Combine(lines[0], "Contents", "MacOS", "Unity Hub"); } -} \ No newline at end of file +} diff --git a/src/ucll/Shared/Platform/PlatformSupport.cs b/src/ucll/Shared/Platform/PlatformSupport.cs index bcd9d2f..934133f 100644 --- a/src/ucll/Shared/Platform/PlatformSupport.cs +++ b/src/ucll/Shared/Platform/PlatformSupport.cs @@ -77,6 +77,12 @@ public static PlatformSupport Create() /// public abstract string UnityHubConfigDirectory { get; } + public abstract string DefaultEditorLogPath { get; } + + public abstract string DefaultUpmLogPath { get; } + + public abstract string GetPlayerLogPath(string companyName, string productName); + /// /// A system process that returns all paths to ProjectSettings/ProjectVersion.txt files on the system. /// @@ -104,4 +110,4 @@ public static PlatformSupport Create() protected virtual string? FindUnityHub() => null; protected static string UserHome => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); -} \ No newline at end of file +} diff --git a/src/ucll/Shared/Platform/WindowsSupport.cs b/src/ucll/Shared/Platform/WindowsSupport.cs index cd55990..19ffecd 100644 --- a/src/ucll/Shared/Platform/WindowsSupport.cs +++ b/src/ucll/Shared/Platform/WindowsSupport.cs @@ -20,6 +20,16 @@ public override string FindInstallationRoot(string editorPath) public override string UnityHubConfigDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UnityHub"); + public override string DefaultEditorLogPath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Unity", "Editor", "Editor.log"); + + public override string DefaultUpmLogPath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Unity", "Editor", "upm.log"); + + public override string GetPlayerLogPath(string companyName, string productName) => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "AppData", "LocalLow", companyName, productName, "Player.log"); + public override ProcessStartInfo GetUnityProjectSearchProcess() { // Use Windows Search index via COM (equivalent to mdfind on macOS) @@ -74,4 +84,4 @@ exit 1 return exitCode == 0 ? output.Trim() : null; } -} \ No newline at end of file +}