diff --git a/CHANGELOG.md b/CHANGELOG.md index e109be0b..cf3c2851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`). ### Hinzugefuegt +- **Clientuebergreifende Sync-Sperre (Mehrplatz).** In den Einstellungen laesst + sich ein gemeinsames Verzeichnis (Netzlaufwerk/UNC) hinterlegen. Synct ein + Arbeitsplatz, legt er dort eine Lock-Datei an (atomar via `CreateNew`); andere + Clients warten, bis sie frei ist (bis 2 Min, sonst wird der Lauf uebersprungen). + Stuerzt ein Client ab, uebernimmt ein anderer die verwaiste Sperre. Ist kein + Verzeichnis konfiguriert oder nicht erreichbar, wird ohne diese Sperre + weitergearbeitet (der lokale Schutz auf dem PC bleibt). - **Feldweises 3-Wege-Merge bei Konflikten (bidirektional).** Wenn derselbe Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt Aenderungen an *unterschiedlichen* Feldern beide erhalten (z.B. einer aendert diff --git a/src/StarfaceOutlookSync/Models/UserSettings.cs b/src/StarfaceOutlookSync/Models/UserSettings.cs index 9ccb17b5..be28bd7c 100644 --- a/src/StarfaceOutlookSync/Models/UserSettings.cs +++ b/src/StarfaceOutlookSync/Models/UserSettings.cs @@ -11,6 +11,11 @@ namespace StarfaceOutlookSync.Models public bool SyncOnStart { get; set; } = false; public bool AutoAcceptOutlookPrompt { get; set; } = false; + // Gemeinsames Verzeichnis (Netzlaufwerk/UNC) fuer die clientuebergreifende + // Sync-Sperre. Leer = keine Sperre (nur lokaler Schutz). Verhindert, dass + // mehrere Arbeitsplaetze gleichzeitig dasselbe Adressbuch synchronisieren. + public string SharedDirectory { get; set; } = ""; + private static readonly string SettingsFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StarfaceOutlookSync", "settings.json"); diff --git a/src/StarfaceOutlookSync/Services/SyncLock.cs b/src/StarfaceOutlookSync/Services/SyncLock.cs new file mode 100644 index 00000000..45c4449a --- /dev/null +++ b/src/StarfaceOutlookSync/Services/SyncLock.cs @@ -0,0 +1,120 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace StarfaceOutlookSync.Services +{ + /// + /// Clientuebergreifende Sperre ueber eine Lock-Datei in einem gemeinsamen + /// Verzeichnis (Netzlaufwerk/Share). Verhindert, dass mehrere Arbeitsplaetze + /// gleichzeitig dasselbe Starface-Adressbuch synchronisieren. + /// + /// Atomar ueber FileMode.CreateNew: das Anlegen schlaegt fehl, wenn die Datei + /// schon existiert. Die Datei wird waehrend des Syncs offen gehalten + /// (FileShare.None) - stuerzt ein Client ab, gibt das Betriebssystem das + /// Handle frei und ein anderer Client kann die (dann verwaiste) Datei + /// uebernehmen. Eine reine Zeitstempel-Pruefung allein waere nicht sicher. + /// + public sealed class SyncLock : IDisposable + { + public const string LockFileName = "starface-sync.lock"; + + // Aelter als das -> als verwaist behandeln und Uebernahme VERSUCHEN. + // Lebt der Eigentuemer noch, schlaegt das Loeschen am offenen Handle fehl, + // die Sperre bleibt also korrekt bestehen. + private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(15); + + private FileStream _stream; + private readonly string _path; + + private SyncLock(string path, FileStream stream) + { + _path = path; + _stream = stream; + } + + /// Sperre, die nichts haelt (wenn kein gemeinsames Verzeichnis konfiguriert/erreichbar ist). + public static SyncLock NoOp() => new SyncLock(null, null); + + /// + /// Versucht die Sperre zu holen. Gibt null zurueck, wenn gerade ein + /// anderer (lebender) Client synct. heldBy enthaelt - sofern lesbar - + /// Infos zum aktuellen Halter. + /// + public static SyncLock TryAcquire(string dir, out string heldBy) + { + heldBy = null; + var path = Path.Combine(dir, LockFileName); + + var lockObj = TryCreate(path); + if (lockObj != null) return lockObj; + + // Existiert bereits. Halter ermitteln und ggf. verwaiste Datei uebernehmen. + heldBy = ReadOwner(path); + if (IsStale(path)) + { + try { File.Delete(path); } // scheitert, wenn der Halter noch lebt (Handle offen) + catch { return null; } + return TryCreate(path); + } + return null; + } + + private static SyncLock TryCreate(string path) + { + try + { + var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + var info = $"{Environment.MachineName}|{Environment.UserName}|{DateTime.UtcNow:o}|pid{GetPid()}"; + var bytes = Encoding.UTF8.GetBytes(info); + fs.Write(bytes, 0, bytes.Length); + fs.Flush(); + return new SyncLock(path, fs); + } + catch (IOException) { return null; } // existiert schon + catch (UnauthorizedAccessException) { return null; } + } + + private static int GetPid() + { + try { return Process.GetCurrentProcess().Id; } catch { return 0; } + } + + private static string ReadOwner(string path) + { + try + { + // Eigene Freigabe, falls der Halter die Datei nur zum Schreiben offen hat. + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var sr = new StreamReader(fs)) + { + var raw = sr.ReadToEnd(); + var parts = raw.Split('|'); + return parts.Length >= 1 ? parts[0] : null; + } + } + catch { return null; } + } + + private static bool IsStale(string path) + { + try + { + var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(path); + return age > StaleAfter; + } + catch { return false; } + } + + public void Dispose() + { + try { _stream?.Dispose(); } catch { } + _stream = null; + if (!string.IsNullOrEmpty(_path)) + { + try { File.Delete(_path); } catch { } + } + } + } +} diff --git a/src/StarfaceOutlookSync/UI/MainForm.cs b/src/StarfaceOutlookSync/UI/MainForm.cs index b3cb5a64..444b364b 100644 --- a/src/StarfaceOutlookSync/UI/MainForm.cs +++ b/src/StarfaceOutlookSync/UI/MainForm.cs @@ -348,8 +348,17 @@ namespace StarfaceOutlookSync.UI return; } + SyncLock crossLock = null; try { + // Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert). + crossLock = await AcquireCrossClientLock(); + if (crossLock == null) + { + SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen."); + return; + } + SetStatus($"Synchronisiere '{profile.Name}'..."); _trayIcon.ShowBalloonTip(2000, "Starface Sync", $"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info); @@ -384,10 +393,53 @@ namespace StarfaceOutlookSync.UI } 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() + { + var dir = UserSettings.Load().SharedDirectory; + if (string.IsNullOrWhiteSpace(dir)) + return SyncLock.NoOp(); + + try + { + if (!System.IO.Directory.Exists(dir)) + { + SetStatus("Gemeinsames Verzeichnis nicht erreichbar - synce ohne Sperre."); + return SyncLock.NoOp(); + } + } + catch + { + 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); + } + } + private void SetStatus(string text) { if (InvokeRequired) diff --git a/src/StarfaceOutlookSync/UI/SettingsForm.cs b/src/StarfaceOutlookSync/UI/SettingsForm.cs index 5d5e2d24..c11be357 100644 --- a/src/StarfaceOutlookSync/UI/SettingsForm.cs +++ b/src/StarfaceOutlookSync/UI/SettingsForm.cs @@ -7,6 +7,8 @@ namespace StarfaceOutlookSync.UI public class SettingsForm : Form { private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook; + private TextBox _txtSharedDir; + private Button _btnBrowseShared; private Button _btnSave, _btnCancel; private readonly UserSettings _settings; @@ -54,35 +56,77 @@ namespace StarfaceOutlookSync.UI var lblHint = new Label { Text = hintText, - Left = 38, Top = 102, Width = 310, Height = 36, + Left = 38, Top = 102, Width = 320, Height = 36, ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray, Font = new Font("Segoe UI", 8) }; + var lblShared = new Label + { + Text = "Gemeinsames Verzeichnis fuer Sync-Sperre (Mehrplatz, optional):", + Left = 20, Top = 150, AutoSize = true + }; + + _txtSharedDir = new TextBox + { + Left = 20, Top = 172, Width = 250, + Text = _settings.SharedDirectory + }; + + _btnBrowseShared = new Button + { + Text = "...", Left = 274, Top = 171, Width = 36, Height = 24 + }; + _btnBrowseShared.Click += (s, e) => BrowseSharedDir(); + + var lblSharedHint = new Label + { + Text = "Netzlaufwerk/UNC, das alle Arbeitsplaetze erreichen. Leer = keine\n" + + "clientuebergreifende Sperre (nur Schutz auf diesem PC).", + Left = 20, Top = 198, Width = 330, Height = 32, + ForeColor = Color.Gray, Font = new Font("Segoe UI", 8) + }; + _btnSave = new Button { - Text = "Speichern", Left = 95, Top = 170, Width = 85, Height = 28, + Text = "Speichern", Left = 95, Top = 240, Width = 85, Height = 28, DialogResult = DialogResult.None }; _btnSave.Click += (s, e) => Save(); _btnCancel = new Button { - Text = "Abbrechen", Left = 189, Top = 170, Width = 85, Height = 28, + Text = "Abbrechen", Left = 189, Top = 240, Width = 85, Height = 28, DialogResult = DialogResult.Cancel }; - Size = new Size(380, 260); - Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel }); + Size = new Size(380, 330); + Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, + lblShared, _txtSharedDir, _btnBrowseShared, lblSharedHint, _btnSave, _btnCancel }); AcceptButton = _btnSave; CancelButton = _btnCancel; } + private void BrowseSharedDir() + { + using (var dlg = new FolderBrowserDialog()) + { + dlg.Description = "Gemeinsames Verzeichnis fuer die Sync-Sperre waehlen"; + if (!string.IsNullOrWhiteSpace(_txtSharedDir.Text)) + { + try { dlg.SelectedPath = _txtSharedDir.Text; } catch { } + } + if (dlg.ShowDialog(this) == DialogResult.OK) + _txtSharedDir.Text = dlg.SelectedPath; + } + } + private void Save() { _settings.StartMinimized = _chkStartMinimized.Checked; _settings.SyncOnStart = _chkSyncOnStart.Checked; _settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked; + _settings.SharedDirectory = _txtSharedDir.Text.Trim(); _settings.Save(); DialogResult = DialogResult.OK; Close();