init 1.0.0
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop gewinnt gegen Companion.
|
||||
/// Bei Gleichstand: späterer Timestamp gewinnt.
|
||||
/// </summary>
|
||||
public class ConflictResolver(EventQueue queue)
|
||||
{
|
||||
public ConflictEntry? TryResolve(SyncEvent remote, string localDeviceId)
|
||||
{
|
||||
var local = queue.GetPending()
|
||||
.FirstOrDefault(e => e.EntityType == remote.EntityType
|
||||
&& e.EntityId == remote.EntityId
|
||||
&& e.DeviceId != remote.DeviceId);
|
||||
if (local is null) return null;
|
||||
|
||||
var winner = (local.DeviceType, remote.DeviceType) switch
|
||||
{
|
||||
(DeviceType.Desktop, DeviceType.Companion) => local,
|
||||
(DeviceType.Companion, DeviceType.Desktop) => remote,
|
||||
_ => local.Timestamp >= remote.Timestamp ? local : remote,
|
||||
};
|
||||
|
||||
if (winner == remote) queue.Acknowledge([local.EventId]);
|
||||
|
||||
return new ConflictEntry
|
||||
{
|
||||
LocalEvent = local,
|
||||
RemoteEvent = remote,
|
||||
Resolution = winner == local ? "LocalWon" : "RemoteWon",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Sync.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM Verschlüsselung + PBKDF2 Schlüsselableitung.
|
||||
///
|
||||
/// Pairing-Flow:
|
||||
/// Sender: EncryptedSyncKey = AES(syncKey, PBKDF2(code))
|
||||
/// Empfänger: syncKey = AES_Decrypt(EncryptedSyncKey, PBKDF2(code))
|
||||
/// → Schlüssel verlässt nie den Server, nur der Code wird geteilt.
|
||||
/// </summary>
|
||||
public static class SyncCrypto
|
||||
{
|
||||
private const int KeySize = 32; // 256 bit
|
||||
private const int NonceSize = 12; // 96 bit – GCM Standard
|
||||
private const int TagSize = 16; // 128 bit Auth-Tag
|
||||
private const int Pbkdf2Iter = 100_000;
|
||||
|
||||
// ── Schlüssel ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static byte[] GenerateKey()
|
||||
{
|
||||
var k = new byte[KeySize];
|
||||
RandomNumberGenerator.Fill(k);
|
||||
return k;
|
||||
}
|
||||
public static string KeyToBase64(byte[] key) => Convert.ToBase64String(key);
|
||||
public static byte[] KeyFromBase64(string b64) => Convert.FromBase64String(b64);
|
||||
|
||||
/// <summary>Leitet Schlüssel aus Einmal-Code ab. PBKDF2 erschwert Brute-Force.</summary>
|
||||
public static byte[] DeriveKeyFromCode(string code)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(code.Trim().ToUpperInvariant());
|
||||
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
|
||||
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
|
||||
}
|
||||
|
||||
public static string EncryptKeyWithCode(byte[] syncKey, string code) =>
|
||||
Convert.ToBase64String(Encrypt(syncKey, DeriveKeyFromCode(code)));
|
||||
|
||||
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
|
||||
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
|
||||
|
||||
// ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
|
||||
public static byte[] Encrypt(byte[] plaintext, byte[] key)
|
||||
{
|
||||
var nonce = new byte[NonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagSize];
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
var result = new byte[NonceSize + ciphertext.Length + TagSize];
|
||||
nonce.CopyTo(result, 0);
|
||||
ciphertext.CopyTo(result, NonceSize);
|
||||
tag.CopyTo(result, NonceSize + ciphertext.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static byte[] Decrypt(byte[] encrypted, byte[] key)
|
||||
{
|
||||
if (encrypted.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Ungültiges Datenformat.");
|
||||
var nonce = encrypted[..NonceSize];
|
||||
var tag = encrypted[^TagSize..];
|
||||
var ciphertext = encrypted[NonceSize..^TagSize];
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// ── JSON-Komfort ───────────────────────────────────────────────────────────
|
||||
|
||||
public static string EncryptObject<T>(T obj, byte[] key) =>
|
||||
Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(obj), key));
|
||||
public static T? DecryptObject<T>(string b64, byte[] key) =>
|
||||
JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(b64), key));
|
||||
|
||||
// ── Schlüsselspeicherung ───────────────────────────────────────────────────
|
||||
|
||||
public static void SaveKey(byte[] key, string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, KeyToBase64(key));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
public static byte[]? LoadKey(string path) =>
|
||||
File.Exists(path) ? KeyFromBase64(File.ReadAllText(path).Trim()) : null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
|
||||
/// erfolgreich zum Server gepusht wurden.
|
||||
/// </summary>
|
||||
public class EventQueue : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<SyncEvent> _queue;
|
||||
private readonly ILiteCollection<SyncMeta> _meta;
|
||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||
private long _currentSeq;
|
||||
|
||||
public EventQueue(string path)
|
||||
{
|
||||
_db = new LiteDatabase(path);
|
||||
_queue = _db.GetCollection<SyncEvent>("queue");
|
||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||
_queue.EnsureIndex(x => x.SequenceNr);
|
||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||
}
|
||||
|
||||
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
|
||||
string entityType, string entityId, string operation, string payload)
|
||||
{
|
||||
var evt = new SyncEvent
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceType = deviceType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
SequenceNr = ++_currentSeq,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Operation = operation,
|
||||
Payload = payload,
|
||||
};
|
||||
_queue.Insert(evt);
|
||||
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
|
||||
return evt;
|
||||
}
|
||||
|
||||
public List<SyncEvent> GetPending(int max = 200) =>
|
||||
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
|
||||
public int PendingCount() => _queue.Count();
|
||||
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
||||
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
||||
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
|
||||
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
|
||||
public void SetLastSyncAt(DateTime dt) =>
|
||||
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
|
||||
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
||||
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
||||
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
public class ConflictEntry
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
|
||||
public SyncEvent LocalEvent { get; init; } = null!;
|
||||
public SyncEvent RemoteEvent { get; init; } = null!;
|
||||
public string Resolution { get; init; } = "";
|
||||
public bool Reviewed { get; set; }
|
||||
}
|
||||
|
||||
internal class SyncMeta
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public long Value { get; set; }
|
||||
public DateTime? Timestamp { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LiteDB" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace LehrerApp.Sync.Models;
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Desktop-Event: Payload ist AES-256-GCM verschlüsselt.</summary>
|
||||
public class SyncEvent
|
||||
{
|
||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||
public string DeviceId { get; init; } = "";
|
||||
public DeviceType DeviceType { get; init; }
|
||||
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||
public long SequenceNr { get; init; }
|
||||
public string EntityType { get; init; } = "";
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = "";
|
||||
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
|
||||
public string Payload { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
|
||||
public class PlainSyncEvent
|
||||
{
|
||||
public Guid EventId { get; init; } = Guid.NewGuid();
|
||||
public string DeviceId { get; init; } = "";
|
||||
public DeviceType DeviceType { get; init; } = DeviceType.Companion;
|
||||
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
||||
public string EntityType { get; init; } = "";
|
||||
public string EntityId { get; init; } = "";
|
||||
public string Operation { get; init; } = "";
|
||||
public string Payload { get; init; } = "";
|
||||
}
|
||||
|
||||
// ── Sync API Responses ────────────────────────────────────────────────────────
|
||||
|
||||
public class PushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> ConflictingEventIds { get; init; } = [];
|
||||
}
|
||||
public class PullResponse
|
||||
{
|
||||
public List<SyncEvent> Events { get; init; } = [];
|
||||
public long ServerSequenceNr { get; init; }
|
||||
}
|
||||
public class PlainPushResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long ServerSequenceNr { get; init; }
|
||||
public List<Guid> RejectedEventIds { get; init; } = [];
|
||||
}
|
||||
|
||||
// ── Snapshot Modelle ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Upload-Request für Device-Pairing.
|
||||
/// EncryptedPayload: LiteDB verschlüsselt mit Sync-Key.
|
||||
/// EncryptedSyncKey: Sync-Key verschlüsselt mit PBKDF2(Code).
|
||||
/// → Empfänger braucht nur den Code um beides zu entschlüsseln.
|
||||
/// </summary>
|
||||
public class SnapshotUploadRequest
|
||||
{
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
public DeviceType DeviceType { get; init; }
|
||||
}
|
||||
public class SnapshotUploadResponse
|
||||
{
|
||||
/// <summary>Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU". 24h gültig, einmalig.</summary>
|
||||
public string Code { get; init; } = "";
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
public class SnapshotDownloadResponse
|
||||
{
|
||||
public string EncryptedPayload { get; init; } = "";
|
||||
public string EncryptedSyncKey { get; init; } = "";
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DeviceType SourceDeviceType { get; init; }
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public class SyncStatus
|
||||
{
|
||||
public SyncState State { get; set; } = SyncState.Idle;
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public int PendingEvents { get; set; }
|
||||
public int ConflictCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum DeviceType { Desktop, Companion }
|
||||
public enum SyncState { Idle, Syncing, Error, Offline }
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Device-Pairing via verschlüsseltem Snapshot + Einmal-Code.
|
||||
///
|
||||
/// Sender: CreateAndUploadAsync() → zeigt Code dem Nutzer
|
||||
/// Empfänger: RestoreFromCodeAsync(code) → entschlüsselt Schlüssel + DB
|
||||
/// </summary>
|
||||
public class SnapshotService(
|
||||
HttpClient http, LiteDbContext db, byte[] syncKey,
|
||||
DeviceType deviceType, string dbPath, string keyPath)
|
||||
{
|
||||
public event Action<SnapshotProgress>? ProgressChanged;
|
||||
|
||||
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
|
||||
db.Checkpoint();
|
||||
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
|
||||
var dbBytes = await File.ReadAllBytesAsync(dbPath, ct);
|
||||
Report(SnapshotStep.Encrypting, "Verschlüsselung läuft…");
|
||||
var encPayload = Convert.ToBase64String(SyncCrypto.Encrypt(dbBytes, syncKey));
|
||||
|
||||
// Schritt 1: Upload ohne Key → Code erhalten
|
||||
Report(SnapshotStep.Uploading, "Code wird angefordert…");
|
||||
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
||||
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
|
||||
r1.EnsureSuccessStatusCode();
|
||||
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
|
||||
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
|
||||
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
|
||||
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
|
||||
new SnapshotUploadRequest { EncryptedPayload = encPayload,
|
||||
EncryptedSyncKey = encKey, DeviceType = deviceType }, ct);
|
||||
r2.EnsureSuccessStatusCode();
|
||||
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
Report(SnapshotStep.Done, $"Bereit – Code: {result.Code}");
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<byte[]> RestoreFromCodeAsync(string code, string targetDbPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sanitized = code.Trim().ToUpperInvariant();
|
||||
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
|
||||
var resp = await http.GetAsync($"/api/snapshot/{sanitized}", ct);
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
|
||||
?? throw new InvalidOperationException("Leere Server-Antwort.");
|
||||
|
||||
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
|
||||
byte[] restoredKey;
|
||||
try { restoredKey = SyncCrypto.DecryptKeyWithCode(dl.EncryptedSyncKey, sanitized); }
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{ throw new InvalidOperationException("Schlüssel-Entschlüsselung fehlgeschlagen – Code korrekt?"); }
|
||||
|
||||
SyncCrypto.SaveKey(restoredKey, keyPath);
|
||||
|
||||
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
|
||||
var dbBytes = SyncCrypto.Decrypt(Convert.FromBase64String(dl.EncryptedPayload), restoredKey);
|
||||
|
||||
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetDbPath)!);
|
||||
if (File.Exists(targetDbPath))
|
||||
File.Move(targetDbPath, $"{targetDbPath}.backup-{DateTime.Now:yyyyMMdd-HHmmss}");
|
||||
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
|
||||
Report(SnapshotStep.Done, "Abgeschlossen – bitte App neu starten.");
|
||||
return restoredKey;
|
||||
}
|
||||
|
||||
private void Report(SnapshotStep step, string msg) =>
|
||||
ProgressChanged?.Invoke(new(step, msg));
|
||||
}
|
||||
|
||||
public record SnapshotProgress(SnapshotStep Step, string Message)
|
||||
{
|
||||
public int Percent => Step switch
|
||||
{
|
||||
SnapshotStep.Checkpointing => 10, SnapshotStep.Reading => 20,
|
||||
SnapshotStep.Encrypting => 35, SnapshotStep.Uploading => 60,
|
||||
SnapshotStep.Downloading => 35, SnapshotStep.Decrypting => 65,
|
||||
SnapshotStep.Writing => 85, SnapshotStep.Done => 100,
|
||||
_ => 0,
|
||||
};
|
||||
public bool IsComplete => Step == SnapshotStep.Done;
|
||||
}
|
||||
public enum SnapshotStep { Idle, Checkpointing, Reading, Encrypting,
|
||||
Uploading, Downloading, Decrypting, Writing, Done }
|
||||
public class SnapshotNotFoundException(string msg) : Exception(msg);
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Net.Http.Json;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Push/Pull Orchestrierung.
|
||||
/// Automatisch alle N Minuten + manuell per SyncNowAsync().
|
||||
/// </summary>
|
||||
public class SyncEngine : IDisposable
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
|
||||
public SyncStatus Status { get; private set; } = new();
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver,
|
||||
HttpClient http, SyncConfig config)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_timer = new Timer(
|
||||
async _ => await SyncNowAsync(true), null,
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
||||
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
||||
{
|
||||
if (Status.State == SyncState.Syncing)
|
||||
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
|
||||
SetState(SyncState.Syncing);
|
||||
try
|
||||
{
|
||||
var (pushed, _) = await PushAsync();
|
||||
var (pulled, conflicts) = await PullAsync();
|
||||
_queue.SetLastSyncAt(DateTime.UtcNow);
|
||||
SetState(SyncState.Idle);
|
||||
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
|
||||
}
|
||||
catch (HttpRequestException) { SetState(SyncState.Offline); return new() { Reason = "Server nicht erreichbar" }; }
|
||||
catch (Exception ex) { SetState(SyncState.Error, ex.Message); return new() { Reason = ex.Message }; }
|
||||
}
|
||||
|
||||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||
{
|
||||
var pending = _queue.GetPending();
|
||||
if (pending.Count == 0) return (0, 0);
|
||||
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
||||
if (result is null) return (0, 0);
|
||||
_queue.Acknowledge(pending
|
||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||
.Select(e => e.EventId));
|
||||
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
||||
return (pending.Count - result.ConflictingEventIds.Count,
|
||||
result.ConflictingEventIds.Count);
|
||||
}
|
||||
|
||||
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
||||
{
|
||||
var since = _queue.GetLastServerSeq();
|
||||
var resp = await _http.GetFromJsonAsync<PullResponse>(
|
||||
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
||||
if (resp is null || resp.Events.Count == 0) return (0, 0);
|
||||
var conflicts = 0;
|
||||
foreach (var evt in resp.Events)
|
||||
{
|
||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||
if (c is not null) { _queue.AddConflict(c); conflicts++; }
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
return (resp.Events.Count, conflicts);
|
||||
}
|
||||
|
||||
private void SetState(SyncState state, string? error = null)
|
||||
{
|
||||
Status = new SyncStatus
|
||||
{
|
||||
State = state,
|
||||
LastSyncAt = _queue.GetLastSyncAt(),
|
||||
PendingEvents = _queue.PendingCount(),
|
||||
ConflictCount = _queue.ConflictCount(),
|
||||
ErrorMessage = error,
|
||||
};
|
||||
StatusChanged?.Invoke(Status);
|
||||
}
|
||||
private void UpdateStatus() => SetState(Status.State);
|
||||
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
|
||||
}
|
||||
|
||||
public class SyncConfig
|
||||
{
|
||||
public string ServerUrl { get; set; } = "";
|
||||
public string DeviceId { get; set; } = "";
|
||||
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
|
||||
public int AutoSyncIntervalMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
public class SyncResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool Skipped { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int EventsPushed { get; set; }
|
||||
public int EventsPulled { get; set; }
|
||||
public int Conflicts { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user