first commit
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SingularityGroup.HotReload.Editor.Cli;
|
||||
|
||||
namespace SingularityGroup.HotReload.Editor {
|
||||
static class DownloadUtility {
|
||||
const string baseUrl = "https://cdn.hotreload.net";
|
||||
|
||||
public static async Task<DownloadResult> DownloadFile(string url, string targetFilePath, IProgress<float> progress, CancellationToken cancellationToken) {
|
||||
var tmpDir = Path.GetDirectoryName(targetFilePath);
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
using(var client = HttpClientUtils.CreateHttpClient()) {
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
return await client.DownloadAsync(url, targetFilePath, progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetPackagePrefix(string version) {
|
||||
if (PackageConst.IsAssetStoreBuild) {
|
||||
return $"releases/asset-store/{version.Replace('.', '-')}";
|
||||
}
|
||||
return $"releases/{version.Replace('.', '-')}";
|
||||
}
|
||||
|
||||
public static string GetDownloadUrl(string key) {
|
||||
return $"{baseUrl}/{key}";
|
||||
}
|
||||
|
||||
public static async Task<DownloadResult> DownloadAsync(this HttpClient client, string requestUri, string destinationFilePath, IProgress<float> progress, CancellationToken cancellationToken = default(CancellationToken)) {
|
||||
// Get the http headers first to examine the content length
|
||||
using (var response = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false)) {
|
||||
if (response.StatusCode != HttpStatusCode.OK) {
|
||||
throw new DownloadException($"Download failed with status code {response.StatusCode} and reason {response.ReasonPhrase}");
|
||||
}
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
if (!contentLength.HasValue) {
|
||||
throw new DownloadException("Download failed: Content length unknown");
|
||||
}
|
||||
|
||||
using (var fs = new FileStream(destinationFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None))
|
||||
using (var download = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) {
|
||||
|
||||
// Ignore progress reporting when no progress reporter was
|
||||
if (progress == null) {
|
||||
await download.CopyToAsync(fs).ConfigureAwait(false);
|
||||
} else {
|
||||
// Convert absolute progress (bytes downloaded) into relative progress (0% - 99.9%)
|
||||
var relativeProgress = new Progress<long>(totalBytes => progress.Report(Math.Min(99.9f, (float)totalBytes / contentLength.Value)));
|
||||
// Use extension method to report progress while downloading
|
||||
await download.CopyToAsync(fs, 81920, relativeProgress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await fs.FlushAsync().ConfigureAwait(false);
|
||||
if (fs.Length != contentLength.Value) {
|
||||
throw new DownloadException("Download failed: download file is corrupted");
|
||||
}
|
||||
return new DownloadResult(HttpStatusCode.OK, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress<long> progress, CancellationToken cancellationToken) {
|
||||
if (source == null)
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
if (!source.CanRead)
|
||||
throw new ArgumentException("Has to be readable", nameof(source));
|
||||
if (destination == null)
|
||||
throw new ArgumentNullException(nameof(destination));
|
||||
if (!destination.CanWrite)
|
||||
throw new ArgumentException("Has to be writable", nameof(destination));
|
||||
if (bufferSize < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(bufferSize));
|
||||
|
||||
var buffer = new byte[bufferSize];
|
||||
long totalBytesRead = 0;
|
||||
int bytesRead;
|
||||
while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) {
|
||||
await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
|
||||
totalBytesRead += bytesRead;
|
||||
progress?.Report(totalBytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DownloadException : ApplicationException {
|
||||
public DownloadException(string message)
|
||||
: base(message) {
|
||||
}
|
||||
|
||||
public DownloadException(string message, Exception innerException)
|
||||
: base(message, innerException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a7a39befa1f455cb21fcad46513b6e5
|
||||
timeCreated: 1676973096
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace SingularityGroup.HotReload.Editor {
|
||||
static class ExponentialBackoff {
|
||||
|
||||
public static TimeSpan GetTimeout(int attempt, int minBackoff = 250, int maxBackoff = 60000, int deltaBackoff = 400) {
|
||||
attempt = Math.Min(25, attempt); // safety to avoid overflow below
|
||||
|
||||
var delta = (uint)(
|
||||
(Math.Pow(2.0, attempt) - 1.0)
|
||||
* deltaBackoff
|
||||
);
|
||||
|
||||
var interval = Math.Min(checked(minBackoff + delta), maxBackoff);
|
||||
return TimeSpan.FromMilliseconds(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5329de48151140eb871721ae80f925cd
|
||||
timeCreated: 1676908147
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using SingularityGroup.HotReload.DTO;
|
||||
using SingularityGroup.HotReload.Editor.Cli;
|
||||
using SingularityGroup.HotReload.EditorDependencies;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if UNITY_2019_4_OR_NEWER
|
||||
using System.Reflection;
|
||||
using Unity.CodeEditor;
|
||||
#endif
|
||||
|
||||
namespace SingularityGroup.HotReload.Editor {
|
||||
static class InstallUtility {
|
||||
const string installFlagPath = PackageConst.LibraryCachePath + "/installFlag.txt";
|
||||
|
||||
public static void DebugClearInstallState() {
|
||||
File.Delete(installFlagPath);
|
||||
}
|
||||
|
||||
// HandleEditorStart is only called on editor start, not on domain reload
|
||||
public static void HandleEditorStart(string updatedFromVersion) {
|
||||
var showOnStartup = HotReloadPrefs.ShowOnStartup;
|
||||
if (showOnStartup == ShowOnStartupEnum.Always || (showOnStartup == ShowOnStartupEnum.OnNewVersion && !String.IsNullOrEmpty(updatedFromVersion))) {
|
||||
// Don't open Hot Reload window inside Virtual Player folder
|
||||
// This is a heuristic since user might have the main player inside VP user-created folder, but that will be rare
|
||||
if (new DirectoryInfo(Path.GetFullPath("..")).Name != "VP") {
|
||||
HotReloadWindow.Open();
|
||||
}
|
||||
}
|
||||
if (HotReloadPrefs.LaunchOnEditorStart) {
|
||||
EditorCodePatcher.DownloadAndRun().Forget();
|
||||
}
|
||||
|
||||
RequestHelper.RequestEditorEventWithRetry(new Stat(StatSource.Client, StatLevel.Debug, StatFeature.Editor, StatEventType.Start)).Forget();
|
||||
}
|
||||
|
||||
public static void CheckForNewInstall() {
|
||||
if(File.Exists(installFlagPath)) {
|
||||
return;
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(installFlagPath));
|
||||
using(File.Create(installFlagPath)) { }
|
||||
//Avoid opening the window on domain reload
|
||||
EditorApplication.delayCall += HandleNewInstall;
|
||||
}
|
||||
|
||||
static void HandleNewInstall() {
|
||||
if (EditorCodePatcher.licenseType == UnityLicenseType.UnityPro) {
|
||||
RedeemLicenseHelper.I.StartRegistration();
|
||||
}
|
||||
// Don't open Hot Reload window inside Virtual Player folder
|
||||
// This is a heuristic since user might have the main player inside VP user-created folder, but that will be rare
|
||||
if (new DirectoryInfo(Path.GetFullPath("..")).Name != "VP") {
|
||||
HotReloadWindow.Open();
|
||||
}
|
||||
HotReloadPrefs.AllowDisableUnityAutoRefresh = true;
|
||||
HotReloadPrefs.AllAssetChanges = true;
|
||||
HotReloadPrefs.AutoRecompileUnsupportedChanges = true;
|
||||
HotReloadPrefs.AutoRecompileUnsupportedChangesOnExitPlayMode = true;
|
||||
if (HotReloadCli.CanOpenInBackground) {
|
||||
HotReloadPrefs.DisableConsoleWindow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee93b2c98bc7d8f4bb38bbbd5961d354
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,190 @@
|
||||
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.Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SingularityGroup.HotReload.Editor {
|
||||
internal class ServerDownloader : IProgress<float> {
|
||||
public float Progress {get; private set;}
|
||||
public bool Started {get; private set;}
|
||||
|
||||
class Config {
|
||||
public Dictionary<string, string> 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 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<bool> EnsureDownloaded(ICliController cliController, CancellationToken cancellationToken) {
|
||||
var targetDir = CliUtils.GetExecutableTargetDir();
|
||||
var targetPath = Path.Combine(targetDir, cliController.BinaryFileName);
|
||||
Started = true;
|
||||
if(File.Exists(targetPath)) {
|
||||
Progress = 1f;
|
||||
return true;
|
||||
}
|
||||
Progress = 0f;
|
||||
await ThreadUtility.SwitchToThreadPool(cancellationToken);
|
||||
|
||||
Directory.CreateDirectory(targetDir);
|
||||
if(TryUseUserDefinedBinaryPath(cliController, targetPath)) {
|
||||
Progress = 1f;
|
||||
return true;
|
||||
}
|
||||
|
||||
var tmpPath = CliUtils.GetTempDownloadFilePath("Server.tmp");
|
||||
var attempt = 0;
|
||||
bool sucess = false;
|
||||
HashSet<string> errors = null;
|
||||
while(!sucess) {
|
||||
try {
|
||||
if (File.Exists(targetPath)) {
|
||||
Progress = 1f;
|
||||
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 (Exception e) {
|
||||
var error = $"{e.GetType().Name}: {e.Message}";
|
||||
errors = (errors ?? new HashSet<string>());
|
||||
if (errors.Add(error)) {
|
||||
Log.Warning($"Download attempt failed. If the issue persists please reach out to customer support for assistance. Exception: {error}");
|
||||
}
|
||||
}
|
||||
if (!sucess) {
|
||||
await Task.Delay(ExponentialBackoff.GetTimeout(attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
Progress = 0;
|
||||
attempt++;
|
||||
}
|
||||
|
||||
if (errors?.Count > 0) {
|
||||
var data = new EditorExtraData {
|
||||
{ StatKey.Errors, new List<string>(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("Download succeeded!");
|
||||
}
|
||||
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryUseUserDefinedBinaryPath(ICliController cliController, string targetPath) {
|
||||
if (!File.Exists(PackageConst.ConfigFileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = JsonConvert.DeserializeObject<Config>(File.ReadAllText(PackageConst.ConfigFileName));
|
||||
var customExecutables = config?.customServerExecutables;
|
||||
if (customExecutables == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
string customBinaryPath;
|
||||
if(!customExecutables.TryGetValue(cliController.PlatformName, out customBinaryPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(customBinaryPath)) {
|
||||
Log.Warning($"unable to find server binary for platform '{cliController.PlatformName}' at '{customBinaryPath}'. " +
|
||||
$"Will proceed with downloading the binary (default behavior)");
|
||||
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("encountered exception when copying server binary in the specified custom executable path '{0}':\n{1}", customBinaryPath, ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static string GetDownloadUrl(ICliController cliController) {
|
||||
const string version = PackageConst.ServerVersion;
|
||||
var key = $"{DownloadUtility.GetPackagePrefix(version)}/server/{cliController.PlatformName}/{cliController.BinaryFileName}";
|
||||
return DownloadUtility.GetDownloadUrl(key);
|
||||
}
|
||||
|
||||
void IProgress<float>.Report(float value) {
|
||||
Progress = value;
|
||||
}
|
||||
|
||||
public Task<bool> PromptForDownload() {
|
||||
if (EditorUtility.DisplayDialog(
|
||||
title: "Install platform specific components",
|
||||
message: InstallDescription,
|
||||
ok: "Install",
|
||||
cancel: "More Info")
|
||||
) {
|
||||
return EnsureDownloaded(HotReloadCli.controller, CancellationToken.None);
|
||||
}
|
||||
Application.OpenURL(Constants.AdditionalContentURL);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public const string InstallDescription = "For Hot Reload to work, additional components specific to your operating system have to be installed";
|
||||
}
|
||||
|
||||
class DownloadResult {
|
||||
public readonly HttpStatusCode statusCode;
|
||||
public readonly string error;
|
||||
public DownloadResult(HttpStatusCode statusCode, string error) {
|
||||
this.statusCode = statusCode;
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f076514e142a4915ab2676a9ca6d884a
|
||||
timeCreated: 1676802482
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SingularityGroup.HotReload.Editor.Cli;
|
||||
using SingularityGroup.HotReload.RuntimeDependencies;
|
||||
using UnityEditor;
|
||||
#if UNITY_EDITOR_WIN
|
||||
using System.Diagnostics;
|
||||
using Debug = UnityEngine.Debug;
|
||||
#endif
|
||||
|
||||
namespace SingularityGroup.HotReload.Editor {
|
||||
static class UpdateUtility {
|
||||
public static async Task<string> Update(string version, IProgress<float> progress, CancellationToken cancellationToken) {
|
||||
await ThreadUtility.SwitchToThreadPool();
|
||||
|
||||
string serverDir;
|
||||
if(!CliUtils.TryFindServerDir(out serverDir)) {
|
||||
progress?.Report(1);
|
||||
return "unable to locate hot reload package";
|
||||
}
|
||||
var packageDir = Path.GetDirectoryName(Path.GetFullPath(serverDir));
|
||||
var cacheDir = Path.GetFullPath(PackageConst.LibraryCachePath);
|
||||
if(Path.GetPathRoot(packageDir) != Path.GetPathRoot(cacheDir)) {
|
||||
progress?.Report(1);
|
||||
return "unable to update package because it is located on a different drive than the unity project";
|
||||
}
|
||||
var updatedPackageCopy = BackupPackage(packageDir, version);
|
||||
|
||||
var key = $"{DownloadUtility.GetPackagePrefix(version)}/HotReload.zip";
|
||||
var url = DownloadUtility.GetDownloadUrl(key);
|
||||
var targetFileName = $"HotReload{version.Replace('.', '-')}.zip";
|
||||
var targetFilePath = CliUtils.GetTempDownloadFilePath(targetFileName);
|
||||
var proxy = new Progress<float>(f => progress?.Report(f * 0.7f));
|
||||
var result = await DownloadUtility.DownloadFile(url, targetFilePath, proxy, cancellationToken).ConfigureAwait(false);
|
||||
if(result.error != null) {
|
||||
progress?.Report(1);
|
||||
return result.error;
|
||||
}
|
||||
|
||||
PackageUpdater.UpdatePackage(targetFilePath, updatedPackageCopy);
|
||||
progress?.Report(0.8f);
|
||||
|
||||
var packageRecycleBinDir = PackageConst.LibraryCachePath + $"/PackageArchived-{version}-{Guid.NewGuid():N}";
|
||||
try {
|
||||
Directory.Move(packageDir, packageRecycleBinDir);
|
||||
Directory.Move(updatedPackageCopy, packageDir);
|
||||
} catch {
|
||||
// fallback to replacing files individually if access to the folder is denied
|
||||
PackageUpdater.UpdatePackage(targetFilePath, packageDir);
|
||||
}
|
||||
try {
|
||||
Directory.Delete(packageRecycleBinDir, true);
|
||||
} catch (IOException) {
|
||||
//ignored
|
||||
}
|
||||
|
||||
progress?.Report(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
static string BackupPackage(string packageDir, string version) {
|
||||
var backupPath = PackageConst.LibraryCachePath + $"/PackageBackup-{version}";
|
||||
if(Directory.Exists(backupPath)) {
|
||||
Directory.Delete(backupPath, true);
|
||||
}
|
||||
DirectoryCopy(packageDir, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
static void DirectoryCopy(string sourceDirPath, string destDirPath) {
|
||||
var rootSource = new DirectoryInfo(sourceDirPath);
|
||||
|
||||
var sourceDirs = rootSource.GetDirectories();
|
||||
// ensure destination directory exists
|
||||
Directory.CreateDirectory(destDirPath);
|
||||
|
||||
// Get the files in the directory and copy them to the new destination
|
||||
var files = rootSource.GetFiles();
|
||||
foreach (var file in files) {
|
||||
string temppath = Path.Combine(destDirPath, file.Name);
|
||||
var copy = file.CopyTo(temppath);
|
||||
copy.LastWriteTimeUtc = file.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
// copying subdirectories, and their contents to destination
|
||||
foreach (var subdir in sourceDirs) {
|
||||
string subDirDestPath = Path.Combine(destDirPath, subdir.Name);
|
||||
DirectoryCopy(subdir.FullName, subDirDestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8485ce38122465e9e70d5992d9ae7ed
|
||||
timeCreated: 1676966641
|
||||
Reference in New Issue
Block a user