using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using SingularityGroup.HotReload.DTO; using SingularityGroup.HotReload.Editor.Cli; using SingularityGroup.HotReload.Localization; using SingularityGroup.HotReload.Newtonsoft.Json; using UnityEditor; using UnityEngine; using Translations = SingularityGroup.HotReload.Editor.Localization.Translations; namespace SingularityGroup.HotReload.Editor { internal class ServerDownloader : IProgress { public float Progress {get; private set;} public bool Started {get; private set;} public int Attempts { get; private set; } class Config { public Dictionary customServerExecutables; } public string GetExecutablePath(ICliController cliController) { var targetDir = CliUtils.GetExecutableTargetDir(); var targetPath = Path.Combine(targetDir, cliController.BinaryFileName); return targetPath; } public bool IsDownloaded(ICliController cliController) { return File.Exists(GetExecutablePath(cliController)); } public string GetBinaryPath(ICliController cliController) { var defaultExecutablePath = CliUtils.GetExecutableTargetDir(); var config = JsonConvert.DeserializeObject(File.ReadAllText(PackageConst.ConfigFilePath)); var customExecutables = config?.customServerExecutables; if (customExecutables == null) { return defaultExecutablePath; } string customBinaryPath; if (!customExecutables.TryGetValue(cliController.PlatformName, out customBinaryPath)) { return defaultExecutablePath; } return Path.GetDirectoryName(customBinaryPath); } public bool CheckIfDownloaded(ICliController cliController) { if(TryUseUserDefinedBinaryPath(cliController, GetExecutablePath(cliController))) { Started = true; Progress = 1f; return true; } else if(IsDownloaded(cliController)) { Started = true; Progress = 1f; return true; } else { Started = false; Progress = 0f; return false; } } public async Task EnsureDownloaded(ICliController cliController, CancellationToken cancellationToken, int maxAttempts = -1) { var targetDir = CliUtils.GetExecutableTargetDir(); var targetPath = Path.Combine(targetDir, cliController.BinaryFileName); Started = true; Progress = 0f; Attempts = 0; if(File.Exists(targetPath)) { Progress = 1f; return true; } await ThreadUtility.SwitchToThreadPool(cancellationToken); Directory.CreateDirectory(targetDir); if(TryUseUserDefinedBinaryPath(cliController, targetPath, true)) { Progress = 1f; return true; } var tmpPath = CliUtils.GetTempDownloadFilePath("Server.tmp"); bool sucess = false; HashSet errors = null; while(!sucess) { try { if (File.Exists(targetPath)) { Progress = 1f; Attempts = 0; return true; } // Note: we are writing to temp file so if downloaded file is corrupted it will not cause issues until it's copied to target location var result = await DownloadUtility.DownloadFile(GetDownloadUrl(cliController), tmpPath, this, cancellationToken).ConfigureAwait(false); sucess = result.statusCode == HttpStatusCode.OK; } catch (OperationCanceledException) { Progress = 0; Started = false; Attempts = 0; throw; } catch (Exception e) { var error = $"{e.GetType().Name}: {e.Message}"; errors = (errors ?? new HashSet()); if (errors.Add(error)) { Log.Warning(Translations.Errors.ErrorDownloadFailed, error); } } if (!sucess) { if (maxAttempts > 0 && Attempts + 1 >= maxAttempts) { Progress = 0; Attempts = 0; Started = false; Log.Warning(Translations.Errors.ErrorDownloadFailedMaxAttempts); return false; } await Task.Delay(ExponentialBackoff.GetTimeout(Attempts++), cancellationToken).ConfigureAwait(false); Progress = 0; } } if (errors?.Count > 0) { var data = new EditorExtraData { { StatKey.Errors, new List(errors) }, }; // sending telemetry requires server to be running so we only attempt after server is downloaded RequestHelper.RequestEditorEventWithRetry(new Stat(StatSource.Client, StatLevel.Error, StatFeature.Editor, StatEventType.Download), data).Forget(); Log.Info(Translations.Errors.ErrorDownloadSucceeded); } const int ERROR_ALREADY_EXISTS = 0xB7; try { File.Move(tmpPath, targetPath); } catch(IOException ex) when((ex.HResult & 0x0000FFFF) == ERROR_ALREADY_EXISTS) { //another downloader came first try { File.Delete(tmpPath); } catch { //ignored } } Progress = 1f; Attempts = 0; return true; } static bool TryUseUserDefinedBinaryPath(ICliController cliController, string targetPath, bool logNotFoundWarning = false) { if (!File.Exists(PackageConst.ConfigFilePath)) { return false; } var config = JsonConvert.DeserializeObject(File.ReadAllText(PackageConst.ConfigFilePath)); var customExecutables = config?.customServerExecutables; if (customExecutables == null) { return false; } string customBinaryPath; if(!customExecutables.TryGetValue(cliController.PlatformName, out customBinaryPath)) { return false; } if (!File.Exists(customBinaryPath)) { if (logNotFoundWarning) { Log.Warning(Translations.Errors.ErrorServerBinaryNotFound, cliController.PlatformName, customBinaryPath); } return false; } try { var targetFile = new FileInfo(targetPath); bool copy = true; if (targetFile.Exists) { copy = File.GetLastWriteTimeUtc(customBinaryPath) > targetFile.LastWriteTimeUtc; } if (copy) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)); File.Copy(customBinaryPath, targetPath, true); } return true; } catch(IOException ex) { Log.Warning(Translations.Errors.ErrorCopyingServerBinary, customBinaryPath, ex); return false; } } public static string GetDownloadUrl(ICliController cliController) { const string version = PackageConst.ServerVersion; // NOTE: server is not translated at the moment so we always use english var key = $"{DownloadUtility.GetPackagePrefix(version, Locale.English)}/server/{cliController.PlatformName}/{cliController.BinaryFileName}"; return DownloadUtility.GetDownloadUrl(key); } void IProgress.Report(float value) { Progress = value; } public async Task PromptForDownload(CancellationToken manualDownloadCancelationToken) { if (EditorUtility.DisplayDialog( title: Translations.Dialogs.DialogTitleInstallComponents, message: Translations.Dialogs.DialogMessageInstallComponents, ok: Translations.Dialogs.DialogButtonInstall, cancel: Translations.Dialogs.DialogButtonMoreInfo) ) { try { return await EnsureDownloaded(HotReloadCli.controller, manualDownloadCancelationToken); } catch (Exception ex) { if (ex is OperationCanceledException || manualDownloadCancelationToken.IsCancellationRequested) { // binary was downloaded manually. Retry once to detect the binary. return await EnsureDownloaded(HotReloadCli.controller, CancellationToken.None, 1); } Log.Exception(ex); } } return false; } } class DownloadResult { public readonly HttpStatusCode statusCode; public readonly string error; public DownloadResult(HttpStatusCode statusCode, string error) { this.statusCode = statusCode; this.error = error; } } }