AMain.cs 87 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229
  1. using System.IO.Compression;
  2. using System.Net;
  3. using System.Net.Sockets;
  4. namespace AutoPatcherAdmin
  5. {
  6. public partial class AMain : Form
  7. {
  8. public const string PatchFileName = @"PList.gz";
  9. public const string StagingDirectoryName = "_staging";
  10. public const string TempUploadDirectory = "Out";
  11. public const string TempDownloadDirectory = "In";
  12. private const int MaxRetries = 3;
  13. private const int RetryDelaySeconds = 30;
  14. public string[] ExcludeList = new string[] { "Thumbs.db" };
  15. public List<FileInformation> OldList, NewList;
  16. public Queue<FileInformation> UploadList;
  17. private PatchCompareResult _compareResult;
  18. private bool _publishPreviewReady;
  19. private byte[]? _remotePListRawBytes;
  20. private readonly List<PreviewRowInfo> _previewRows = new();
  21. private readonly HashSet<string> _activeRowKeys = new(StringComparer.OrdinalIgnoreCase);
  22. private DateTime _lastPublishStart;
  23. private long _lastPublishOriginalBytes;
  24. private long _lastPublishCompressedBytes;
  25. private int _lastPublishFileCount;
  26. private CancellationTokenSource _cts = new CancellationTokenSource();
  27. private DgvProgressBarColumn? _progressColumn;
  28. private int _totalPublishFiles;
  29. private int _overallSteps;
  30. private int _liveConnections;
  31. private int _connectionCeiling;
  32. private ToolStripStatusLabel? _statusConnectionLabel;
  33. private readonly object _connectionCreateLock = new();
  34. #if DEBUG
  35. private static readonly string LogFile = Path.Combine(AppContext.BaseDirectory, "patcher.log");
  36. #endif
  37. private sealed class PreviewRowInfo
  38. {
  39. public string Action { get; init; } = string.Empty;
  40. public string Path { get; init; } = string.Empty;
  41. public long Size { get; init; }
  42. }
  43. public AMain()
  44. {
  45. InitializeComponent();
  46. ClientTextBox.Text = Settings.Client;
  47. HostTextBox.Text = Settings.Host;
  48. LoginTextBox.Text = Settings.Login;
  49. PasswordTextBox.Text = Settings.Password;
  50. AllowCleanCheckBox.Checked = Settings.AllowCleanUp;
  51. CompressFilesCheckBox.Checked = Settings.CompressFiles;
  52. ProtocolDropDown.SelectedIndex = ProtocolDropDown.FindString(Settings.Protocol);
  53. PortNumericUpDown.Value = Math.Clamp(Settings.Port, 0, 65535);
  54. PreviewActionFilterDropDown.SelectedIndex = 0;
  55. DeleteDirectory(TempDownloadDirectory);
  56. DeleteDirectory(TempUploadDirectory);
  57. UpdateSummaryLabels(null);
  58. ClearPreviewGrid();
  59. ProcessButton.Enabled = false;
  60. _progressColumn = new DgvProgressBarColumn();
  61. PreviewGrid.Columns.Add(_progressColumn);
  62. PreviewGrid.Sort(_progressColumn, System.ComponentModel.ListSortDirection.Descending);
  63. }
  64. protected override void OnFormClosing(FormClosingEventArgs e)
  65. {
  66. _cts.Cancel();
  67. DeleteDirectory(TempDownloadDirectory);
  68. DeleteDirectory(TempUploadDirectory);
  69. base.OnFormClosing(e);
  70. }
  71. private void SaveConnectionSettings()
  72. {
  73. Settings.Client = ClientTextBox.Text;
  74. Settings.Protocol = ProtocolDropDown.SelectedItem as string ?? "Ftp";
  75. Settings.Host = NormalizeHost(HostTextBox.Text.Trim(), Settings.Protocol);
  76. HostTextBox.Text = Settings.Host;
  77. Settings.Login = LoginTextBox.Text;
  78. Settings.Password = PasswordTextBox.Text;
  79. Settings.AllowCleanUp = AllowCleanCheckBox.Checked;
  80. Settings.CompressFiles = CompressFilesCheckBox.Checked;
  81. Settings.Port = (int)PortNumericUpDown.Value;
  82. Settings.Save();
  83. }
  84. private static string NormalizeHost(string host, string protocol)
  85. {
  86. if (string.IsNullOrWhiteSpace(host) || host.Contains("://")) return host;
  87. string scheme = (protocol ?? "Ftp").ToLowerInvariant() switch
  88. {
  89. "sftp" => "sftp://",
  90. "http" => "http://",
  91. "https" => "https://",
  92. _ => "ftp://"
  93. };
  94. return scheme + host;
  95. }
  96. private void BrowseClientButton_Click(object sender, EventArgs e)
  97. {
  98. using var dialog = new FolderBrowserDialog();
  99. dialog.Description = "Select client directory";
  100. string current = ClientTextBox.Text.TrimEnd('\\', '/');
  101. if (Directory.Exists(current))
  102. dialog.SelectedPath = current;
  103. if (dialog.ShowDialog() == DialogResult.OK)
  104. ClientTextBox.Text = dialog.SelectedPath + @"\";
  105. }
  106. private void OnUi(Action action)
  107. {
  108. if (IsDisposed) return;
  109. if (InvokeRequired)
  110. Invoke(action);
  111. else
  112. action();
  113. }
  114. private void PostUi(Action action)
  115. {
  116. if (IsDisposed) return;
  117. if (InvokeRequired)
  118. {
  119. try
  120. {
  121. BeginInvoke(action);
  122. }
  123. catch (InvalidOperationException)
  124. {
  125. }
  126. }
  127. else
  128. {
  129. action();
  130. }
  131. }
  132. private T OnUi<T>(Func<T> action)
  133. {
  134. if (InvokeRequired)
  135. return (T)Invoke(action);
  136. return action();
  137. }
  138. private void SetActionText(string text)
  139. {
  140. PostUi(() => ActionLabel.Text = text);
  141. }
  142. private void SetFileText(string text)
  143. {
  144. PostUi(() => FileLabel.Text = text);
  145. }
  146. private void ShowError(Exception ex)
  147. {
  148. OnUi(() =>
  149. {
  150. MessageBox.Show(this, GetFriendlyError(ex), "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
  151. ActionLabel.Text = "Error.";
  152. });
  153. }
  154. private static string GetFriendlyError(Exception ex)
  155. {
  156. if (ex is SocketException se)
  157. return $"Network error: {se.Message}\nCheck host address and that the server is accessible.";
  158. if (ex is IOException && ex.InnerException is SocketException se2)
  159. return $"Network error: {se2.Message}\nCheck host address and that the server is accessible.";
  160. if (ex is WebException we)
  161. {
  162. return we.Status switch
  163. {
  164. WebExceptionStatus.ConnectFailure => "Could not connect to the server. Check the host address and that the server is running.",
  165. WebExceptionStatus.Timeout => "Connection timed out. The server may be busy or unreachable.",
  166. WebExceptionStatus.NameResolutionFailure => "Host name could not be resolved. Check the host address.",
  167. WebExceptionStatus.ConnectionClosed => "The server closed the connection unexpectedly.",
  168. WebExceptionStatus.ProtocolError => $"Server returned an error: {we.Message}",
  169. _ => $"Network error: {we.Message}"
  170. };
  171. }
  172. if (ex is DirectoryNotFoundException)
  173. return $"Directory not found:\n{ex.Message}";
  174. if (ex is FileNotFoundException fnf)
  175. return $"File not found:\n{fnf.FileName ?? ex.Message}";
  176. if (ex is UnauthorizedAccessException)
  177. return $"Access denied. Check server permissions.\n{ex.Message}";
  178. string typeName = ex.GetType().FullName ?? string.Empty;
  179. if (typeName.Contains("SshAuthenticationException"))
  180. return "Authentication failed. Check the username and password.";
  181. if (typeName.Contains("SshConnectionException"))
  182. return "SSH connection failed. Check the host address and that the SFTP server is running.";
  183. if (ex.InnerException != null)
  184. return GetFriendlyError(ex.InnerException);
  185. return ex.Message;
  186. }
  187. private static bool IsDisconnectException(Exception ex)
  188. {
  189. if (ex is SocketException) return true;
  190. if (ex is IOException && ex.InnerException is SocketException) return true;
  191. if (ex is TimeoutException) return true;
  192. if (ex is WebException we && we.Status is
  193. WebExceptionStatus.ConnectionClosed or
  194. WebExceptionStatus.ConnectFailure or
  195. WebExceptionStatus.ReceiveFailure or
  196. WebExceptionStatus.SendFailure or
  197. WebExceptionStatus.Timeout) return true;
  198. string typeName = ex.GetType().FullName ?? string.Empty;
  199. if (typeName.Contains("SshConnectionException") ||
  200. typeName.Contains("SshOperationTimeoutException")) return true;
  201. return ex.InnerException != null && IsDisconnectException(ex.InnerException);
  202. }
  203. private static bool IsConnectionLimitException(Exception ex)
  204. {
  205. string msg = ex.Message;
  206. if (msg.Contains("421") ||
  207. msg.Contains("too many", StringComparison.OrdinalIgnoreCase) ||
  208. msg.Contains("connection limit", StringComparison.OrdinalIgnoreCase) ||
  209. msg.Contains("max sessions", StringComparison.OrdinalIgnoreCase))
  210. return true;
  211. string typeName = ex.GetType().FullName ?? string.Empty;
  212. if (typeName.Contains("ChannelOpen") || typeName.Contains("TooMany"))
  213. return true;
  214. return ex.InnerException != null && IsConnectionLimitException(ex.InnerException);
  215. }
  216. private void RetryOnDisconnect(Action operation, string phase)
  217. {
  218. int attempt = 0;
  219. while (true)
  220. {
  221. try
  222. {
  223. operation();
  224. return;
  225. }
  226. catch (OperationCanceledException)
  227. {
  228. throw;
  229. }
  230. catch (Exception ex) when (IsDisconnectException(ex) && attempt < MaxRetries)
  231. {
  232. attempt++;
  233. Log($"Disconnect during {phase} (attempt {attempt}/{MaxRetries}): {ex.Message}");
  234. for (int remaining = RetryDelaySeconds; remaining > 0; remaining--)
  235. {
  236. CheckCancelled();
  237. SetActionText($"{phase} — connection lost, retrying in {remaining}s ({attempt}/{MaxRetries})...");
  238. SetFileText(string.Empty);
  239. Thread.Sleep(1000);
  240. }
  241. SetActionText($"Reconnecting ({attempt}/{MaxRetries})...");
  242. }
  243. }
  244. }
  245. private bool ValidateSettings()
  246. {
  247. if (!Directory.Exists(Settings.Client))
  248. {
  249. string msg = $"Client directory not found:\n{Settings.Client}\n\nUpdate the Client Directory setting and try again.";
  250. OnUi(() => MessageBox.Show(this, msg, "Validation Failed", MessageBoxButtons.OK, MessageBoxIcon.Error));
  251. SetActionText("Error: client directory not found.");
  252. return false;
  253. }
  254. if (!Uri.TryCreate(Settings.Host, UriKind.Absolute, out _))
  255. {
  256. string msg = $"Host address is not a valid URL:\n{Settings.Host}\n\nCheck the Host Address setting.";
  257. OnUi(() => MessageBox.Show(this, msg, "Validation Failed", MessageBoxButtons.OK, MessageBoxIcon.Error));
  258. SetActionText("Error: invalid host address.");
  259. return false;
  260. }
  261. return true;
  262. }
  263. private void CheckCancelled()
  264. {
  265. if (_cts.IsCancellationRequested)
  266. throw new OperationCanceledException("Operation cancelled.");
  267. }
  268. [System.Diagnostics.Conditional("DEBUG")]
  269. private static void Log(string message)
  270. {
  271. #if DEBUG
  272. try
  273. {
  274. var logFile1 = LogFile + ".1";
  275. if (File.Exists(LogFile) && new FileInfo(LogFile).Length > 1_048_576)
  276. {
  277. if (File.Exists(logFile1)) File.Delete(logFile1);
  278. File.Move(LogFile, logFile1);
  279. }
  280. File.AppendAllText(LogFile, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}\r\n");
  281. }
  282. catch { }
  283. #endif
  284. }
  285. private void CompletePListOperation(string status)
  286. {
  287. SetActionText(status);
  288. SetFileText(string.Empty);
  289. }
  290. private void CompleteDownload()
  291. {
  292. OnUi(() =>
  293. {
  294. FileLabel.Text = "Complete...";
  295. SpeedLabel.Text = "Complete...";
  296. ActionLabel.Text = "Complete...";
  297. });
  298. }
  299. private void CompleteUpload()
  300. {
  301. _publishPreviewReady = false;
  302. ShowPublishCompleteAndReset();
  303. }
  304. private void ShowPublishCompleteAndReset()
  305. {
  306. var elapsed = DateTime.Now - _lastPublishStart;
  307. string timeStr = elapsed.TotalMinutes >= 1
  308. ? $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"
  309. : $"{elapsed.Seconds}s";
  310. var result = _compareResult;
  311. int added = result?.Added.Count ?? 0;
  312. int changed = result?.Changed.Count ?? 0;
  313. int deleted = result?.Deleted.Count ?? 0;
  314. long origBytes = _lastPublishOriginalBytes;
  315. long compBytes = _lastPublishCompressedBytes;
  316. int fileCount = _lastPublishFileCount;
  317. Log($"Publish complete — {fileCount} files uploaded ({added} added, {changed} changed, {deleted} removed), time: {timeStr}");
  318. OnUi(() =>
  319. {
  320. using var dlg = new PublishCompleteDialog(
  321. filesUploaded: fileCount,
  322. added: added,
  323. changed: changed,
  324. removed: Settings.AllowCleanUp ? deleted : 0,
  325. origBytes: origBytes,
  326. compBytes: compBytes,
  327. showCompression: Settings.CompressFiles && origBytes > 0,
  328. timeElapsed: timeStr);
  329. dlg.ShowDialog(this);
  330. ActionLabel.Text = "Publish complete.";
  331. FileLabel.Text = string.Empty;
  332. SpeedLabel.Text = string.Empty;
  333. _compareResult = null;
  334. UpdateSummaryLabels(null);
  335. ClearPreviewGrid();
  336. });
  337. }
  338. private void CleanUp()
  339. {
  340. if (!Settings.AllowCleanUp) return;
  341. var rootPath = (new Uri(Settings.Host)).AbsolutePath;
  342. using IPatchTransport transport = PatchTransportFactory.Create();
  343. var stale = OldList.Where(f => !NeedFile(f.FileName)).ToList();
  344. int total = stale.Count;
  345. int done = 0;
  346. foreach (var entry in stale)
  347. {
  348. CheckCancelled();
  349. done++;
  350. var isCompressed = entry.Length != entry.Compressed;
  351. var filename = entry.FileName + (isCompressed ? ".gz" : "");
  352. SetActionText($"Removing old file {done} of {total}...");
  353. SetFileText(filename);
  354. try
  355. {
  356. var filePath = Path.Combine(rootPath, filename).Replace(@"\", "/");
  357. if (transport.FileExists(filePath))
  358. transport.DeleteFile(filePath);
  359. }
  360. catch { }
  361. }
  362. }
  363. private bool NeedFile(string fileName)
  364. {
  365. for (int i = 0; i < NewList.Count; i++)
  366. {
  367. if (fileName.EndsWith(NewList[i].FileName) && !InExcludeList(NewList[i].FileName))
  368. return true;
  369. }
  370. return false;
  371. }
  372. private void GetOldFileList()
  373. {
  374. OldList = new List<FileInformation>();
  375. byte[]? data = DownloadFile(PatchFileName);
  376. _remotePListRawBytes = data;
  377. if (data != null)
  378. {
  379. using MemoryStream stream = new MemoryStream(data);
  380. using BinaryReader reader = new BinaryReader(stream);
  381. int count = reader.ReadInt32();
  382. for (int i = 0; i < count; i++)
  383. {
  384. OldList.Add(new FileInformation(reader));
  385. }
  386. }
  387. }
  388. private static string CombineRemotePath(string rootPath, string fileName)
  389. {
  390. rootPath = (rootPath ?? string.Empty).Replace('\\', '/').TrimEnd('/');
  391. fileName = (fileName ?? string.Empty).Replace('\\', '/').TrimStart('/');
  392. return string.IsNullOrEmpty(rootPath) ? "/" + fileName : rootPath + "/" + fileName;
  393. }
  394. private byte[] CreateNewList()
  395. {
  396. using MemoryStream stream = new MemoryStream();
  397. using BinaryWriter writer = new BinaryWriter(stream);
  398. writer.Write(NewList.Count);
  399. for (int i = 0; i < NewList.Count; i++)
  400. {
  401. NewList[i].Save(writer);
  402. }
  403. return stream.ToArray();
  404. }
  405. private void GetNewFileList()
  406. {
  407. NewList = new List<FileInformation>();
  408. string[] files = Directory.GetFiles(Settings.Client, "*.*" ,SearchOption.AllDirectories);
  409. for (int i = 0; i < files.Length; i++)
  410. {
  411. NewList.Add(GetFileInformation(files[i]));
  412. }
  413. }
  414. private bool InExcludeList(string fileName)
  415. {
  416. fileName = (fileName ?? string.Empty).Replace('\\', '/');
  417. foreach (var item in ExcludeList)
  418. {
  419. if (fileName.EndsWith(item, StringComparison.OrdinalIgnoreCase)) return true;
  420. }
  421. return false;
  422. }
  423. private FileInformation FindOldFile(string fileName)
  424. {
  425. return OldList?.FirstOrDefault(x => string.Equals(x.FileName.Replace('\\', '/'), fileName.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase));
  426. }
  427. private PatchCompareResult BuildPListCompareResult()
  428. {
  429. var result = new PatchCompareResult();
  430. var oldByName = (OldList ?? Enumerable.Empty<FileInformation>())
  431. .ToDictionary(x => x.FileName.Replace('\\', '/'), StringComparer.OrdinalIgnoreCase);
  432. foreach (var info in NewList ?? Enumerable.Empty<FileInformation>())
  433. {
  434. if (InExcludeList(info.FileName)) continue;
  435. string path = info.FileName.Replace('\\', '/');
  436. var patchFile = new PListFile { Path = path, Length = info.Length };
  437. if (!oldByName.TryGetValue(path, out var old))
  438. {
  439. result.Added.Add(patchFile);
  440. }
  441. else if (old.Length != info.Length || old.Creation != info.Creation)
  442. {
  443. result.Changed.Add(patchFile);
  444. }
  445. else
  446. {
  447. result.Unchanged.Add(patchFile);
  448. }
  449. }
  450. var localPaths = new HashSet<string>(
  451. (NewList ?? Enumerable.Empty<FileInformation>())
  452. .Where(f => !InExcludeList(f.FileName))
  453. .Select(f => f.FileName.Replace('\\', '/')),
  454. StringComparer.OrdinalIgnoreCase);
  455. foreach (var old in OldList ?? Enumerable.Empty<FileInformation>())
  456. {
  457. if (!localPaths.Contains(old.FileName.Replace('\\', '/')))
  458. result.Deleted.Add(new PListFile { Path = old.FileName.Replace('\\', '/'), Length = old.Length });
  459. }
  460. return result;
  461. }
  462. private void UpdateSummaryLabels(PatchCompareResult? result)
  463. {
  464. int added = result?.Added.Count ?? 0;
  465. int changed = result?.Changed.Count ?? 0;
  466. int unchanged = result?.Unchanged.Count ?? 0;
  467. int deleted = result?.Deleted.Count ?? 0;
  468. int uploadCount = result?.UploadCount ?? 0;
  469. long uploadBytes = result?.UploadBytes ?? 0;
  470. OnUi(() =>
  471. {
  472. SummaryAddedLabel.Text = $"Added: {added}";
  473. SummaryChangedLabel.Text = $"Changed: {changed}";
  474. SummaryUnchangedLabel.Text = $"Unchanged: {unchanged}";
  475. SummaryDeletedLabel.Text = $"Deleted: {deleted}";
  476. string sizeNote = Settings.CompressFiles && uploadCount > 0 ? " (uncompressed)" : string.Empty;
  477. SummaryUploadSizeLabel.Text = $"Upload: {uploadCount} files / {FormatBytes(uploadBytes)}{sizeNote}";
  478. });
  479. }
  480. private void ClearPreviewGrid()
  481. {
  482. _totalPublishFiles = 0;
  483. _overallSteps = 0;
  484. OnUi(() =>
  485. {
  486. _previewRows.Clear();
  487. _activeRowKeys.Clear();
  488. PreviewGrid.Rows.Clear();
  489. ProcessButton.Enabled = false;
  490. OverallProgressBar.Value = 0;
  491. });
  492. _publishPreviewReady = false;
  493. }
  494. private void PopulateUploadQueue(IReadOnlyList<FileInformation> files)
  495. {
  496. OnUi(() =>
  497. {
  498. _previewRows.Clear();
  499. PreviewGrid.Rows.Clear();
  500. foreach (var file in files)
  501. {
  502. string normalizedPath = file.FileName.Replace('\\', '/');
  503. int rowIndex = PreviewGrid.Rows.Add("Pending", normalizedPath, FormatBytes(file.Length), 0);
  504. var row = PreviewGrid.Rows[rowIndex];
  505. row.Tag = normalizedPath;
  506. row.DefaultCellStyle.BackColor = Color.FromArgb(220, 235, 255);
  507. row.DefaultCellStyle.ForeColor = Color.FromArgb(21, 67, 120);
  508. }
  509. });
  510. }
  511. private void UpdateRowStatus(string key, string statusText, Color backColor, Color foreColor)
  512. {
  513. PostUi(() =>
  514. {
  515. _activeRowKeys.Remove(key);
  516. int activeCount = _activeRowKeys.Count;
  517. int colIndex = _progressColumn?.Index ?? -1;
  518. for (int i = 0; i < PreviewGrid.Rows.Count; i++)
  519. {
  520. var row = PreviewGrid.Rows[i];
  521. if (!string.Equals(row.Tag as string, key, StringComparison.OrdinalIgnoreCase))
  522. continue;
  523. if (i < activeCount)
  524. {
  525. // Row is inside the active zone — relocate it to just below active rows
  526. string path = row.Cells[1].Value as string ?? key;
  527. string size = row.Cells[2].Value as string ?? string.Empty;
  528. int progress = colIndex >= 0 ? Convert.ToInt32(row.Cells[colIndex].Value ?? 0) : 0;
  529. PreviewGrid.Rows.RemoveAt(i);
  530. int insertAt = Math.Min(activeCount, PreviewGrid.Rows.Count);
  531. PreviewGrid.Rows.Insert(insertAt, statusText, path, size, progress);
  532. var newRow = PreviewGrid.Rows[insertAt];
  533. newRow.Tag = key;
  534. newRow.DefaultCellStyle.BackColor = backColor;
  535. newRow.DefaultCellStyle.ForeColor = foreColor;
  536. }
  537. else
  538. {
  539. // Already below the active zone — update in place
  540. row.Cells[0].Value = statusText;
  541. row.DefaultCellStyle.BackColor = backColor;
  542. row.DefaultCellStyle.ForeColor = foreColor;
  543. }
  544. break;
  545. }
  546. });
  547. }
  548. private void MoveRowToTop(string key, string statusText, Color backColor, Color foreColor)
  549. {
  550. _activeRowKeys.Add(key);
  551. int colIndex = _progressColumn?.Index ?? -1;
  552. for (int i = 0; i < PreviewGrid.Rows.Count; i++)
  553. {
  554. var row = PreviewGrid.Rows[i];
  555. if (!string.Equals(row.Tag as string, key, StringComparison.OrdinalIgnoreCase))
  556. continue;
  557. string path = row.Cells[1].Value as string ?? key;
  558. string size = row.Cells[2].Value as string ?? string.Empty;
  559. int progress = colIndex >= 0 ? Convert.ToInt32(row.Cells[colIndex].Value ?? 0) : 0;
  560. PreviewGrid.Rows.RemoveAt(i);
  561. PreviewGrid.Rows.Insert(0, statusText, path, size, progress);
  562. var newRow = PreviewGrid.Rows[0];
  563. newRow.Tag = key;
  564. newRow.DefaultCellStyle.BackColor = backColor;
  565. newRow.DefaultCellStyle.ForeColor = foreColor;
  566. PreviewGrid.FirstDisplayedScrollingRowIndex = 0;
  567. break;
  568. }
  569. }
  570. private void SetUploadRowPreparing(string rawFileName, string verb)
  571. {
  572. string key = rawFileName.Replace('\\', '/');
  573. PostUi(() => MoveRowToTop(key, verb, Color.FromArgb(243, 232, 255), Color.FromArgb(80, 20, 130)));
  574. }
  575. private void SetUploadRowUploading(string contentFileName)
  576. {
  577. string key = NormalizeUploadKey(contentFileName);
  578. PostUi(() => MoveRowToTop(key, "Uploading", Color.FromArgb(255, 246, 214), Color.FromArgb(120, 78, 0)));
  579. }
  580. private void RemoveUploadRow(string contentFileName)
  581. {
  582. string key = NormalizeUploadKey(contentFileName);
  583. PostUi(() =>
  584. {
  585. _activeRowKeys.Remove(key);
  586. foreach (DataGridViewRow row in PreviewGrid.Rows)
  587. {
  588. if (string.Equals(row.Tag as string, key, StringComparison.OrdinalIgnoreCase))
  589. {
  590. PreviewGrid.Rows.Remove(row);
  591. break;
  592. }
  593. }
  594. });
  595. }
  596. private void SetUploadRowStaging(string contentFileName)
  597. {
  598. string key = NormalizeUploadKey(contentFileName);
  599. PostUi(() => MoveRowToTop(key, "Staging", Color.FromArgb(200, 245, 245), Color.FromArgb(0, 100, 110)));
  600. }
  601. private void SetUploadRowVerifying(string contentFileName)
  602. {
  603. string key = NormalizeUploadKey(contentFileName);
  604. PostUi(() => MoveRowToTop(key, "Verifying", Color.FromArgb(255, 250, 200), Color.FromArgb(100, 85, 0)));
  605. }
  606. private static string NormalizeUploadKey(string contentFileName)
  607. {
  608. string key = contentFileName.Replace('\\', '/');
  609. if (key.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
  610. key = key[..^3];
  611. return key;
  612. }
  613. private void SetUploadRowProgress(string contentFileName, int pct)
  614. {
  615. if (_progressColumn == null) return;
  616. string key = NormalizeUploadKey(contentFileName);
  617. int colIndex = _progressColumn.Index;
  618. PostUi(() =>
  619. {
  620. foreach (DataGridViewRow row in PreviewGrid.Rows)
  621. {
  622. if (string.Equals(row.Tag as string, key, StringComparison.OrdinalIgnoreCase))
  623. {
  624. row.Cells[colIndex].Value = pct;
  625. break;
  626. }
  627. }
  628. });
  629. }
  630. private void AdvanceOverallProgress()
  631. {
  632. if (_totalPublishFiles <= 0) return;
  633. int steps = Interlocked.Increment(ref _overallSteps);
  634. int pct = Math.Min(100, steps * 100 / (_totalPublishFiles * 4));
  635. PostUi(() => OverallProgressBar.Value = pct);
  636. }
  637. private void PopulatePreviewGrid(PatchCompareResult result)
  638. {
  639. OnUi(() =>
  640. {
  641. _previewRows.Clear();
  642. AddPreviewRows("Added", result.Added);
  643. AddPreviewRows("Changed", result.Changed);
  644. AddPreviewRows("Deleted", result.Deleted);
  645. AddPreviewRows("Unchanged", result.Unchanged);
  646. RenderPreviewRows();
  647. });
  648. }
  649. private void AddPreviewRows(string action, IEnumerable<PListFile> files)
  650. {
  651. foreach (var file in files.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase))
  652. {
  653. _previewRows.Add(new PreviewRowInfo
  654. {
  655. Action = action,
  656. Path = file.Path,
  657. Size = file.Length
  658. });
  659. }
  660. }
  661. private void RenderPreviewRows()
  662. {
  663. string filter = PreviewActionFilterDropDown.SelectedItem as string ?? "All";
  664. PreviewGrid.Rows.Clear();
  665. foreach (var row in _previewRows.Where(x => filter == "All" || x.Action == filter))
  666. {
  667. int rowIndex = PreviewGrid.Rows.Add(row.Action, row.Path, FormatBytes(row.Size));
  668. ApplyPreviewRowStyle(PreviewGrid.Rows[rowIndex], row.Action);
  669. }
  670. }
  671. private static void ApplyPreviewRowStyle(DataGridViewRow row, string action)
  672. {
  673. switch (action)
  674. {
  675. case "Added":
  676. row.DefaultCellStyle.BackColor = Color.FromArgb(225, 248, 225);
  677. row.DefaultCellStyle.ForeColor = Color.DarkGreen;
  678. break;
  679. case "Changed":
  680. row.DefaultCellStyle.BackColor = Color.FromArgb(255, 246, 214);
  681. row.DefaultCellStyle.ForeColor = Color.FromArgb(120, 78, 0);
  682. break;
  683. case "Deleted":
  684. row.DefaultCellStyle.BackColor = Color.FromArgb(255, 225, 225);
  685. row.DefaultCellStyle.ForeColor = Color.DarkRed;
  686. break;
  687. case "Unchanged":
  688. row.DefaultCellStyle.BackColor = Color.FromArgb(238, 238, 238);
  689. row.DefaultCellStyle.ForeColor = Color.DimGray;
  690. break;
  691. }
  692. }
  693. private void PreviewActionFilterDropDown_SelectedIndexChanged(object sender, EventArgs e)
  694. {
  695. RenderPreviewRows();
  696. }
  697. private bool PreparePublishPreview()
  698. {
  699. if (!ValidateSettings()) return false;
  700. SetActionText("Verifying connection...");
  701. try
  702. {
  703. using var probe = PatchTransportFactory.Create();
  704. probe.DirectoryExists(new Uri(Settings.Host).AbsolutePath);
  705. }
  706. catch (OperationCanceledException) { throw; }
  707. catch (Exception ex)
  708. {
  709. ShowError(ex);
  710. SetActionText("Connection failed — check host and credentials.");
  711. return false;
  712. }
  713. Log($"Compare started — host: {Settings.Host}");
  714. UploadList = new Queue<FileInformation>();
  715. ClearPreviewGrid();
  716. UpdateSummaryLabels(null);
  717. SetActionText("Connecting...");
  718. RetryOnDisconnect(() =>
  719. {
  720. GetOldFileList();
  721. }, "Connecting");
  722. bool forceReuploadAll = false;
  723. if (OldList.Count == 0)
  724. {
  725. var result = OnUi(() => MessageBox.Show(this,
  726. "No existing PList was found on the host. Publishing now will upload every file. Continue with compare?",
  727. "PList not found",
  728. MessageBoxButtons.YesNo,
  729. MessageBoxIcon.Warning));
  730. if (result != DialogResult.Yes)
  731. {
  732. SetActionText("Cancelled.");
  733. return false;
  734. }
  735. }
  736. CheckCancelled();
  737. SetActionText("Comparing files...");
  738. GetNewFileList();
  739. CheckCancelled();
  740. _compareResult = BuildPListCompareResult();
  741. UpdateSummaryLabels(_compareResult);
  742. PopulatePreviewGrid(_compareResult);
  743. Log($"Compare done — added:{_compareResult.Added.Count} changed:{_compareResult.Changed.Count} deleted:{_compareResult.Deleted.Count} unchanged:{_compareResult.Unchanged.Count}");
  744. var changedPaths = new HashSet<string>(
  745. _compareResult.Added.Concat(_compareResult.Changed).Select(x => x.Path),
  746. StringComparer.OrdinalIgnoreCase);
  747. for (int i = 0; i < NewList.Count; i++)
  748. {
  749. FileInformation info = NewList[i];
  750. string normalizedPath = info.FileName.Replace('\\', '/');
  751. if (InExcludeList(info.FileName)) continue;
  752. if (forceReuploadAll || changedPaths.Contains(normalizedPath))
  753. {
  754. UploadList.Enqueue(info);
  755. }
  756. else
  757. {
  758. var old = FindOldFile(info.FileName);
  759. if (old != null)
  760. NewList[i] = old;
  761. }
  762. }
  763. bool hasPListChanges = _compareResult.UploadCount > 0 || _compareResult.Deleted.Count > 0;
  764. _publishPreviewReady = hasPListChanges;
  765. OnUi(() =>
  766. {
  767. ProcessButton.Enabled = _publishPreviewReady;
  768. ActionLabel.Text = _publishPreviewReady
  769. ? $"Ready: {_compareResult.UploadCount} files to upload, {_compareResult.Deleted.Count} files to remove."
  770. : "Up to date — all files match remote.";
  771. });
  772. return true;
  773. }
  774. private void DisableAllButtons()
  775. {
  776. OnUi(() =>
  777. {
  778. CompareButton.Enabled = false;
  779. ProcessButton.Enabled = false;
  780. ListButton.Enabled = false;
  781. DownloadExistingButton.Enabled = false;
  782. ClearRepositoryButton.Enabled = false;
  783. TestConnectionButton.Enabled = false;
  784. PreviewActionFilterDropDown.Enabled = false;
  785. AbortButton.Enabled = true;
  786. });
  787. }
  788. private void RestoreButtons()
  789. {
  790. OnUi(() =>
  791. {
  792. CompareButton.Enabled = true;
  793. ProcessButton.Enabled = _publishPreviewReady;
  794. ListButton.Enabled = true;
  795. DownloadExistingButton.Enabled = true;
  796. ClearRepositoryButton.Enabled = true;
  797. TestConnectionButton.Enabled = true;
  798. PreviewActionFilterDropDown.Enabled = true;
  799. AbortButton.Enabled = false;
  800. AbortButton.Text = "Cancel";
  801. PreviewGrid.Enabled = true;
  802. if (_statusConnectionLabel != null) _statusConnectionLabel.Text = string.Empty;
  803. Cursor = Cursors.Default;
  804. });
  805. }
  806. private static string FormatSpeed(long bytesPerSecond)
  807. {
  808. if (bytesPerSecond >= 1L << 30) return $"{bytesPerSecond / (double)(1L << 30):0.##} GB/s";
  809. if (bytesPerSecond >= 1L << 20) return $"{bytesPerSecond / (double)(1L << 20):0.##} MB/s";
  810. if (bytesPerSecond >= 1L << 10) return $"{bytesPerSecond / (double)(1L << 10):0.##} KB/s";
  811. return $"{bytesPerSecond} B/s";
  812. }
  813. private static string FormatBytes(long bytes)
  814. {
  815. if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):0.##} GB";
  816. if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):0.##} MB";
  817. if (bytes >= 1L << 10) return $"{bytes / (double)(1L << 10):0.##} KB";
  818. return $"{bytes} B";
  819. }
  820. private bool NeedUpdate(FileInformation info)
  821. {
  822. for (int i = 0; i < OldList.Count; i++)
  823. {
  824. FileInformation old = OldList[i];
  825. if (old.FileName != info.FileName) continue;
  826. if (old.Length != info.Length) return true;
  827. if (old.Creation != info.Creation)
  828. return !RemoteContentMatches(old, info);
  829. return false;
  830. }
  831. return true;
  832. }
  833. private bool RemoteContentMatches(FileInformation old, FileInformation current)
  834. {
  835. try
  836. {
  837. string remoteFileName = old.FileName;
  838. if (old.Compressed > 0 && old.Compressed != old.Length)
  839. remoteFileName += ".gz";
  840. byte[]? remoteBytes = DownloadFile(remoteFileName);
  841. if (remoteBytes == null) return false;
  842. if (old.Compressed > 0 && old.Compressed != old.Length)
  843. remoteBytes = Decompress(remoteBytes);
  844. byte[] localBytes = File.ReadAllBytes(Path.Combine(Settings.Client, current.FileName));
  845. return remoteBytes.SequenceEqual(localBytes);
  846. }
  847. catch
  848. {
  849. return false;
  850. }
  851. }
  852. private FileInformation? GetFileInformation(string fileName)
  853. {
  854. if (!File.Exists(fileName))
  855. {
  856. return null;
  857. }
  858. FileInfo info = new(fileName);
  859. FileInformation file = new()
  860. {
  861. FileName = fileName.Remove(0, Settings.Client.Length).TrimStart('\\'),
  862. Length = (int)info.Length,
  863. Creation = info.LastWriteTime
  864. };
  865. return file;
  866. }
  867. private void BeginUpload()
  868. {
  869. if (UploadList == null) return;
  870. if (!ValidateSettings()) throw new OperationCanceledException("Validation failed.");
  871. // Guard against stale compare: re-download the PList and warn if it changed.
  872. SetActionText("Checking remote state...");
  873. byte[]? currentRemote = DownloadFile(PatchFileName);
  874. if (currentRemote != null && _remotePListRawBytes != null &&
  875. !currentRemote.SequenceEqual(_remotePListRawBytes))
  876. {
  877. var answer = OnUi(() => MessageBox.Show(this,
  878. "The remote PList has changed since you ran Compare.\n" +
  879. "Another publish may have happened in the meantime.\n\n" +
  880. "Continue with the current publish plan?",
  881. "Remote Changed",
  882. MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2));
  883. if (answer != DialogResult.Yes)
  884. throw new OperationCanceledException("Publish cancelled: remote PList changed.");
  885. }
  886. Log($"Publish started — {UploadList.Count} files");
  887. _lastPublishStart = DateTime.Now;
  888. DeleteDirectory(TempUploadDirectory);
  889. var uploadSnapshot = UploadList.ToList();
  890. _totalPublishFiles = uploadSnapshot.Count;
  891. _overallSteps = 0;
  892. OnUi(() => OverallProgressBar.Value = 0);
  893. PopulateUploadQueue(uploadSnapshot);
  894. var pListFiles = new List<string>();
  895. int totalToCompress = uploadSnapshot.Count;
  896. int compressed = 0;
  897. long totalOriginalBytes = 0L;
  898. long totalCompressedBytes = 0L;
  899. var contentFilePaths = new string[totalToCompress];
  900. var uploadedInfos = new FileInformation[totalToCompress];
  901. string prepVerb = Settings.CompressFiles ? "Compressing" : "Preparing";
  902. PostUi(() => ActionLabel.Text = $"{prepVerb} {totalToCompress} files...");
  903. // activeKeys — files being compressed right now (at most ProcessorCount)
  904. // completedKeys — files that just finished; drained by the timer to turn green
  905. var activeKeys = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
  906. var completedKeys = new System.Collections.Concurrent.ConcurrentQueue<string>();
  907. int compressDone = 0;
  908. string completedStatus = Settings.CompressFiles ? "Compressed" : "Prepared";
  909. // WinForms Timer fires directly on the UI thread (no BeginInvoke queue).
  910. // Each tick: bubble active rows to top, drain up to 20 completed rows to green.
  911. System.Windows.Forms.Timer? uiTimer = null;
  912. OnUi(() =>
  913. {
  914. uiTimer = new System.Windows.Forms.Timer { Interval = 80 };
  915. uiTimer.Tick += (_, _) =>
  916. {
  917. if (Volatile.Read(ref compressDone) != 0) return;
  918. ActionLabel.Text = $"{prepVerb} {Volatile.Read(ref compressed)} of {totalToCompress}...";
  919. // Turn recently-completed rows green (cap at 20 per tick to stay fast)
  920. int drain = 20;
  921. while (drain-- > 0 && completedKeys.TryDequeue(out string? doneKey))
  922. {
  923. _activeRowKeys.Remove(doneKey);
  924. foreach (DataGridViewRow r in PreviewGrid.Rows)
  925. {
  926. if (string.Equals(r.Tag as string, doneKey, StringComparison.OrdinalIgnoreCase))
  927. {
  928. r.Cells[0].Value = completedStatus;
  929. r.DefaultCellStyle.BackColor = Color.FromArgb(210, 240, 210);
  930. r.DefaultCellStyle.ForeColor = Color.FromArgb(20, 100, 20);
  931. break;
  932. }
  933. }
  934. }
  935. // Bubble active rows to top
  936. foreach (string key in activeKeys.Keys)
  937. MoveRowToTop(key, prepVerb, Color.FromArgb(243, 232, 255), Color.FromArgb(80, 20, 130));
  938. };
  939. uiTimer.Start();
  940. });
  941. try
  942. {
  943. Parallel.For(0, totalToCompress, new ParallelOptions
  944. {
  945. MaxDegreeOfParallelism = Environment.ProcessorCount,
  946. CancellationToken = _cts.Token
  947. }, i =>
  948. {
  949. FileInformation info = uploadSnapshot[i];
  950. string key = info.FileName.Replace('\\', '/');
  951. activeKeys[key] = true;
  952. string localPath = Path.Combine(Settings.Client, info.FileName);
  953. byte[] raw = File.ReadAllBytes(localPath);
  954. Interlocked.Add(ref totalOriginalBytes, raw.Length);
  955. contentFilePaths[i] = CreateTempUploadFiles(info, raw);
  956. Interlocked.Add(ref totalCompressedBytes, info.Compressed);
  957. uploadedInfos[i] = info;
  958. AdvanceOverallProgress();
  959. activeKeys.TryRemove(key, out _);
  960. completedKeys.Enqueue(key);
  961. Interlocked.Increment(ref compressed);
  962. });
  963. }
  964. finally
  965. {
  966. Volatile.Write(ref compressDone, 1);
  967. OnUi(() => { uiTimer?.Stop(); uiTimer?.Dispose(); });
  968. }
  969. // Final sweep — mark everything remaining as Compressed.
  970. OnUi(() =>
  971. {
  972. _activeRowKeys.Clear();
  973. foreach (DataGridViewRow row in PreviewGrid.Rows)
  974. {
  975. row.Cells[0].Value = completedStatus;
  976. row.DefaultCellStyle.BackColor = Color.FromArgb(210, 240, 210);
  977. row.DefaultCellStyle.ForeColor = Color.FromArgb(20, 100, 20);
  978. }
  979. ActionLabel.Text = $"{prepVerb} complete — {totalToCompress} files.";
  980. });
  981. var contentFiles = contentFilePaths.ToList();
  982. _lastPublishFileCount = totalToCompress;
  983. _lastPublishOriginalBytes = totalOriginalBytes;
  984. _lastPublishCompressedBytes = totalCompressedBytes;
  985. if (Settings.CompressFiles && totalToCompress > 0 && totalOriginalBytes > 0)
  986. {
  987. long saved = totalOriginalBytes - totalCompressedBytes;
  988. int savePct = (int)((double)saved / totalOriginalBytes * 100);
  989. OnUi(() => SummaryUploadSizeLabel.Text += $" → {FormatBytes(totalCompressedBytes)} compressed ({savePct}% saved)");
  990. }
  991. pListFiles.Add(CreateTempUploadFiles(new FileInformation { FileName = PatchFileName }, CreateNewList()));
  992. CheckCancelled();
  993. try
  994. {
  995. RetryOnDisconnect(() => UploadFilesStaged(contentFiles, pListFiles), "Upload");
  996. }
  997. catch
  998. {
  999. DeleteDirectory(TempUploadDirectory);
  1000. throw;
  1001. }
  1002. SetActionText("Cleaning old files...");
  1003. try
  1004. {
  1005. RetryOnDisconnect(CleanUp, "Cleanup");
  1006. }
  1007. catch (Exception ex)
  1008. {
  1009. OnUi(() => MessageBox.Show(this, "Publish completed, but old-file cleanup failed:\r\n\r\n" + GetFriendlyError(ex), "Cleanup failed", MessageBoxButtons.OK, MessageBoxIcon.Warning));
  1010. }
  1011. UploadList = null;
  1012. CompleteUpload();
  1013. }
  1014. private string CreateTempUploadFiles(FileInformation info, byte[] raw)
  1015. {
  1016. string fileName = info.FileName.Replace(@"\", "/");
  1017. byte[] data = (!Settings.CompressFiles || fileName == PatchFileName) ? raw : Compress(raw);
  1018. info.Compressed = data.Length;
  1019. if (fileName != PatchFileName && Settings.CompressFiles)
  1020. {
  1021. fileName += ".gz";
  1022. }
  1023. var sourceDir = Path.GetDirectoryName(fileName);
  1024. var tempSourceDir = string.IsNullOrEmpty(sourceDir)
  1025. ? TempUploadDirectory
  1026. : Path.Combine(TempUploadDirectory, sourceDir);
  1027. var tempFilePath = Path.Combine(TempUploadDirectory, fileName).Replace(@"\", "/");
  1028. if (!Directory.Exists(tempSourceDir))
  1029. {
  1030. Directory.CreateDirectory(tempSourceDir);
  1031. }
  1032. File.WriteAllBytes(tempFilePath, data);
  1033. File.SetLastWriteTime(tempFilePath, info.Creation);
  1034. return fileName;
  1035. }
  1036. private void UploadFilesStaged(IReadOnlyList<string> contentFiles, IReadOnlyList<string> pListFiles)
  1037. {
  1038. var rootPath = (new Uri(Settings.Host)).AbsolutePath;
  1039. var publishId = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
  1040. var stagingRoot = CombineRemotePath(rootPath, StagingDirectoryName);
  1041. var stagingPath = CombineRemotePath(stagingRoot, publishId);
  1042. var allFiles = contentFiles.Concat(pListFiles).ToList();
  1043. int netDegree = Math.Max(2, Math.Min(Environment.ProcessorCount / 2, 8));
  1044. try
  1045. {
  1046. // Create staging tree with a single connection before parallel uploads begin.
  1047. SetActionText("Preparing staging area...");
  1048. using (var setup = PatchTransportFactory.Create())
  1049. {
  1050. setup.EnsureDirectory(rootPath);
  1051. EnsureRemoteDirectories(setup, allFiles, stagingPath);
  1052. }
  1053. // Stage content files in parallel.
  1054. int staged = 0;
  1055. int stageActive = 0;
  1056. int totalStage = contentFiles.Count;
  1057. SetActionText($"Staging {totalStage} files...");
  1058. RunParallelWithTransport(contentFiles, netDegree, (t, file) =>
  1059. {
  1060. CheckCancelled();
  1061. SetUploadRowStaging(file);
  1062. Interlocked.Increment(ref stageActive);
  1063. EventHandler<PatchTransferProgress> onProgress =
  1064. (_, e) => SetUploadRowProgress(file, (int)(e.FileProgress * 100));
  1065. t.ProgressChanged += onProgress;
  1066. int snapActive;
  1067. try
  1068. {
  1069. t.UploadDirectory(TempUploadDirectory, stagingPath, new[] { file }, "Staging", _cts.Token);
  1070. SetUploadRowProgress(file, 100);
  1071. AdvanceOverallProgress();
  1072. snapActive = Volatile.Read(ref stageActive);
  1073. }
  1074. finally
  1075. {
  1076. t.ProgressChanged -= onProgress;
  1077. Interlocked.Decrement(ref stageActive);
  1078. }
  1079. UpdateRowStatus(NormalizeUploadKey(file), "Staged", Color.FromArgb(190, 225, 240), Color.FromArgb(0, 70, 110));
  1080. int done = Interlocked.Increment(ref staged);
  1081. PostUi(() =>
  1082. {
  1083. ActionLabel.Text = $"Staging — {done} / {totalStage} done ({snapActive} active)";
  1084. FileLabel.Text = file;
  1085. });
  1086. });
  1087. // Stage PList files sequentially (1-2 files; not worth a parallel pool).
  1088. using (var mTransport = PatchTransportFactory.Create())
  1089. mTransport.UploadDirectory(TempUploadDirectory, stagingPath, pListFiles, "Staging PList", _cts.Token);
  1090. // Verify staged files in parallel.
  1091. VerifyStagedFiles(stagingPath, allFiles, netDegree);
  1092. // Create live destination directories before promoting in parallel.
  1093. using (var setup = PatchTransportFactory.Create())
  1094. EnsureRemoteDirectories(setup, contentFiles, rootPath);
  1095. // Promote content files in parallel.
  1096. PromoteStagedFiles(stagingPath, rootPath, contentFiles, "Uploading", netDegree,
  1097. onStart: SetUploadRowUploading,
  1098. onComplete: RemoveUploadRow);
  1099. // Promote PList atomically on a single connection.
  1100. using (var mTransport = PatchTransportFactory.Create())
  1101. {
  1102. foreach (var file in pListFiles)
  1103. {
  1104. CheckCancelled();
  1105. SetActionText("Publishing PList...");
  1106. string src = CombineRemotePath(stagingPath, file);
  1107. string dst = CombineRemotePath(rootPath, file);
  1108. mTransport.EnsureDirectory(GetRemoteDirectory(dst));
  1109. mTransport.MoveFile(src, dst);
  1110. }
  1111. }
  1112. using (var cleanup = PatchTransportFactory.Create())
  1113. TryRemoveRemoteStagingDirectories(cleanup, stagingRoot, stagingPath);
  1114. }
  1115. catch
  1116. {
  1117. SetActionText("Cleaning up staging...");
  1118. try
  1119. {
  1120. using var cleanup = PatchTransportFactory.Create();
  1121. TryRemoveRemoteStagingDirectories(cleanup, stagingRoot, stagingPath);
  1122. }
  1123. catch { }
  1124. throw;
  1125. }
  1126. DeleteDirectory(TempUploadDirectory);
  1127. }
  1128. private void VerifyStagedFiles(string stagingPath, IReadOnlyList<string> files, int degree)
  1129. {
  1130. int total = files.Count;
  1131. int verified = 0;
  1132. int verifyActive = 0;
  1133. SetActionText($"Verifying {total} files...");
  1134. RunParallelWithTransport(files, degree, (transport, file) =>
  1135. {
  1136. CheckCancelled();
  1137. SetUploadRowVerifying(file);
  1138. Interlocked.Increment(ref verifyActive);
  1139. string localPath = Path.Combine(TempUploadDirectory, file);
  1140. string remotePath = CombineRemotePath(stagingPath, file);
  1141. if (!File.Exists(localPath))
  1142. throw new FileNotFoundException("Local staged file is missing.", localPath);
  1143. long localLength = new FileInfo(localPath).Length;
  1144. long remoteLength = transport.GetFileLength(remotePath);
  1145. int snapActive = Volatile.Read(ref verifyActive);
  1146. Interlocked.Decrement(ref verifyActive);
  1147. if (localLength != remoteLength)
  1148. throw new InvalidOperationException($"Staged upload verification failed for {file}: local {localLength} bytes, remote {remoteLength} bytes.");
  1149. AdvanceOverallProgress();
  1150. int done = Interlocked.Increment(ref verified);
  1151. PostUi(() => ActionLabel.Text = $"Verifying — {done} / {total} done ({snapActive} active)");
  1152. });
  1153. }
  1154. private void PromoteStagedFiles(string stagingPath, string rootPath, IReadOnlyList<string> files,
  1155. string label, int degree,
  1156. Action<string>? onStart = null, Action<string>? onComplete = null)
  1157. {
  1158. int total = files.Count;
  1159. int promoted = 0;
  1160. int promoteActive = 0;
  1161. SetActionText($"{label} {total} files...");
  1162. RunParallelWithTransport(files, degree, (transport, file) =>
  1163. {
  1164. CheckCancelled();
  1165. onStart?.Invoke(file);
  1166. Interlocked.Increment(ref promoteActive);
  1167. try
  1168. {
  1169. transport.MoveFile(CombineRemotePath(stagingPath, file), CombineRemotePath(rootPath, file));
  1170. }
  1171. finally
  1172. {
  1173. Interlocked.Decrement(ref promoteActive);
  1174. }
  1175. onComplete?.Invoke(file);
  1176. AdvanceOverallProgress();
  1177. int done = Interlocked.Increment(ref promoted);
  1178. int snapActive = Volatile.Read(ref promoteActive);
  1179. PostUi(() =>
  1180. {
  1181. ActionLabel.Text = $"{label} — {done} / {total} done ({snapActive} active)";
  1182. FileLabel.Text = file;
  1183. });
  1184. });
  1185. }
  1186. private void RunParallelWithTransport(IReadOnlyList<string> files, int degree, Action<IPatchTransport, string> body)
  1187. {
  1188. if (files.Count == 0) return;
  1189. int requestedWorkerCount = Math.Min(degree, files.Count);
  1190. int discoveredCeiling = DiscoverConnectionCeiling(requestedWorkerCount);
  1191. int activeTarget = GetSafeConnectionTarget(discoveredCeiling);
  1192. int workerCount = Math.Min(requestedWorkerCount, discoveredCeiling);
  1193. var fileQueue = new System.Collections.Concurrent.ConcurrentQueue<string>(files);
  1194. // Keep one safety margin below the discovered/current ceiling. For example, an
  1195. // 8-connection ceiling starts 6 workers and displays as "Connections: N / 8".
  1196. Volatile.Write(ref _connectionCeiling, discoveredCeiling);
  1197. Volatile.Write(ref _liveConnections, 0);
  1198. PostUi(RefreshConnectionLabel);
  1199. var workers = new Task[workerCount];
  1200. for (int w = 0; w < workerCount; w++)
  1201. {
  1202. int workerId = w;
  1203. workers[w] = Task.Run(() =>
  1204. {
  1205. IPatchTransport? conn = null;
  1206. int reconnects = 0;
  1207. const int maxReconnects = 3;
  1208. try
  1209. {
  1210. // Stop this worker if the dynamic target has scaled below our ID.
  1211. while (Volatile.Read(ref activeTarget) > workerId && fileQueue.TryDequeue(out string? file))
  1212. {
  1213. _cts.Token.ThrowIfCancellationRequested();
  1214. if (conn == null)
  1215. {
  1216. // Stagger connection creation so a burst of simultaneous handshakes
  1217. // does not look like a connection-flood attack to the SSH daemon.
  1218. lock (_connectionCreateLock)
  1219. Thread.Sleep(100);
  1220. try
  1221. {
  1222. conn = PatchTransportFactory.Create();
  1223. Interlocked.Increment(ref _liveConnections);
  1224. PostUi(RefreshConnectionLabel);
  1225. reconnects = 0;
  1226. }
  1227. catch (OperationCanceledException) { throw; }
  1228. catch (Exception ex)
  1229. {
  1230. // File not yet processed — put it back for surviving workers.
  1231. fileQueue.Enqueue(file);
  1232. if (IsConnectionLimitException(ex))
  1233. {
  1234. // Treat the last attempted target as the host/session
  1235. // ceiling, then keep active workers at 80% of that.
  1236. int cur = Volatile.Read(ref activeTarget);
  1237. int reduced = GetSafeConnectionTarget(cur);
  1238. Interlocked.CompareExchange(ref activeTarget, reduced, cur);
  1239. Volatile.Write(ref _connectionCeiling, cur);
  1240. PostUi(RefreshConnectionLabel);
  1241. Log($"Server connection limit reached — using {reduced}/{cur} worker(s): {ex.Message}");
  1242. }
  1243. else
  1244. {
  1245. Log($"Connection failed, worker {workerId} exiting: {ex.Message}");
  1246. }
  1247. return;
  1248. }
  1249. }
  1250. try
  1251. {
  1252. body(conn, file);
  1253. }
  1254. catch (OperationCanceledException)
  1255. {
  1256. fileQueue.Enqueue(file);
  1257. throw;
  1258. }
  1259. catch (Exception ex) when (IsDisconnectException(ex))
  1260. {
  1261. // Transport dropped mid-transfer — re-queue and reconnect next iteration.
  1262. fileQueue.Enqueue(file);
  1263. Interlocked.Decrement(ref _liveConnections);
  1264. PostUi(RefreshConnectionLabel);
  1265. try { conn.Dispose(); } catch { }
  1266. conn = null;
  1267. if (++reconnects >= maxReconnects)
  1268. {
  1269. Log($"Worker {workerId} exiting after {maxReconnects} consecutive transport failures: {ex.Message}");
  1270. return;
  1271. }
  1272. Log($"Transport dropped, re-queuing '{file}' (attempt {reconnects}/{maxReconnects}).");
  1273. }
  1274. }
  1275. }
  1276. finally
  1277. {
  1278. if (conn != null)
  1279. {
  1280. Interlocked.Decrement(ref _liveConnections);
  1281. PostUi(RefreshConnectionLabel);
  1282. }
  1283. conn?.Dispose();
  1284. }
  1285. });
  1286. }
  1287. try
  1288. {
  1289. Task.WaitAll(workers);
  1290. }
  1291. catch (AggregateException aex)
  1292. {
  1293. var inner = aex.Flatten().InnerExceptions.FirstOrDefault(e => e is not OperationCanceledException)
  1294. ?? aex.InnerException;
  1295. if (inner != null)
  1296. System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(inner).Throw();
  1297. throw;
  1298. }
  1299. _cts.Token.ThrowIfCancellationRequested();
  1300. if (!fileQueue.IsEmpty)
  1301. throw new InvalidOperationException(
  1302. $"Upload incomplete — {fileQueue.Count} file(s) could not be processed. " +
  1303. "The server rejected all connections or all workers exceeded the reconnect limit.");
  1304. }
  1305. private int DiscoverConnectionCeiling(int requestedCeiling)
  1306. {
  1307. if (requestedCeiling <= 1)
  1308. return 1;
  1309. var rootPath = new Uri(Settings.Host).AbsolutePath;
  1310. SetActionText($"Checking connection ceiling up to {requestedCeiling}...");
  1311. var discovered = 1;
  1312. for (var candidate = 1; candidate <= requestedCeiling; candidate++)
  1313. {
  1314. _cts.Token.ThrowIfCancellationRequested();
  1315. if (CanOpenConcurrentProbeConnections(candidate, rootPath))
  1316. {
  1317. discovered = candidate;
  1318. continue;
  1319. }
  1320. break;
  1321. }
  1322. Log($"Connection ceiling discovered: {discovered}; active target: {GetSafeConnectionTarget(discovered)}.");
  1323. return discovered;
  1324. }
  1325. private bool CanOpenConcurrentProbeConnections(int count, string rootPath)
  1326. {
  1327. using var startGate = new ManualResetEventSlim(false);
  1328. var transports = new IPatchTransport?[count];
  1329. Exception? failure = null;
  1330. var tasks = new Task[count];
  1331. for (var i = 0; i < count; i++)
  1332. {
  1333. var index = i;
  1334. tasks[index] = Task.Run(() =>
  1335. {
  1336. try
  1337. {
  1338. startGate.Wait(_cts.Token);
  1339. _cts.Token.ThrowIfCancellationRequested();
  1340. var transport = PatchTransportFactory.Create();
  1341. transports[index] = transport;
  1342. // Force a lightweight request so FTP/HTTP restrictions are probed too.
  1343. transport.DirectoryExists(rootPath);
  1344. // Keep persistent transports open briefly so later probes overlap.
  1345. _cts.Token.WaitHandle.WaitOne(250);
  1346. }
  1347. catch (Exception ex)
  1348. {
  1349. Interlocked.CompareExchange(ref failure, ex, null);
  1350. }
  1351. }, _cts.Token);
  1352. }
  1353. startGate.Set();
  1354. try
  1355. {
  1356. Task.WaitAll(tasks);
  1357. }
  1358. catch (AggregateException ex)
  1359. {
  1360. failure ??= ex.Flatten().InnerExceptions.FirstOrDefault();
  1361. }
  1362. finally
  1363. {
  1364. foreach (var transport in transports)
  1365. {
  1366. try { transport?.Dispose(); } catch { }
  1367. }
  1368. }
  1369. if (failure == null)
  1370. return true;
  1371. if (failure is OperationCanceledException)
  1372. throw failure;
  1373. if (IsConnectionLimitException(failure) || IsDisconnectException(failure))
  1374. {
  1375. Log($"Connection ceiling probe failed at {count}: {failure.Message}");
  1376. return false;
  1377. }
  1378. Log($"Connection ceiling probe stopped at {count}: {failure.GetType().Name}: {failure.Message}");
  1379. return false;
  1380. }
  1381. private static int GetSafeConnectionTarget(int connectionCeiling)
  1382. {
  1383. return Math.Max(1, (int)Math.Floor(connectionCeiling * 0.8));
  1384. }
  1385. private static void EnsureRemoteDirectories(IPatchTransport transport, IReadOnlyList<string> files, string remoteRoot)
  1386. {
  1387. var dirs = files
  1388. .Select(f => GetRemoteDirectory(CombineRemotePath(remoteRoot, f)))
  1389. .Distinct(StringComparer.OrdinalIgnoreCase)
  1390. .OrderBy(d => d.Length) // parents before children
  1391. .ToList();
  1392. foreach (var dir in dirs)
  1393. transport.EnsureDirectory(dir);
  1394. }
  1395. private static string GetRemoteDirectory(string remotePath)
  1396. {
  1397. remotePath = (remotePath ?? string.Empty).Replace('\\', '/');
  1398. int index = remotePath.LastIndexOf('/');
  1399. return index <= 0 ? "/" : remotePath.Substring(0, index);
  1400. }
  1401. private void TryRemoveRemoteDirectory(IPatchTransport transport, string remoteDirectory)
  1402. {
  1403. try
  1404. {
  1405. if (transport.DirectoryExists(remoteDirectory))
  1406. transport.DeleteDirectory(remoteDirectory);
  1407. }
  1408. catch
  1409. {
  1410. // Staging cleanup is best-effort; files have already been promoted.
  1411. }
  1412. }
  1413. private void TryRemoveRemoteStagingDirectories(IPatchTransport transport, string stagingRoot, string stagingPath)
  1414. {
  1415. TryRemoveRemoteDirectory(transport, stagingPath);
  1416. TryRemoveEmptyRemoteDirectory(transport, stagingRoot);
  1417. }
  1418. private void TryRemoveEmptyRemoteDirectory(IPatchTransport transport, string remoteDirectory)
  1419. {
  1420. try
  1421. {
  1422. if (transport.DirectoryExists(remoteDirectory))
  1423. transport.DeleteEmptyDirectory(remoteDirectory);
  1424. }
  1425. catch
  1426. {
  1427. // Another publish may still be using the staging root, or the host may reject empty-dir removal.
  1428. }
  1429. }
  1430. private byte[]? DownloadFile(string fileName)
  1431. {
  1432. try
  1433. {
  1434. using IPatchTransport transport = PatchTransportFactory.Create();
  1435. var rootPath = (new Uri(Settings.Host)).AbsolutePath;
  1436. string remotePath = CombineRemotePath(rootPath, fileName);
  1437. if (!transport.FileExists(remotePath))
  1438. return null;
  1439. return transport.DownloadFile(remotePath);
  1440. }
  1441. catch (Exception ex) when (IsRemoteFileNotFound(ex))
  1442. {
  1443. return null;
  1444. }
  1445. }
  1446. private static bool IsRemoteFileNotFound(Exception ex)
  1447. {
  1448. if (ex is WebException webException)
  1449. {
  1450. if ((webException.Response as FtpWebResponse)?.StatusCode == FtpStatusCode.ActionNotTakenFileUnavailable)
  1451. return true;
  1452. if ((webException.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.NotFound)
  1453. return true;
  1454. }
  1455. if (ex is HttpRequestException { StatusCode: HttpStatusCode.NotFound })
  1456. return true;
  1457. string typeName = ex.GetType().FullName ?? string.Empty;
  1458. if (typeName.Contains("SftpPathNotFoundException", StringComparison.Ordinal))
  1459. return true;
  1460. return ex.InnerException != null && IsRemoteFileNotFound(ex.InnerException);
  1461. }
  1462. private void DownloadFiles()
  1463. {
  1464. var rootPath = (new Uri(Settings.Host)).AbsolutePath;
  1465. using IPatchTransport transport = PatchTransportFactory.Create();
  1466. transport.ProgressChanged += (_, e) => PostUi(() =>
  1467. {
  1468. FileLabel.Text = e.FileName;
  1469. SpeedLabel.Text = FormatSpeed(e.BytesPerSecond);
  1470. ActionLabel.Text = $"Downloading... {e.RemainingFiles} remaining";
  1471. });
  1472. if (!Directory.Exists(TempDownloadDirectory))
  1473. {
  1474. Directory.CreateDirectory(TempDownloadDirectory);
  1475. }
  1476. var remotePaths = OldList.Select(x =>
  1477. {
  1478. bool compressed = x.Length != x.Compressed;
  1479. return CombineRemotePath(rootPath, x.FileName + (compressed ? ".gz" : ""));
  1480. }).ToList();
  1481. transport.DownloadFiles(remotePaths, TempDownloadDirectory, _cts.Token);
  1482. }
  1483. private void MoveTempDownloadedFiles()
  1484. {
  1485. for (int i = 0; i < OldList.Count; i++)
  1486. {
  1487. var info = OldList[i];
  1488. var compressed = OldList[i].Length != OldList[i].Compressed;
  1489. var filename = OldList[i].FileName + (compressed ? ".gz" : "");
  1490. var currentPath = Path.Combine(TempDownloadDirectory, filename);
  1491. var relativeDestDir = Path.GetDirectoryName(info.FileName);
  1492. var destDir = string.IsNullOrEmpty(relativeDestDir)
  1493. ? Settings.Client
  1494. : Path.Combine(Settings.Client, relativeDestDir);
  1495. var destFilename = Path.Combine(Settings.Client, info.FileName);
  1496. if (!Directory.Exists(destDir))
  1497. {
  1498. Directory.CreateDirectory(destDir);
  1499. }
  1500. if (File.Exists(destFilename))
  1501. {
  1502. File.Delete(destFilename);
  1503. }
  1504. if (compressed)
  1505. {
  1506. byte[] raw = File.ReadAllBytes(currentPath);
  1507. File.WriteAllBytes(destFilename, Decompress(raw));
  1508. }
  1509. else
  1510. {
  1511. File.Move(currentPath, destFilename);
  1512. }
  1513. File.SetLastWriteTime(destFilename, info.Creation);
  1514. }
  1515. DeleteDirectory(TempDownloadDirectory);
  1516. }
  1517. private async void ListButton_Click(object sender, EventArgs e)
  1518. {
  1519. SaveConnectionSettings();
  1520. if (!ValidateSettings()) return;
  1521. _cts = new CancellationTokenSource();
  1522. DisableAllButtons();
  1523. Log("PList upload started.");
  1524. try
  1525. {
  1526. await Task.Run(() =>
  1527. {
  1528. GetOldFileList();
  1529. CheckCancelled();
  1530. GetNewFileList();
  1531. CheckCancelled();
  1532. for (int i = 0; i < NewList.Count; i++)
  1533. {
  1534. CheckCancelled();
  1535. FileInformation info = NewList[i];
  1536. for (int o = 0; o < OldList.Count; o++)
  1537. {
  1538. if (OldList[o].FileName != info.FileName) continue;
  1539. NewList[i].Compressed = OldList[o].Compressed;
  1540. break;
  1541. }
  1542. if (info.Compressed == 0)
  1543. info.Compressed = info.Length;
  1544. }
  1545. var pListFiles = new List<string>
  1546. {
  1547. CreateTempUploadFiles(new FileInformation { FileName = PatchFileName }, CreateNewList())
  1548. };
  1549. RetryOnDisconnect(() => UploadFilesStaged(new List<string>(), pListFiles), "Upload");
  1550. CompletePListOperation("PList updated.");
  1551. Log("PList upload complete.");
  1552. });
  1553. }
  1554. catch (OperationCanceledException)
  1555. {
  1556. ActionLabel.Text = "Cancelled.";
  1557. DeleteDirectory(TempUploadDirectory);
  1558. Log("PList upload cancelled.");
  1559. ClearPreviewGrid();
  1560. UpdateSummaryLabels(null);
  1561. MessageBox.Show(this, "Cancel complete. Any partial work has been cleaned up.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1562. }
  1563. catch (Exception ex)
  1564. {
  1565. ActionLabel.Text = "Error.";
  1566. DeleteDirectory(TempUploadDirectory);
  1567. Log($"PList upload error: {ex.Message}");
  1568. MessageBox.Show(this, GetFriendlyError(ex), "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1569. }
  1570. finally
  1571. {
  1572. RestoreButtons();
  1573. }
  1574. }
  1575. private async void ProcessButton_Click(object sender, EventArgs e)
  1576. {
  1577. if (!_publishPreviewReady || UploadList == null)
  1578. {
  1579. MessageBox.Show(this, "Run Compare first so you can review the publish plan.", "Compare required", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1580. return;
  1581. }
  1582. _cts = new CancellationTokenSource();
  1583. DisableAllButtons();
  1584. ActionLabel.Text = $"Publishing {_compareResult?.UploadCount ?? 0} changed files...";
  1585. try
  1586. {
  1587. await Task.Run(BeginUpload);
  1588. }
  1589. catch (OperationCanceledException)
  1590. {
  1591. _publishPreviewReady = false;
  1592. ActionLabel.Text = "Cancelled.";
  1593. Log("Publish cancelled.");
  1594. ClearPreviewGrid();
  1595. UpdateSummaryLabels(null);
  1596. MessageBox.Show(this, "Cancel complete. Any partial work has been cleaned up.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1597. }
  1598. catch (Exception ex)
  1599. {
  1600. _publishPreviewReady = false;
  1601. ActionLabel.Text = "Error.";
  1602. Log($"Publish error: {ex.Message}");
  1603. MessageBox.Show(this, GetFriendlyError(ex), "Publish Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1604. }
  1605. finally
  1606. {
  1607. RestoreButtons();
  1608. }
  1609. }
  1610. private async void CompareButton_Click(object sender, EventArgs e)
  1611. {
  1612. SaveConnectionSettings();
  1613. _cts = new CancellationTokenSource();
  1614. DisableAllButtons();
  1615. try
  1616. {
  1617. await Task.Run(PreparePublishPreview);
  1618. }
  1619. catch (OperationCanceledException)
  1620. {
  1621. ActionLabel.Text = "Cancelled.";
  1622. Log("Compare cancelled.");
  1623. ClearPreviewGrid();
  1624. UpdateSummaryLabels(null);
  1625. MessageBox.Show(this, "Cancel complete. Any partial work has been cleaned up.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1626. }
  1627. catch (Exception ex)
  1628. {
  1629. ActionLabel.Text = "Error.";
  1630. Log($"Compare error: {ex.Message}");
  1631. MessageBox.Show(this, GetFriendlyError(ex), "Compare Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1632. }
  1633. finally
  1634. {
  1635. RestoreButtons();
  1636. }
  1637. }
  1638. private async void DownloadExistingButton_Click(object sender, EventArgs e)
  1639. {
  1640. SaveConnectionSettings();
  1641. if (!ValidateSettings()) return;
  1642. _cts = new CancellationTokenSource();
  1643. DisableAllButtons();
  1644. Log("Download started.");
  1645. try
  1646. {
  1647. await Task.Run(() =>
  1648. {
  1649. RetryOnDisconnect(() =>
  1650. {
  1651. GetOldFileList();
  1652. DownloadFiles();
  1653. }, "Download");
  1654. MoveTempDownloadedFiles();
  1655. Log("Download complete.");
  1656. });
  1657. CompleteDownload();
  1658. }
  1659. catch (OperationCanceledException)
  1660. {
  1661. ActionLabel.Text = "Cancelled.";
  1662. Log("Download cancelled.");
  1663. await Task.Run(() => DeleteDirectory(TempDownloadDirectory));
  1664. ClearPreviewGrid();
  1665. UpdateSummaryLabels(null);
  1666. MessageBox.Show(this, "Cancel complete. Any partial work has been cleaned up.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1667. }
  1668. catch (Exception ex)
  1669. {
  1670. ActionLabel.Text = "Error.";
  1671. Log($"Download error: {ex.Message}");
  1672. await Task.Run(() => DeleteDirectory(TempDownloadDirectory));
  1673. MessageBox.Show(this, GetFriendlyError(ex), "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1674. }
  1675. finally
  1676. {
  1677. RestoreButtons();
  1678. }
  1679. }
  1680. private async void ClearRepositoryButton_Click(object sender, EventArgs e)
  1681. {
  1682. SaveConnectionSettings();
  1683. if (!ValidateSettings()) return;
  1684. var confirm = MessageBox.Show(this,
  1685. "WARNING: This will permanently delete ALL files from the remote repository, including the PList and all client files.\n\nThis cannot be undone. Are you absolutely sure?",
  1686. "Clear Remote Repository",
  1687. MessageBoxButtons.YesNo,
  1688. MessageBoxIcon.Warning,
  1689. MessageBoxDefaultButton.Button2);
  1690. if (confirm != DialogResult.Yes)
  1691. {
  1692. ActionLabel.Text = "Clear cancelled.";
  1693. return;
  1694. }
  1695. _cts = new CancellationTokenSource();
  1696. DisableAllButtons();
  1697. try
  1698. {
  1699. await Task.Run(ClearRemoteRepository);
  1700. }
  1701. catch (OperationCanceledException)
  1702. {
  1703. ActionLabel.Text = "Cancelled.";
  1704. MessageBox.Show(this, "Cancel complete. Any partial work has been cleaned up.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Information);
  1705. }
  1706. catch (Exception ex)
  1707. {
  1708. MessageBox.Show(this, GetFriendlyError(ex), "Clear Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1709. ActionLabel.Text = "Error during clear.";
  1710. }
  1711. finally
  1712. {
  1713. ClearPreviewGrid();
  1714. UpdateSummaryLabels(null);
  1715. RestoreButtons();
  1716. }
  1717. }
  1718. private void AbortButton_Click(object sender, EventArgs e)
  1719. {
  1720. _cts.Cancel();
  1721. SetActionText("Cancelling...");
  1722. OnUi(() =>
  1723. {
  1724. AbortButton.Enabled = false;
  1725. AbortButton.Text = "Cancelling...";
  1726. PreviewGrid.Enabled = false;
  1727. Cursor = Cursors.WaitCursor;
  1728. });
  1729. }
  1730. private async void TestConnectionButton_Click(object sender, EventArgs e)
  1731. {
  1732. SaveConnectionSettings();
  1733. if (!ValidateSettings()) return;
  1734. _cts = new CancellationTokenSource();
  1735. DisableAllButtons();
  1736. SetActionText("Testing connection...");
  1737. try
  1738. {
  1739. bool hasPList = false;
  1740. await Task.Run(() =>
  1741. {
  1742. using var transport = PatchTransportFactory.Create();
  1743. var rootPath = new Uri(Settings.Host).AbsolutePath;
  1744. hasPList = transport.FileExists(CombineRemotePath(rootPath, PatchFileName));
  1745. });
  1746. ActionLabel.Text = hasPList
  1747. ? "Connection OK — PList found."
  1748. : "Connection OK — no PList yet (fresh repository).";
  1749. }
  1750. catch (OperationCanceledException)
  1751. {
  1752. ActionLabel.Text = "Cancelled.";
  1753. }
  1754. catch (Exception ex)
  1755. {
  1756. ActionLabel.Text = "Connection failed.";
  1757. MessageBox.Show(this, GetFriendlyError(ex), "Connection Test Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
  1758. }
  1759. finally
  1760. {
  1761. RestoreButtons();
  1762. }
  1763. }
  1764. private void ClearRemoteRepository()
  1765. {
  1766. var rootPath = (new Uri(Settings.Host)).AbsolutePath;
  1767. var failures = new List<string>();
  1768. using IPatchTransport transport = PatchTransportFactory.Create();
  1769. // --- Delete files ---
  1770. SetActionText("Fetching remote file list...");
  1771. IReadOnlyList<string> remoteFiles = transport.EnumerateFiles(rootPath, _cts.Token);
  1772. var filesToDelete = remoteFiles
  1773. .Where(x => !string.Equals(x.TrimEnd('/'), NormalizeRemotePath(rootPath), StringComparison.OrdinalIgnoreCase))
  1774. .OrderBy(x => IsPListPath(x) ? 1 : 0)
  1775. .ThenBy(x => x, StringComparer.OrdinalIgnoreCase)
  1776. .ToList();
  1777. int fileTotal = filesToDelete.Count;
  1778. int fileDone = 0;
  1779. foreach (string remotePath in filesToDelete)
  1780. {
  1781. CheckCancelled();
  1782. fileDone++;
  1783. string displayPath = MakeRelativeRemotePath(rootPath, remotePath);
  1784. SetActionText($"Deleting file {fileDone} of {fileTotal}...");
  1785. SetFileText(displayPath);
  1786. try
  1787. {
  1788. transport.DeleteFile(remotePath);
  1789. if (transport.FileExists(remotePath))
  1790. {
  1791. failures.Add($"{displayPath}: file still exists after delete");
  1792. Log($"Clear Remote verify failed: {displayPath}");
  1793. }
  1794. else
  1795. {
  1796. Log($"Clear Remote deleted: {displayPath}");
  1797. }
  1798. }
  1799. catch (Exception ex)
  1800. {
  1801. failures.Add($"{displayPath}: {ex.Message}");
  1802. Log($"Clear Remote failed: {displayPath}: {ex.Message}");
  1803. }
  1804. }
  1805. // --- Delete directories (deepest first so parents are empty when removed) ---
  1806. SetActionText("Fetching remote directory list...");
  1807. SetFileText(string.Empty);
  1808. IReadOnlyList<string> remoteDirs = transport.EnumerateDirectories(rootPath, _cts.Token);
  1809. int dirTotal = remoteDirs.Count;
  1810. int dirDone = 0;
  1811. foreach (string dirPath in remoteDirs)
  1812. {
  1813. CheckCancelled();
  1814. dirDone++;
  1815. string displayPath = MakeRelativeRemotePath(rootPath, dirPath);
  1816. SetActionText($"Removing directory {dirDone} of {dirTotal}...");
  1817. SetFileText(displayPath);
  1818. try
  1819. {
  1820. if (transport.DirectoryExists(dirPath))
  1821. transport.DeleteDirectory(dirPath);
  1822. Log($"Clear Remote removed dir: {displayPath}");
  1823. }
  1824. catch (Exception ex)
  1825. {
  1826. failures.Add($"{displayPath}/: {ex.Message}");
  1827. Log($"Clear Remote dir failed: {displayPath}: {ex.Message}");
  1828. }
  1829. }
  1830. if (fileTotal == 0 && dirTotal == 0)
  1831. {
  1832. SetActionText("Remote repository is already empty.");
  1833. return;
  1834. }
  1835. if (failures.Count > 0)
  1836. throw new InvalidOperationException($"Remote clear completed with {failures.Count} failure(s):\r\n\r\n" + string.Join("\r\n", failures.Take(20)));
  1837. OldList = new List<FileInformation>();
  1838. SetFileText(string.Empty);
  1839. SetActionText($"Remote repository cleared — {fileTotal} file(s), {dirTotal} director(ies) removed.");
  1840. }
  1841. private static string NormalizeRemotePath(string path)
  1842. {
  1843. path = (path ?? string.Empty).Replace('\\', '/');
  1844. return path.StartsWith("/") ? path : "/" + path;
  1845. }
  1846. private static bool IsPListPath(string remotePath)
  1847. {
  1848. string fileName = Path.GetFileName(remotePath.Replace('\\', '/'));
  1849. return string.Equals(fileName, PatchFileName, StringComparison.OrdinalIgnoreCase);
  1850. }
  1851. private static string MakeRelativeRemotePath(string rootPath, string remotePath)
  1852. {
  1853. rootPath = NormalizeRemotePath(rootPath).TrimEnd('/');
  1854. remotePath = NormalizeRemotePath(remotePath);
  1855. if (!string.IsNullOrEmpty(rootPath) &&
  1856. remotePath.StartsWith(rootPath + "/", StringComparison.OrdinalIgnoreCase))
  1857. return remotePath.Substring(rootPath.Length + 1);
  1858. return remotePath.TrimStart('/');
  1859. }
  1860. private void AMain_Load(object sender, EventArgs e)
  1861. {
  1862. ActionLabel.TextChanged += (_, _) => StatusActionLabel.Text = ActionLabel.Text;
  1863. FileLabel.TextChanged += (_, _) => StatusFileLabel.Text = FileLabel.Text;
  1864. _statusConnectionLabel = new ToolStripStatusLabel
  1865. {
  1866. AutoSize = false,
  1867. Width = 160,
  1868. TextAlign = ContentAlignment.MiddleCenter,
  1869. Text = string.Empty
  1870. };
  1871. MainStatusStrip.Items.Insert(1, _statusConnectionLabel);
  1872. }
  1873. private void RefreshConnectionLabel()
  1874. {
  1875. if (_statusConnectionLabel == null) return;
  1876. int live = Volatile.Read(ref _liveConnections);
  1877. int ceil = Volatile.Read(ref _connectionCeiling);
  1878. _statusConnectionLabel.Text = live > 0 ? $"Connections: {live} / {ceil}" : string.Empty;
  1879. }
  1880. private void DeleteDirectory(string target_dir)
  1881. {
  1882. if (!Directory.Exists(target_dir)) return;
  1883. string[] files = Directory.GetFiles(target_dir);
  1884. string[] dirs = Directory.GetDirectories(target_dir);
  1885. foreach (string file in files)
  1886. {
  1887. File.SetAttributes(file, FileAttributes.Normal);
  1888. File.Delete(file);
  1889. }
  1890. foreach (string dir in dirs)
  1891. {
  1892. DeleteDirectory(dir);
  1893. }
  1894. Directory.Delete(target_dir, false);
  1895. }
  1896. private byte[] Decompress(byte[] raw)
  1897. {
  1898. using (GZipStream gStream = new GZipStream(new MemoryStream(raw), CompressionMode.Decompress))
  1899. {
  1900. const int size = 4096; //4kb
  1901. byte[] buffer = new byte[size];
  1902. using (MemoryStream mStream = new MemoryStream())
  1903. {
  1904. int count;
  1905. do
  1906. {
  1907. count = gStream.Read(buffer, 0, size);
  1908. if (count > 0)
  1909. {
  1910. mStream.Write(buffer, 0, count);
  1911. }
  1912. } while (count > 0);
  1913. return mStream.ToArray();
  1914. }
  1915. }
  1916. }
  1917. private byte[] Compress(byte[] raw)
  1918. {
  1919. using (MemoryStream mStream = new MemoryStream())
  1920. {
  1921. using (GZipStream gStream = new GZipStream(mStream, CompressionMode.Compress, true))
  1922. gStream.Write(raw, 0, raw.Length);
  1923. return mStream.ToArray();
  1924. }
  1925. }
  1926. }
  1927. public class FileInformation
  1928. {
  1929. public string FileName; //Relative.
  1930. public int Length, Compressed;
  1931. public DateTime Creation;
  1932. public FileInformation()
  1933. {
  1934. Creation = DateTime.Now;
  1935. }
  1936. public FileInformation(BinaryReader reader)
  1937. {
  1938. FileName = reader.ReadString();
  1939. Length = reader.ReadInt32();
  1940. Compressed = reader.ReadInt32();
  1941. Creation = DateTime.FromBinary(reader.ReadInt64());
  1942. }
  1943. public void Save(BinaryWriter writer)
  1944. {
  1945. writer.Write(FileName);
  1946. writer.Write(Length);
  1947. writer.Write(Compressed);
  1948. writer.Write(Creation.ToBinary());
  1949. }
  1950. }
  1951. }