From 03365307422c19ee06b382191f4aee51d8cabaf1 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 8 Jun 2026 13:43:03 +0200 Subject: [PATCH] Unify manual and automatic sync via SyncCoordinator Der manuelle "Synchronisieren"-Button (SyncProgressForm) lief ueber eine eigene Engine und umging die clientuebergreifende Lock-Datei, den lokalen Guard und einen Teil des Protokolls. Jetzt teilen sich beide Pfade einen SyncCoordinator. - Neuer SyncCoordinator kapselt: prozessweiten Re-Entrancy-Guard (statisch, so koennen manueller + automatischer Sync nicht mehr gleichzeitig laufen), Lock-Datei (mit Warte-Status), Konflikt-Notizen anderer Arbeitsplaetze, Protokoll (Start/Ergebnis/Aenderungen/Konflikte/Fehler) und das Verteilen eigener Konflikt-Notizen. runEngine wird vom Aufrufer uebergeben, damit das Threading pro Pfad erhalten bleibt (UI-Thread vs. Task.Run). - MainForm.RunSync und SyncProgressForm.RunSync nutzen den Coordinator; UI (Tray-Meldung vs. Fenster) bleibt jeweils beim Aufrufer. - Lock/Notiz/Guard-Logik aus MainForm entfernt (jetzt zentral). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + .../Services/SyncCoordinator.cs | 161 ++++++++++++++++++ src/StarfaceOutlookSync/UI/MainForm.cs | 159 +++-------------- .../UI/SyncProgressForm.cs | 62 ++++--- 4 files changed, 230 insertions(+), 159 deletions(-) create mode 100644 src/StarfaceOutlookSync/Services/SyncCoordinator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index adbf33ee..078e093b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,13 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). ### Geaendert +- **Manueller und automatischer Sync vereinheitlicht.** Beide Pfade laufen jetzt + ueber einen gemeinsamen `SyncCoordinator` und nutzen damit dieselben Schutz- + und Komfortfunktionen: lokaler Re-Entrancy-Schutz (prozessweit - auch manuell + vs. automatisch koennen nicht mehr gleichzeitig laufen), clientuebergreifende + Lock-Datei, Konflikt-Notizen anderer Arbeitsplaetze und vollstaendiges + Protokoll. Vorher umging der manuelle "Synchronisieren"-Button (Fenster) die + Lock-Datei und einen Teil des Protokolls. - **Doppelte Syncs verhindert (lokal).** Der Schutz gegen gleichzeitig laufende Syncs (manuell + Auto-Sync-Timer) ist jetzt atomar (`Interlocked`) statt eines nicht-atomaren `volatile bool`, bei dem beide in einem Zeitfenster diff --git a/src/StarfaceOutlookSync/Services/SyncCoordinator.cs b/src/StarfaceOutlookSync/Services/SyncCoordinator.cs new file mode 100644 index 00000000..ac3ad821 --- /dev/null +++ b/src/StarfaceOutlookSync/Services/SyncCoordinator.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using StarfaceOutlookSync.Models; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Gemeinsame Orchestrierung fuer JEDEN Sync-Lauf - egal ob automatisch + /// (Tray/Timer) oder manuell (Fortschrittsfenster). Sorgt einheitlich fuer: + /// - lokalen Re-Entrancy-Schutz (prozessweit, statisch), + /// - clientuebergreifende Lock-Datei, + /// - Anzeige/Logging von Konflikt-Notizen anderer Arbeitsplaetze, + /// - Protokollierung (Start, Ergebnis, Aenderungen, Konflikte, Fehler), + /// - Verteilen eigener Konflikt-Notizen. + /// Die eigentliche UI (Tray-Meldung vs. Fenster) bleibt beim Aufrufer. + /// + public class SyncCoordinator + { + // Prozessweit: nie zwei Syncs gleichzeitig - auch nicht manuell + automatisch. + private static int _running = 0; + + private readonly ProfileManager _profileManager = new ProfileManager(); + private readonly ConflictNotifier _notifier = new ConflictNotifier(); + + public class SyncOutcome + { + public bool Skipped; + public string SkipReason = ""; + public SyncResult Result; + public Exception Error; + public List RemoteNotices = new List(); + } + + /// + /// Fuehrt einen Sync koordiniert aus. runEngine kapselt den eigentlichen + /// Engine-Aufruf (inkl. ggf. Task.Run/Progress-Anbindung des Aufrufers), + /// damit das Threading-Verhalten pro Pfad erhalten bleibt. status erhaelt + /// Fortschrittstexte (Warten auf Sperre, Synchronisiere ...). + /// + public async Task RunAsync(SyncProfile profile, + Func> runEngine, Action status) + { + var outcome = new SyncOutcome(); + + if (Interlocked.CompareExchange(ref _running, 1, 0) != 0) + { + outcome.Skipped = true; + outcome.SkipReason = "Sync laeuft bereits, bitte warten..."; + return outcome; + } + + SyncLock crossLock = null; + var sharedDir = UserSettings.Load().SharedDirectory; + try + { + crossLock = await AcquireCrossClientLock(sharedDir, status); + if (crossLock == null) + { + outcome.Skipped = true; + outcome.SkipReason = "Anderer Arbeitsplatz synchronisiert gerade - uebersprungen."; + Logger.Log($"Sync '{profile.Name}' uebersprungen: anderer Arbeitsplatz synct gerade."); + return outcome; + } + + // Konflikt-Hinweise anderer Arbeitsplaetze ermitteln, BEVOR der Sync + // den eigenen Stand auf den Gewinner-Wert angleicht. + try + { + outcome.RemoteNotices = _notifier.GetPending(sharedDir, MyContactsByStarfaceId()); + foreach (var n in outcome.RemoteNotices) + Logger.Log($" KONFLIKT (anderer Arbeitsplatz): {n}"); + } + catch { } + + Logger.Log($"Sync gestartet: '{profile.Name}' (Richtung: {profile.SyncDirection})"); + status?.Invoke($"Synchronisiere '{profile.Name}'..."); + + SyncResult result; + try + { + result = await runEngine(); + } + catch (Exception ex) + { + outcome.Error = ex; + Logger.Log($"Sync FEHLER '{profile.Name}': {ex.Message}"); + return outcome; + } + outcome.Result = result; + + var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert" + + (result.Errors > 0 ? $", {result.Errors} Fehler" : "") + + (result.Conflicts.Count > 0 ? $", {result.Conflicts.Count} Konflikt(e)" : ""); + + Logger.Log($"Sync fertig: {msg}"); + foreach (var ch in result.Changes) Logger.Log($" {ch}"); + foreach (var em in result.ErrorMessages) Logger.Log($" Fehler: {em}"); + foreach (var c in result.Conflicts) Logger.Log($" KONFLIKT: {c}"); + + // Andere Arbeitsplaetze ueber eigene Konflikte informieren. + if (result.Conflicts.Count > 0) + _notifier.Write(sharedDir, result.Conflicts); + + return outcome; + } + finally + { + crossLock?.Dispose(); + Interlocked.Exchange(ref _running, 0); + } + } + + private async Task AcquireCrossClientLock(string dir, Action status) + { + if (string.IsNullOrWhiteSpace(dir)) + return SyncLock.NoOp(); + + try + { + // Legt das Verzeichnis bei Bedarf an (idempotent). Funktioniert mit + // UNC-Pfaden (\\server\freigabe\...) - kein Netzlaufwerk noetig. + Directory.CreateDirectory(dir); + } + catch + { + status?.Invoke("Gemeinsames Verzeichnis nicht erreichbar - synce ohne Sperre."); + return SyncLock.NoOp(); + } + + var deadline = DateTime.UtcNow.AddMinutes(2); + int attempt = 0; + while (true) + { + var l = SyncLock.TryAcquire(dir, out var heldBy); + if (l != null) return l; + if (DateTime.UtcNow >= deadline) return null; + + attempt++; + status?.Invoke($"Warte auf anderen Arbeitsplatz ({heldBy ?? "unbekannt"})... ({attempt})"); + await Task.Delay(3000); + } + } + + private Dictionary MyContactsByStarfaceId() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var p in _profileManager.GetProfiles()) + foreach (var m in _profileManager.GetMappings(p.Id)) + if (!string.IsNullOrEmpty(m.StarfaceId) && !map.ContainsKey(m.StarfaceId)) + map[m.StarfaceId] = m.LastOutlook; + } + catch { } + return map; + } + } +} diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs index 5851803e..fc79387d 100644 --- a/src/StarfaceOutlookSync/UI/MainForm.cs +++ b/src/StarfaceOutlookSync/UI/MainForm.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Drawing; using System.Linq; -using System.Threading; using System.Threading.Tasks; using System.Timers; using System.Windows.Forms; @@ -16,7 +15,7 @@ namespace StarfaceOutlookSync.UI { private readonly ProfileManager _profileManager = new ProfileManager(); private readonly SyncEngine _syncEngine = new SyncEngine(); - private readonly ConflictNotifier _conflictNotifier = new ConflictNotifier(); + private readonly SyncCoordinator _coordinator = new SyncCoordinator(); private NotifyIcon _trayIcon; private ContextMenuStrip _trayMenu; private ListView _profileList; @@ -24,9 +23,6 @@ namespace StarfaceOutlookSync.UI private StatusStrip _statusBar; private ToolStripStatusLabel _statusLabel; private Timer _autoSyncTimer; - // 0 = frei, 1 = Sync laeuft. Per Interlocked atomar geschaltet, damit - // ein manueller Sync und der Auto-Sync-Timer nicht gleichzeitig starten. - private int _syncRunning = 0; // Aktive Benachrichtigungs-Einstellung (pro Sync-Lauf gesetzt). private bool _notifyGeneral = true; @@ -348,42 +344,36 @@ namespace StarfaceOutlookSync.UI private async Task RunSync(SyncProfile profile) { - // Atomar pruefen-und-setzen: verhindert, dass manueller Sync und - // Auto-Sync-Timer gleichzeitig denselben/einen Sync starten. - if (Interlocked.CompareExchange(ref _syncRunning, 1, 0) != 0) - { - SetStatus("Sync laeuft bereits, bitte warten..."); - return; - } - - SyncLock crossLock = null; var settings = UserSettings.Load(); - var sharedDir = settings.SharedDirectory; _notifyGeneral = settings.NotificationsEnabled; _notifyWarn = settings.NotifyWarningsErrors; + try { - // Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert). - crossLock = await AcquireCrossClientLock(sharedDir); - if (crossLock == null) + // Lokaler Guard, Lock-Datei, Konflikt-Notizen und Protokoll laufen + // zentral im Coordinator (gemeinsam mit dem manuellen Sync-Pfad). + var outcome = await _coordinator.RunAsync( + profile, + runEngine: () => _syncEngine.SyncProfileAsync(profile), + status: SetStatus); + + if (outcome.Skipped) { - SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen."); - Logger.Log($"Sync '{profile.Name}' uebersprungen: anderer Arbeitsplatz synct gerade."); + SetStatus(outcome.SkipReason); return; } - Logger.Log($"Sync gestartet: '{profile.Name}' (Richtung: {profile.SyncDirection})"); + // Konflikt-Hinweise anderer Arbeitsplaetze als Tray-Meldung (falls aktiv). + ShowRemoteNoticesTray(outcome.RemoteNotices); - // Konflikt-Hinweise von ANDEREN Arbeitsplaetzen anzeigen, BEVOR der - // Sync den eigenen Stand auf den Gewinner-Wert aktualisiert (sonst - // koennte man nicht mehr erkennen, ob man betroffen war). - ShowRemoteConflictNotices(sharedDir); - - SetStatus($"Synchronisiere '{profile.Name}'..."); - Balloon(2000, "Starface Sync", $"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info, warn: false); - - var result = await _syncEngine.SyncProfileAsync(profile); + if (outcome.Error != null) + { + Balloon(3000, "Starface Sync Fehler", outcome.Error.Message, ToolTipIcon.Error, warn: true); + SetStatus($"Fehler: {outcome.Error.Message}"); + return; + } + var result = outcome.Result; var msg = $"{profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert"; if (result.Errors > 0) msg += $", {result.Errors} Fehler"; if (result.Conflicts.Count > 0) msg += $", {result.Conflicts.Count} Konflikt(e)"; @@ -393,27 +383,13 @@ namespace StarfaceOutlookSync.UI Balloon(3000, "Starface Sync", msg, result.Errors > 0 ? ToolTipIcon.Warning : ToolTipIcon.Info, warn: noteworthy); - // Protokoll (immer, unabhaengig von Benachrichtigungs-Einstellung). - Logger.Log($"Sync fertig: {msg}"); - foreach (var ch in result.Changes) - Logger.Log($" {ch}"); - foreach (var em in result.ErrorMessages) - Logger.Log($" Fehler: {em}"); - - // Echte Feld-Konflikte gesondert melden, damit der Benutzer weiss, - // dass ein Wert ueberschrieben wurde. + // Echte Feld-Konflikte gesondert melden (eigener Wert wurde ueberschrieben). if (result.Conflicts.Count > 0) { var detail = string.Join("\n", result.Conflicts.Take(5).Select(c => c.ToString())); if (result.Conflicts.Count > 5) detail += $"\n... und {result.Conflicts.Count - 5} weitere"; Balloon(10000, $"Konflikt bei {result.Conflicts.Count} Kontakt(en)", detail, ToolTipIcon.Warning, warn: true); - - foreach (var c in result.Conflicts) - Logger.Log($" KONFLIKT: {c}"); - - // Andere Arbeitsplaetze ueber das gemeinsame Verzeichnis informieren. - _conflictNotifier.Write(sharedDir, result.Conflicts); } SetStatus(msg); @@ -424,95 +400,16 @@ namespace StarfaceOutlookSync.UI SetStatus($"Fehler: {ex.Message}"); Logger.Log($"Sync FEHLER '{profile.Name}': {ex.Message}"); } - finally - { - crossLock?.Dispose(); - Interlocked.Exchange(ref _syncRunning, 0); - } } - /// - /// Holt die clientuebergreifende Lock-Datei aus dem konfigurierten - /// gemeinsamen Verzeichnis. Wartet bis zu 2 Minuten, falls ein anderer - /// Arbeitsplatz gerade synct. Gibt null zurueck, wenn die Sperre nicht - /// erlangt werden konnte (-> diesen Lauf ueberspringen). - /// Ist kein Verzeichnis konfiguriert oder es ist nicht erreichbar, wird - /// ohne clientuebergreifende Sperre fortgefahren (lokaler Schutz bleibt). - /// - private async Task AcquireCrossClientLock(string dir) + /// Zeigt Konflikt-Hinweise anderer Arbeitsplaetze als Tray-Meldung. + private void ShowRemoteNoticesTray(System.Collections.Generic.List notices) { - if (string.IsNullOrWhiteSpace(dir)) - return SyncLock.NoOp(); - - try - { - // Legt das Verzeichnis bei Bedarf an (idempotent). Funktioniert mit - // UNC-Pfaden (\\server\freigabe\...) - kein Netzlaufwerk noetig. - System.IO.Directory.CreateDirectory(dir); - } - catch - { - SetStatus("Gemeinsames Verzeichnis nicht erreichbar - synce ohne Sperre."); - return SyncLock.NoOp(); - } - - var deadline = DateTime.UtcNow.AddMinutes(2); - int attempt = 0; - while (true) - { - var l = SyncLock.TryAcquire(dir, out var heldBy); - if (l != null) return l; - - if (DateTime.UtcNow >= deadline) return null; - - attempt++; - SetStatus($"Warte auf anderen Arbeitsplatz ({heldBy ?? "unbekannt"})... ({attempt})"); - await Task.Delay(3000); - } - } - - /// - /// Zeigt Konflikt-Hinweise an, die andere Arbeitsplaetze ueber das - /// gemeinsame Verzeichnis hinterlassen haben und Kontakte betreffen, die - /// auch dieser Client gemappt hat. - /// - private void ShowRemoteConflictNotices(string sharedDir) - { - if (string.IsNullOrWhiteSpace(sharedDir)) return; - try - { - var pending = _conflictNotifier.GetPending(sharedDir, MyContactsByStarfaceId()); - if (pending.Count == 0) return; - - foreach (var p in pending) - Logger.Log($" KONFLIKT (anderer Arbeitsplatz): {p}"); - - var detail = string.Join("\n", pending.Take(5).Select(p => p.ToString())); - if (pending.Count > 5) - detail += $"\n... und {pending.Count - 5} weitere"; - Balloon(10000, - $"Konflikt an anderem Arbeitsplatz ({pending.Count})", detail, ToolTipIcon.Warning, warn: true); - } - catch { } - } - - /// - /// Eigener Kontaktstand je StarfaceId (ueber alle Profile), aus den - /// Mapping-Snapshots. Dient dazu, bei Konflikt-Notizen zu pruefen, ob der - /// eigene Feldwert ueberhaupt vom uebernommenen Wert abweicht. - /// - private Dictionary MyContactsByStarfaceId() - { - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - foreach (var p in _profileManager.GetProfiles()) - foreach (var m in _profileManager.GetMappings(p.Id)) - if (!string.IsNullOrEmpty(m.StarfaceId) && !map.ContainsKey(m.StarfaceId)) - map[m.StarfaceId] = m.LastOutlook; - } - catch { } - return map; + if (notices == null || notices.Count == 0) return; + var detail = string.Join("\n", notices.Take(5).Select(p => p.ToString())); + if (notices.Count > 5) + detail += $"\n... und {notices.Count - 5} weitere"; + Balloon(10000, $"Konflikt an anderem Arbeitsplatz ({notices.Count})", detail, ToolTipIcon.Warning, warn: true); } /// diff --git a/src/StarfaceOutlookSync/UI/SyncProgressForm.cs b/src/StarfaceOutlookSync/UI/SyncProgressForm.cs index da0f14e2..a6aa5344 100644 --- a/src/StarfaceOutlookSync/UI/SyncProgressForm.cs +++ b/src/StarfaceOutlookSync/UI/SyncProgressForm.cs @@ -11,6 +11,7 @@ namespace StarfaceOutlookSync.UI { private readonly SyncProfile _profile; private readonly SyncEngine _engine = new SyncEngine(); + private readonly SyncCoordinator _coordinator = new SyncCoordinator(); private TextBox _txtLog; private ProgressBar _progressBar; private Button _btnClose, _btnStart; @@ -92,49 +93,54 @@ namespace StarfaceOutlookSync.UI _progressBar.Style = ProgressBarStyle.Marquee; _lblResult.Text = ""; - _engine.OnProgress += AppendLog; - - Logger.Log($"Sync gestartet (manuell): '{_profile.Name}' (Richtung: {_profile.SyncDirection})"); - - try + // Engine-Aufruf inkl. Live-Log: laeuft im Hintergrund, damit das + // Fenster reagiert. Guard/Lock/Notizen/Protokoll uebernimmt der + // Coordinator (gemeinsam mit dem Auto-/Tray-Sync). + Func> runEngine = async () => { - var result = await Task.Run(() => _engine.SyncProfileAsync(_profile)); + _engine.OnProgress += AppendLog; + try { return await Task.Run(() => _engine.SyncProfileAsync(_profile)); } + finally { _engine.OnProgress -= AppendLog; } + }; - _progressBar.Style = ProgressBarStyle.Blocks; - _progressBar.Value = 100; + var outcome = await _coordinator.RunAsync(_profile, runEngine, status: AppendLog); - var resultText = $"Erstellt: {result.Created} | Aktualisiert: {result.Updated} | Fehler: {result.Errors}"; - _lblResult.Text = resultText; + _progressBar.Style = ProgressBarStyle.Blocks; + _progressBar.Value = 100; + + if (outcome.Skipped) + { + _lblResult.Text = outcome.SkipReason; + _lblResult.ForeColor = Color.OrangeRed; + AppendLog(outcome.SkipReason); + } + else if (outcome.Error != null) + { + _lblResult.Text = $"Fehler: {outcome.Error.Message}"; + _lblResult.ForeColor = Color.Red; + AppendLog($"FEHLER: {outcome.Error.Message}"); + } + else + { + var result = outcome.Result; + _lblResult.Text = $"Erstellt: {result.Created} | Aktualisiert: {result.Updated} | Fehler: {result.Errors}"; _lblResult.ForeColor = result.Errors > 0 ? Color.OrangeRed : Color.Green; - // Persistentes Protokoll (was wurde geaendert). - Logger.Log($"Sync fertig: {_profile.Name}: {result.Created} erstellt, {result.Updated} aktualisiert" - + (result.Errors > 0 ? $", {result.Errors} Fehler" : "") - + (result.Conflicts.Count > 0 ? $", {result.Conflicts.Count} Konflikt(e)" : "")); - foreach (var ch in result.Changes) - Logger.Log($" {ch}"); + // Konflikt-Hinweise anderer Arbeitsplaetze im Fenster zeigen. + foreach (var n in outcome.RemoteNotices) + AppendLog($"Konflikt an anderem Arbeitsplatz: {n}"); + foreach (var c in result.Conflicts) - Logger.Log($" KONFLIKT: {c}"); + AppendLog($"KONFLIKT: {c}"); if (result.ErrorMessages.Count > 0) { AppendLog("--- Fehler ---"); foreach (var err in result.ErrorMessages) - { AppendLog(err); - Logger.Log($" Fehler: {err}"); - } } } - catch (Exception ex) - { - _lblResult.Text = $"Fehler: {ex.Message}"; - _lblResult.ForeColor = Color.Red; - AppendLog($"FEHLER: {ex.Message}"); - Logger.Log($"Sync FEHLER '{_profile.Name}': {ex.Message}"); - } - _engine.OnProgress -= AppendLog; _btnStart.Enabled = true; _btnStart.Text = "Erneut synchronisieren"; _btnClose.Enabled = true;