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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ with CI/CD pipelines.
| `version-usage` | Lists installed Unity versions and indicates which are used. |
| `create <directory> [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). |

Expand Down
56 changes: 56 additions & 0 deletions src/ucll.tests/UnityLogPathsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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);
}
}
}
9 changes: 8 additions & 1 deletion src/ucll/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ public static void Build(IConfigurator config)
.WithExample("upm-git-url", "--favorite")
.WithExample("upm-git-url", "searchPath");

config.AddCommand<LogsCommand>("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<HubCommand>("hub")
.WithDescription("Execute Unity Hub interactively or with additional CLI arguments")
.WithExample("hub")
Expand All @@ -119,4 +126,4 @@ public static void Build(IConfigurator config)
.WithExample("completion")
.WithExample("completion", "zsh");
}
}
}
41 changes: 41 additions & 0 deletions src/ucll/Commands/Logs/LogsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
internal class LogsCommand(PlatformSupport platformSupport, UnityHub unityHub)
: SearchPathCommand<LogsSettings>(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();
}
}
29 changes: 29 additions & 0 deletions src/ucll/Commands/Logs/LogsSettings.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
88 changes: 88 additions & 0 deletions src/ucll/Commands/Logs/UnityLogPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Text.RegularExpressions;

internal static partial class UnityLogPaths
{
public static IReadOnlyList<UnityLogPath> 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<string>();
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("""(?:"(?<quoted>[^"]*)"|(?<bare>\S+))""")]
private static partial Regex CommandLineArgRegex();
}

internal record UnityLogPath(string Type, string Path);
9 changes: 8 additions & 1 deletion src/ucll/Shared/Platform/LinuxSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -44,4 +51,4 @@ public override ProcessStartInfo GetUnityProjectSearchProcess()
.Where(parts => parts.Length == 2)
.Select(parts => parts[1].Trim()).FirstOrDefault();
}
}
}
9 changes: 8 additions & 1 deletion src/ucll/Shared/Platform/MacSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,4 +71,4 @@ public override ProcessStartInfo GetUnityProjectSearchProcess()

return Path.Combine(lines[0], "Contents", "MacOS", "Unity Hub");
}
}
}
8 changes: 7 additions & 1 deletion src/ucll/Shared/Platform/PlatformSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ public static PlatformSupport Create()
/// </summary>
public abstract string UnityHubConfigDirectory { get; }

public abstract string DefaultEditorLogPath { get; }

public abstract string DefaultUpmLogPath { get; }

public abstract string GetPlayerLogPath(string companyName, string productName);

/// <summary>
/// A system process that returns all paths to ProjectSettings/ProjectVersion.txt files on the system.
/// </summary>
Expand Down Expand Up @@ -104,4 +110,4 @@ public static PlatformSupport Create()
protected virtual string? FindUnityHub() => null;

protected static string UserHome => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
}
12 changes: 11 additions & 1 deletion src/ucll/Shared/Platform/WindowsSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -74,4 +84,4 @@ exit 1

return exitCode == 0 ? output.Trim() : null;
}
}
}