[Add] Hot Reload

This commit is contained in:
2026-02-27 03:16:18 +07:00
parent 5067cb51a1
commit b37579153b
431 changed files with 43054 additions and 1 deletions
@@ -0,0 +1,101 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using SingularityGroup.HotReload.Editor.Cli;
using SingularityGroup.HotReload.Editor.Localization;
using SingularityGroup.HotReload.Localization;
using Translations = SingularityGroup.HotReload.Editor.Localization.Translations;
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, string locale) {
if (PackageConst.IsAssetStoreBuild) {
return $"releases/asset-store/{(locale == Locale.SimplifiedChinese ? "zh/" : "")}{version.Replace('.', '-')}";
}
return $"releases/{(locale == Locale.SimplifiedChinese ? "zh/" : "")}{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(string.Format(Translations.Errors.ExceptionDownloadFailed, response.StatusCode, response.ReasonPhrase));
}
var contentLength = response.Content.Headers.ContentLength;
if (!contentLength.HasValue) {
throw new DownloadException(Translations.Errors.ExceptionDownloadContentLengthUnknown);
}
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(Translations.Errors.ExceptionDownloadFileCorrupted);
}
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(Translations.Utility.StreamHasToBeReadable, nameof(source));
if (destination == null)
throw new ArgumentNullException(nameof(destination));
if (!destination.CanWrite)
throw new ArgumentException(Translations.Utility.StreamHasToBeWritable, 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,10 @@
fileFormatVersion: 2
guid: 2a7a39befa1f455cb21fcad46513b6e5
timeCreated: 1676973096
AssetOrigin:
serializedVersion: 1
productId: 254358
packageName: Hot Reload | Edit Code Without Compiling
packageVersion: 1.13.17
assetPath: Packages/com.singularitygroup.hotreload/Editor/Installation/DownloadUtility.cs
uploadId: 870414
@@ -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,10 @@
fileFormatVersion: 2
guid: 5329de48151140eb871721ae80f925cd
timeCreated: 1676908147
AssetOrigin:
serializedVersion: 1
productId: 254358
packageName: Hot Reload | Edit Code Without Compiling
packageVersion: 1.13.17
assetPath: Packages/com.singularitygroup.hotreload/Editor/Installation/ExponentialBackoff.cs
uploadId: 870414
@@ -0,0 +1,62 @@
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 {
static 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))) {
if (!HotReloadPrefs.DeactivateHotReload) {
HotReloadWindow.Open();
}
}
if (HotReloadPrefs.LaunchOnEditorStart && !HotReloadPrefs.DeactivateHotReload) {
EditorCodePatcher.DownloadAndRun().Forget();
}
RequestHelper.RequestEditorEventWithRetry(new Stat(StatSource.Client, StatLevel.Debug, StatFeature.Editor, StatEventType.Start)).Forget();
}
public static void CheckForNewInstall() {
if(File.Exists(installFlagPath) || MultiplayerPlaymodeHelper.IsClone) {
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();
}
HotReloadPrefs.AllowDisableUnityAutoRefresh = true;
HotReloadPrefs.AllAssetChanges = true;
HotReloadPrefs.AutoRecompileUnsupportedChanges = true;
HotReloadPrefs.AutoRecompileUnsupportedChangesOnExitPlayMode = true;
#if UNITY_EDITOR_WIN
HotReloadPrefs.UseWatchman = false;
#endif
if (HotReloadCli.CanOpenInBackground) {
HotReloadPrefs.DisableConsoleWindow = true;
}
}
}
}
@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: ee93b2c98bc7d8f4bb38bbbd5961d354
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 254358
packageName: Hot Reload | Edit Code Without Compiling
packageVersion: 1.13.17
assetPath: Packages/com.singularitygroup.hotreload/Editor/Installation/InstallUtility.cs
uploadId: 870414
@@ -0,0 +1,228 @@
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<float> {
public float Progress {get; private set;}
public bool Started {get; private set;}
public int Attempts { 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 string GetBinaryPath(ICliController cliController) {
var defaultExecutablePath = CliUtils.GetExecutableTargetDir();
var config = JsonConvert.DeserializeObject<Config>(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<bool> 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<string> 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<string>());
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<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(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<Config>(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<float>.Report(float value) {
Progress = value;
}
public async Task<bool> 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;
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: f076514e142a4915ab2676a9ca6d884a
timeCreated: 1676802482
AssetOrigin:
serializedVersion: 1
productId: 254358
packageName: Hot Reload | Edit Code Without Compiling
packageVersion: 1.13.17
assetPath: Packages/com.singularitygroup.hotreload/Editor/Installation/ServerDownloader.cs
uploadId: 870414
@@ -0,0 +1,95 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SingularityGroup.HotReload.Editor.Cli;
using SingularityGroup.HotReload.Editor.Localization;
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 Translations.Utility.UnableToLocateHotReloadPackage;
}
var packageDir = Path.GetDirectoryName(Path.GetFullPath(serverDir));
var cacheDir = Path.GetFullPath(PackageConst.LibraryCachePath);
if(Path.GetPathRoot(packageDir) != Path.GetPathRoot(cacheDir)) {
progress?.Report(1);
return Translations.Utility.UnableToUpdatePackageDifferentDrive;
}
var updatedPackageCopy = BackupPackage(packageDir, version);
var key = $"{DownloadUtility.GetPackagePrefix(version, PackageConst.DefaultLocale)}/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,10 @@
fileFormatVersion: 2
guid: d8485ce38122465e9e70d5992d9ae7ed
timeCreated: 1676966641
AssetOrigin:
serializedVersion: 1
productId: 254358
packageName: Hot Reload | Edit Code Without Compiling
packageVersion: 1.13.17
assetPath: Packages/com.singularitygroup.hotreload/Editor/Installation/UpdateUtility.cs
uploadId: 870414