using System.Diagnostics; using System.Windows.Forms.VisualStyles; namespace ClinicBackupSync; sealed class MainForm : Form { readonly SyncSettings _settings; readonly FolderSync _sync; readonly CancellationTokenSource _cancellation = new(); readonly Task _loop; readonly EventWaitHandle _showSignal = new(false, EventResetMode.AutoReset, @"Local\ClinicBackupSync.Show"); readonly NotifyIcon _tray; readonly Label _status; readonly Label _summary; readonly ListView _files; readonly ListView _log; readonly SplitContainer _split; readonly TextBox _folderBox; readonly NumericUpDown _interval; readonly UploadProgressBar _uploadBar = new() { Dock = DockStyle.Top, Visible = false }; const int TryColumn = 4; int _shownActivity; bool _exit; public MainForm(SyncSettings settings) { _settings = settings; Text = "Clinic Pro backup sync"; StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(860, 560); Size = new Size(980, 720); BackColor = Color.FromArgb(231, 238, 242); Font = new Font("Segoe UI", 10); var header = new Panel { Dock = DockStyle.Top, Height = 78, BackColor = Color.FromArgb(16, 40, 58), }; header.Controls.Add(new Label { Text = "Clinic Pro backups", ForeColor = Color.White, Font = new Font("Segoe UI", 16, FontStyle.Bold), AutoSize = true, Location = new Point(20, 12), }); _status = new Label { ForeColor = Color.FromArgb(183, 198, 211), AutoSize = true, Location = new Point(22, 44), Text = "Starting…", }; header.Controls.Add(_status); var tools = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 52, Padding = new Padding(16, 10, 16, 8), BackColor = Color.FromArgb(231, 238, 242), WrapContents = false, }; tools.Controls.Add(MakeButton("Upload now", AccentClick)); tools.Controls.Add(MakeButton("Open folder", (_, _) => OpenFolder(_settings.WatchFolder), outline: true)); _folderBox = new TextBox { Text = _settings.WatchFolder, Dock = DockStyle.Fill, Margin = new Padding(0, 4, 8, 0), }; _interval = new NumericUpDown { Minimum = 5, Maximum = 86400, Value = Math.Clamp(_settings.RetrySeconds, 5, 86400), Width = 72, Dock = DockStyle.Fill, Margin = new Padding(0, 4, 8, 0), ThousandsSeparator = true, }; var settingsBar = new TableLayoutPanel { Dock = DockStyle.Top, Height = 44, ColumnCount = 7, RowCount = 1, Padding = new Padding(16, 6, 16, 0), BackColor = Color.FromArgb(231, 238, 242), }; settingsBar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 84)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); settingsBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); settingsBar.Controls.Add(SettingsLabel("Folder"), 0, 0); settingsBar.Controls.Add(_folderBox, 1, 0); settingsBar.Controls.Add(MakeButton("Browse", BrowseFolderClick, outline: true), 2, 0); settingsBar.Controls.Add(SettingsLabel("Check every"), 3, 0); settingsBar.Controls.Add(_interval, 4, 0); settingsBar.Controls.Add(SettingsLabel("seconds"), 5, 0); settingsBar.Controls.Add(MakeButton("Save", SaveSettingsClick), 6, 0); _summary = new Label { Dock = DockStyle.Top, Height = 28, Padding = new Padding(20, 6, 12, 0), Text = "Backup files", Font = new Font("Segoe UI", 10, FontStyle.Bold), }; _files = CreateList(); _files.SmallImageList = new ImageList { ImageSize = new Size(1, 28) }; _files.OwnerDraw = true; _files.Columns.Add("File", 260); _files.Columns.Add("Size", 90, HorizontalAlignment.Right); _files.Columns.Add("Modified", 150); _files.Columns.Add("Status", 110); _files.Columns.Add("Try", 78); _files.Columns.Add("Details", 240); _files.DrawColumnHeader += (_, e) => e.DrawDefault = true; _files.DrawItem += (_, _) => { }; _files.DrawSubItem += DrawFileSubItem; _files.MouseClick += OnFileClick; _files.MouseMove += (_, e) => { var hit = _files.HitTest(e.Location); _files.Cursor = IsTryHit(hit, e.Location) ? Cursors.Hand : Cursors.Default; }; _log = CreateList(); _log.Columns.Add("Time", 150); _log.Columns.Add("Result", 90); _log.Columns.Add("File", 300); _log.Columns.Add("Message", 380); _split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, BackColor = Color.FromArgb(231, 238, 242), }; var logHeader = new Panel { Dock = DockStyle.Top, Height = 46, Padding = new Padding(4, 4, 8, 0) }; var logTitle = new Label { Text = "Upload log", Font = new Font("Segoe UI", 10, FontStyle.Bold), AutoSize = true, Location = new Point(4, 8), }; var clearLog = MakeButton("Clear log", ClearLogClick, outline: true); clearLog.Anchor = AnchorStyles.Top | AnchorStyles.Right; logHeader.Controls.Add(logTitle); logHeader.Controls.Add(clearLog); logHeader.Resize += (_, _) => { clearLog.Top = 4; clearLog.Left = Math.Max(110, logHeader.ClientSize.Width - clearLog.Width - 4); }; var filesHost = new Panel { Dock = DockStyle.Fill, Padding = new Padding(16, 0, 16, 8) }; filesHost.Controls.Add(_files); var logHost = new Panel { Dock = DockStyle.Fill, Padding = new Padding(16, 0, 16, 16) }; logHost.Controls.Add(_log); logHost.Controls.Add(logHeader); _split.Panel1.Controls.Add(filesHost); _split.Panel2.Controls.Add(logHost); Controls.Add(_split); Controls.Add(_summary); Controls.Add(settingsBar); Controls.Add(tools); Controls.Add(_uploadBar); Controls.Add(header); _sync = new FolderSync(settings); var windowIcon = Icon ?? SystemIcons.Application; _tray = new NotifyIcon { Icon = (Icon)windowIcon.Clone(), Visible = true, Text = "Clinic backup sync", }; var menu = new ContextMenuStrip(); menu.Items.Add("Show", null, (_, _) => ShowFromSignal()); menu.Items.Add("Upload now", null, (_, _) => _sync.ScanNow()); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add("Exit", null, (_, _) => ExitApp()); _tray.ContextMenuStrip = menu; _tray.DoubleClick += (_, _) => ShowFromSignal(); _sync.StatusChanged += OnStatus; _sync.ViewChanged += OnViewChanged; _sync.NewFileFound += OnNewFile; _sync.ProgressChanged += OnProgress; LoadHistory(); _loop = Task.Run(() => _sync.RunAsync(_cancellation.Token)); _ = Task.Run(WaitForShowSignal); } protected override void OnShown(EventArgs e) { base.OnShown(e); var distance = Math.Max(160, (int)(_split.Height * 0.46)); if (distance < _split.Height - 120) { _split.SplitterDistance = distance; } RefreshLists(); } protected override void OnFormClosing(FormClosingEventArgs e) { if (!_exit && e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; Hide(); _tray.ShowBalloonTip(3000, "Clinic backup", "Sync keeps running in the notification area.", ToolTipIcon.Info); return; } _tray.Visible = false; _tray.Dispose(); _cancellation.Cancel(); try { _showSignal.Set(); _loop.Wait(TimeSpan.FromSeconds(3)); } catch (AggregateException) { } _sync.Dispose(); base.OnFormClosing(e); } void DrawFileSubItem(object? sender, DrawListViewSubItemEventArgs e) { if (e.Item is null) { return; } var selected = e.Item.Selected; var background = selected ? SystemColors.Highlight : Color.White; var foreground = selected ? SystemColors.HighlightText : e.Item.ForeColor; using (var brush = new SolidBrush(background)) { e.Graphics.FillRectangle(brush, e.Bounds); } using (var pen = new Pen(Color.FromArgb(220, 228, 232))) { e.Graphics.DrawLine(pen, e.Bounds.Left, e.Bounds.Bottom - 1, e.Bounds.Right, e.Bounds.Bottom - 1); } if (e.ColumnIndex == TryColumn) { if (e.SubItem?.Text == "Try") { var bounds = TryButtonBounds(e.Bounds); ButtonRenderer.DrawButton(e.Graphics, bounds, "Try", _files.Font, false, PushButtonState.Normal); } return; } var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | (e.ColumnIndex == 1 ? TextFormatFlags.Right : TextFormatFlags.Left); var textBounds = Rectangle.Inflate(e.Bounds, -4, 0); TextRenderer.DrawText(e.Graphics, e.SubItem?.Text ?? "", e.SubItem?.Font ?? _files.Font, textBounds, foreground, flags); } void OnFileClick(object? sender, MouseEventArgs e) { var hit = _files.HitTest(e.Location); if (!IsTryHit(hit, e.Location) || hit.Item is null) { return; } _sync.RetryFile(hit.Item.Text); } static bool IsTryHit(ListViewHitTestInfo hit, Point location) { if (hit.Item is null || hit.SubItem is null || hit.Item.SubItems.IndexOf(hit.SubItem) != TryColumn) { return false; } return hit.SubItem.Text == "Try" && TryButtonBounds(hit.SubItem.Bounds).Contains(location); } static Rectangle TryButtonBounds(Rectangle cell) { var width = Math.Min(64, Math.Max(40, cell.Width - 12)); var height = Math.Min(24, Math.Max(18, cell.Height - 6)); return new Rectangle(cell.X + 6, cell.Y + ((cell.Height - height) / 2), width, height); } void BrowseFolderClick(object? sender, EventArgs e) { using var dialog = new FolderBrowserDialog { Description = "Choose the backup folder to watch", UseDescriptionForTitle = true, SelectedPath = Directory.Exists(_folderBox.Text) ? _folderBox.Text : _settings.WatchFolder, }; if (dialog.ShowDialog(this) == DialogResult.OK) { _folderBox.Text = dialog.SelectedPath; } } void SaveSettingsClick(object? sender, EventArgs e) { var folder = _folderBox.Text.Trim(); if (folder.Length == 0) { _status.Text = "Choose a backup folder."; return; } try { folder = Path.GetFullPath(folder); } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { _status.Text = "That folder path is not valid."; return; } var interval = (int)_interval.Value; _settings.WatchFolder = folder; _settings.RetrySeconds = interval; try { _settings.Save(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _status.Text = "Could not save settings."; return; } _folderBox.Text = folder; _sync.ApplyWatch(folder, interval); } void ClearLogClick(object? sender, EventArgs e) { _sync.ClearActivity(); var cleared = AppLog.Clear(); _log.Items.Clear(); _shownActivity = 0; _status.Text = cleared ? "Log cleared." : "Could not clear the log file."; } void OnNewFile(string name) { if (IsDisposed || !IsHandleCreated) { return; } BeginInvoke(() => { RefreshLists(); _status.Text = $"New file found: {name}"; var tip = name.Length <= 60 ? name : name[..60]; _tray.ShowBalloonTip(4000, "New backup file", tip, ToolTipIcon.Info); }); } void AccentClick(object? sender, EventArgs e) { _sync.ScanNow(); _status.Text = "Looking for new backup files…"; } void OnStatus(string message) { if (IsDisposed || !IsHandleCreated) { return; } BeginInvoke(() => { _status.Text = message; var tip = message.Length <= 63 ? message : message[..63]; _tray.Text = tip; if (message.StartsWith("Uploading ", StringComparison.Ordinal)) { _uploadBar.ShowUpload(message["Uploading ".Length..]); } else { _uploadBar.HideIdle(); } }); } void OnProgress(UploadProgress progress) { if (IsDisposed || !IsHandleCreated) { return; } BeginInvoke(() => { var percent = progress.Total <= 0 ? 0 : (int)Math.Clamp(progress.Sent * 100 / progress.Total, 0, 100); _uploadBar.SetProgress(progress.FileName, percent); _status.Text = $"Uploading {progress.FileName} {percent}%"; var tip = _status.Text.Length <= 63 ? _status.Text : _status.Text[..63]; _tray.Text = tip; foreach (ListViewItem item in _files.Items) { if (item.SubItems.Count > 5 && item.Text == progress.FileName && item.SubItems[3].Text == "Uploading") { item.SubItems[5].Text = $"{percent}% sent"; break; } } }); } void OnViewChanged() { if (IsDisposed || !IsHandleCreated) { return; } BeginInvoke(RefreshLists); } void RefreshLists() { var rows = _sync.ListFiles(); _files.BeginUpdate(); _files.Items.Clear(); var uploaded = 0; var failed = 0; foreach (var row in rows) { if (row.Status == "Uploaded") { uploaded++; } else if (row.Status == "Failed") { failed++; } var item = new ListViewItem(row.Name); item.SubItems.Add(FormatSize(row.Length)); item.SubItems.Add(row.ModifiedLocal.ToString("dd MMM yyyy HH:mm")); item.SubItems.Add(row.Status); item.SubItems.Add(row.Status == "Failed" ? "Try" : ""); item.SubItems.Add(row.Detail); item.ForeColor = StatusColor(row.Status); _files.Items.Add(item); } _files.EndUpdate(); _summary.Text = rows.Length == 0 ? $"No backup files in {_settings.WatchFolder}" : $"{rows.Length} files {uploaded} uploaded {failed} failed"; var activity = _sync.ActivitySnapshot(); if (activity.Length < _shownActivity) { _shownActivity = 0; _log.Items.Clear(); } _log.BeginUpdate(); for (var index = _shownActivity; index < activity.Length; index++) { var entry = activity[index]; var item = new ListViewItem(entry.WhenLocal.ToString("dd MMM yyyy HH:mm:ss")); item.SubItems.Add(entry.Result == "Uploaded" ? "Success" : "Failed"); item.SubItems.Add(entry.FileName); item.SubItems.Add(entry.Message); item.ForeColor = StatusColor(entry.Result); _log.Items.Add(item); item.EnsureVisible(); } _log.EndUpdate(); _shownActivity = activity.Length; } void LoadHistory() { if (!File.Exists(AppPaths.LogFile)) { return; } string text; try { using var stream = new FileStream(AppPaths.LogFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); text = reader.ReadToEnd(); } catch (IOException) { return; } var lines = text.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); var start = Math.Max(0, lines.Length - 200); for (var index = start; index < lines.Length; index++) { if (!TryParseHistory(lines[index], out var item)) { continue; } _log.Items.Add(item); } if (_log.Items.Count > 0) { _log.Items[^1].EnsureVisible(); } } static bool TryParseHistory(string line, out ListViewItem item) { item = null!; if (line.Length < 20) { return false; } var uploaded = line.IndexOf(" Uploaded ", StringComparison.Ordinal); var failed = line.IndexOf(" Upload failed for ", StringComparison.Ordinal); if (uploaded < 0 && failed < 0) { return false; } var timeText = line[..19]; if (failed >= 0) { var rest = line[(failed + " Upload failed for ".Length)..]; var split = rest.IndexOf(':'); var name = split >= 0 ? rest[..split] : rest; var message = split >= 0 ? rest[(split + 1)..].Trim() : ""; item = new ListViewItem(timeText); item.SubItems.Add("Failed"); item.SubItems.Add(name.Trim()); item.SubItems.Add(message); item.ForeColor = StatusColor("Failed"); return true; } var body = line[(uploaded + " Uploaded ".Length)..]; var colon = body.IndexOf(':'); var fileName = colon >= 0 ? body[..colon] : body; var detail = colon >= 0 ? body[(colon + 1)..].Trim() : ""; item = new ListViewItem(timeText); item.SubItems.Add("Success"); item.SubItems.Add(fileName.Trim()); item.SubItems.Add(detail); item.ForeColor = StatusColor("Uploaded"); return true; } void WaitForShowSignal() { while (!_cancellation.IsCancellationRequested) { if (!_showSignal.WaitOne(500)) { continue; } if (_cancellation.IsCancellationRequested || IsDisposed) { break; } try { BeginInvoke(ShowFromSignal); } catch (InvalidOperationException) { break; } } } void ShowFromSignal() { Show(); WindowState = FormWindowState.Normal; Activate(); } void ExitApp() { _exit = true; Close(); } static ListView CreateList() { var list = new ListView { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, GridLines = true, HeaderStyle = ColumnHeaderStyle.Nonclickable, BorderStyle = BorderStyle.None, }; typeof(ListView).GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(list, true); return list; } static Label SettingsLabel(string text) { return new Label { Text = text, AutoSize = true, Margin = new Padding(0, 8, 8, 0), TextAlign = System.Drawing.ContentAlignment.MiddleLeft, }; } static Button MakeButton(string text, EventHandler click, bool outline = false) { var button = new Button { Text = text, AutoSize = true, Padding = new Padding(12, 4, 12, 4), Margin = new Padding(0, 0, 8, 0), FlatStyle = FlatStyle.Flat, BackColor = outline ? Color.White : Color.FromArgb(12, 110, 105), ForeColor = outline ? Color.FromArgb(16, 40, 58) : Color.White, Cursor = Cursors.Hand, }; button.FlatAppearance.BorderColor = Color.FromArgb(180, 196, 206); button.Click += click; return button; } static Color StatusColor(string status) { return status switch { "Uploaded" => Color.FromArgb(8, 110, 100), "Failed" => Color.FromArgb(154, 52, 18), "Uploading" => Color.FromArgb(20, 84, 140), _ => Color.FromArgb(20, 36, 51), }; } static string FormatSize(long size) { var value = (double)size; foreach (var unit in new[] { "B", "KB", "MB", "GB" }) { if (value < 1024 || unit == "GB") { return unit == "B" ? $"{size} B" : $"{value:0.#} {unit}"; } value /= 1024; } return $"{size} B"; } static void OpenFolder(string folder) { if (!Directory.Exists(folder)) { return; } Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{folder}\"", UseShellExecute = true, }); } }