6124d6060c
Co-authored-by: Cursor <cursoragent@cursor.com>
945 lines
38 KiB
C#
945 lines
38 KiB
C#
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
|
||
}
|