Forge
csharpdeeb25a2
1#nullable enable
2using System.Diagnostics;
3using System.Diagnostics.CodeAnalysis;
4using CascadeIDE.Features.Os.DataAcquisition;
5using CascadeIDE.Contracts;
6
7namespace CascadeIDE.Features.Launch.DataAcquisition;
8
9/// <summary>Открытие URL после старта Kestrel: <c>launchUrl</c> (как в VS) или первый <c>http(s)</c> из <c>ASPNETCORE_URLS</c>.</summary>
10[IoBoundary]
11public static class KestrelLaunchBrowser
12{
13 /// <summary>Устаревшее имя: то же, что <see cref="TryOpenAfterLaunch"/> без <paramref name="launchUrl"/>.</summary>
14 public static void TryOpenFirstUrl(IReadOnlyDictionary<string, string>? environment) =>
15 TryOpenAfterLaunch(environment, launchUrl: null);
16
17 /// <param name="launchUrl">Из профиля: полный <c>http(s)://…</c> или путь вроде <c>/graphql</c> / <c>graphql</c> (к базе из <c>ASPNETCORE_URLS</c>).</param>
18 public static void TryOpenAfterLaunch(IReadOnlyDictionary<string, string>? environment, string? launchUrl)
19 {
20 var u = ResolveUrlToOpen(environment, launchUrl);
21 if (string.IsNullOrEmpty(u))
22 return;
23 TryStartBrowser(u, shell: null);
24 }
25
26 public static void TryOpenAfterLaunch(IReadOnlyDictionary<string, string>? environment, string? launchUrl, IOsShellLauncher? shell)
27 {
28 var u = ResolveUrlToOpen(environment, launchUrl);
29 if (string.IsNullOrEmpty(u))
30 return;
31 TryStartBrowser(u, shell);
32 }
33
34 /// <summary>Разрешить URL для открытия (тесты и прозрачность).</summary>
35 public static string? ResolveUrlToOpen(IReadOnlyDictionary<string, string>? environment, string? launchUrl)
36 {
37 if (string.IsNullOrWhiteSpace(launchUrl))
38 return GetFirstHttpOrHttpsFromApplicationUrls(environment);
39 return ResolveNonEmptyLaunchUrl(environment, launchUrl.Trim());
40 }
41
42 /// <summary>Не пустой <paramref name="trimmedLaunchUrl"/>: абсолютный http(s) либо относительный путь к базе из <c>ASPNETCORE_URLS</c>.</summary>
43 private static string? ResolveNonEmptyLaunchUrl(
44 IReadOnlyDictionary<string, string>? environment,
45 string trimmedLaunchUrl)
46 {
47 if (TryNormalizeAbsoluteHttpUrl(trimmedLaunchUrl, out var absolute))
48 return absolute;
49 var baseFromEnv = GetFirstHttpOrHttpsFromApplicationUrls(environment);
50 if (string.IsNullOrEmpty(baseFromEnv))
51 return null;
52 return CombineBaseWithPath(baseFromEnv, trimmedLaunchUrl);
53 }
54
55 private static void TryStartBrowser(string url, IOsShellLauncher? shell)
56 {
57 (shell ?? OsShell.Default).TryOpenUrl(url, e => Trace.WriteLine("[KestrelLaunchBrowser] OpenUrl: " + e));
58 }
59
60 private static bool TryNormalizeAbsoluteHttpUrl(string value, [NotNullWhen(true)] out string? normalized)
61 {
62 normalized = null;
63 if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
64 return false;
65 if (!IsHttpOrHttpsScheme(uri.Scheme))
66 return false;
67 normalized = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
68 return true;
69 }
70
71 private static bool IsHttpOrHttpsScheme(string scheme) =>
72 string.Equals(scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
73 string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
74
75 private static string? CombineBaseWithPath(string baseUrl, string pathOrSegment)
76 {
77 try
78 {
79 if (string.IsNullOrEmpty(pathOrSegment))
80 return baseUrl;
81
82 var builder = new UriBuilder(baseUrl);
83
84 if (pathOrSegment.StartsWith('/'))
85 ApplyRootRelativePath(builder, pathOrSegment);
86 else
87 AppendPathSegment(builder, pathOrSegment);
88
89 return builder.Uri.ToString();
90 }
91 catch (Exception ex)
92 {
93 Trace.WriteLine("[KestrelLaunchBrowser] CombineBaseWithPath: " + ex);
94 return null;
95 }
96 }
97
98 /// <summary>Путь от корня сайта, например <c>/graphql</c> или <c>/g?q=1</c>.</summary>
99 private static void ApplyRootRelativePath(UriBuilder builder, string pathOrSegment)
100 {
101 var q = pathOrSegment.IndexOf('?', StringComparison.Ordinal);
102 if (q >= 0)
103 {
104 builder.Path = pathOrSegment[..q];
105 builder.Query = pathOrSegment[(q + 1)..];
106 }
107 else
108 builder.Path = pathOrSegment;
109 }
110
111 /// <summary>Сегмент без ведущего <c>/</c> — дописывается к текущему <see cref="UriBuilder.Path"/>.</summary>
112 private static void AppendPathSegment(UriBuilder builder, string segment)
113 {
114 var head = builder.Path;
115 var tail = segment.TrimStart('/');
116 if (string.IsNullOrEmpty(head) || head == "/")
117 builder.Path = "/" + tail;
118 else
119 builder.Path = head.TrimEnd('/') + "/" + tail;
120 }
121
122 private static string? GetFirstHttpOrHttpsFromApplicationUrls(IReadOnlyDictionary<string, string>? environment)
123 {
124 if (!TryGetAspNetCoreUrlsRaw(environment, out var raw) || string.IsNullOrWhiteSpace(raw))
125 return null;
126 return GetFirstHttpListenUrl(raw);
127 }
128
129 /// <summary>Как <see cref="IReadOnlyDictionary{TKey, TValue}.TryGetValue"/>, но false для null/пустой/пробельной строки.</summary>
130 private static bool TryGetNonWhiteSpace(
131 IReadOnlyDictionary<string, string> environment,
132 string key,
133 [NotNullWhen(true)] out string? value)
134 {
135 if (environment.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v))
136 {
137 value = v;
138 return true;
139 }
140
141 value = null;
142 return false;
143 }
144
145 private static bool TryGetAspNetCoreUrlsRaw(
146 IReadOnlyDictionary<string, string>? environment,
147 [NotNullWhen(true)] out string? raw)
148 {
149 raw = null;
150 if (environment is null || environment.Count == 0)
151 return false;
152 if (TryGetNonWhiteSpace(environment, "ASPNETCORE_URLS", out raw))
153 return true;
154 if (TryGetNonWhiteSpace(environment, "ASPNETCORE__URLS", out raw))
155 return true;
156 return false;
157 }
158
159 private static string? GetFirstHttpListenUrl(string aspNetCoreUrls) =>
160 aspNetCoreUrls
161 .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
162 .FirstOrDefault(u => u.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
163 u.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
164}
165
View only · write via MCP/CIDE