-
Notifications
You must be signed in to change notification settings - Fork 1k
Expand file tree
/
Copy pathAssetPathUtility.cs
More file actions
672 lines (592 loc) · 27.7 KB
/
AssetPathUtility.cs
File metadata and controls
672 lines (592 loc) · 27.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity asset paths.
/// </summary>
public static class AssetPathUtility
{
/// <summary>
/// Normalizes path separators to forward slashes without modifying the path structure.
/// Use this for non-asset paths (e.g., file system paths, relative directories).
/// </summary>
public static string NormalizeSeparators(string path)
{
if (string.IsNullOrEmpty(path))
return path;
return path.Replace('\\', '/');
}
/// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// Also protects against path traversal attacks using "../" sequences.
/// </summary>
public static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
path = NormalizeSeparators(path);
// Check for path traversal sequences
if (path.Contains(".."))
{
McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'");
return null;
}
// Ensure path starts with Assets/
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if a given asset path is valid and safe (no traversal, within Assets folder).
/// </summary>
/// <returns>True if the path is valid, false otherwise.</returns>
public static bool IsValidAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
// Normalize for comparison
string normalized = NormalizeSeparators(path);
// Must start with Assets/
if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Must not contain traversal sequences
if (normalized.Contains(".."))
{
return false;
}
// Must not contain invalid path characters
char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' };
foreach (char c in invalidChars)
{
if (normalized.IndexOf(c) >= 0)
{
return false;
}
}
return true;
}
/// <summary>
/// Gets the MCP for Unity package root path.
/// Works for registry Package Manager, local Package Manager, and Asset Store installations.
/// </summary>
/// <returns>The package root path (virtual for PM, absolute for Asset Store), or null if not found</returns>
public static string GetMcpPackageRootPath()
{
try
{
// WSL workaround: when the package lives on a WSL UNC path and Unity
// runs on Windows, UXML/USS files cannot be parsed from the UNC path.
// UIAssetSync copies them to Assets/MCPForUnityUI/ on domain reload.
if (UIAssetSync.NeedsSync())
{
return UIAssetSync.SyncedBasePath;
}
// Try Package Manager first (registry and local installs)
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath))
{
return packageInfo.assetPath;
}
// Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity)
string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}");
if (guids.Length == 0)
{
McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase");
return null;
}
string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]);
// Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs
// Extract {packageRoot}
int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal);
if (editorIndex >= 0)
{
return scriptPath.Substring(0, editorIndex);
}
McpLog.Warn($"Could not determine package root from script path: {scriptPath}");
return null;
}
catch (Exception ex)
{
McpLog.Error($"Failed to get package root path: {ex.Message}");
return null;
}
}
/// <summary>
/// Reads and parses the package.json file for MCP for Unity.
/// Handles both Package Manager (registry/local) and Asset Store installations.
/// </summary>
/// <returns>JObject containing package.json data, or null if not found or parse failed</returns>
public static JObject GetPackageJson()
{
try
{
string packageRoot = GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return null;
}
string packageJsonPath = Path.Combine(packageRoot, "package.json");
// Convert virtual asset path to file system path
if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
// Package Manager install - must use PackageInfo.resolvedPath
// Virtual paths like "Packages/..." don't work with File.Exists()
// Registry packages live in Library/PackageCache/package@version/
var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly);
if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath))
{
packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json");
}
else
{
McpLog.Warn("Could not resolve Package Manager path for package.json");
return null;
}
}
else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
// Asset Store install - convert to absolute file system path
// Application.dataPath is the absolute path to the Assets folder
string relativePath = packageRoot.Substring("Assets/".Length);
packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json");
}
if (!File.Exists(packageJsonPath))
{
McpLog.Warn($"package.json not found at: {packageJsonPath}");
return null;
}
string json = File.ReadAllText(packageJsonPath);
return JObject.Parse(json);
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read or parse package.json: {ex.Message}");
return null;
}
}
/// <summary>
/// Gets the package source for the MCP server (used with uvx --from).
/// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.),
/// then falls back to PyPI package reference.
/// When the override is a local path, auto-corrects to the "Server" subdirectory
/// if the path doesn't contain pyproject.toml but Server/pyproject.toml exists.
/// </summary>
/// <returns>Package source string for uvx --from argument</returns>
public static string GetMcpServerPackageSource()
{
// Check for override first (supports git URLs, file:// paths, local paths)
string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(sourceOverride))
{
string resolved = ResolveLocalServerPath(sourceOverride);
// Persist the corrected path so future reads are consistent
if (resolved != sourceOverride)
{
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, resolved);
McpLog.Info($"Auto-corrected server source override from '{sourceOverride}' to '{resolved}'");
}
return resolved;
}
// Default to PyPI package (avoids Windows long path issues with git clone)
string version = GetPackageVersion();
if (version == "unknown")
{
// Fall back to latest PyPI version so configs remain valid in test scenarios
return "mcpforunityserver";
}
// Package.json uses semver prerelease tags (e.g., 9.4.5-beta.1) that are not valid
// PEP 440 pins for uvx. Use the beta prerelease range instead of a pinned prerelease.
if (IsSemVerPreRelease(version))
{
return "mcpforunityserver>=0.0.0a0";
}
return $"mcpforunityserver=={version}";
}
/// <summary>
/// Validates and auto-corrects a local server source path to ensure it points to the
/// directory containing pyproject.toml. If the path points to a parent directory
/// (e.g. the repo root "unity-mcp") instead of the Python package directory ("Server"),
/// this checks for a "Server" subdirectory with pyproject.toml and returns that path.
/// Non-local paths (URLs, PyPI references) are returned unchanged.
/// </summary>
internal static string ResolveLocalServerPath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
// Skip non-local paths (git URLs, PyPI package names, etc.)
if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase))
{
return path;
}
// If it looks like a PyPI package reference (no path separators), skip
if (!path.Contains('/') && !path.Contains('\\') && !path.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
return path;
}
// Strip file:// prefix for filesystem checks, preserve for return value
string checkPath = path;
string prefix = string.Empty;
if (checkPath.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
prefix = checkPath.Substring(0, 7); // preserve original casing
checkPath = checkPath.Substring(7);
}
// Already correct — pyproject.toml exists at this path
if (System.IO.File.Exists(System.IO.Path.Combine(checkPath, "pyproject.toml")))
{
return path;
}
// Check if "Server" subdirectory contains pyproject.toml
string serverSubDir = System.IO.Path.Combine(checkPath, "Server");
if (System.IO.File.Exists(System.IO.Path.Combine(serverSubDir, "pyproject.toml")))
{
return prefix + serverSubDir;
}
// Return as-is; uvx will report the error if the path is truly invalid
return path;
}
/// <summary>
/// Deprecated: Use GetMcpServerPackageSource() instead.
/// Kept for backwards compatibility.
/// </summary>
[System.Obsolete("Use GetMcpServerPackageSource() instead")]
public static string GetMcpServerGitUrl() => GetMcpServerPackageSource();
/// <summary>
/// Gets structured uvx command parts for different client configurations
/// </summary>
/// <returns>Tuple containing (uvxPath, fromUrl, packageName)</returns>
public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts()
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
string fromUrl = GetMcpServerPackageSource();
string packageName = "mcp-for-unity";
return (uvxPath, fromUrl, packageName);
}
/// <summary>
/// Builds the uvx package source arguments for the MCP server.
/// Handles prerelease package mode (prerelease from PyPI) vs stable mode (pinned version or override).
/// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports.
/// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <param name="quoteFromPath">Whether to quote the --from path (needed for command-line strings, not for arg lists)</param>
/// <returns>The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0")</returns>
public static string GetBetaServerFromArgs(bool quoteFromPath = false)
{
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgs(gitUrlOverride, packageSource, quoteFromPath);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
/// <param name="quoteFromPath">Whether to quote the --from path</param>
public static string GetBetaServerFromArgs(string gitUrlOverride, string packageSource, bool quoteFromPath = false)
{
// Explicit override (local path, git URL, etc.) always wins
if (!string.IsNullOrEmpty(gitUrlOverride))
{
string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride;
return $"--from {fromValue}";
}
bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase);
// Prerelease package mode: use prerelease from PyPI.
if (usePrereleaseRange)
{
// Use --prerelease explicit with version specifier to only get prereleases of our package,
// not of dependencies (which can be broken on PyPI).
string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0";
return $"--prerelease explicit --from {fromValue}";
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(packageSource))
{
string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource;
return $"--from {fromValue}";
}
return string.Empty;
}
/// <summary>
/// Builds the uvx package source arguments as a list (for JSON config builders).
/// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <returns>List of arguments to add to uvx command</returns>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList()
{
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgsList(gitUrlOverride, packageSource);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList(string gitUrlOverride, string packageSource)
{
var args = new System.Collections.Generic.List<string>();
// Explicit override (local path, git URL, etc.) always wins
if (!string.IsNullOrEmpty(gitUrlOverride))
{
args.Add("--from");
args.Add(gitUrlOverride);
return args;
}
bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase);
// Prerelease package mode: use prerelease from PyPI.
if (usePrereleaseRange)
{
args.Add("--prerelease");
args.Add("explicit");
args.Add("--from");
args.Add("mcpforunityserver>=0.0.0a0");
return args;
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(packageSource))
{
args.Add("--from");
args.Add(packageSource);
}
return args;
}
/// <summary>
/// Determines whether uvx should use --no-cache --refresh flags.
/// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path.
/// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache.
/// Note: --reinstall is not supported by uvx and will cause a warning.
/// </summary>
public static bool ShouldForceUvxRefresh()
{
bool devForceRefresh = false;
try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }
if (devForceRefresh)
return true;
// Auto-enable force refresh when using a local path override.
return IsLocalServerPath();
}
private static bool _offlineCacheResult;
private static double _offlineCacheTimestamp = -999;
private const double OfflineCacheTtlSeconds = 30.0;
/// <summary>
/// Determines whether uvx should use --offline mode for faster startup.
/// Runs a lightweight probe (uvx --offline ... mcp-for-unity --help) with a 3-second timeout
/// to check if the package is already cached. If cached, --offline skips the network
/// dependency check that can hang for 30+ seconds on poor connections.
/// Returns false if force refresh is enabled (new download needed).
/// The result is cached for 30 seconds to avoid redundant subprocess spawns.
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static bool ShouldUseUvxOffline()
{
if (ShouldForceUvxRefresh())
return false;
return GetCachedOfflineProbeResult();
}
private static bool GetCachedOfflineProbeResult()
{
double now = EditorApplication.timeSinceStartup;
if (now - _offlineCacheTimestamp < OfflineCacheTtlSeconds)
return _offlineCacheResult;
bool result = RunOfflineProbe();
_offlineCacheResult = result;
_offlineCacheTimestamp = now;
return result;
}
private static bool RunOfflineProbe()
{
try
{
string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
if (string.IsNullOrEmpty(uvxPath))
return false;
string fromArgs = GetBetaServerFromArgs(quoteFromPath: false);
string probeArgs = string.IsNullOrEmpty(fromArgs)
? "--offline mcp-for-unity --help"
: $"--offline {fromArgs} mcp-for-unity --help";
return ExecPath.TryRun(uvxPath, probeArgs, null, out _, out _, timeoutMs: 3000);
}
catch
{
return false;
}
}
/// <summary>
/// Returns the uvx dev-mode flags as a single string for command-line builders.
/// Returns "--no-cache --refresh " if force refresh is enabled,
/// "--offline " if the cache is warm, or string.Empty otherwise.
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static string GetUvxDevFlags()
{
bool forceRefresh = ShouldForceUvxRefresh();
return GetUvxDevFlags(forceRefresh, !forceRefresh && GetCachedOfflineProbeResult());
}
/// <summary>
/// Returns the uvx dev-mode flags from pre-captured bool values.
/// Use this overload when values were captured on the main thread for background use.
/// </summary>
public static string GetUvxDevFlags(bool forceRefresh, bool useOffline)
{
if (forceRefresh) return "--no-cache --refresh ";
if (useOffline) return "--offline ";
return string.Empty;
}
/// <summary>
/// Returns the uvx dev-mode flags as a list of individual arguments.
/// Suitable for callers that build argument lists (ConfigJsonBuilder, CodexConfigHelper).
/// Must be called on the main thread (reads EditorPrefs).
/// </summary>
public static IReadOnlyList<string> GetUvxDevFlagsList()
{
bool forceRefresh = ShouldForceUvxRefresh();
if (forceRefresh) return new[] { "--no-cache", "--refresh" };
if (GetCachedOfflineProbeResult()) return new[] { "--offline" };
return Array.Empty<string>();
}
/// <summary>
/// Returns true if the server URL is a local path (file:// or absolute path).
/// </summary>
public static bool IsLocalServerPath()
{
string fromUrl = GetMcpServerPackageSource();
if (string.IsNullOrEmpty(fromUrl))
return false;
// Check for file:// protocol or absolute local path
if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
return true;
try
{
return System.IO.Path.IsPathRooted(fromUrl);
}
catch (System.ArgumentException)
{
// fromUrl contains characters illegal in paths (e.g. a remote URL)
return false;
}
}
/// <summary>
/// Gets the local server path if GitUrlOverride points to a local directory.
/// Returns null if not using a local path.
/// </summary>
public static string GetLocalServerPath()
{
if (!IsLocalServerPath())
return null;
string fromUrl = GetMcpServerPackageSource();
if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
// Strip file:// prefix
fromUrl = fromUrl.Substring(7);
}
return fromUrl;
}
/// <summary>
/// Cleans stale Python build artifacts from the local server path.
/// This is necessary because Python's build system doesn't remove deleted files from build/,
/// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools.
/// </summary>
/// <returns>True if cleaning was performed, false if not applicable or failed.</returns>
public static bool CleanLocalServerBuildArtifacts()
{
string localPath = GetLocalServerPath();
if (string.IsNullOrEmpty(localPath))
return false;
// Clean the build/ directory which can contain stale .py files
string buildPath = System.IO.Path.Combine(localPath, "build");
if (System.IO.Directory.Exists(buildPath))
{
try
{
System.IO.Directory.Delete(buildPath, recursive: true);
McpLog.Info($"Cleaned stale build artifacts from: {buildPath}");
return true;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to clean build artifacts: {ex.Message}");
return false;
}
}
return false;
}
/// <summary>
/// Gets the package version from package.json
/// </summary>
/// <returns>Version string, or "unknown" if not found</returns>
public static string GetPackageVersion()
{
try
{
var packageJson = GetPackageJson();
if (packageJson == null)
{
return "unknown";
}
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? "unknown" : version;
}
catch (Exception ex)
{
McpLog.Warn($"Failed to get package version: {ex.Message}");
return "unknown";
}
}
/// <summary>
/// Returns true if the installed package version is a prerelease (beta, alpha, rc, etc.).
/// Used to auto-enable beta server mode for beta package users.
/// </summary>
public static bool IsPreReleaseVersion()
{
try
{
string version = GetPackageVersion();
if (string.IsNullOrEmpty(version) || version == "unknown")
return false;
return IsSemVerPreRelease(version);
}
catch
{
return false;
}
}
private static bool IsSemVerPreRelease(string version)
{
if (string.IsNullOrEmpty(version))
return false;
// Common semver prerelease indicators:
// e.g., "9.3.0-beta.1", "9.3.0-alpha", "9.3.0-rc.2", "9.3.0-preview"
return version.Contains("-beta", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-alpha", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-rc", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-preview", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-pre", StringComparison.OrdinalIgnoreCase);
}
}
}