From 3cb3f9c3da197fb539bcea6fc01b44ceb944d72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Erik=20Raurs=C3=B8?= Date: Fri, 17 Jul 2026 21:01:37 +0200 Subject: [PATCH] fix: keep dead-ref projects from emptying the list; flag missing projects Parse each Unity Hub project entry independently so one malformed or incomplete entry (e.g. a moved/deleted "dead reference") can no longer abort parsing and blank the whole list. Also fixes GetItems rendering nothing when "Group favorites first" is disabled (previously a missing else branch). Projects whose folder no longer exists on disk are now flagged with a warning icon and a "Missing" tag, are non-launching (toast instead of starting Unity), and can be hidden via a new "Hide projects missing from disk" setting. Co-Authored-By: Claude Opus 4.8 (1M context) --- UnityExtension/Commands/OpenUnityCommand.cs | 8 ++ UnityExtension/Data/UnityProject.cs | 6 + UnityExtension/Helpers/ProjectParser.cs | 127 +++++++++++++++----- UnityExtension/Helpers/Resources.cs | 4 + UnityExtension/Helpers/SettingsManager.cs | 11 ++ UnityExtension/Pages/UnityExtensionPage.cs | 25 ++-- 6 files changed, 145 insertions(+), 36 deletions(-) diff --git a/UnityExtension/Commands/OpenUnityCommand.cs b/UnityExtension/Commands/OpenUnityCommand.cs index ac27029..320fa7d 100644 --- a/UnityExtension/Commands/OpenUnityCommand.cs +++ b/UnityExtension/Commands/OpenUnityCommand.cs @@ -24,6 +24,14 @@ public OpenUnityCommand(UnityProject project) public override CommandResult Invoke() { + // Dead reference: the project's folder no longer exists on disk. Don't try to + // launch Unity against a missing path (also guards against the folder being + // deleted after the list was built). + if (!Directory.Exists(_projectPath)) + { + return CommandResult.ShowToast($"Project folder no longer exists:\n{_projectPath}"); + } + // Lazy-load only when invoking _editorPath ??= EditorParser.GetExecutablePathForVersion(_projectVersion); diff --git a/UnityExtension/Data/UnityProject.cs b/UnityExtension/Data/UnityProject.cs index 0e2831a..510ca18 100644 --- a/UnityExtension/Data/UnityProject.cs +++ b/UnityExtension/Data/UnityProject.cs @@ -7,4 +7,10 @@ internal sealed record UnityProject public string Version { get; init; } = string.Empty; public long LastModified { get; init; } public bool IsFavorite { get; init; } + + /// + /// Whether the project's folder still exists on disk. Dead references + /// (moved/deleted projects still listed by Unity Hub) have this set to false. + /// + public bool Exists { get; init; } } diff --git a/UnityExtension/Helpers/ProjectParser.cs b/UnityExtension/Helpers/ProjectParser.cs index 852d831..3c2e43d 100644 --- a/UnityExtension/Helpers/ProjectParser.cs +++ b/UnityExtension/Helpers/ProjectParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -9,49 +9,122 @@ public static class ProjectParser { internal static List GetUnityProjects() { - var result = new List(); - var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var projectsJsonFilePath = Path.Combine(appDataPath, Resources.ProjectsJsonPath); + var projectsJsonFilePath = Resources.ProjectsJsonPath; if (!File.Exists(projectsJsonFilePath)) { - return result; + return new List(); } + string jsonContent; try { - var jsonContent = File.ReadAllText(projectsJsonFilePath); - var projectsRoot = JsonDocument.Parse(jsonContent).RootElement; + jsonContent = File.ReadAllText(projectsJsonFilePath); + } + catch (Exception) + { + // File exists but couldn't be read (locked, permissions, etc.) + return new List(); + } + + return ParseProjects(jsonContent); + } + + /// + /// Parses the contents of Unity Hub's projects-v1.json into a list of projects. + /// Each entry is parsed independently: a single malformed or incomplete entry + /// (e.g. a dead reference to a moved/deleted project) is skipped rather than + /// aborting the whole list. + /// + internal static List ParseProjects(string jsonContent) + { + var result = new List(); + + JsonElement projectsRoot; + try + { + projectsRoot = JsonDocument.Parse(jsonContent).RootElement; + } + catch (JsonException) + { + // Corrupt/unreadable JSON → empty list; the page shows "No projects found". + return result; + } + + if ( + !projectsRoot.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Object + ) + { + return result; + } - if (projectsRoot.TryGetProperty("data", out var data)) + foreach (var property in data.EnumerateObject()) + { + try { - foreach (var property in data.EnumerateObject()) + var projectPath = property.Name; + var projectInfo = property.Value; + + var fallbackTitle = Path.GetFileName(projectPath.TrimEnd('/', '\\')); + + var title = + projectInfo.TryGetProperty("title", out var titleEl) + && titleEl.ValueKind == JsonValueKind.String + && !string.IsNullOrEmpty(titleEl.GetString()) + ? titleEl.GetString()! + : fallbackTitle; + + var version = + projectInfo.TryGetProperty("version", out var versionEl) + && versionEl.ValueKind == JsonValueKind.String + ? versionEl.GetString()! + : "Unknown"; + + long lastModified = 0; + if ( + projectInfo.TryGetProperty("lastModified", out var lastModifiedEl) + && lastModifiedEl.ValueKind == JsonValueKind.Number + && lastModifiedEl.TryGetInt64(out var parsedLastModified) + ) { - var projectPath = property.Name; - var projectInfo = property.Value; + lastModified = parsedLastModified; + } + + var isFavorite = + projectInfo.TryGetProperty("isFavorite", out var isFavoriteEl) + && isFavoriteEl.ValueKind == JsonValueKind.True; - var project = new UnityProject + result.Add( + new UnityProject { Path = projectPath, - Title = - projectInfo.GetProperty("title").GetString() - ?? Path.GetFileName(projectPath), - Version = projectInfo.GetProperty("version").GetString() ?? "Unknown", - LastModified = projectInfo.GetProperty("lastModified").GetInt64(), - IsFavorite = - projectInfo.TryGetProperty("isFavorite", out var isFavorite) - && isFavorite.GetBoolean(), - }; - - result.Add(project); - } + Title = title, + Version = version, + LastModified = lastModified, + IsFavorite = isFavorite, + Exists = SafeDirectoryExists(projectPath), + } + ); + } + catch (Exception) + { + // Skip only this malformed entry; keep parsing the rest. } } + + return result; + } + + private static bool SafeDirectoryExists(string path) + { + try + { + return !string.IsNullOrEmpty(path) && Directory.Exists(path); + } catch (Exception) { - // Any error in parsing will result in an empty list + return false; } - - return result; } } diff --git a/UnityExtension/Helpers/Resources.cs b/UnityExtension/Helpers/Resources.cs index d4ca297..b3b4a1e 100644 --- a/UnityExtension/Helpers/Resources.cs +++ b/UnityExtension/Helpers/Resources.cs @@ -13,5 +13,9 @@ public static class Resources ); public static IconInfo IconUrl => new("\uE8A7"); + + // Segoe Fluent "Warning" glyph \u2014 used to flag projects whose folder is gone from disk. + public static IconInfo IconMissing => new("\uE7BA"); + public static IconInfo IconUnity => IconHelpers.FromRelativePath("Assets\\UnityLogo.png"); } diff --git a/UnityExtension/Helpers/SettingsManager.cs b/UnityExtension/Helpers/SettingsManager.cs index 817f07a..a6c6cf1 100644 --- a/UnityExtension/Helpers/SettingsManager.cs +++ b/UnityExtension/Helpers/SettingsManager.cs @@ -9,6 +9,9 @@ internal sealed class SettingsManager : JsonSettingsManager private ToggleSetting GroupFavoritesFirstSetting { get; } public bool GroupFavoritesFirst => GroupFavoritesFirstSetting.Value; + private ToggleSetting HideMissingProjectsSetting { get; } + public bool HideMissingProjects => HideMissingProjectsSetting.Value; + private static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("UnityExtension"); @@ -27,7 +30,15 @@ public SettingsManager() defaultValue: true ); + HideMissingProjectsSetting = new ToggleSetting( + key: "hideMissingProjects", + label: "Hide projects missing from disk", + description: "Don't list Unity projects whose folder no longer exists on disk", + defaultValue: false + ); + Settings.Add(GroupFavoritesFirstSetting); + Settings.Add(HideMissingProjectsSetting); LoadSettings(); diff --git a/UnityExtension/Pages/UnityExtensionPage.cs b/UnityExtension/Pages/UnityExtensionPage.cs index 09536ba..af7e135 100644 --- a/UnityExtension/Pages/UnityExtensionPage.cs +++ b/UnityExtension/Pages/UnityExtensionPage.cs @@ -34,6 +34,11 @@ public override IListItem[] GetItems() { var projects = ProjectParser.GetUnityProjects(); + if (_settingsManager.HideMissingProjects) + { + projects = projects.Where(p => p.Exists).ToList(); + } + if (projects.Count == 0) { items.Add( @@ -50,14 +55,11 @@ public override IListItem[] GetItems() { projects.Sort((a, b) => b.LastModified.CompareTo(a.LastModified)); - if (_settingsManager.GroupFavoritesFirst) - { - var favoriteProjects = projects.Where(p => p.IsFavorite).ToList(); - var nonFavoriteProjects = projects.Where(p => !p.IsFavorite).ToList(); + IEnumerable ordered = _settingsManager.GroupFavoritesFirst + ? projects.Where(p => p.IsFavorite).Concat(projects.Where(p => !p.IsFavorite)) + : projects; - items.AddRange(favoriteProjects.Select(CreateProjectListItem)); - items.AddRange(nonFavoriteProjects.Select(CreateProjectListItem)); - } + items.AddRange(ordered.Select(CreateProjectListItem)); } } catch (Exception ex) @@ -94,13 +96,18 @@ private static ListItem CreateProjectListItem(UnityProject project) tags.Add(new Tag("⭐ Favorite") { Foreground = ColorHelpers.FromRgb(222, 186, 56) }); } + if (!project.Exists) + { + tags.Add(new Tag("Missing") { Foreground = ColorHelpers.FromRgb(224, 108, 117) }); + } + tags.Add(new Tag(project.Version)); return new ListItem(defaultCommand) { Title = project.Title, - Subtitle = project.Path, - Icon = Resources.IconUnity, + Subtitle = project.Exists ? project.Path : $"{project.Path} (folder not found)", + Icon = project.Exists ? Resources.IconUnity : Resources.IconMissing, Tags = tags.ToArray(), MoreCommands = contextCommands.ToArray(), };