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:
@@ -38,6 +38,13 @@ Versionsschema ist `x.x.x.x` (siehe `release.sh`).
|
|||||||
|
|
||||||
### Hinzugefuegt
|
### 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
|
- **Feldweises 3-Wege-Merge bei Konflikten (bidirektional).** Wenn derselbe
|
||||||
Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt
|
Kontakt zwischen zwei Syncs auf beiden Seiten geaendert wurde, bleiben jetzt
|
||||||
Aenderungen an *unterschiedlichen* Feldern beide erhalten (z.B. einer aendert
|
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 SyncOnStart { get; set; } = false;
|
||||||
public bool AutoAcceptOutlookPrompt { 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(
|
private static readonly string SettingsFile = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"StarfaceOutlookSync", "settings.json");
|
"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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,8 +348,17 @@ namespace StarfaceOutlookSync.UI
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SyncLock crossLock = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Clientuebergreifende Sperre (falls gemeinsames Verzeichnis konfiguriert).
|
||||||
|
crossLock = await AcquireCrossClientLock();
|
||||||
|
if (crossLock == null)
|
||||||
|
{
|
||||||
|
SetStatus("Anderer Arbeitsplatz synchronisiert gerade - uebersprungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SetStatus($"Synchronisiere '{profile.Name}'...");
|
SetStatus($"Synchronisiere '{profile.Name}'...");
|
||||||
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
|
_trayIcon.ShowBalloonTip(2000, "Starface Sync",
|
||||||
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
|
$"Synchronisiere '{profile.Name}'...", ToolTipIcon.Info);
|
||||||
@@ -384,10 +393,53 @@ namespace StarfaceOutlookSync.UI
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
crossLock?.Dispose();
|
||||||
Interlocked.Exchange(ref _syncRunning, 0);
|
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)
|
private void SetStatus(string text)
|
||||||
{
|
{
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace StarfaceOutlookSync.UI
|
|||||||
public class SettingsForm : Form
|
public class SettingsForm : Form
|
||||||
{
|
{
|
||||||
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
|
private CheckBox _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook;
|
||||||
|
private TextBox _txtSharedDir;
|
||||||
|
private Button _btnBrowseShared;
|
||||||
private Button _btnSave, _btnCancel;
|
private Button _btnSave, _btnCancel;
|
||||||
private readonly UserSettings _settings;
|
private readonly UserSettings _settings;
|
||||||
|
|
||||||
@@ -54,35 +56,77 @@ namespace StarfaceOutlookSync.UI
|
|||||||
var lblHint = new Label
|
var lblHint = new Label
|
||||||
{
|
{
|
||||||
Text = hintText,
|
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,
|
ForeColor = UserSettings.IsOutlookSecurityLockedByPolicy() ? Color.OrangeRed : Color.Gray,
|
||||||
Font = new Font("Segoe UI", 8)
|
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
|
_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
|
DialogResult = DialogResult.None
|
||||||
};
|
};
|
||||||
_btnSave.Click += (s, e) => Save();
|
_btnSave.Click += (s, e) => Save();
|
||||||
|
|
||||||
_btnCancel = new Button
|
_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
|
DialogResult = DialogResult.Cancel
|
||||||
};
|
};
|
||||||
|
|
||||||
Size = new Size(380, 260);
|
Size = new Size(380, 330);
|
||||||
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint, _btnSave, _btnCancel });
|
Controls.AddRange(new Control[] { _chkStartMinimized, _chkSyncOnStart, _chkAutoAcceptOutlook, lblHint,
|
||||||
|
lblShared, _txtSharedDir, _btnBrowseShared, lblSharedHint, _btnSave, _btnCancel });
|
||||||
AcceptButton = _btnSave;
|
AcceptButton = _btnSave;
|
||||||
CancelButton = _btnCancel;
|
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()
|
private void Save()
|
||||||
{
|
{
|
||||||
_settings.StartMinimized = _chkStartMinimized.Checked;
|
_settings.StartMinimized = _chkStartMinimized.Checked;
|
||||||
_settings.SyncOnStart = _chkSyncOnStart.Checked;
|
_settings.SyncOnStart = _chkSyncOnStart.Checked;
|
||||||
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
|
_settings.AutoAcceptOutlookPrompt = _chkAutoAcceptOutlook.Checked;
|
||||||
|
_settings.SharedDirectory = _txtSharedDir.Text.Trim();
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
DialogResult = DialogResult.OK;
|
DialogResult = DialogResult.OK;
|
||||||
Close();
|
Close();
|
||||||
|
|||||||
Reference in New Issue
Block a user