RequestHelper.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. #if ENABLE_MONO && (DEVELOPMENT_BUILD || UNITY_EDITOR)
  2. using System;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Net;
  6. using System.Net.Http;
  7. using System.Net.Http.Headers;
  8. using System.Runtime.CompilerServices;
  9. using System.Text;
  10. using System.Threading;
  11. using System.Threading.Tasks;
  12. using JetBrains.Annotations;
  13. using SingularityGroup.HotReload.DTO;
  14. using SingularityGroup.HotReload.Newtonsoft.Json;
  15. using UnityEngine;
  16. using UnityEngine.Networking;
  17. [assembly: InternalsVisibleTo("CodePatcherEditor")]
  18. [assembly: InternalsVisibleTo("TestProject")]
  19. [assembly: InternalsVisibleTo("SingularityGroup.HotReload.IntegrationTests")]
  20. [assembly: InternalsVisibleTo("SingularityGroup.HotReload.EditorTests")]
  21. namespace SingularityGroup.HotReload {
  22. class HttpResponse {
  23. public readonly HttpStatusCode statusCode;
  24. public readonly Exception exception;
  25. public readonly string responseText;
  26. public HttpResponse(HttpStatusCode statusCode, Exception exception, string responseText) {
  27. this.statusCode = statusCode;
  28. this.exception = exception;
  29. this.responseText = responseText;
  30. }
  31. }
  32. public class ChangelogVersion {
  33. public string versionNum;
  34. public List<string> fixes;
  35. public List<string> improvements;
  36. public string date;
  37. public List<string> features;
  38. public string generalInfo;
  39. }
  40. static class RequestHelper {
  41. internal const ushort defaultPort = 33242;
  42. internal const string defaultServerHost = "127.0.0.1";
  43. const string ChangelogURL = "https://d2tc55zjhw51ly.cloudfront.net/releases/latest/changelog.json";
  44. static readonly string defaultOrigin = Path.GetDirectoryName(UnityHelper.DataPath);
  45. public static string origin { get; private set; } = defaultOrigin;
  46. static PatchServerInfo serverInfo = new PatchServerInfo(defaultServerHost, null, null);
  47. public static PatchServerInfo ServerInfo => serverInfo;
  48. static string cachedUrl;
  49. static string url => cachedUrl ?? (cachedUrl = CreateUrl(serverInfo));
  50. public static int port => serverInfo?.port ?? defaultPort;
  51. static readonly HttpClient client = CreateHttpClientWithOrigin();
  52. // separate client for each long polling request
  53. static readonly HttpClient clientPollPatches = CreateHttpClientWithOrigin();
  54. static readonly HttpClient clientPollAssets = CreateHttpClientWithOrigin();
  55. static readonly HttpClient clientPollStatus = CreateHttpClientWithOrigin();
  56. static readonly HttpClient[] allClients = new[] { client, clientPollPatches, clientPollAssets, clientPollStatus };
  57. static HttpClient CreateHttpClientWithOrigin() {
  58. var httpClient = HttpClientUtils.CreateHttpClient();
  59. httpClient.DefaultRequestHeaders.Add("origin", Path.GetDirectoryName(UnityHelper.DataPath));
  60. return httpClient;
  61. }
  62. /// <summary>
  63. /// Create url for a hostname and port
  64. /// </summary>
  65. internal static string CreateUrl(PatchServerInfo server) {
  66. return $"http://{server.hostName}:{server.port.ToString()}";
  67. }
  68. public static void SetServerPort(int port) {
  69. serverInfo = new PatchServerInfo(serverInfo.hostName, port, serverInfo.commitHash, serverInfo.rootPath);
  70. cachedUrl = null;
  71. Log.Debug($"SetServerInfo to {CreateUrl(serverInfo)}");
  72. }
  73. public static void SetServerInfo(PatchServerInfo info) {
  74. if (info != null) Log.Debug($"SetServerInfo to {CreateUrl(info)}");
  75. serverInfo = info;
  76. cachedUrl = null;
  77. if (info?.customRequestOrigin != null) {
  78. SetOrigin(info.customRequestOrigin);
  79. }
  80. }
  81. // This function is not thread safe but is currently called before the first request is sent so no issue.
  82. static void SetOrigin(string newOrigin) {
  83. if (newOrigin == origin) {
  84. return;
  85. }
  86. origin = newOrigin;
  87. foreach (var httpClient in allClients) {
  88. httpClient.DefaultRequestHeaders.Remove("origin");
  89. httpClient.DefaultRequestHeaders.Add("origin", newOrigin);
  90. }
  91. }
  92. static string[] assemblySearchPaths;
  93. public static void ChangeAssemblySearchPaths(string[] paths) {
  94. assemblySearchPaths = paths;
  95. }
  96. // Don't use for requests to HR server
  97. [UsedImplicitly]
  98. internal static async Task<string> GetAsync(string path) {
  99. using (UnityWebRequest www = UnityWebRequest.Get(path)) {
  100. await SendRequestAsync(www);
  101. if (string.IsNullOrEmpty(www.error)) {
  102. return www.downloadHandler.text;
  103. } else {
  104. return null;
  105. }
  106. }
  107. }
  108. internal static Task<UnityWebRequestAsyncOperation> SendRequestAsync(UnityWebRequest www) {
  109. var req = www.SendWebRequest();
  110. var tcs = new TaskCompletionSource<UnityWebRequestAsyncOperation>();
  111. req.completed += op => tcs.TrySetResult((UnityWebRequestAsyncOperation)op);
  112. return tcs.Task;
  113. }
  114. static bool pollPending;
  115. internal static async void PollMethodPatches(string lastPatchId, Action<MethodPatchResponse> onResponseReceived) {
  116. if (pollPending) {
  117. return;
  118. }
  119. pollPending = true;
  120. var searchPaths = assemblySearchPaths ?? CodePatcher.I.GetAssemblySearchPaths();
  121. var body = SerializeRequestBody(new MethodPatchRequest(lastPatchId, searchPaths, TimeSpan.FromSeconds(20), Path.GetDirectoryName(Application.dataPath)));
  122. await ThreadUtility.SwitchToThreadPool();
  123. try {
  124. var result = await PostJson(url + "/patch", body, 30, overrideClient: clientPollPatches).ConfigureAwait(false);
  125. if(result.statusCode == HttpStatusCode.OK) {
  126. var responses = JsonConvert.DeserializeObject<MethodPatchResponse[]>(result.responseText);
  127. await ThreadUtility.SwitchToMainThread();
  128. foreach(var response in responses) {
  129. onResponseReceived(response);
  130. }
  131. } else if(result.statusCode == HttpStatusCode.Unauthorized || result.statusCode == 0) {
  132. // Server is not running or not authorized.
  133. // We don't want to spam requests in that case.
  134. await Task.Delay(5000);
  135. } else if(result.statusCode == HttpStatusCode.ServiceUnavailable) {
  136. //Server shut down
  137. await Task.Delay(5000);
  138. } else {
  139. Log.Info("PollMethodPatches failed with code {0} {1} {2}", (int)result.statusCode, result.responseText, result.exception);
  140. }
  141. } finally {
  142. pollPending = false;
  143. }
  144. }
  145. static bool pollPatchStatusPending;
  146. internal static async void PollPatchStatus(Action<PatchStatusResponse> onResponseReceived, PatchStatus latestStatus) {
  147. if (pollPatchStatusPending) return;
  148. pollPatchStatusPending = true;
  149. var body = SerializeRequestBody(new PatchStatusRequest(TimeSpan.FromSeconds(20), latestStatus));
  150. try {
  151. var result = await PostJson(url + "/patchStatus", body, 30, overrideClient: clientPollStatus).ConfigureAwait(false);
  152. if(result.statusCode == HttpStatusCode.OK) {
  153. var response = JsonConvert.DeserializeObject<PatchStatusResponse>(result.responseText);
  154. await ThreadUtility.SwitchToMainThread();
  155. onResponseReceived(response);
  156. } else if(result.statusCode == HttpStatusCode.Unauthorized || result.statusCode == 0) {
  157. // Server is not running or not authorized.
  158. // We don't want to spam requests in that case.
  159. await Task.Delay(5000);
  160. } else if(result.statusCode == HttpStatusCode.ServiceUnavailable) {
  161. //Server shut down
  162. await Task.Delay(5000);
  163. } else {
  164. Log.Info("PollPatchStatus failed with code {0} {1} {2}", (int)result.statusCode, result.responseText, result.exception);
  165. }
  166. } finally {
  167. pollPatchStatusPending = false;
  168. }
  169. }
  170. static bool assetPollPending;
  171. internal static async void PollAssetChanges(Action<string> onResponseReceived) {
  172. if (assetPollPending) return;
  173. assetPollPending = true;
  174. try {
  175. await ThreadUtility.SwitchToThreadPool();
  176. var body = SerializeRequestBody(new AssetChangesRequest(TimeSpan.FromSeconds(20)));
  177. var result = await PostJson(url + "/assetChanges", body, 30, overrideClient: clientPollAssets).ConfigureAwait(false);
  178. if (result.statusCode == HttpStatusCode.OK) {
  179. var responses = JsonConvert.DeserializeObject<List<string>>(result.responseText);
  180. await ThreadUtility.SwitchToMainThread();
  181. // Looping in reverse order fixes moving files:
  182. // by default new files come in before old ones which causes issues because meta files for old location has to be deleted first
  183. for (var i = responses.Count - 1; i >= 0; i--) {
  184. var response = responses[i];
  185. // Avoid importing assets twice
  186. if (responses.Contains(response + ".meta")) {
  187. Log.Debug($"Ignoring asset change inside Unity: {response}");
  188. continue;
  189. }
  190. onResponseReceived(response);
  191. }
  192. } else if(result.statusCode == HttpStatusCode.Unauthorized || result.statusCode == 0) {
  193. // Server is not running or not authorized.
  194. // We don't want to spam requests in that case.
  195. await Task.Delay(5000);
  196. } else if(result.statusCode == HttpStatusCode.ServiceUnavailable) {
  197. //Server shut down
  198. await Task.Delay(5000);
  199. } else {
  200. Log.Info("PollAssetChanges failed with code {0} {1} {2}", (int)result.statusCode, result.responseText, result.exception);
  201. }
  202. } finally {
  203. assetPollPending = false;
  204. }
  205. }
  206. public static async Task<FlushErrorsResponse> RequestFlushErrors(int timeoutSeconds = 30) {
  207. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  208. var resp = await PostJson(CreateUrl(serverInfo) + "/flush", "", timeoutSeconds, cts.Token);
  209. if (resp.statusCode == HttpStatusCode.OK) {
  210. try {
  211. return JsonConvert.DeserializeObject<FlushErrorsResponse>(resp.responseText);
  212. } catch {
  213. return null;
  214. }
  215. }
  216. return null;
  217. }
  218. public static async Task<LoginStatusResponse> RequestLogin(string email, string password, int timeoutSeconds) {
  219. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  220. var json = SerializeRequestBody(new Dictionary<string, object> {
  221. { "email", email },
  222. { "password", password },
  223. });
  224. var resp = await PostJson(url + "/login", json, timeoutSeconds, cts.Token);
  225. if (resp.exception == null) {
  226. return JsonConvert.DeserializeObject<LoginStatusResponse>(resp.responseText);
  227. } else {
  228. return LoginStatusResponse.FromRequestError($"{resp.exception.GetType().Name} {resp.exception.Message}");
  229. }
  230. }
  231. public static async Task<LoginStatusResponse> GetLoginStatus(int timeoutSeconds) {
  232. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  233. var resp = await PostJson(url + "/status", string.Empty, timeoutSeconds, cts.Token);
  234. if (resp.exception == null) {
  235. return JsonConvert.DeserializeObject<LoginStatusResponse>(resp.responseText);
  236. } else {
  237. return LoginStatusResponse.FromRequestError($"{resp.exception.GetType().Name} {resp.exception.Message}");
  238. }
  239. }
  240. internal static async Task<LoginStatusResponse> RequestLogout(int timeoutSeconds = 10) {
  241. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  242. var resp = await PostJson(CreateUrl(serverInfo) + "/logout", "", timeoutSeconds, cts.Token);
  243. if (resp.statusCode == HttpStatusCode.OK) {
  244. try {
  245. return JsonConvert.DeserializeObject<LoginStatusResponse>(resp.responseText);
  246. } catch (Exception ex) {
  247. return LoginStatusResponse.FromRequestError($"Deserializing response failed with {ex.GetType().Name}: {ex.Message}");
  248. }
  249. } else {
  250. return LoginStatusResponse.FromRequestError(resp.responseText ?? "Request timeout");
  251. }
  252. }
  253. internal static async Task<ActivatePromoCodeResponse> RequestActivatePromoCode(string promoCode, int timeoutSeconds = 20) {
  254. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  255. await ThreadUtility.SwitchToThreadPool();
  256. try {
  257. using (var resp = await client.PostAsync(CreateUrl(serverInfo) + "/activatePromoCode", new StringContent(promoCode), cts.Token).ConfigureAwait(false)) {
  258. var str = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  259. try {
  260. return JsonConvert.DeserializeObject<ActivatePromoCodeResponse>(str);
  261. } catch {
  262. return null;
  263. }
  264. }
  265. } catch {
  266. return null;
  267. }
  268. }
  269. internal static async Task RequestEditorEventWithRetry(Stat stat, EditorExtraData extraData = null) {
  270. int attempt = 0;
  271. do {
  272. var resp = await RequestHelper.RequestEditorEvent(stat, extraData);
  273. if (resp.statusCode == HttpStatusCode.OK) {
  274. return;
  275. }
  276. await Task.Delay(TimeSpan.FromMilliseconds(200));
  277. } while (attempt++ < 10000);
  278. }
  279. internal static Task<HttpResponse> RequestEditorEvent(Stat stat, EditorExtraData extraData = null) {
  280. var body = SerializeRequestBody(new EditorEventRequest(stat, extraData));
  281. return PostJson(url + "/editorEvent", body, int.MaxValue);
  282. }
  283. public static async Task KillServer() {
  284. await ThreadUtility.SwitchToThreadPool();
  285. await KillServerInternal().ConfigureAwait(false);
  286. }
  287. internal static async Task KillServerInternal() {
  288. try {
  289. using(await client.PostAsync(CreateUrl(serverInfo) + "/kill", new StringContent(origin)).ConfigureAwait(false)) { }
  290. } catch {
  291. //ignored
  292. }
  293. }
  294. public static async Task<bool> PingServer(Uri uri) {
  295. await ThreadUtility.SwitchToThreadPool();
  296. try {
  297. using (var resp = await client.GetAsync(uri).ConfigureAwait(false)) {
  298. return resp.StatusCode == HttpStatusCode.OK;
  299. }
  300. } catch {
  301. return false;
  302. }
  303. }
  304. public static bool IsReleaseMode() {
  305. # if (UNITY_EDITOR && UNITY_2022_1_OR_NEWER)
  306. return UnityEditor.Compilation.CompilationPipeline.codeOptimization == UnityEditor.Compilation.CodeOptimization.Release;
  307. # elif (UNITY_EDITOR)
  308. return false;
  309. # elif (DEBUG)
  310. return false;
  311. # else
  312. return true;
  313. #endif
  314. }
  315. public static Task RequestClearPatches() {
  316. var body = SerializeRequestBody(new CompileRequest(serverInfo.rootPath, IsReleaseMode()));
  317. return PostJson(url + "/clearpatches", body, 10);
  318. }
  319. public static async Task RequestCompile(Action<string> onResponseReceived) {
  320. var body = SerializeRequestBody(new CompileRequest(serverInfo.rootPath, IsReleaseMode()));
  321. var result = await PostJson(url + "/compile", body, 10);
  322. if (result.statusCode == HttpStatusCode.OK && !string.IsNullOrEmpty(result.responseText)) {
  323. var responses = JsonConvert.DeserializeObject<List<string>>(result.responseText);
  324. if (responses == null) {
  325. return;
  326. }
  327. await ThreadUtility.SwitchToMainThread();
  328. foreach (var response in responses) {
  329. // Avoid importing assets twice
  330. if (responses.Contains(response + ".meta")) {
  331. Log.Debug($"Ignoring asset change inside Unity: {response}");
  332. continue;
  333. }
  334. onResponseReceived(response);
  335. }
  336. }
  337. }
  338. internal static async Task<List<ChangelogVersion>> FetchChangelog(int timeoutSeconds = 20) {
  339. var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
  340. await ThreadUtility.SwitchToThreadPool();
  341. try {
  342. using(var resp = await client.GetAsync(ChangelogURL, cts.Token).ConfigureAwait(false)) {
  343. var str = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  344. if (resp.StatusCode == HttpStatusCode.OK) {
  345. return JsonConvert.DeserializeObject<List<ChangelogVersion>>(str);
  346. }
  347. return new List<ChangelogVersion>();
  348. }
  349. } catch {
  350. return new List<ChangelogVersion>();
  351. }
  352. }
  353. [UsedImplicitly]
  354. internal static async Task<bool> Post(string route, string json) {
  355. var resp = await PostJson(url + route, json, 10);
  356. return resp.statusCode == HttpStatusCode.OK;
  357. }
  358. internal static async Task<MobileHandshakeResponse> RequestHandshake(PatchServerInfo info, HashSet<string> defineSymbols, string projectExclusionRegex) {
  359. await ThreadUtility.SwitchToThreadPool();
  360. var body = SerializeRequestBody(new MobileHandshakeRequest(defineSymbols, projectExclusionRegex));
  361. var requestUrl = CreateUrl(info) + "/handshake";
  362. Log.Debug($"RequestHandshake to {requestUrl}");
  363. var resp = await PostJson(requestUrl, body, 120).ConfigureAwait(false);
  364. if (resp.statusCode == HttpStatusCode.OK) {
  365. return JsonConvert.DeserializeObject<MobileHandshakeResponse>(resp.responseText);
  366. } else if(resp.statusCode == HttpStatusCode.ServiceUnavailable) {
  367. return new MobileHandshakeResponse(null, ServerHandshake.Result.WaitForCompiling.ToString());
  368. } else {
  369. return new MobileHandshakeResponse(null, resp.responseText);
  370. }
  371. }
  372. static string SerializeRequestBody<T>(T request) {
  373. return JsonConvert.SerializeObject(request);
  374. }
  375. static async Task<HttpResponse> PostJson(string uri, string json, int timeoutSeconds, CancellationToken token = default(CancellationToken), HttpClient overrideClient = null) {
  376. var httpClient = overrideClient ?? client;
  377. await ThreadUtility.SwitchToThreadPool();
  378. try {
  379. var content = new StringContent(json, Encoding.UTF8, "application/json");
  380. using(var resp = await httpClient.PostAsync(uri, content, token).ConfigureAwait(false)) {
  381. var str = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  382. return new HttpResponse(resp.StatusCode, null, str);
  383. }
  384. } catch(Exception ex) {
  385. return new HttpResponse(0, ex, null);
  386. }
  387. }
  388. }
  389. }
  390. #endif