Add cross-client sync lock via shared directory

Verhindert, dass mehrere Arbeitsplaetze gleichzeitig dasselbe Starface-
Adressbuch synchronisieren (Dubletten/Lost-Updates bei echter Ueberlappung).

- Neues optionales Setting "Gemeinsames Verzeichnis" (UserSettings.SharedDirectory)
  in der Einstellungen-Maske inkl. Ordner-Browser.
- SyncLock: atomare Lock-Datei (FileMode.CreateNew) im gemeinsamen Verzeichnis,
  waehrend des Syncs offen gehalten -> bei Absturz gibt das OS das Handle frei
  und ein anderer Client uebernimmt die verwaiste Datei (Stale-Erkennung 15 Min,
  Loeschen scheitert am offenen Handle eines lebenden Halters).
- MainForm wartet vor dem Sync bis zu 2 Min auf eine freie Sperre, sonst wird
  der Lauf uebersprungen. Ohne/bei nicht erreichbarem Verzeichnis laeuft der
  Sync ohne diese Sperre weiter (lokaler Interlocked-Schutz bleibt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:38:45 +02:00
parent d3fa452504
commit 212ced4c81
5 changed files with 233 additions and 5 deletions
+7
View File
@@ -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
@@ -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");
@@ -0,0 +1,120 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace StarfaceOutlookSync.Services
{
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Sperre, die nichts haelt (wenn kein gemeinsames Verzeichnis konfiguriert/erreichbar ist).</summary>
public static SyncLock NoOp() => new SyncLock(null, null);
/// <summary>
/// Versucht die Sperre zu holen. Gibt null zurueck, wenn gerade ein
/// anderer (lebender) Client synct. heldBy enthaelt - sofern lesbar -
/// Infos zum aktuellen Halter.
/// </summary>
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 { }
}
}
}
}
+52
View File
@@ -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);
}
}
/// <summary>
/// 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).
/// </summary>
private async Task<SyncLock> 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)
+49 -5
View File
@@ -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();