Initial commit: R0Installer source, patch generator, API backend and build scripts
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace R0Installer
|
||||
{
|
||||
public class UpdateManager
|
||||
{
|
||||
private string checkApiTemplate;
|
||||
private string userAgent;
|
||||
private int maxThreads;
|
||||
private string tempFilenameTemplate;
|
||||
private string fullSilentArgs;
|
||||
|
||||
public Action<int, string, string> OnProgress;
|
||||
public Action<string> OnStatus;
|
||||
|
||||
private long speedCalcLastBytes = 0;
|
||||
private DateTime speedCalcLastTime = DateTime.Now;
|
||||
private double currentSpeed = 0;
|
||||
|
||||
private string hpatchzPath;
|
||||
|
||||
public UpdateManager(string updateConfigJson, string projectConfigJson)
|
||||
{
|
||||
checkApiTemplate = ConfigManager.ExtractJsonValue(updateConfigJson, "check_api");
|
||||
tempFilenameTemplate = ConfigManager.ExtractJsonValue(updateConfigJson, "temp_filename_template");
|
||||
|
||||
string downloadConfig = ConfigManager.ExtractJsonObject(projectConfigJson, "download_config");
|
||||
if (!string.IsNullOrEmpty(downloadConfig))
|
||||
{
|
||||
maxThreads = ConfigManager.ExtractJsonInt(downloadConfig, "max_threads", 8);
|
||||
userAgent = ConfigManager.ExtractJsonValue(downloadConfig, "user_agent");
|
||||
}
|
||||
else
|
||||
{
|
||||
maxThreads = 8;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent)) userAgent = "r0_installer";
|
||||
if (string.IsNullOrEmpty(tempFilenameTemplate)) tempFilenameTemplate = "update_v{version}.exe";
|
||||
|
||||
// 全量安装包(Inno Setup 打包)静默安装参数,可由更新配置覆盖。
|
||||
// /VERYSILENT 完全无界面;/SUPPRESSMSGBOXES 抑制弹窗;/NORESTART 安装后不自动重启。
|
||||
fullSilentArgs = ConfigManager.ExtractJsonValue(updateConfigJson, "silent_install_args");
|
||||
if (string.IsNullOrEmpty(fullSilentArgs)) fullSilentArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART";
|
||||
|
||||
string exeDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
|
||||
hpatchzPath = Path.Combine(exeDir, "hpatchz.exe");
|
||||
}
|
||||
|
||||
#region Version check
|
||||
|
||||
public async Task<UpdateCheckResult> CheckForUpdate(string currentVersion)
|
||||
{
|
||||
try
|
||||
{
|
||||
string apiUrl = "https://a-p-i.r0csgo.com" + String.Format(checkApiTemplate, Uri.EscapeDataString(currentVersion));
|
||||
|
||||
using (WebClient client = new WebClient())
|
||||
{
|
||||
client.Encoding = Encoding.UTF8;
|
||||
client.Headers.Add("User-Agent", userAgent);
|
||||
string json = await client.DownloadStringTaskAsync(apiUrl);
|
||||
|
||||
string data = ConfigManager.ExtractJsonObject(json, "data");
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
return ParseUpdateCheckResult(data);
|
||||
|
||||
return ParseUpdateCheckResult(json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("检查更新失败: " + ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private UpdateCheckResult ParseUpdateCheckResult(string json)
|
||||
{
|
||||
UpdateCheckResult result = new UpdateCheckResult();
|
||||
|
||||
result.has_update = ConfigManager.ExtractJsonBool(json, "has_update");
|
||||
result.silent = ConfigManager.ExtractJsonBool(json, "silent");
|
||||
result.latest_version = ConfigManager.ExtractJsonValue(json, "latest_version");
|
||||
result.current_version = ConfigManager.ExtractJsonValue(json, "current_version");
|
||||
result.update_type = ConfigManager.ExtractJsonValue(json, "update_type");
|
||||
string rawUrl = ConfigManager.ExtractJsonValue(json, "download_url");
|
||||
result.download_url = string.IsNullOrEmpty(rawUrl) ? "" : rawUrl.Replace("\\/", "/");
|
||||
result.file_size = ConfigManager.ExtractJsonValue(json, "file_size");
|
||||
result.changelog = ConfigManager.ExtractJsonValue(json, "changelog");
|
||||
result.total_size = ConfigManager.ExtractJsonValue(json, "total_size");
|
||||
|
||||
string threadsStr = ConfigManager.ExtractJsonValue(json, "threads");
|
||||
if (!string.IsNullOrEmpty(threadsStr))
|
||||
{
|
||||
int parsedThreads;
|
||||
if (int.TryParse(threadsStr, out parsedThreads) && parsedThreads > 0)
|
||||
{
|
||||
maxThreads = parsedThreads;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.update_type == "multi_incremental")
|
||||
{
|
||||
result.incremental_updates = ParseIncrementalUpdates(json);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<IncrementalUpdate> ParseIncrementalUpdates(string json)
|
||||
{
|
||||
List<IncrementalUpdate> updates = new List<IncrementalUpdate>();
|
||||
|
||||
string arrayJson = ConfigManager.ExtractJsonArray(json, "incremental_updates");
|
||||
if (string.IsNullOrEmpty(arrayJson)) return updates;
|
||||
|
||||
List<string> objects = ConfigManager.ParseJsonObjectArray(arrayJson);
|
||||
foreach (string objJson in objects)
|
||||
{
|
||||
IncrementalUpdate update = new IncrementalUpdate();
|
||||
update.from_version = ConfigManager.ExtractJsonValue(objJson, "from_version");
|
||||
update.to_version = ConfigManager.ExtractJsonValue(objJson, "to_version");
|
||||
string rawUpdateUrl = ConfigManager.ExtractJsonValue(objJson, "download_url");
|
||||
update.download_url = string.IsNullOrEmpty(rawUpdateUrl) ? "" : rawUpdateUrl.Replace("\\/", "/");
|
||||
update.file_size = ConfigManager.ExtractJsonValue(objJson, "file_size");
|
||||
update.changelog = ConfigManager.ExtractJsonValue(objJson, "changelog");
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full update
|
||||
|
||||
public async Task<bool> PerformFullUpdate(UpdateCheckResult updateInfo)
|
||||
{
|
||||
return await PerformFullUpdate(updateInfo, false);
|
||||
}
|
||||
|
||||
public async Task<bool> PerformFullUpdate(UpdateCheckResult updateInfo, bool silent)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReportStatus("正在下载完整安装包...");
|
||||
|
||||
string filename = tempFilenameTemplate.Replace("{version}", updateInfo.latest_version);
|
||||
string tempPath = ConfigManager.ResolveWritableDownloadPath(
|
||||
Path.Combine(ConfigManager.GetWorkDirectory(), filename));
|
||||
|
||||
string actualPath = await DownloadFileMultiThread(updateInfo.download_url, tempPath, "安装包");
|
||||
|
||||
if (!ConfigManager.IsDownloadedFileValid(actualPath))
|
||||
throw new Exception("下载的安装包无效");
|
||||
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = actualPath;
|
||||
psi.UseShellExecute = true;
|
||||
psi.WorkingDirectory = Path.GetDirectoryName(actualPath);
|
||||
|
||||
if (silent)
|
||||
{
|
||||
// Inno Setup 安装包静默安装:传入 /VERYSILENT 等参数,等待安装结束。
|
||||
// 本程序 manifest 已要求管理员权限,子进程继承提权,Inno 不会再二次提权重启,
|
||||
// 因此 WaitForExit 能准确反映安装完成。
|
||||
ReportStatus("正在静默安装更新...");
|
||||
psi.Arguments = fullSilentArgs;
|
||||
|
||||
using (Process process = Process.Start(psi))
|
||||
{
|
||||
if (process != null)
|
||||
{
|
||||
await Task.Run(new Action(process.WaitForExit));
|
||||
int exitCode = process.ExitCode;
|
||||
// Inno Setup 退出码:0=成功,3010=成功但需重启。
|
||||
if (exitCode != 0 && exitCode != 3010)
|
||||
throw new Exception(String.Format("静默安装失败,退出代码: {0}", exitCode));
|
||||
}
|
||||
}
|
||||
|
||||
ReportProgress(100, "安装完成");
|
||||
}
|
||||
else
|
||||
{
|
||||
ReportStatus("正在启动安装程序...");
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("全量更新失败: " + ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Incremental update
|
||||
|
||||
public async Task<bool> PerformIncrementalUpdate(string downloadUrl, string installPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReportStatus("正在下载增量更新包...");
|
||||
string workDir = ConfigManager.GetWorkDirectory();
|
||||
string tempZipPath = Path.Combine(workDir, "r0_patch_" + Guid.NewGuid().ToString("N") + ".zip");
|
||||
|
||||
string actualZipPath = await DownloadFileMultiThread(downloadUrl, tempZipPath, "增量包");
|
||||
|
||||
ReportStatus("正在解压增量包...");
|
||||
string extractPath = Path.Combine(workDir, "r0_patch_" + Guid.NewGuid().ToString("N"));
|
||||
ZipFile.ExtractToDirectory(actualZipPath, extractPath);
|
||||
|
||||
ReportStatus("正在应用更新...");
|
||||
bool success = await ApplyPatch(extractPath, installPath);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(actualZipPath))
|
||||
File.Delete(actualZipPath);
|
||||
if (Directory.Exists(extractPath))
|
||||
Directory.Delete(extractPath, true);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("增量更新失败: " + ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PerformMultiIncrementalUpdate(List<IncrementalUpdate> updates, string installPath)
|
||||
{
|
||||
for (int i = 0; i < updates.Count; i++)
|
||||
{
|
||||
IncrementalUpdate update = updates[i];
|
||||
ReportStatus(String.Format("正在应用更新 {0}/{1}: {2} -> {3}", i + 1, updates.Count, update.from_version, update.to_version));
|
||||
|
||||
bool success = await PerformIncrementalUpdate(update.download_url, installPath);
|
||||
if (!success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyPatch(string patchPath, string installPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string manifestPath = Path.Combine(patchPath, "patch.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new Exception("增量包格式错误:找不到 patch.json");
|
||||
}
|
||||
|
||||
string manifestJson = File.ReadAllText(manifestPath, Encoding.UTF8);
|
||||
PatchManifest manifest = ParsePatchManifest(manifestJson);
|
||||
|
||||
int totalOperations = manifest.modified.Count + manifest.new_files.Count + manifest.deleted.Count;
|
||||
int currentOperation = 0;
|
||||
|
||||
foreach (PatchDiffInfo diff in manifest.modified)
|
||||
{
|
||||
currentOperation++;
|
||||
int percent = (int)(currentOperation * 100.0 / totalOperations);
|
||||
ReportProgress(percent, "正在更新: " + diff.path);
|
||||
|
||||
string targetFile = Path.Combine(installPath, diff.path);
|
||||
string patchFile = Path.Combine(patchPath, diff.patch_file);
|
||||
|
||||
if (!File.Exists(targetFile))
|
||||
{
|
||||
Debug.WriteLine("警告: 目标文件不存在,跳过: " + diff.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(patchFile))
|
||||
{
|
||||
Debug.WriteLine("警告: 补丁文件不存在,跳过: " + diff.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
string currentHash = CalculateMD5(targetFile);
|
||||
if (currentHash != diff.old_hash)
|
||||
{
|
||||
Debug.WriteLine("警告: 原文件hash不匹配,跳过: " + diff.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
string tempOutputFile = targetFile + ".new";
|
||||
bool patchSuccess = await ApplyHDiff(targetFile, patchFile, tempOutputFile);
|
||||
|
||||
if (patchSuccess && File.Exists(tempOutputFile))
|
||||
{
|
||||
string newHash = CalculateMD5(tempOutputFile);
|
||||
if (newHash == diff.new_hash)
|
||||
{
|
||||
File.Delete(targetFile);
|
||||
File.Move(tempOutputFile, targetFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine("警告: 新文件hash不匹配: " + diff.path);
|
||||
File.Delete(tempOutputFile);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine("警告: 应用补丁失败: " + diff.path);
|
||||
if (File.Exists(tempOutputFile))
|
||||
File.Delete(tempOutputFile);
|
||||
}
|
||||
}
|
||||
|
||||
string newFilesDir = Path.Combine(patchPath, "new");
|
||||
foreach (PatchFileInfo newFile in manifest.new_files)
|
||||
{
|
||||
currentOperation++;
|
||||
int percent = (int)(currentOperation * 100.0 / totalOperations);
|
||||
ReportProgress(percent, "正在添加: " + newFile.path);
|
||||
|
||||
string sourceFile = Path.Combine(newFilesDir, newFile.path);
|
||||
string targetFile = Path.Combine(installPath, newFile.path);
|
||||
|
||||
if (File.Exists(sourceFile))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetFile));
|
||||
|
||||
if (File.Exists(targetFile))
|
||||
File.Delete(targetFile);
|
||||
|
||||
File.Copy(sourceFile, targetFile, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine("警告: 新文件不存在于增量包: " + newFile.path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string deletePath in manifest.deleted)
|
||||
{
|
||||
currentOperation++;
|
||||
int percent = (int)(currentOperation * 100.0 / totalOperations);
|
||||
ReportProgress(percent, "正在删除: " + deletePath);
|
||||
|
||||
string targetFile = Path.Combine(installPath, deletePath);
|
||||
if (File.Exists(targetFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(targetFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("警告: 删除文件失败: " + deletePath + " - " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReportProgress(100, "更新完成");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("应用增量补丁失败: " + ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyHDiff(string oldFile, string patchFile, string outputFile)
|
||||
{
|
||||
if (!File.Exists(hpatchzPath))
|
||||
{
|
||||
string altPath = Path.Combine(Path.GetDirectoryName(oldFile), "hpatchz.exe");
|
||||
if (File.Exists(altPath))
|
||||
{
|
||||
hpatchzPath = altPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine("hpatchz.exe 未找到");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = hpatchzPath;
|
||||
psi.Arguments = String.Format("\"{0}\" \"{1}\" \"{2}\"", oldFile, patchFile, outputFile);
|
||||
psi.UseShellExecute = false;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
psi.CreateNoWindow = true;
|
||||
|
||||
using (Process process = Process.Start(psi))
|
||||
{
|
||||
process.WaitForExit(60000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("hpatchz执行失败: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PatchManifest ParsePatchManifest(string json)
|
||||
{
|
||||
PatchManifest manifest = new PatchManifest();
|
||||
|
||||
manifest.from_version = ConfigManager.ExtractJsonValue(json, "from_version");
|
||||
manifest.to_version = ConfigManager.ExtractJsonValue(json, "to_version");
|
||||
|
||||
manifest.modified = ParseModifiedArray(json);
|
||||
manifest.new_files = ParseNewFilesArray(json);
|
||||
manifest.deleted = ParseDeletedArray(json);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private List<PatchDiffInfo> ParseModifiedArray(string json)
|
||||
{
|
||||
List<PatchDiffInfo> list = new List<PatchDiffInfo>();
|
||||
|
||||
string arrayJson = ConfigManager.ExtractJsonArray(json, "modified");
|
||||
if (string.IsNullOrEmpty(arrayJson)) return list;
|
||||
|
||||
List<string> objects = ConfigManager.ParseJsonObjectArray(arrayJson);
|
||||
foreach (string objJson in objects)
|
||||
{
|
||||
PatchDiffInfo info = new PatchDiffInfo();
|
||||
info.path = ConfigManager.ExtractJsonValue(objJson, "path");
|
||||
info.patch_file = ConfigManager.ExtractJsonValue(objJson, "patch_file");
|
||||
info.old_hash = ConfigManager.ExtractJsonValue(objJson, "old_hash");
|
||||
info.new_hash = ConfigManager.ExtractJsonValue(objJson, "new_hash");
|
||||
long.TryParse(ConfigManager.ExtractJsonValue(objJson, "new_size"), out info.new_size);
|
||||
list.Add(info);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<PatchFileInfo> ParseNewFilesArray(string json)
|
||||
{
|
||||
List<PatchFileInfo> list = new List<PatchFileInfo>();
|
||||
|
||||
string arrayJson = ConfigManager.ExtractJsonArray(json, "new_files");
|
||||
if (string.IsNullOrEmpty(arrayJson)) return list;
|
||||
|
||||
List<string> objects = ConfigManager.ParseJsonObjectArray(arrayJson);
|
||||
foreach (string objJson in objects)
|
||||
{
|
||||
PatchFileInfo info = new PatchFileInfo();
|
||||
info.path = ConfigManager.ExtractJsonValue(objJson, "path");
|
||||
info.hash = ConfigManager.ExtractJsonValue(objJson, "hash");
|
||||
long.TryParse(ConfigManager.ExtractJsonValue(objJson, "size"), out info.size);
|
||||
list.Add(info);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<string> ParseDeletedArray(string json)
|
||||
{
|
||||
string arrayJson = ConfigManager.ExtractJsonArray(json, "deleted");
|
||||
if (string.IsNullOrEmpty(arrayJson)) return new List<string>();
|
||||
return ConfigManager.ParseJsonStringArray(arrayJson);
|
||||
}
|
||||
|
||||
private string CalculateMD5(string filePath)
|
||||
{
|
||||
using (var md5 = MD5.Create())
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
byte[] hash = md5.ComputeHash(stream);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (byte b in hash)
|
||||
sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download with retry and resume
|
||||
|
||||
private const int DOWNLOAD_MAX_RETRIES = 10;
|
||||
private const int RETRY_DELAY_BASE_MS = 2000;
|
||||
|
||||
private class ChunkInfo
|
||||
{
|
||||
public int Index;
|
||||
public long StartPos;
|
||||
public long EndPos;
|
||||
public long Downloaded;
|
||||
}
|
||||
|
||||
private async Task<string> DownloadFileMultiThread(string url, string savePath, string displayName)
|
||||
{
|
||||
string downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath);
|
||||
|
||||
for (int attempt = 1; ; attempt++)
|
||||
{
|
||||
Exception caught = null;
|
||||
try
|
||||
{
|
||||
ConfigManager.PrepareDownloadFile(downloadPath);
|
||||
await DownloadFileInternal(url, downloadPath, displayName);
|
||||
return downloadPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
caught = ex;
|
||||
if (IsFileAccessError(ex) && attempt < DOWNLOAD_MAX_RETRIES)
|
||||
downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath);
|
||||
}
|
||||
|
||||
Debug.WriteLine(String.Format("下载失败(第{0}次): {1}\nURL: {2}", attempt, caught.Message, url));
|
||||
if (attempt >= DOWNLOAD_MAX_RETRIES)
|
||||
throw new Exception(String.Format("下载{0}失败,已重试{1}次: {2}", displayName, DOWNLOAD_MAX_RETRIES, caught.Message));
|
||||
|
||||
int delay = Math.Min(RETRY_DELAY_BASE_MS * attempt, 15000);
|
||||
ReportStatus(String.Format("下载中断({0}),{1}秒后第{2}次重试...", caught.Message, delay / 1000, attempt));
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFileAccessError(Exception ex)
|
||||
{
|
||||
while (ex != null)
|
||||
{
|
||||
if (ex is UnauthorizedAccessException || ex is IOException)
|
||||
return true;
|
||||
ex = ex.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private class ProbeResult
|
||||
{
|
||||
public long FileSize = -1;
|
||||
public bool SupportsRange = false;
|
||||
}
|
||||
|
||||
private async Task DownloadFileInternal(string url, string savePath, string displayName)
|
||||
{
|
||||
speedCalcLastBytes = 0;
|
||||
speedCalcLastTime = DateTime.Now;
|
||||
currentSpeed = 0;
|
||||
|
||||
if (maxThreads <= 1)
|
||||
{
|
||||
await DownloadSingleThreadNoRange(url, savePath, displayName);
|
||||
return;
|
||||
}
|
||||
|
||||
ProbeResult probe = await ProbeUrl(url);
|
||||
if (probe.FileSize <= 0 || !probe.SupportsRange)
|
||||
{
|
||||
await DownloadSingleThreadNoRange(url, savePath, displayName);
|
||||
return;
|
||||
}
|
||||
|
||||
long fileSize = probe.FileSize;
|
||||
int chunkCount = maxThreads;
|
||||
long chunkBytes = fileSize / chunkCount;
|
||||
ChunkInfo[] allChunks = new ChunkInfo[chunkCount];
|
||||
for (int i = 0; i < chunkCount; i++)
|
||||
{
|
||||
allChunks[i] = new ChunkInfo
|
||||
{
|
||||
Index = i,
|
||||
StartPos = (long)i * chunkBytes,
|
||||
EndPos = (i == chunkCount - 1) ? fileSize - 1 : (long)i * chunkBytes + chunkBytes - 1,
|
||||
Downloaded = 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (File.Exists(savePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (new FileInfo(savePath).Length != fileSize)
|
||||
{
|
||||
File.Delete(savePath);
|
||||
using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write))
|
||||
fs.SetLength(fileSize);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(savePath)) File.Delete(savePath);
|
||||
using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write))
|
||||
fs.SetLength(fileSize);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write))
|
||||
fs.SetLength(fileSize);
|
||||
}
|
||||
|
||||
object progressLock = new object();
|
||||
Task[] workers = new Task[chunkCount];
|
||||
for (int i = 0; i < chunkCount; i++)
|
||||
{
|
||||
ChunkInfo chunk = allChunks[i];
|
||||
workers[i] = Task.Run(async () =>
|
||||
{
|
||||
await DownloadChunkWithRetry(url, savePath, chunk, allChunks, progressLock, fileSize, displayName);
|
||||
});
|
||||
}
|
||||
await Task.WhenAll(workers);
|
||||
}
|
||||
|
||||
private async Task<ProbeResult> ProbeUrl(string url)
|
||||
{
|
||||
ProbeResult result = new ProbeResult();
|
||||
try
|
||||
{
|
||||
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
|
||||
req.Method = "HEAD"; req.Timeout = 15000; req.UserAgent = userAgent;
|
||||
using (HttpWebResponse resp = (HttpWebResponse)await Task.Factory.FromAsync<WebResponse>(req.BeginGetResponse, req.EndGetResponse, null))
|
||||
{
|
||||
result.FileSize = resp.ContentLength;
|
||||
string acceptRanges = resp.Headers["Accept-Ranges"];
|
||||
result.SupportsRange = !string.IsNullOrEmpty(acceptRanges)
|
||||
&& acceptRanges.IndexOf("none", StringComparison.OrdinalIgnoreCase) < 0;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task DownloadChunkWithRetry(string url, string savePath, ChunkInfo chunk, ChunkInfo[] allChunks, object progressLock, long totalSize, string displayName)
|
||||
{
|
||||
for (int attempt = 0; attempt < DOWNLOAD_MAX_RETRIES; attempt++)
|
||||
{
|
||||
HttpWebResponse response = null;
|
||||
Stream responseStream = null;
|
||||
FileStream fs = null;
|
||||
try
|
||||
{
|
||||
long resumeFrom = chunk.StartPos + chunk.Downloaded;
|
||||
if (resumeFrom > chunk.EndPos) return;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AddRange(resumeFrom, chunk.EndPos);
|
||||
request.Timeout = 60000;
|
||||
request.ReadWriteTimeout = 60000;
|
||||
request.KeepAlive = true;
|
||||
request.UserAgent = userAgent;
|
||||
|
||||
response = (HttpWebResponse)await Task.Factory.FromAsync<WebResponse>(request.BeginGetResponse, request.EndGetResponse, null);
|
||||
responseStream = response.GetResponseStream();
|
||||
|
||||
byte[] buffer = new byte[65536];
|
||||
fs = new FileStream(savePath, FileMode.Open, FileAccess.Write, FileShare.Write);
|
||||
fs.Seek(resumeFrom, SeekOrigin.Begin);
|
||||
|
||||
int bytesRead;
|
||||
while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await WriteStreamAsync(fs, buffer, 0, bytesRead);
|
||||
chunk.Downloaded += bytesRead;
|
||||
|
||||
lock (progressLock)
|
||||
{
|
||||
long totalDownloaded = 0;
|
||||
foreach (ChunkInfo c in allChunks) totalDownloaded += c.Downloaded;
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
double elapsed = (now - speedCalcLastTime).TotalSeconds;
|
||||
if (elapsed >= 0.3)
|
||||
{
|
||||
long delta = totalDownloaded - speedCalcLastBytes;
|
||||
if (delta > 0)
|
||||
{
|
||||
double instantSpeed = delta / elapsed;
|
||||
currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7;
|
||||
}
|
||||
speedCalcLastBytes = totalDownloaded;
|
||||
speedCalcLastTime = now;
|
||||
}
|
||||
|
||||
int percent = (int)(totalDownloaded * 100 / totalSize);
|
||||
string sizeInfo = String.Format("{0}/{1}", FormatFileSize(totalDownloaded), FormatFileSize(totalSize));
|
||||
string speedInfo = FormatSpeed(currentSpeed);
|
||||
ReportProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), speedInfo);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(String.Format("分片{0}下载失败(第{1}次): {2}", chunk.Index, attempt + 1, ex.Message));
|
||||
|
||||
WebException webEx = ex as WebException;
|
||||
if (webEx == null && ex.InnerException != null)
|
||||
webEx = ex.InnerException as WebException;
|
||||
if (webEx != null && webEx.Response is HttpWebResponse)
|
||||
{
|
||||
HttpStatusCode code = ((HttpWebResponse)webEx.Response).StatusCode;
|
||||
if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized)
|
||||
throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } }
|
||||
if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } }
|
||||
if (response != null) { try { response.Close(); } catch { } }
|
||||
}
|
||||
|
||||
if (attempt + 1 < DOWNLOAD_MAX_RETRIES)
|
||||
await Task.Delay(Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 10000));
|
||||
}
|
||||
throw new Exception(String.Format("分片{0}下载失败,已达最大重试次数", chunk.Index));
|
||||
}
|
||||
|
||||
private async Task DownloadSingleThreadNoRange(string url, string savePath, string displayName)
|
||||
{
|
||||
speedCalcLastBytes = 0;
|
||||
speedCalcLastTime = DateTime.Now;
|
||||
currentSpeed = 0;
|
||||
|
||||
if (File.Exists(savePath)) { try { File.Delete(savePath); } catch { } }
|
||||
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
HttpWebResponse response = null;
|
||||
Stream responseStream = null;
|
||||
FileStream fs = null;
|
||||
Exception caught = null;
|
||||
try
|
||||
{
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.Timeout = 60000; request.ReadWriteTimeout = 60000; request.UserAgent = userAgent;
|
||||
|
||||
response = (HttpWebResponse)await Task.Factory.FromAsync<WebResponse>(request.BeginGetResponse, request.EndGetResponse, null);
|
||||
|
||||
long totalSize = response.ContentLength > 0 ? response.ContentLength : 0;
|
||||
fs = new FileStream(savePath, FileMode.Create, FileAccess.Write);
|
||||
|
||||
responseStream = response.GetResponseStream();
|
||||
byte[] buffer = new byte[65536];
|
||||
long downloaded = 0;
|
||||
|
||||
int bytesRead;
|
||||
while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await WriteStreamAsync(fs, buffer, 0, bytesRead);
|
||||
downloaded += bytesRead;
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
double elapsed = (now - speedCalcLastTime).TotalSeconds;
|
||||
if (elapsed >= 0.3)
|
||||
{
|
||||
long delta = downloaded - speedCalcLastBytes;
|
||||
if (delta > 0)
|
||||
{
|
||||
double instantSpeed = delta / elapsed;
|
||||
currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7;
|
||||
}
|
||||
speedCalcLastBytes = downloaded;
|
||||
speedCalcLastTime = now;
|
||||
}
|
||||
|
||||
int percent = totalSize > 0 ? (int)(downloaded * 100 / totalSize) : 0;
|
||||
string sizeInfo = totalSize > 0 ? String.Format("{0}/{1}", FormatFileSize(downloaded), FormatFileSize(totalSize)) : FormatFileSize(downloaded);
|
||||
string speedInfo = FormatSpeed(currentSpeed);
|
||||
ReportProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), speedInfo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
caught = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } }
|
||||
if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } }
|
||||
if (response != null) { try { response.Close(); } catch { } }
|
||||
}
|
||||
|
||||
WebException singleWebEx = caught as WebException;
|
||||
if (singleWebEx == null && caught.InnerException != null)
|
||||
singleWebEx = caught.InnerException as WebException;
|
||||
if (singleWebEx != null && singleWebEx.Response is HttpWebResponse)
|
||||
{
|
||||
HttpStatusCode code = ((HttpWebResponse)singleWebEx.Response).StatusCode;
|
||||
if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized)
|
||||
throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url));
|
||||
}
|
||||
|
||||
Debug.WriteLine(String.Format("单线程下载失败(第{0}次): {1}", attempt + 1, caught.Message));
|
||||
if (attempt >= DOWNLOAD_MAX_RETRIES - 1) throw caught;
|
||||
|
||||
int delay = Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 15000);
|
||||
ReportStatus(String.Format("下载中断({0}),{1}秒后重试...", caught.Message, delay / 1000));
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<int> ReadStreamAsync(Stream stream, byte[] buffer, int offset, int count)
|
||||
{
|
||||
return Task.Factory.FromAsync<int>(
|
||||
(callback, state) => stream.BeginRead(buffer, offset, count, callback, state),
|
||||
stream.EndRead, null);
|
||||
}
|
||||
|
||||
private Task WriteStreamAsync(Stream stream, byte[] buffer, int offset, int count)
|
||||
{
|
||||
return Task.Factory.FromAsync(
|
||||
(callback, state) => stream.BeginWrite(buffer, offset, count, callback, state),
|
||||
stream.EndWrite, null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void ReportProgress(int percent, string message)
|
||||
{
|
||||
ReportProgress(percent, message, "");
|
||||
}
|
||||
|
||||
private void ReportProgress(int percent, string message, string speedText)
|
||||
{
|
||||
if (OnProgress != null)
|
||||
{
|
||||
OnProgress(percent, message, speedText);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportStatus(string message)
|
||||
{
|
||||
if (OnStatus != null)
|
||||
{
|
||||
OnStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
if (bytes >= 1073741824)
|
||||
return String.Format("{0:F2} GB", bytes / 1073741824.0);
|
||||
if (bytes >= 1048576)
|
||||
return String.Format("{0:F2} MB", bytes / 1048576.0);
|
||||
if (bytes >= 1024)
|
||||
return String.Format("{0:F2} KB", bytes / 1024.0);
|
||||
return String.Format("{0} B", bytes);
|
||||
}
|
||||
|
||||
private string FormatSpeed(double bytesPerSecond)
|
||||
{
|
||||
if (bytesPerSecond <= 0) return "";
|
||||
if (bytesPerSecond >= 1073741824)
|
||||
return String.Format("{0:F2} GB/s", bytesPerSecond / 1073741824.0);
|
||||
if (bytesPerSecond >= 1048576)
|
||||
return String.Format("{0:F2} MB/s", bytesPerSecond / 1048576.0);
|
||||
if (bytesPerSecond >= 1024)
|
||||
return String.Format("{0:F2} KB/s", bytesPerSecond / 1024.0);
|
||||
return String.Format("{0:F0} B/s", bytesPerSecond);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Data structures
|
||||
|
||||
public class UpdateCheckResult
|
||||
{
|
||||
public bool has_update;
|
||||
public bool silent;
|
||||
public string latest_version;
|
||||
public string current_version;
|
||||
public string update_type;
|
||||
public string download_url;
|
||||
public string file_size;
|
||||
public string changelog;
|
||||
public string total_size;
|
||||
public List<IncrementalUpdate> incremental_updates;
|
||||
}
|
||||
|
||||
public class IncrementalUpdate
|
||||
{
|
||||
public string from_version;
|
||||
public string to_version;
|
||||
public string download_url;
|
||||
public string file_size;
|
||||
public string changelog;
|
||||
}
|
||||
|
||||
public class PatchManifest
|
||||
{
|
||||
public string from_version;
|
||||
public string to_version;
|
||||
public List<PatchDiffInfo> modified = new List<PatchDiffInfo>();
|
||||
public List<PatchFileInfo> new_files = new List<PatchFileInfo>();
|
||||
public List<string> deleted = new List<string>();
|
||||
}
|
||||
|
||||
public class PatchDiffInfo
|
||||
{
|
||||
public string path;
|
||||
public string patch_file;
|
||||
public string old_hash;
|
||||
public string new_hash;
|
||||
public long new_size;
|
||||
}
|
||||
|
||||
public class PatchFileInfo
|
||||
{
|
||||
public string path;
|
||||
public string hash;
|
||||
public long size;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user