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();