using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Security.Cryptography; using System.Text; using System.Threading; namespace Clipman { internal sealed class StorageStateChangedEventArgs : EventArgs { public bool Unavailable { get; private set; } public string Error { get; private set; } public StorageStateChangedEventArgs(bool unavailable, string error) { Unavailable = unavailable; Error = error ?? string.Empty; } } internal sealed class ClipStore : IDisposable { private readonly object sync = new object(); private FileSystemWatcher watcher; private Timer reloadTimer; private Timer serverPollTimer; private int serverPollGeneration; private ClipDatabase database = new ClipDatabase(); private Func passwordProvider; private ServerStorageClient serverClient; private string serverRevision = string.Empty; private bool serverSyncInProgress; private bool storageUnavailable; private bool storageStateChangedNotificationPending; private bool pendingStorageOriginalUnavailable; private bool pendingStorageUnavailable; private string pendingStorageError = string.Empty; private long serverLastPollUnixMs; private long serverLastSuccessUnixMs; private long serverLastUploadUnixMs; private long serverNextPollUnixMs; private int serverConsecutiveFailures; private long serverFirstTransientFailureUnixMs; private bool lastChangeWasExternal; private string machineName; // Sync channels (sync-rules-spec.md sections 3-6). Every field below is inert while // RulesActiveLocked() is false, which is the state of a client without the feature. private const string SyncRulesFileName = "clipman-sync-rules.clipdb"; private const string ChannelFilePrefix = "clipman-channel-"; private const string PendingWritesFileName = "pending-writes.json"; private const long ChannelAnnouncementLifetimeMs = 10000; private const long SyncRulesAbsentRecheckMs = 60000; private readonly List channelSlots = new List(); private Dictionary residence = new Dictionary(StringComparer.Ordinal); private Dictionary assembledMarkers = new Dictionary(StringComparer.Ordinal); private List channelWriteOrder = new List(); private SyncRulesDocument syncRules; private string syncRulesRevision = string.Empty; private ServerStorageClient syncRulesClient; private string serverUrl = string.Empty; private string serverToken = string.Empty; private string serverCaCertPem = string.Empty; private string serverCaHost = string.Empty; private long syncRulesNextCheckUnixMs; private bool selfRegistrationAttempted; private bool channelsAdoptedRemoteState; private bool viewChangedNotificationPending; private string channelAnnouncement = string.Empty; private long channelAnnouncementUnixMs; /// /// One sync channel as it stood at the last transfer. is the empty string /// for the core channel, which keeps using the existing database file and bucket. /// is the dirty-detection hash of the plaintext with the /// database-level UpdatedUnixMs zeroed (spec section 5, upload step 4). /// private sealed class ChannelSlot { public string Key; public string Revision; public string PlainHash; public ClipDatabase Database; public ServerStorageClient Client; public string CacheFilePath; public bool Exists; public bool NeedsUpload; public ChannelSlot() { Key = string.Empty; Revision = string.Empty; PlainHash = string.Empty; CacheFilePath = string.Empty; Database = new ClipDatabase(); } } public event EventHandler Changed; public event EventHandler StorageStateChanged; public string DatabasePath { get; private set; } public string LastStorageError { get; private set; } public ServerSyncStatus GetServerSyncStatus() { lock (sync) { return new ServerSyncStatus { Enabled = serverClient != null, Configured = serverClient != null && serverClient.IsConfigured, Revision = serverRevision, LastPollUnixMs = serverLastPollUnixMs, LastSuccessUnixMs = serverLastSuccessUnixMs, LastUploadUnixMs = serverLastUploadUnixMs, NextPollUnixMs = serverNextPollUnixMs, ConsecutiveFailures = serverConsecutiveFailures, LastError = LastStorageError }; } } public bool LastChangeWasExternal { get { lock (sync) { return lastChangeWasExternal; } } } public ClipStore(string databasePath) : this(databasePath, string.Empty) { } public ClipStore(string databasePath, string password) : this(databasePath, password, Environment.MachineName) { } public ClipStore(string databasePath, string password, string machineName) { this.machineName = NormalizeMachineName(machineName); passwordProvider = () => password ?? string.Empty; SetDatabasePath(databasePath); } public ClipStore(string databasePath, Func passwordProvider) : this(databasePath, passwordProvider, Environment.MachineName) { } public ClipStore(string databasePath, Func passwordProvider, string machineName) { this.machineName = NormalizeMachineName(machineName); this.passwordProvider = passwordProvider ?? (() => string.Empty); SetDatabasePath(databasePath); } public void SetMachineName(string value) { lock (sync) { machineName = NormalizeMachineName(value); } } public void SetDatabasePath(string databasePath) { SetDatabasePath(databasePath, passwordProvider); } public void SetDatabasePath(string databasePath, string password) { SetDatabasePath(databasePath, () => password ?? string.Empty); } public void SetDatabasePath(string databasePath, Func passwordProvider) { if (string.IsNullOrWhiteSpace(databasePath)) { throw new ArgumentException("Database path cannot be blank.", "databasePath"); } lock (sync) { DatabasePath = databasePath; this.passwordProvider = passwordProvider ?? (() => string.Empty); LoadLocked(); ResetWatcherLocked(); } OnChanged(); } public void ConfigureServerStorage( bool enabled, string serverUrl, string serverToken, string serverCaCertPem, string serverCaHost) { var queueInitialSync = false; lock (sync) { serverPollGeneration++; if (serverPollTimer != null) { serverPollTimer.Dispose(); serverPollTimer = null; } this.serverUrl = serverUrl ?? string.Empty; this.serverToken = ServerSettingsSanitizer.CleanToken(serverToken); this.serverCaCertPem = serverCaCertPem ?? string.Empty; this.serverCaHost = serverCaHost ?? string.Empty; serverClient = enabled ? new ServerStorageClient( serverUrl, serverToken, CurrentPassword(), serverCaCertPem, serverCaHost) : null; serverRevision = string.Empty; syncRulesClient = null; syncRulesRevision = string.Empty; syncRulesNextCheckUnixMs = 0; selfRegistrationAttempted = false; ConfigureChannelClientsLocked(); ResetServerStatusLocked(); if (serverClient == null || !serverClient.IsConfigured) { return; } var pollGeneration = serverPollGeneration; serverPollTimer = new Timer( delegate { PollServer(pollGeneration); }, null, CalculateServerPollDelayMilliseconds(TimeUtil.NowUnixMs(), serverNextPollUnixMs), Timeout.Infinite); queueInitialSync = true; } if (queueInitialSync) { QueueInitialServerSync(); } OnChanged(); } public void ChangeDatabasePassword() { lock (sync) { SaveLocked(); } } public void Reload() { lock (sync) { LoadLocked(); if (serverClient != null && serverClient.IsConfigured) { ServerSyncLocked(false, false); } ResetWatcherLocked(); } OnChanged(); } public void RecordServerRetryFailure(Exception error) { lock (sync) { SetStorageStateLocked(true, "Server retry failed: " + (error == null ? "Unknown error." : error.Message)); MarkServerFailureLocked(); } OnChanged(); } public List GetEntries() { return GetEntries("LastUsed", "All", true); } public List GetEntries(string sortMode) { return GetEntries(sortMode, "All", true); } public List GetEntries(string sortMode, string groupFilter) { return GetEntries(sortMode, groupFilter, true); } public List GetEntries(string sortMode, string groupFilter, bool descending) { lock (sync) { var filtered = FilterByGroup(database.Entries, groupFilter).ToList(); var pinned = filtered .Where(e => e.Pinned) .OrderBy(e => e.ManualOrder) .ThenByDescending(e => e.CreatedUnixMs); var normal = SortNormalEntries(filtered.Where(e => !e.Pinned), sortMode, descending); return pinned.Concat(normal).Select(Clone).ToList(); } } public ClipEntry GetEntryById(string id) { if (string.IsNullOrWhiteSpace(id)) return null; lock (sync) { var entry = database.Entries.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.Ordinal)); return entry == null ? null : Clone(entry); } } public ClipEntry GetNewestRemoteEntry(string localMachineName) { var local = (localMachineName ?? string.Empty).Trim(); lock (sync) { var entry = database.Entries .Where(e => e != null && !string.IsNullOrEmpty(e.Text) && !string.Equals((e.SourceMachine ?? string.Empty).Trim(), local, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(e => e.CreatedUnixMs) .FirstOrDefault(); return entry == null ? null : Clone(entry); } } public bool HasRecentlyTouchedRemoteText(string text, string localMachineName, long withinMilliseconds) { if (string.IsNullOrEmpty(text)) return false; var local = (localMachineName ?? string.Empty).Trim(); var cutoff = TimeUtil.NowUnixMs() - Math.Max(1, withinMilliseconds); lock (sync) { return database.Entries.Any(e => e != null && string.Equals(e.Text ?? string.Empty, text, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(e.SourceMachine) && !string.Equals((e.SourceMachine ?? string.Empty).Trim(), local, StringComparison.OrdinalIgnoreCase) && Math.Max(e.CreatedUnixMs, e.LastUsedUnixMs) >= cutoff); } } private static IEnumerable SortNormalEntries(IEnumerable entries, string sortMode, bool descending) { switch ((sortMode ?? string.Empty).Trim().ToUpperInvariant()) { case "ADDED": return descending ? entries.OrderByDescending(e => e.CreatedUnixMs) : entries.OrderBy(e => e.CreatedUnixMs); case "TEXT": return descending ? entries.OrderByDescending(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) : entries.OrderBy(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase); case "GROUP": return descending ? entries .OrderByDescending(e => string.IsNullOrWhiteSpace(e.Group) ? "\uffff" : e.Group.Trim(), StringComparer.CurrentCultureIgnoreCase) .ThenByDescending(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) : entries .OrderBy(e => string.IsNullOrWhiteSpace(e.Group) ? "\uffff" : e.Group.Trim(), StringComparer.CurrentCultureIgnoreCase) .ThenBy(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase); case "MACHINE": return descending ? entries .OrderByDescending(e => string.IsNullOrWhiteSpace(e.SourceMachine) ? "\uffff" : e.SourceMachine.Trim(), StringComparer.CurrentCultureIgnoreCase) .ThenByDescending(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) : entries .OrderBy(e => string.IsNullOrWhiteSpace(e.SourceMachine) ? "\uffff" : e.SourceMachine.Trim(), StringComparer.CurrentCultureIgnoreCase) .ThenBy(e => e.Text ?? string.Empty, StringComparer.CurrentCultureIgnoreCase); case "MANUAL": return descending ? entries.OrderByDescending(e => e.ManualOrder) : entries.OrderBy(e => e.ManualOrder); default: return descending ? entries.OrderByDescending(e => e.LastUsedUnixMs) : entries.OrderBy(e => e.LastUsedUnixMs); } } public ClipEntry AddText(string text, string duplicateMode, int maxEntries, int maxDays) { return AddText(text, duplicateMode, maxEntries, maxDays, string.Empty); } public ClipEntry AddText(string text, string duplicateMode, int maxEntries, int maxDays, string group, RichTextPayload richText = null) { if (string.IsNullOrEmpty(text)) { return null; } lock (sync) { var existing = database.Entries.FirstOrDefault(e => e.Text == text); var mode = (duplicateMode ?? "MoveToTop").Trim(); if (existing != null && mode.Equals("Ignore", StringComparison.OrdinalIgnoreCase)) { return Clone(existing); } if (existing != null && mode.Equals("MoveToTop", StringComparison.OrdinalIgnoreCase)) { existing.LastUsedUnixMs = TimeUtil.NowUnixMs(); existing.SourceMachine = CurrentMachineName(); var normalizedRichText = RichTextData.Normalize(richText); if (normalizedRichText != null) { existing.RichText = normalizedRichText; existing.RichTextUpdatedUnixMs = existing.LastUsedUnixMs; } PruneLocked(maxEntries, maxDays); SaveLocked(); OnChanged(); return Clone(existing); } var now = TimeUtil.NowUnixMs(); var newEntryRichText = RichTextData.Normalize(richText); var entry = new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = text, Group = (group ?? string.Empty).Trim(), SourceMachine = CurrentMachineName(), CreatedUnixMs = now, LastUsedUnixMs = now, ModifiedUnixMs = now, ManualOrder = NextManualOrderLocked(), RichText = newEntryRichText, RichTextUpdatedUnixMs = newEntryRichText == null ? 0 : now }; database.Entries.Add(entry); PruneLocked(maxEntries, maxDays); SaveLocked(); OnChanged(); return Clone(entry); } } public int PushEntriesToOtherMachines(IEnumerable ids, bool keepDuplicateEntries) { var idSet = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id))); if (idSet.Count == 0) return 0; lock (sync) { var selected = database.Entries .Where(e => e != null && idSet.Contains(e.Id) && !string.IsNullOrEmpty(e.Text)) .ToList(); if (selected.Count == 0) return 0; var now = TimeUtil.NowUnixMs(); foreach (var entry in selected) { var stamp = now++; if (!keepDuplicateEntries) { entry.SourceMachine = CurrentMachineName(); entry.CreatedUnixMs = stamp; entry.LastUsedUnixMs = stamp; continue; } database.Entries.Add(new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = entry.Text ?? string.Empty, Name = entry.Name ?? string.Empty, Group = entry.Group ?? string.Empty, SourceMachine = CurrentMachineName(), CreatedUnixMs = stamp, LastUsedUnixMs = stamp, ModifiedUnixMs = stamp, Pinned = false, IsTemplate = entry.IsTemplate, ManualOrder = NextManualOrderLocked(), RichText = RichTextData.Clone(entry.RichText), RichTextUpdatedUnixMs = entry.RichTextUpdatedUnixMs }); } SaveLocked(); OnChanged(); return selected.Count; } } public void MarkUsed(string id) { MarkUsedMany(new[] { id }); } public void MarkUsedMany(IEnumerable ids) { var selected = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrEmpty(id)), StringComparer.Ordinal); if (selected.Count == 0) return; lock (sync) { var entries = database.Entries.Where(entry => selected.Contains(entry.Id)).ToList(); if (entries.Count == 0) return; var now = TimeUtil.NowUnixMs(); foreach (var entry in entries) entry.LastUsedUnixMs = now; SaveLocked(); } } public void Delete(string id) { lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; database.Entries.Remove(entry); AddDeletedEntryLocked(id, entry.Text); SaveLocked(); OnChanged(); } } public int DeleteMany(IEnumerable ids) { var idSet = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrEmpty(id))); if (idSet.Count == 0) return 0; lock (sync) { var removedIds = database.Entries .Where(e => idSet.Contains(e.Id)) .Select(e => new { e.Id, e.Text }) .ToList(); var removed = database.Entries.RemoveAll(e => idSet.Contains(e.Id)); if (removed == 0) return 0; foreach (var removedEntry in removedIds) { AddDeletedEntryLocked(removedEntry.Id, removedEntry.Text); } SaveLocked(); OnChanged(); return removed; } } public void ReplaceAll(IEnumerable entries) { lock (sync) { database = new ClipDatabase(); foreach (var entry in entries.Where(e => e != null && !string.IsNullOrEmpty(e.Text))) { database.Entries.Add(new ClipEntry { Id = string.IsNullOrWhiteSpace(entry.Id) ? Guid.NewGuid().ToString("N") : entry.Id, Text = entry.Text, Name = entry.Name ?? string.Empty, Group = entry.Group ?? string.Empty, SourceMachine = entry.SourceMachine ?? string.Empty, CreatedUnixMs = entry.CreatedUnixMs == 0 ? TimeUtil.NowUnixMs() : entry.CreatedUnixMs, LastUsedUnixMs = entry.LastUsedUnixMs == 0 ? TimeUtil.NowUnixMs() : entry.LastUsedUnixMs, ModifiedUnixMs = entry.ModifiedUnixMs, Pinned = entry.Pinned, IsTemplate = entry.IsTemplate, ManualOrder = entry.ManualOrder, RichText = RichTextData.Clone(entry.RichText), RichTextUpdatedUnixMs = entry.RichTextUpdatedUnixMs }); } NormalizeManualOrderLocked(); SaveLocked(); OnChanged(); } } public void ImportFromFile(string path, bool replace) { var imported = LoadEntriesFromFile(path, CurrentPassword()); ImportEntries(imported, replace); } public void ImportFromFile(string path, bool replace, string importPassword) { var imported = LoadEntriesFromFile(path, importPassword ?? string.Empty); ImportEntries(imported, replace); } private void ImportEntries(List imported, bool replace) { if (replace) { ReplaceAll(imported); return; } lock (sync) { foreach (var entry in imported) { if (string.IsNullOrEmpty(entry.Text)) continue; if (database.Entries.Any(e => e.Text == entry.Text)) continue; if (entry.ManualOrder <= 0) entry.ManualOrder = NextManualOrderLocked(); database.Entries.Add(entry); } NormalizeManualOrderLocked(); SaveLocked(); OnChanged(); } } public void ExportToFile(string path) { ExportToFile(path, CurrentPassword()); } public void ExportToFile(string path, string exportPassword) { lock (sync) { database.UpdatedUnixMs = TimeUtil.NowUnixMs(); ClipDatabaseFile.SaveAtomic(path, database, exportPassword == null ? CurrentPassword() : exportPassword); } } public bool HasCurrentPassword() { return !string.IsNullOrEmpty(CurrentPassword()); } public bool CurrentPasswordMatches(string password) { return string.Equals(CurrentPassword(), password ?? string.Empty, StringComparison.Ordinal); } public bool TogglePinned(string id) { lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return false; entry.Pinned = !entry.Pinned; entry.ModifiedUnixMs = TimeUtil.NowUnixMs(); SaveLocked(); OnChanged(); return entry.Pinned; } } public void SetPinned(string id, bool pinned) { lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; entry.Pinned = pinned; entry.ModifiedUnixMs = TimeUtil.NowUnixMs(); SaveLocked(); OnChanged(); } } public void SetName(string id, string name) { lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; entry.Name = (name ?? string.Empty).Trim(); entry.ModifiedUnixMs = TimeUtil.NowUnixMs(); SaveLocked(); OnChanged(); } } public void SetNameAndText(string id, string name, string text) { if (string.IsNullOrEmpty(id)) return; lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; var now = TimeUtil.NowUnixMs(); entry.Name = (name ?? string.Empty).Trim(); var hasEmbeddedImage = RichImageData.StoredByteCount(entry.RichText) > 0; var nextText = hasEmbeddedImage ? entry.Text ?? string.Empty : text ?? string.Empty; if (!string.Equals(entry.Text ?? string.Empty, nextText, StringComparison.Ordinal)) { entry.RichText = null; entry.RichTextUpdatedUnixMs = now; } entry.Text = nextText; entry.LastUsedUnixMs = now; entry.ModifiedUnixMs = now; SaveLocked(); OnChanged(); } } public void SetTemplate(string id, bool isTemplate) { if (string.IsNullOrEmpty(id)) return; lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; if (RichImageData.StoredByteCount(entry.RichText) > 0) return; entry.IsTemplate = isTemplate; entry.ModifiedUnixMs = TimeUtil.NowUnixMs(); SaveLocked(); OnChanged(); } } public void SetGroup(IEnumerable ids, string groupName) { var idSet = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrEmpty(id))); if (idSet.Count == 0) return; lock (sync) { var requestedGroup = (groupName ?? string.Empty).Trim(); var canonicalGroup = CanonicalLabels(database.Entries, e => e.Group) .FirstOrDefault(group => string.Equals(group, requestedGroup, StringComparison.CurrentCultureIgnoreCase)) ?? requestedGroup; var now = TimeUtil.NowUnixMs(); foreach (var entry in database.Entries.Where(e => idSet.Contains(e.Id))) { entry.Group = canonicalGroup; entry.ModifiedUnixMs = now; } SaveLocked(); OnChanged(); } } public List GetGroups() { lock (sync) { return CanonicalLabels(database.Entries, e => e.Group); } } private string CanonicalGroupLocked(string groupName) { var requested = (groupName ?? string.Empty).Trim(); return CanonicalLabels(database.Entries, entry => entry.Group) .FirstOrDefault(group => string.Equals(group, requested, StringComparison.CurrentCultureIgnoreCase)) ?? requested; } public ClipEntry AddManualEntry(string text, string name, string group, bool pinned, bool isTemplate, string duplicateMode, int maxEntries, int maxDays) { if (string.IsNullOrWhiteSpace(text)) return null; text = text.Trim(); lock (sync) { var now = TimeUtil.NowUnixMs(); var existing = database.Entries.FirstOrDefault(e => e.Text == text); var mode = (duplicateMode ?? "MoveToTop").Trim(); if (existing != null && mode.Equals("Ignore", StringComparison.OrdinalIgnoreCase)) { return Clone(existing); } ClipEntry entry; if (existing != null && mode.Equals("MoveToTop", StringComparison.OrdinalIgnoreCase)) { entry = existing; entry.Name = (name ?? string.Empty).Trim(); entry.Group = CanonicalGroupLocked(group); entry.Pinned = pinned; entry.IsTemplate = isTemplate; entry.SourceMachine = CurrentMachineName(); entry.LastUsedUnixMs = now; entry.ModifiedUnixMs = now; } else { entry = new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = text, Name = (name ?? string.Empty).Trim(), Group = CanonicalGroupLocked(group), Pinned = pinned, IsTemplate = isTemplate, SourceMachine = CurrentMachineName(), CreatedUnixMs = now, LastUsedUnixMs = now, ModifiedUnixMs = now, ManualOrder = NextManualOrderLocked() }; database.Entries.Add(entry); } PruneLocked(maxEntries, maxDays); SaveLocked(); OnChanged(); return Clone(entry); } } public ClipEntry MergeCapturedText(string baseId, string firstTapId, string mergedText, int maxEntries, int maxDays, string group) { if (string.IsNullOrEmpty(mergedText)) return null; lock (sync) { var baseEntry = database.Entries.FirstOrDefault(item => string.Equals(item.Id, baseId, StringComparison.OrdinalIgnoreCase)); var firstTapEntry = database.Entries.FirstOrDefault(item => string.Equals(item.Id, firstTapId, StringComparison.OrdinalIgnoreCase)); var target = baseEntry != null && !baseEntry.Pinned ? baseEntry : firstTapEntry != null && !firstTapEntry.Pinned ? firstTapEntry : null; var now = TimeUtil.NowUnixMs(); if (target == null) { target = new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = mergedText, Group = (group ?? string.Empty).Trim(), SourceMachine = CurrentMachineName(), CreatedUnixMs = now, LastUsedUnixMs = now, ModifiedUnixMs = now, ManualOrder = NextManualOrderLocked() }; database.Entries.Add(target); } else { target.Text = mergedText; target.SourceMachine = CurrentMachineName(); target.LastUsedUnixMs = now; target.ModifiedUnixMs = now; target.IsTemplate = false; target.RichText = null; target.RichTextUpdatedUnixMs = now; if (string.IsNullOrWhiteSpace(target.Group) && !string.IsNullOrWhiteSpace(group)) { target.Group = group.Trim(); } } if (firstTapEntry != null && !firstTapEntry.Pinned && !string.Equals(firstTapEntry.Id, target.Id, StringComparison.OrdinalIgnoreCase)) { AddDeletedEntryLocked(firstTapEntry.Id, firstTapEntry.Text); database.Entries.Remove(firstTapEntry); } PruneLocked(maxEntries, maxDays); SaveLocked(); OnChanged(); return Clone(target); } } public bool TrySetNameIfUnchanged(string id, string expectedText, string name) { if (string.IsNullOrEmpty(id)) return false; lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null || !string.IsNullOrWhiteSpace(entry.Name) || !string.Equals(entry.Text ?? string.Empty, expectedText ?? string.Empty, StringComparison.Ordinal)) { return false; } entry.Name = (name ?? string.Empty).Trim(); entry.ModifiedUnixMs = TimeUtil.NowUnixMs(); SaveLocked(); OnChanged(); return true; } } public long EmbeddedImageByteCount() { lock (sync) { long total = 0; foreach (var entry in database.Entries) { if (entry == null) continue; total += RichImageData.StoredByteCount(entry.RichText); if (total >= RichImageData.MaximumDatabaseImageBytes) return total; } return total; } } public void ReplaceText(string id, string text) { if (string.IsNullOrEmpty(id)) return; lock (sync) { var entry = database.Entries.FirstOrDefault(e => e.Id == id); if (entry == null) return; var nextText = text ?? string.Empty; entry.Text = nextText; entry.LastUsedUnixMs = TimeUtil.NowUnixMs(); entry.ModifiedUnixMs = entry.LastUsedUnixMs; entry.RichText = null; entry.RichTextUpdatedUnixMs = entry.LastUsedUnixMs; SaveLocked(); OnChanged(); } } public void MoveEntries(IEnumerable ids, int direction) { MoveEntries(ids, direction, null); } public void MoveEntries(IEnumerable ids, int direction, IEnumerable visibleIds) { var selectedIds = new HashSet((ids ?? Enumerable.Empty()).Where(id => !string.IsNullOrEmpty(id))); if (selectedIds.Count == 0 || direction == 0) return; var visibleOrder = (visibleIds ?? Enumerable.Empty()) .Where(id => !string.IsNullOrEmpty(id)) .Distinct() .ToList(); lock (sync) { NormalizeManualOrderLocked(); var selectedEntries = database.Entries.Where(e => selectedIds.Contains(e.Id)).ToList(); if (selectedEntries.Count == 0) return; if (selectedEntries.Any(e => e.Pinned != selectedEntries[0].Pinned)) return; var pinnedBand = selectedEntries[0].Pinned; var band = database.Entries .Where(e => e.Pinned == pinnedBand) .OrderBy(e => e.ManualOrder) .ToList(); var ordered = visibleOrder.Count == 0 ? band : visibleOrder .Select(id => band.FirstOrDefault(entry => entry.Id == id)) .Where(entry => entry != null) .ToList(); var selected = ordered.Where(e => selectedIds.Contains(e.Id)).ToList(); if (selected.Count != selectedEntries.Count) return; var indexes = selected.Select(e => ordered.IndexOf(e)).OrderBy(i => i).ToList(); var first = indexes.First(); var last = indexes.Last(); if ((direction < 0 && first == 0) || (direction > 0 && last == ordered.Count - 1)) return; var manualOrderSlots = ordered.Select(e => e.ManualOrder).OrderBy(value => value).ToList(); foreach (var entry in selected) { ordered.Remove(entry); } if (direction < 0) { ordered.InsertRange(Math.Max(0, first - 1), selected); } else { ordered.InsertRange(Math.Min(ordered.Count, last + 1 - selected.Count + 1), selected); } var now = TimeUtil.NowUnixMs(); for (var i = 0; i < ordered.Count; i++) { var nextOrder = manualOrderSlots[i]; if (ordered[i].ManualOrder != nextOrder) { ordered[i].ManualOrder = nextOrder; ordered[i].ModifiedUnixMs = now; } } SaveLocked(); OnChanged(); } } public void SetManualOrder(IEnumerable orderedIds) { var order = (orderedIds ?? Enumerable.Empty()) .Where(id => !string.IsNullOrEmpty(id)) .Select((id, index) => new { Id = id, Index = index + 1L }) .ToDictionary(x => x.Id, x => x.Index); if (order.Count == 0) return; lock (sync) { var now = TimeUtil.NowUnixMs(); foreach (var entry in database.Entries) { long manualOrder; if (order.TryGetValue(entry.Id, out manualOrder)) { if (entry.ManualOrder != manualOrder) { entry.ManualOrder = manualOrder; entry.ModifiedUnixMs = now; } } } SaveLocked(); OnChanged(); } } public List InsertEntriesAfter(IEnumerable entries, string afterId, bool removeDuplicates) { var source = (entries ?? Enumerable.Empty()) .Where(e => e != null && !string.IsNullOrEmpty(e.Text)) .ToList(); var inserted = new List(); if (source.Count == 0) return inserted; lock (sync) { NormalizeManualOrderLocked(); if (removeDuplicates) { var texts = new HashSet(source.Select(e => e.Text)); database.Entries.RemoveAll(e => texts.Contains(e.Text)); } var after = string.IsNullOrEmpty(afterId) ? null : database.Entries.FirstOrDefault(e => e.Id == afterId); var order = after == null ? NextManualOrderLocked() : after.ManualOrder + 1; foreach (var entry in database.Entries.Where(e => e.ManualOrder >= order)) { entry.ManualOrder += source.Count; } foreach (var entry in source) { var now = TimeUtil.NowUnixMs(); var newEntry = new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = entry.Text, Name = entry.Name ?? string.Empty, Group = entry.Group ?? string.Empty, SourceMachine = entry.SourceMachine ?? string.Empty, CreatedUnixMs = entry.CreatedUnixMs == 0 ? now : entry.CreatedUnixMs, LastUsedUnixMs = entry.LastUsedUnixMs == 0 ? now : entry.LastUsedUnixMs, ModifiedUnixMs = entry.ModifiedUnixMs == 0 ? now : entry.ModifiedUnixMs, Pinned = entry.Pinned, IsTemplate = entry.IsTemplate, ManualOrder = order++, RichText = RichTextData.Clone(entry.RichText), RichTextUpdatedUnixMs = entry.RichTextUpdatedUnixMs }; database.Entries.Add(newEntry); inserted.Add(Clone(newEntry)); } NormalizeManualOrderLocked(); SaveLocked(); OnChanged(); return inserted; } } public List InsertEntriesAtNormalStart(IEnumerable entries, bool removeDuplicates) { var source = (entries ?? Enumerable.Empty()) .Where(e => e != null && !string.IsNullOrEmpty(e.Text)) .ToList(); var inserted = new List(); if (source.Count == 0) return inserted; lock (sync) { NormalizeManualOrderLocked(); if (removeDuplicates) { var texts = new HashSet(source.Select(e => e.Text)); database.Entries.RemoveAll(e => texts.Contains(e.Text)); } var firstNormal = database.Entries .Where(e => !e.Pinned) .OrderBy(e => e.ManualOrder) .FirstOrDefault(); var order = firstNormal == null ? NextManualOrderLocked() : firstNormal.ManualOrder; foreach (var entry in database.Entries.Where(e => e.ManualOrder >= order)) { entry.ManualOrder += source.Count; } foreach (var entry in source) { var now = TimeUtil.NowUnixMs(); var newEntry = new ClipEntry { Id = Guid.NewGuid().ToString("N"), Text = entry.Text, Name = entry.Name ?? string.Empty, Group = entry.Group ?? string.Empty, SourceMachine = entry.SourceMachine ?? string.Empty, CreatedUnixMs = entry.CreatedUnixMs == 0 ? now : entry.CreatedUnixMs, LastUsedUnixMs = entry.LastUsedUnixMs == 0 ? now : entry.LastUsedUnixMs, ModifiedUnixMs = entry.ModifiedUnixMs == 0 ? now : entry.ModifiedUnixMs, Pinned = false, IsTemplate = entry.IsTemplate, ManualOrder = order++, RichText = RichTextData.Clone(entry.RichText), RichTextUpdatedUnixMs = entry.RichTextUpdatedUnixMs }; database.Entries.Add(newEntry); inserted.Add(Clone(newEntry)); } NormalizeManualOrderLocked(); SaveLocked(); OnChanged(); return inserted; } } public List LoadEntriesFromFile(string path) { return LoadEntriesFromFile(path, string.Empty); } public List LoadEntriesFromFile(string path, string password) { var extension = Path.GetExtension(path).ToLowerInvariant(); if (extension == ".txt") { return File.ReadAllText(path).Split(new[] { "\r\n---\r\n", "\n---\n" }, StringSplitOptions.RemoveEmptyEntries) .Select(t => new ClipEntry { Text = t.Trim(), SourceMachine = CurrentMachineName(), CreatedUnixMs = TimeUtil.NowUnixMs(), LastUsedUnixMs = TimeUtil.NowUnixMs() }) .Where(e => e.Text.Length > 0) .ToList(); } if (SqliteClipboardImporter.LooksLikeSqliteDatabase(path)) { return SqliteClipboardImporter.LoadEntries(path); } var db = ClipDatabaseFile.Load(path, password); return (db.Entries ?? new List()) .Where(e => !string.IsNullOrEmpty(e.Text)) .Select(Clone) .ToList(); } private void LoadLocked() { var password = CurrentPassword(); try { SyncConflictResolver.ResolveDatabaseConflicts(DatabasePath, password); ResolveSyncRulesConflictsLocked(password); LoadSyncRulesFromDiskLocked(password); if (RulesActiveLocked()) { LoadChannelsFromDiskLocked(password); SetStorageStateLocked(false, string.Empty); return; } ClearChannelStateLocked(); database = ClipDatabaseFile.Load(DatabasePath, password); if (database.Entries == null) database.Entries = new List(); NormalizeDeletedEntriesLocked(); ApplyDeletedEntriesLocked(); NormalizeManualOrderLocked(); SetStorageStateLocked(false, string.Empty); } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; database = new ClipDatabase(); ClearChannelStateLocked(); SetStorageStateLocked(true, ex.Message); } } private void SaveLocked() { if (RulesActiveLocked()) { SaveChannelsLocked(false); return; } SaveSingleDatabaseLocked(); } private void SaveSingleDatabaseLocked() { database.UpdatedUnixMs = TimeUtil.NowUnixMs(); try { if (storageUnavailable) { MergeExistingDatabaseIfAvailableLocked(); } NormalizeDeletedEntriesLocked(); ApplyDeletedEntriesLocked(); ClipDatabaseFile.SaveAtomic(DatabasePath, database, CurrentPassword()); SetStorageStateLocked(false, string.Empty); if (watcher == null) { ResetWatcherLocked(); } UploadToServerLocked(); } catch (Exception ex) { if (!IsStorageAccessException(ex) && !IsRecoverableServerException(ex)) throw; SetStorageStateLocked(true, ex.Message); } } private string CurrentPassword() { return passwordProvider == null ? string.Empty : (passwordProvider() ?? string.Empty); } private void PruneLocked(int maxEntries, int maxDays) { if (maxDays > 0) { var cutoff = TimeUtil.NowUnixMs() - (long)TimeSpan.FromDays(maxDays).TotalMilliseconds; database.Entries.RemoveAll(e => !IsProtected(e) && e.LastUsedUnixMs > 0 && e.LastUsedUnixMs < cutoff); } if (maxEntries > 0) { var removable = database.Entries .Where(e => !IsProtected(e)) .OrderByDescending(e => e.LastUsedUnixMs) .Skip(maxEntries) .Select(e => e.Id) .ToList(); if (removable.Count > 0) { database.Entries.RemoveAll(e => removable.Contains(e.Id)); } } } private long NextManualOrderLocked() { return database.Entries.Count == 0 ? 1 : database.Entries.Max(e => e.ManualOrder) + 1; } private void NormalizeManualOrderLocked() { var next = 1L; foreach (var entry in database.Entries.OrderBy(e => e.ManualOrder <= 0 ? long.MaxValue : e.ManualOrder).ThenBy(e => e.CreatedUnixMs)) { if (entry.CreatedUnixMs == 0) entry.CreatedUnixMs = TimeUtil.NowUnixMs(); if (entry.LastUsedUnixMs == 0) entry.LastUsedUnixMs = entry.CreatedUnixMs; if (entry.Name == null) entry.Name = string.Empty; if (entry.Group == null) entry.Group = string.Empty; if (entry.SourceMachine == null) entry.SourceMachine = string.Empty; entry.ManualOrder = next++; } } private void ResetWatcherLocked() { if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); watcher = null; reloadTimer = null; var dir = Path.GetDirectoryName(DatabasePath); var file = Path.GetFileName(DatabasePath); if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(file)) { return; } try { Directory.CreateDirectory(dir); reloadTimer = new Timer(delegate { ReloadFromWatcher(); }, null, Timeout.Infinite, Timeout.Infinite); // Channel and rules files are siblings of the history file, so the watcher covers // every container in the folder and WatcherChanged filters by name. A rules file is // watched even while rules are inactive, or a device would never notice another // device enabling them in a shared folder. watcher = new FileSystemWatcher(dir, "*" + ClipDatabaseFile.CompressedExtension) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime }; watcher.Changed += WatcherChanged; watcher.Created += WatcherChanged; watcher.Renamed += WatcherChanged; watcher.EnableRaisingEvents = true; } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; SetStorageStateLocked(true, ex.Message); if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); watcher = null; reloadTimer = null; } } private void ResetServerStatusLocked() { serverLastPollUnixMs = 0; serverLastSuccessUnixMs = 0; serverLastUploadUnixMs = 0; serverNextPollUnixMs = 0; serverConsecutiveFailures = 0; serverFirstTransientFailureUnixMs = 0; } private void MarkServerSuccessLocked(bool upload) { var now = TimeUtil.NowUnixMs(); serverLastSuccessUnixMs = now; if (upload) { serverLastUploadUnixMs = now; } serverConsecutiveFailures = 0; serverNextPollUnixMs = 0; serverFirstTransientFailureUnixMs = 0; } private void MarkServerFailureLocked() { var now = TimeUtil.NowUnixMs(); serverConsecutiveFailures = Math.Min(serverConsecutiveFailures + 1, 8); var delaySeconds = Math.Min(60, 2 << Math.Min(serverConsecutiveFailures, 5)); serverNextPollUnixMs = now + delaySeconds * 1000L; } internal const int TransientServerFailureGraceMilliseconds = 30000; // A background poll that times out or loses its connection is usually a brief network // hiccup that the next retry recovers from. Keep retrying with backoff, but only report // storage as unavailable once such failures have lasted for the grace period, so a // single slow request does not flip the tray and announcements to "storage unavailable". private void ReportServerPollFailureLocked(Exception ex, string message) { if (!storageUnavailable && IsTransientServerFailure(ex)) { var now = TimeUtil.NowUnixMs(); if (serverFirstTransientFailureUnixMs == 0) { serverFirstTransientFailureUnixMs = now; } if (!ShouldReportTransientServerFailure(serverFirstTransientFailureUnixMs, now)) { MarkServerFailureLocked(); return; } } SetStorageStateLocked(true, message); MarkServerFailureLocked(); } internal static bool ShouldReportTransientServerFailure(long firstFailureUnixMs, long nowUnixMs) { return firstFailureUnixMs > 0 && nowUnixMs - firstFailureUnixMs >= TransientServerFailureGraceMilliseconds; } internal static bool IsTransientServerFailure(Exception ex) { var web = ex as WebException; if (web != null) { switch (web.Status) { case WebExceptionStatus.Timeout: case WebExceptionStatus.RequestCanceled: case WebExceptionStatus.ConnectFailure: case WebExceptionStatus.ConnectionClosed: case WebExceptionStatus.KeepAliveFailure: case WebExceptionStatus.NameResolutionFailure: case WebExceptionStatus.PipelineFailure: case WebExceptionStatus.ReceiveFailure: case WebExceptionStatus.SendFailure: return true; default: return false; } } return ex is TimeoutException || ex is System.Net.Sockets.SocketException || (ex is IOException && ex.InnerException is System.Net.Sockets.SocketException); } internal static int CalculateServerPollDelayMilliseconds(long nowUnixMs, long nextPollUnixMs) { const int normalPollMilliseconds = 2000; if (nextPollUnixMs <= nowUnixMs + normalPollMilliseconds) { return normalPollMilliseconds; } return (int)Math.Min(int.MaxValue, nextPollUnixMs - nowUnixMs); } private void ScheduleNextServerPoll(int pollGeneration) { lock (sync) { if (pollGeneration != serverPollGeneration || serverPollTimer == null || serverClient == null || !serverClient.IsConfigured) { return; } var delay = CalculateServerPollDelayMilliseconds(TimeUtil.NowUnixMs(), serverNextPollUnixMs); serverPollTimer.Change(delay, Timeout.Infinite); } } private void PollServer(int pollGeneration) { var changed = false; try { lock (sync) { try { if (serverClient == null || !serverClient.IsConfigured || serverSyncInProgress) return; var now = TimeUtil.NowUnixMs(); if (serverNextPollUnixMs > now) return; serverLastPollUnixMs = now; changed = ServerSyncLocked(false, true); } catch (WebException ex) { if (serverClient != null && serverClient.IsNotFound(ex)) { try { changed = SyncFromServerLocked(true); return; } catch (WebException retryEx) { if (serverClient != null && serverClient.IsNotFound(retryEx)) return; ReportServerPollFailureLocked(retryEx, "Server poll failed: " + retryEx.Message); return; } } ReportServerPollFailureLocked(ex, "Server poll failed: " + ex.Message); return; } catch (Exception ex) { if (!IsRecoverableServerException(ex) && !IsTransientServerFailure(ex)) return; ReportServerPollFailureLocked(ex, "Server poll failed: " + ex.Message); return; } } } finally { // A channel pass that failed part way can still have changed the view, and every // failure branch above returns early, so the latch is consulted here rather than // relying on the value returned by the pass. if (changed || TakeViewChangedNotification()) { OnChanged(true); } RaiseStorageStateChangedIfPending(); ScheduleNextServerPoll(pollGeneration); } } private bool TakeViewChangedNotification() { lock (sync) { var pending = viewChangedNotificationPending; viewChangedNotificationPending = false; return pending; } } /// /// One server sync pass. The sync rules bucket is consulted first (spec section 5, download /// step 1) so that enabling the feature elsewhere is noticed; with rules inactive the rest /// is the unchanged single-bucket flow. /// private bool ServerSyncLocked(bool uploadLocalWhenMissing, bool headFirst) { if (serverClient == null || !serverClient.IsConfigured) return false; var changed = false; if (RefreshSyncRulesFromServerLocked()) { ReconfigureChannelsLocked(); changed = true; } if (!RulesActiveLocked()) { if (headFirst) { var metadata = serverClient.GetMetadata(); if (string.IsNullOrWhiteSpace(metadata.Revision) || string.Equals(metadata.Revision, serverRevision, StringComparison.Ordinal)) { // Changed metadata is not a successful poll until its download completes. SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(false); return changed; } } return SyncFromServerLocked(uploadLocalWhenMissing) || changed; } changed = SyncChannelsFromServerLocked(uploadLocalWhenMissing) || changed; SelfRegisterDeviceLocked(); changed = RetryPendingWritesLocked(TimeUtil.NowUnixMs()) || changed; if (AnyChannelNeedsUploadLocked()) { SaveChannelsLocked(false); } return changed; } private bool SyncFromServerLocked(bool uploadLocalWhenMissing) { if (serverClient == null || !serverClient.IsConfigured) return false; if (serverSyncInProgress) return false; if (RulesActiveLocked()) return SyncChannelsFromServerLocked(uploadLocalWhenMissing); serverSyncInProgress = true; try { ServerDatabaseDownload download; try { download = serverClient.Download(); } catch (WebException ex) { if (serverClient.IsNotFound(ex)) { if (uploadLocalWhenMissing && !storageUnavailable && database.Entries.Count > 0) { ClipDatabaseFile.SaveAtomic(DatabasePath, database, CurrentPassword()); var metadata = serverClient.Upload(File.ReadAllBytes(DatabasePath), string.Empty); serverRevision = metadata == null ? string.Empty : metadata.Revision; SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(true); } return false; } throw; } if (download.Data == null || download.Data.Length == 0) return false; var tempPath = DatabasePath + ".server-download.tmp"; WriteBytesAtomic(tempPath, download.Data); var downloadedDatabase = ClipDatabaseFile.Load(tempPath, CurrentPassword()); TryDelete(tempPath); var uploadMerged = HasLocalStateMissingFromServer(downloadedDatabase, database); var changed = MergeDatabaseIntoLocked(database, downloadedDatabase); if (database.Entries == null) database.Entries = new List(); NormalizeManualOrderLocked(); serverRevision = download.Metadata == null ? string.Empty : download.Metadata.Revision; ClipDatabaseFile.SaveAtomic(DatabasePath, database, CurrentPassword()); if (uploadMerged) { var mergedMetadata = serverClient.Upload(File.ReadAllBytes(DatabasePath), serverRevision); serverRevision = mergedMetadata == null ? string.Empty : mergedMetadata.Revision; } SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(uploadMerged); return changed; } finally { serverSyncInProgress = false; } } private void QueueInitialServerSync() { ThreadPool.QueueUserWorkItem(delegate { var changed = false; lock (sync) { try { changed = ServerSyncLocked(true, false); } catch (Exception ex) { SetStorageStateLocked(true, "Server sync failed: " + ex.Message); MarkServerFailureLocked(); } } OnChanged(changed); }); } private void UploadToServerLocked() { if (serverClient == null || !serverClient.IsConfigured || serverSyncInProgress) return; if (!File.Exists(DatabasePath)) return; serverSyncInProgress = true; try { try { var metadata = serverClient.Upload(File.ReadAllBytes(DatabasePath), serverRevision); serverRevision = metadata == null ? string.Empty : metadata.Revision; SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(true); return; } catch (WebException ex) { if (!serverClient.IsConflict(ex)) throw; } var server = serverClient.Download(); var localDatabase = database; WriteBytesAtomic(DatabasePath + ".server.tmp", server.Data); var serverDatabase = ClipDatabaseFile.Load(DatabasePath + ".server.tmp", CurrentPassword()); MergeDatabaseIntoLocked(localDatabase, serverDatabase); database = localDatabase; NormalizeManualOrderLocked(); ClipDatabaseFile.SaveAtomic(DatabasePath, database, CurrentPassword()); var retry = serverClient.Upload(File.ReadAllBytes(DatabasePath), server.Metadata == null ? string.Empty : server.Metadata.Revision); serverRevision = retry == null ? string.Empty : retry.Revision; TryDelete(DatabasePath + ".server.tmp"); SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(true); } catch { serverRevision = string.Empty; MarkServerFailureLocked(); throw; } finally { serverSyncInProgress = false; } } private static bool MergeDatabaseIntoLocked(ClipDatabase target, ClipDatabase source) { if (target == null || source == null || source.Entries == null) return false; if (target.Entries == null) target.Entries = new List(); var changed = false; changed = MergeDeletedEntries(target, source) || changed; ApplyDeletedEntries(target); foreach (var entry in source.Entries.Where(e => e != null && !string.IsNullOrEmpty(e.Text))) { if (IsDeleted(target, entry)) continue; var existing = target.Entries.FirstOrDefault(e => !string.IsNullOrEmpty(e.Id) && string.Equals(e.Id, entry.Id, StringComparison.Ordinal)); if (existing == null) { existing = target.Entries.FirstOrDefault(e => string.Equals(e.Text, entry.Text, StringComparison.Ordinal)); } if (existing == null) { target.Entries.Add(Clone(entry)); changed = true; continue; } changed = MergeEntryMetadata(existing, entry) || changed; } ApplyDeletedEntries(target); var markerCount = target.DeletedEntries == null ? 0 : target.DeletedEntries.Count; NormalizeDeletedEntries(target); if (target.DeletedEntries != null && target.DeletedEntries.Count != markerCount) changed = true; return changed; } private static bool MergeEntryMetadata(ClipEntry existing, ClipEntry incoming) { var changed = false; if (string.Equals(existing.Text, incoming.Text, StringComparison.Ordinal)) { var canonicalId = CanonicalEntryId(existing.Id, incoming.Id); if (!string.Equals(existing.Id, canonicalId, StringComparison.Ordinal)) { existing.Id = canonicalId; changed = true; } } var incomingWins = incoming.LastUsedUnixMs >= existing.LastUsedUnixMs; var incomingCreatedWins = incoming.CreatedUnixMs > existing.CreatedUnixMs; var incomingModifiedWins = incoming.ModifiedUnixMs > existing.ModifiedUnixMs; var bothLegacy = incoming.ModifiedUnixMs <= 0 && existing.ModifiedUnixMs <= 0; var legacyTextRepair = bothLegacy && !string.IsNullOrWhiteSpace(existing.Id) && string.Equals(existing.Id, incoming.Id, StringComparison.OrdinalIgnoreCase) && !string.Equals(existing.Text ?? string.Empty, incoming.Text ?? string.Empty, StringComparison.Ordinal); if (incoming.LastUsedUnixMs > existing.LastUsedUnixMs) { existing.LastUsedUnixMs = incoming.LastUsedUnixMs; changed = true; } if (incoming.CreatedUnixMs > 0 && (existing.CreatedUnixMs == 0 || incomingCreatedWins || (!incomingWins && incoming.CreatedUnixMs < existing.CreatedUnixMs))) { existing.CreatedUnixMs = incoming.CreatedUnixMs; changed = true; } if (incomingModifiedWins) { var textChanged = !string.Equals(existing.Text ?? string.Empty, incoming.Text ?? string.Empty, StringComparison.Ordinal); existing.Text = incoming.Text ?? string.Empty; existing.Name = (incoming.Name ?? string.Empty).Trim(); existing.Group = (incoming.Group ?? string.Empty).Trim(); existing.Pinned = incoming.Pinned; existing.IsTemplate = incoming.IsTemplate; existing.ManualOrder = incoming.ManualOrder; existing.ModifiedUnixMs = incoming.ModifiedUnixMs; if (textChanged) { existing.RichText = RichTextData.Clone(incoming.RichText); existing.RichTextUpdatedUnixMs = Math.Max(incoming.RichTextUpdatedUnixMs, incoming.ModifiedUnixMs); } changed = true; } else if (bothLegacy) { if (legacyTextRepair) { existing.Text = incoming.Text ?? string.Empty; existing.RichText = RichTextData.Clone(incoming.RichText); existing.RichTextUpdatedUnixMs = Math.Max(existing.RichTextUpdatedUnixMs, incoming.RichTextUpdatedUnixMs); changed = true; } if (!string.IsNullOrWhiteSpace(incoming.Name) && incomingWins && !string.Equals(existing.Name ?? string.Empty, incoming.Name.Trim(), StringComparison.Ordinal)) { existing.Name = incoming.Name.Trim(); changed = true; } if (!string.IsNullOrWhiteSpace(incoming.Group) && incomingWins && !string.Equals(existing.Group ?? string.Empty, incoming.Group.Trim(), StringComparison.Ordinal)) { existing.Group = incoming.Group.Trim(); changed = true; } if (incoming.Pinned && !existing.Pinned) { existing.Pinned = true; changed = true; } if (incoming.IsTemplate && !existing.IsTemplate) { existing.IsTemplate = true; changed = true; } if (existing.ManualOrder <= 0 || (incoming.ManualOrder > 0 && incoming.ManualOrder < existing.ManualOrder)) { if (existing.ManualOrder != incoming.ManualOrder) { existing.ManualOrder = incoming.ManualOrder; changed = true; } } } if (!string.IsNullOrWhiteSpace(incoming.SourceMachine) && (incomingWins || incomingCreatedWins) && !string.Equals(existing.SourceMachine ?? string.Empty, incoming.SourceMachine.Trim(), StringComparison.Ordinal)) { existing.SourceMachine = incoming.SourceMachine.Trim(); changed = true; } if (incoming.RichTextUpdatedUnixMs > existing.RichTextUpdatedUnixMs) { existing.RichText = RichTextData.Clone(incoming.RichText); existing.RichTextUpdatedUnixMs = incoming.RichTextUpdatedUnixMs; changed = true; } return changed; } private static string CanonicalEntryId(string left, string right) { if (string.IsNullOrWhiteSpace(left)) return right; if (string.IsNullOrWhiteSpace(right)) return left; var insensitive = StringComparer.OrdinalIgnoreCase.Compare(left, right); if (insensitive != 0) return insensitive < 0 ? left : right; return string.CompareOrdinal(left, right) <= 0 ? left : right; } public List GetDevices() { lock (sync) { return CanonicalLabels(database.Entries, e => e.SourceMachine); } } private static List CanonicalLabels(IEnumerable entries, Func selector) { return entries .Select(entry => new { Entry = entry, Label = (selector(entry) ?? string.Empty).Trim() }) .Where(item => item.Label.Length > 0) .GroupBy(item => item.Label, StringComparer.CurrentCultureIgnoreCase) .Select(group => group .GroupBy(item => item.Label, StringComparer.Ordinal) .Select(spelling => new { Label = spelling.Key, Count = spelling.Count(), Latest = spelling.Max(item => Math.Max(item.Entry.ModifiedUnixMs, Math.Max(item.Entry.LastUsedUnixMs, item.Entry.CreatedUnixMs))) }) .OrderByDescending(item => item.Count) .ThenByDescending(item => item.Latest) .ThenBy(item => item.Label, StringComparer.Ordinal) .First().Label) .OrderBy(label => label, StringComparer.CurrentCultureIgnoreCase) .ToList(); } private static bool HasLocalStateMissingFromServer(ClipDatabase target, ClipDatabase source) { if (target == null || source == null || source.Entries == null) return false; NormalizeDeletedEntries(target); NormalizeDeletedEntries(source); if (source.DeletedEntries != null) { foreach (var deleted in source.DeletedEntries.Where(d => d != null && !string.IsNullOrWhiteSpace(d.Id))) { var targetDeleted = target.DeletedEntries == null ? null : target.DeletedEntries.FirstOrDefault(d => string.Equals(d.Id, deleted.Id, StringComparison.Ordinal)); if (targetDeleted == null || deleted.DeletedUnixMs > targetDeleted.DeletedUnixMs) { return true; } } } var targetEntries = target.Entries ?? new List(); foreach (var entry in source.Entries.Where(e => e != null && !string.IsNullOrEmpty(e.Text))) { if (IsDeleted(target, entry)) continue; var targetEntry = targetEntries.FirstOrDefault(e => !string.IsNullOrEmpty(e.Id) && string.Equals(e.Id, entry.Id, StringComparison.Ordinal)); if (targetEntry == null && !targetEntries.Any(e => (!string.IsNullOrEmpty(e.Id) && string.Equals(e.Id, entry.Id, StringComparison.Ordinal)) || string.Equals(e.Text, entry.Text, StringComparison.Ordinal))) { return true; } if (targetEntry != null && entry.ModifiedUnixMs > targetEntry.ModifiedUnixMs) { return true; } } return false; } private static void WriteBytesAtomic(string path, byte[] data) { var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var temp = path + ".download.tmp"; File.WriteAllBytes(temp, data ?? new byte[0]); if (File.Exists(path)) { File.Replace(temp, path, null); } else { File.Move(temp, path); } } private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { } } private void WatcherChanged(object sender, FileSystemEventArgs e) { if (!IsWatchedDatabaseFile(Path.GetFileName(DatabasePath), e == null ? null : e.Name)) return; if (reloadTimer != null) reloadTimer.Change(500, Timeout.Infinite); } /// /// Whether a container in the history folder is one this store reads: the history file /// itself, the sync rules document, or a sync channel. /// internal static bool IsWatchedDatabaseFile(string coreFileName, string changedFileName) { var name = (changedFileName ?? string.Empty).Trim(); if (name.Length == 0) return false; name = Path.GetFileName(name); if (string.Equals(name, coreFileName, StringComparison.OrdinalIgnoreCase)) return true; return IsChannelOrRulesFileName(name); } private static bool IsChannelOrRulesFileName(string name) { if (string.IsNullOrEmpty(name)) return false; if (string.Equals(name, SyncRulesFileName, StringComparison.OrdinalIgnoreCase)) return true; return name.StartsWith(ChannelFilePrefix, StringComparison.OrdinalIgnoreCase) && name.EndsWith(ClipDatabaseFile.CompressedExtension, StringComparison.OrdinalIgnoreCase); } private void ReloadFromWatcher() { lock (sync) { try { LoadLocked(); } catch { return; } } OnChanged(true); } public void ReloadExternalChangeAndSync() { ReloadExternalChange(); SyncExternalChange(); } public void ReloadExternalChange() { lock (sync) { LoadLocked(); } OnChanged(false); } public void SyncExternalChange() { lock (sync) { if (RulesActiveLocked()) { SaveChannelsLocked(true); return; } UploadToServerLocked(); } } private static ClipEntry Clone(ClipEntry entry) { return new ClipEntry { Id = entry.Id, Text = entry.Text ?? string.Empty, Name = entry.Name ?? string.Empty, Group = entry.Group ?? string.Empty, SourceMachine = entry.SourceMachine ?? string.Empty, CreatedUnixMs = entry.CreatedUnixMs, LastUsedUnixMs = entry.LastUsedUnixMs, ModifiedUnixMs = entry.ModifiedUnixMs, Pinned = entry.Pinned, IsTemplate = entry.IsTemplate, ManualOrder = entry.ManualOrder, RichText = RichTextData.Clone(entry.RichText), RichTextUpdatedUnixMs = entry.RichTextUpdatedUnixMs }; } private static IEnumerable FilterByGroup(IEnumerable source, string groupFilter) { var filter = (groupFilter ?? "All").Trim(); if (filter.Length == 0 || filter.Equals("All", StringComparison.OrdinalIgnoreCase)) { return source; } if (filter.Equals("Pinned", StringComparison.OrdinalIgnoreCase)) { return source.Where(e => e.Pinned); } if (filter.Equals("Named", StringComparison.OrdinalIgnoreCase)) { return source.Where(e => !string.IsNullOrWhiteSpace(e.Name)); } if (filter.Equals("Ungrouped", StringComparison.OrdinalIgnoreCase)) { return source.Where(e => string.IsNullOrWhiteSpace(e.Group)); } return source.Where(e => string.Equals((e.Group ?? string.Empty).Trim(), filter, StringComparison.CurrentCultureIgnoreCase)); } private static bool IsProtected(ClipEntry entry) { return entry != null && (entry.Pinned || !string.IsNullOrWhiteSpace(entry.Name)); } private void MergeExistingDatabaseIfAvailableLocked() { if (!File.Exists(DatabasePath)) return; var existing = ClipDatabaseFile.Load(DatabasePath, CurrentPassword()); if (existing == null || existing.Entries == null || existing.Entries.Count == 0) return; MergeDatabaseIntoLocked(database, existing); NormalizeManualOrderLocked(); } private void AddDeletedEntryLocked(string id, string text) { if (string.IsNullOrWhiteSpace(id)) return; if (database.DeletedEntries == null) database.DeletedEntries = new List(); var textHash = ComputeTextHash(text); var existing = database.DeletedEntries.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal)); if (existing == null) { database.DeletedEntries.Add(new DeletedClipEntry { Id = id, TextHash = textHash, DeletedUnixMs = TimeUtil.NowUnixMs(), SourceMachine = CurrentMachineName() }); } else { existing.TextHash = textHash; existing.DeletedUnixMs = TimeUtil.NowUnixMs(); existing.SourceMachine = CurrentMachineName(); } NormalizeDeletedEntriesLocked(); } private void NormalizeDeletedEntriesLocked() { NormalizeDeletedEntries(database); } private static void NormalizeDeletedEntries(ClipDatabase target) { if (target == null) return; if (target.DeletedEntries == null) { target.DeletedEntries = new List(); return; } var cutoff = TimeUtil.NowUnixMs() - (long)TimeSpan.FromDays(90).TotalMilliseconds; target.DeletedEntries = target.DeletedEntries .Where(d => d != null && !string.IsNullOrWhiteSpace(d.Id) && (d.DeletedUnixMs == 0 || d.DeletedUnixMs >= cutoff)) .Select(d => { d.TextHash = d.TextHash ?? string.Empty; return d; }) .GroupBy(d => d.Id, StringComparer.Ordinal) .Select(g => g.OrderByDescending(d => d.DeletedUnixMs).First()) .Where(marker => !string.IsNullOrWhiteSpace(marker.TextHash) || !(target.Entries ?? new List()).Any(entry => string.Equals(entry.Id, marker.Id, StringComparison.Ordinal) && Math.Max(Math.Max(entry.CreatedUnixMs, entry.LastUsedUnixMs), entry.ModifiedUnixMs) > marker.DeletedUnixMs)) .ToList(); } private void ApplyDeletedEntriesLocked() { ApplyDeletedEntries(database); } private static void ApplyDeletedEntries(ClipDatabase target) { if (target == null || target.Entries == null || target.DeletedEntries == null || target.DeletedEntries.Count == 0) return; target.Entries.RemoveAll(e => IsDeleted(target, e)); } private static bool IsDeleted(ClipDatabase target, string id) { return target != null && !string.IsNullOrWhiteSpace(id) && target.DeletedEntries != null && target.DeletedEntries.Any(d => string.Equals(d.Id, id, StringComparison.Ordinal)); } private static bool IsDeleted(ClipDatabase target, ClipEntry entry) { if (entry == null) return false; var entryChangedUnixMs = Math.Max(Math.Max(entry.CreatedUnixMs, entry.LastUsedUnixMs), entry.ModifiedUnixMs); if (target != null && target.DeletedEntries != null && target.DeletedEntries.Any(d => string.Equals(d.Id, entry.Id, StringComparison.Ordinal) && (!string.IsNullOrWhiteSpace(d.TextHash) || d.DeletedUnixMs <= 0 || entryChangedUnixMs <= d.DeletedUnixMs))) { return true; } if (target == null || target.DeletedEntries == null || string.IsNullOrEmpty(entry.Text)) return false; var textHash = ComputeTextHash(entry.Text); return target.DeletedEntries.Any(d => !string.IsNullOrWhiteSpace(d.TextHash) && string.Equals(d.TextHash, textHash, StringComparison.Ordinal) && (d.DeletedUnixMs <= 0 || entryChangedUnixMs <= d.DeletedUnixMs)); } private static bool MergeDeletedEntries(ClipDatabase target, ClipDatabase source) { if (target == null || source == null) return false; NormalizeDeletedEntries(target); NormalizeDeletedEntries(source); if (source.DeletedEntries == null || source.DeletedEntries.Count == 0) return false; var changed = false; foreach (var sourceDeleted in source.DeletedEntries) { if (sourceDeleted == null || string.IsNullOrWhiteSpace(sourceDeleted.Id)) continue; var targetDeleted = target.DeletedEntries.FirstOrDefault(d => string.Equals(d.Id, sourceDeleted.Id, StringComparison.Ordinal)); if (targetDeleted == null) { target.DeletedEntries.Add(new DeletedClipEntry { Id = sourceDeleted.Id, TextHash = sourceDeleted.TextHash ?? string.Empty, DeletedUnixMs = sourceDeleted.DeletedUnixMs, SourceMachine = sourceDeleted.SourceMachine ?? string.Empty }); changed = true; } else if (sourceDeleted.DeletedUnixMs > targetDeleted.DeletedUnixMs) { targetDeleted.DeletedUnixMs = sourceDeleted.DeletedUnixMs; targetDeleted.SourceMachine = sourceDeleted.SourceMachine ?? string.Empty; targetDeleted.TextHash = sourceDeleted.TextHash ?? targetDeleted.TextHash ?? string.Empty; changed = true; } else if (string.IsNullOrWhiteSpace(targetDeleted.TextHash) && !string.IsNullOrWhiteSpace(sourceDeleted.TextHash)) { targetDeleted.TextHash = sourceDeleted.TextHash; changed = true; } } NormalizeDeletedEntries(target); return changed; } private static string ComputeTextHash(string text) { if (string.IsNullOrEmpty(text)) return string.Empty; using (var sha = SHA256.Create()) { return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(text))).Replace("-", string.Empty).ToLowerInvariant(); } } // --------------------------------------------------------------------------------------- // Sync channels. Everything below runs only while a usable, enabled sync rules document is // in effect; sync-rules-spec.md sections 5 and 6 are the algorithm. // --------------------------------------------------------------------------------------- public SyncRulesDocument GetSyncRules() { lock (sync) { return SyncRuleEngine.Copy(syncRules); } } public bool SyncRulesReadOnly() { lock (sync) { return SyncRuleEngine.ReadOnly(syncRules); } } public List GetSyncChannelKeys() { lock (sync) { return AllChannelKeysLocked(); } } /// /// Publishes a new sync rules document and re-routes this device immediately (spec section /// 5, "Rules edits"). Returns null on success or a message describing why the edit was /// refused. /// public string SetSyncRules(SyncRulesDocument doc) { var error = SyncRuleEngine.Validate(doc); if (error != null) return error; lock (sync) { if (SyncRuleEngine.ReadOnly(syncRules)) { return "These sync rules were written by a newer version of Clipman, so this device can only apply them."; } var unsubscribedRemoval = RefusalForUnsubscribedRemovalLocked(doc); if (unsubscribedRemoval != null) return unsubscribedRemoval; var next = SyncRuleEngine.Copy(doc); next.Clipman = SyncRuleEngine.DocumentKind; if (next.Version < SyncRuleEngine.CurrentVersion) next.Version = SyncRuleEngine.CurrentVersion; next.UpdatedUnixMs = TimeUtil.NowUnixMs(); next.UpdatedBy = CurrentMachineName(); try { RerouteChannelsLeavingDocumentLocked(next); } catch (Exception ex) { if (!IsStorageAccessException(ex) && !IsRecoverableServerException(ex)) throw; return "Clipman could not move the entries out of the channels being removed: " + ex.Message; } try { PublishSyncRulesLocked(next); } catch (Exception ex) { if (!IsStorageAccessException(ex) && !IsRecoverableServerException(ex)) throw; return "Clipman could not save the sync rules: " + ex.Message; } selfRegistrationAttempted = false; LoadLocked(); SaveLocked(); ResetWatcherLocked(); } OnChanged(); return null; } /// /// Refuses an edit that removes or unroutes a channel this device does not subscribe to /// (spec section 5, "Rules edits": editors must be subscribed to every channel an edit /// affects). Mirrors the CLI's refusal. Compares against the currently-active document, not /// the candidate, since that is what this device can actually see right now. Returns null /// when the edit does not remove any channel this device is unsubscribed from. /// private string RefusalForUnsubscribedRemovalLocked(SyncRulesDocument next) { if (syncRules == null) return null; var currentKeys = AllChannelKeysLocked(); if (currentKeys.Count == 0) return null; var survivingKeys = new HashSet(StringComparer.Ordinal); foreach (var channel in (next == null ? null : next.Channels) ?? new List()) { if (channel == null) continue; var key = SyncRuleEngine.ChannelKey(channel.Name); if (key.Length > 0) survivingKeys.Add(key); } var subscribed = SyncRuleEngine.SubscribedChannels(syncRules, CurrentMachineName()); if (subscribed == null) return null; foreach (var key in currentKeys) { if (survivingKeys.Contains(key)) continue; if (!subscribed.Contains(key)) { return "This device is not subscribed to the " + ChannelDisplayNameLocked(key) + " channel and cannot see its entries. Subscribe to it before removing it."; } } return null; } /// /// Spec section 5, "Channel deletion": the editing client re-routes a departing channel's /// entries and uploads the affected channels before the document forgets the channel. The /// re-route runs under a transitional document that keeps the old channel list - so this /// device stays subscribed and can still see those entries - while carrying the new routes, /// with a route that is disappearing neutralized so its entries fall through to whatever /// else matches, or to core. Only then is the real document published. /// private void RerouteChannelsLeavingDocumentLocked(SyncRulesDocument next) { if (!RulesActiveLocked() || channelSlots.Count == 0) return; var surviving = new Dictionary(StringComparer.Ordinal); foreach (var channel in (next == null ? null : next.Channels) ?? new List()) { if (channel == null) continue; var key = SyncRuleEngine.ChannelKey(channel.Name); if (key.Length > 0 && !surviving.ContainsKey(key)) surviving[key] = channel.Route; } var leaving = false; var transitional = SyncRuleEngine.Copy(syncRules); foreach (var channel in transitional.Channels) { var key = SyncRuleEngine.ChannelKey(channel.Name); SyncRoute replacement; if (surviving.TryGetValue(key, out replacement)) { channel.Route = replacement == null ? new SyncRoute() : replacement; continue; } // This channel is being removed: neutralizing its route re-routes its residents // through the remaining rules and empties the channel under gain-before-lose. channel.Route = new SyncRoute(); leaving = true; } if (!leaving) return; var previous = syncRules; syncRules = transitional; try { SaveChannelsLocked(false); } finally { syncRules = previous; } if (storageUnavailable) { throw new IOException(string.IsNullOrEmpty(LastStorageError) ? "The entries in the channels being removed could not be relocated." : LastStorageError); } } /// /// The channels committed by the most recent save, in commit order. Relocations must list /// the gaining channel before the losing one (spec section 5, upload step 5). /// internal List LastChannelWriteOrder() { lock (sync) { return new List(channelWriteOrder); } } /// /// The one-shot notice for an entry delivered to a channel this device does not subscribe /// to (spec section 6). Empty once it has aged out. /// public string ChannelAnnouncement() { lock (sync) { if (channelAnnouncement.Length == 0) return string.Empty; if (TimeUtil.NowUnixMs() - channelAnnouncementUnixMs > ChannelAnnouncementLifetimeMs) { channelAnnouncement = string.Empty; return string.Empty; } return channelAnnouncement; } } private bool RulesActiveLocked() { return syncRules != null && syncRules.Enabled; } private void ClearChannelStateLocked() { channelSlots.Clear(); residence = new Dictionary(StringComparer.Ordinal); assembledMarkers = new Dictionary(StringComparer.Ordinal); } private string DatabaseDirectoryLocked() { var directory = Path.GetDirectoryName(DatabasePath); return string.IsNullOrEmpty(directory) ? string.Empty : directory; } internal static string ChannelFileName(string channelKey) { return ChannelFilePrefix + SyncRuleEngine.ChannelStorageName(channelKey) + ClipDatabaseFile.CompressedExtension; } private string ChannelPathLocked(string channelKey) { if (string.IsNullOrEmpty(channelKey)) return DatabasePath; return Path.Combine(DatabaseDirectoryLocked(), ChannelFileName(channelKey)); } private string SyncRulesPathLocked() { return Path.Combine(DatabaseDirectoryLocked(), SyncRulesFileName); } private string PendingWritesPathLocked() { return Path.Combine(DatabaseDirectoryLocked(), PendingWritesFileName); } private List AllChannelKeysLocked() { var keys = new List(); if (syncRules == null) return keys; foreach (var channel in syncRules.Channels ?? new List()) { if (channel == null) continue; var key = SyncRuleEngine.ChannelKey(channel.Name); if (key.Length > 0 && !keys.Contains(key)) keys.Add(key); } return keys; } /// /// The channels this device downloads besides core, in rules-document order. A device that /// is not listed in the document subscribes to everything (spec section 4). /// private List SubscribedChannelKeysLocked() { var keys = new List(); if (!RulesActiveLocked()) return keys; var subscribed = SyncRuleEngine.SubscribedChannels(syncRules, CurrentMachineName()); foreach (var key in AllChannelKeysLocked()) { if (subscribed != null && !subscribed.Contains(key)) continue; keys.Add(key); } return keys; } private string ChannelDisplayNameLocked(string channelKey) { if (string.IsNullOrEmpty(channelKey)) return "the main history"; if (syncRules != null) { foreach (var channel in syncRules.Channels ?? new List()) { if (channel == null) continue; if (string.Equals(SyncRuleEngine.ChannelKey(channel.Name), channelKey, StringComparison.Ordinal)) { return (channel.Name ?? string.Empty).Trim().Length == 0 ? channelKey : channel.Name.Trim(); } } } return channelKey; } private void AnnounceChannelWriteLocked(string channelKey) { channelAnnouncement = "Added to " + ChannelDisplayNameLocked(channelKey) + " for your other devices."; channelAnnouncementUnixMs = TimeUtil.NowUnixMs(); } private void LoadSyncRulesFromDiskLocked(string password) { syncRules = null; var path = SyncRulesPathLocked(); if (!File.Exists(path)) return; try { var document = ClipDatabaseFile.Load(path, password); if (SyncRuleEngine.IsUsable(document)) syncRules = document; } catch (Exception) { // A damaged or unreadable rules document must never stop history from syncing; // the client simply behaves as one without the feature. syncRules = null; } } private void PersistSyncRulesCacheLocked() { if (syncRules == null) return; try { ClipDatabaseFile.SaveAtomic(SyncRulesPathLocked(), syncRules, CurrentPassword(), DatabasePath); } catch (Exception ex) { if (!IsStorageAccessException(ex)) throw; } } /// /// Resolves cloud-storage conflict copies of the rules document by whole-document /// last-writer-wins (spec section 4). The history merger must never be used here: it would /// rewrite the rules file as an empty history database. /// private void ResolveSyncRulesConflictsLocked(string password) { try { var path = SyncRulesPathLocked(); var conflicts = SyncConflictResolver.FindConflictSiblings(path).ToList(); if (conflicts.Count == 0) return; SyncRulesDocument winner = null; if (File.Exists(path)) { var current = ClipDatabaseFile.Load(path, password); if (SyncRuleEngine.IsUsable(current)) winner = current; } // Only copies that were actually understood and folded into the winner are removed. // Deleting one that could not be read - a different password, a truncated download - // would throw away the only copy of an edit made on another device. var consumed = new List(); var replaced = false; foreach (var conflict in conflicts) { SyncRulesDocument candidate; try { candidate = ClipDatabaseFile.Load(conflict, password); } catch (Exception) { continue; } if (!SyncRuleEngine.IsUsable(candidate)) continue; var merged = SyncRuleEngine.MergeDocuments(winner, candidate); if (!ReferenceEquals(merged, winner)) replaced = true; winner = merged; consumed.Add(conflict); } if (winner != null && replaced) { ClipDatabaseFile.SaveAtomic(path, winner, password, DatabasePath); } foreach (var conflict in consumed) { TryDelete(conflict); } } catch (Exception) { } } /// /// Resolves cloud-storage conflict copies of one channel file. Every sibling the conflict /// scanner yields starts with this channel's own stem, so shape alone cannot tell a /// conflict copy from a second channel whose key extends this one ("work" and "work-pc"). /// The discriminator is the rules document: a sibling that is a container this store owns - /// another declared channel, or the rules document itself - is never a conflict copy, and /// its presence makes the whole resolution unsafe to run. /// private void ResolveChannelConflictsLocked(string path, string password) { var owned = OwnedContainerFileNamesLocked(); foreach (var sibling in SyncConflictResolver.FindConflictSiblings(path)) { if (owned.Contains(Path.GetFileName(sibling))) return; } SyncConflictResolver.ResolveDatabaseConflicts(path, password); } private HashSet OwnedContainerFileNamesLocked() { var owned = new HashSet(StringComparer.OrdinalIgnoreCase); owned.Add(SyncRulesFileName); owned.Add(Path.GetFileName(DatabasePath)); foreach (var key in AllChannelKeysLocked()) { owned.Add(ChannelFileName(key)); } return owned; } private void LoadChannelsFromDiskLocked(string password) { channelSlots.Clear(); // Exists tracks the server bucket, which a local load knows nothing about. It starts // true so an upload before the first poll behaves exactly as it does today; a 404 // during a poll corrects it and the next upload then creates the bucket conditionally. var core = new ChannelSlot(); core.CacheFilePath = DatabasePath; core.Database = LoadChannelDatabase(DatabasePath, password); core.Exists = true; core.PlainHash = PlainHash(core.Database); channelSlots.Add(core); foreach (var key in SubscribedChannelKeysLocked()) { var path = ChannelPathLocked(key); ResolveChannelConflictsLocked(path, password); var slot = new ChannelSlot(); slot.Key = key; slot.CacheFilePath = path; slot.Database = LoadChannelDatabase(path, password); slot.Exists = true; slot.PlainHash = PlainHash(slot.Database); channelSlots.Add(slot); } ConfigureChannelClientsLocked(); AssembleViewLocked(); } private static ClipDatabase LoadChannelDatabase(string path, string password) { var loaded = ClipDatabaseFile.Load(path, password); NormalizeChannelDatabase(loaded); return loaded; } private static void NormalizeChannelDatabase(ClipDatabase target) { if (target == null) return; if (target.Entries == null) target.Entries = new List(); if (target.Version < 1) target.Version = 1; NormalizeDeletedEntries(target); ApplyDeletedEntries(target); } private void ConfigureChannelClientsLocked() { foreach (var slot in channelSlots) { slot.Client = slot.Key.Length == 0 ? serverClient : ChannelClientLocked(slot.Key); } } private ServerStorageClient ChannelClientLocked(string channelKey) { // Core is never addressed as a channel; deriving a channel id for the empty key would // point at a bucket that is nobody's history. if (string.IsNullOrEmpty(channelKey)) return null; if (serverClient == null || !serverClient.IsConfigured) return null; var databaseId = ServerDatabaseIdentity.ChannelFromTokenAndPassword(serverToken, CurrentPassword(), channelKey); if (databaseId.Length == 0) return null; return new ServerStorageClient(serverUrl, serverToken, CurrentPassword(), serverCaCertPem, serverCaHost, databaseId); } private void EnsureSyncRulesClientLocked() { if (syncRulesClient != null) return; if (serverClient == null || !serverClient.IsConfigured) return; var databaseId = ServerDatabaseIdentity.SyncRulesFromTokenAndPassword(serverToken, CurrentPassword()); if (databaseId.Length == 0) return; syncRulesClient = new ServerStorageClient(serverUrl, serverToken, CurrentPassword(), serverCaCertPem, serverCaHost, databaseId); } /// /// Merges every subscribed channel into the single view the user sees and records which /// channel each surviving entry came from (spec section 5, download steps 3 and 4). /// private void AssembleViewLocked() { var now = TimeUtil.NowUnixMs(); var view = new ClipDatabase(); view.Version = 1; view.UpdatedUnixMs = now; var owners = new List(); var indexById = new Dictionary(StringComparer.Ordinal); foreach (var slot in channelSlots) { if (slot.Database == null || slot.Database.Entries == null) continue; if (slot.Database.Version > view.Version) view.Version = slot.Database.Version; foreach (var entry in slot.Database.Entries) { if (entry == null) continue; var identifier = ComparableId(entry.Id); int index; if (identifier.Length > 0 && indexById.TryGetValue(identifier, out index)) { // The same id in two channels is a move race: the copy with the higher // ModifiedUnixMs wins wholesale, and a tie keeps the earlier channel. if (entry.ModifiedUnixMs > view.Entries[index].ModifiedUnixMs) { view.Entries[index] = Clone(entry); owners[index] = slot.Key; } continue; } view.Entries.Add(Clone(entry)); owners.Add(slot.Key); if (identifier.Length > 0) indexById[identifier] = view.Entries.Count - 1; } } // Tombstones apply within their own channel only, with one exception: a marker with a // non-empty TextHash also suppresses matching text in other channels. A relocation // marker has an empty TextHash and never reaches across. var suppressed = new bool[view.Entries.Count]; foreach (var slot in channelSlots) { if (slot.Database == null || slot.Database.DeletedEntries == null) continue; foreach (var marker in slot.Database.DeletedEntries) { if (marker == null || string.IsNullOrEmpty(marker.TextHash)) continue; for (var index = 0; index < view.Entries.Count; index++) { if (suppressed[index]) continue; if (string.Equals(owners[index], slot.Key, StringComparison.Ordinal)) continue; if (TextMarkerSuppresses(marker, view.Entries[index])) suppressed[index] = true; } } } var kept = new List(); var keptOwners = new List(); var live = new HashSet(StringComparer.Ordinal); for (var index = 0; index < view.Entries.Count; index++) { if (suppressed[index]) continue; kept.Add(view.Entries[index]); keptOwners.Add(owners[index]); live.Add(ComparableId(view.Entries[index].Id)); } view.Entries = ApplyCombinedManualOrder(kept, keptOwners); // The view carries every channel's markers except those contradicted by a live entry // elsewhere: applying a relocation marker to the view would delete the entry it only // meant to move. foreach (var slot in channelSlots) { if (slot.Database == null || slot.Database.DeletedEntries == null) continue; foreach (var marker in slot.Database.DeletedEntries) { if (marker == null) continue; if (live.Contains(ComparableId(marker.Id))) continue; view.DeletedEntries.Add(CloneMarker(marker)); } } database = view; NormalizeDeletedEntriesLocked(); NormalizeManualOrderLocked(); residence = new Dictionary(StringComparer.Ordinal); for (var index = 0; index < kept.Count; index++) { residence[kept[index].Id ?? string.Empty] = keptOwners[index]; } assembledMarkers = MarkersById(database.DeletedEntries); } /// /// Merges channel-local manual sequences by creation time. This retains deliberate order /// inside each channel without promoting a new channel's first entry above older history. /// private static List ApplyCombinedManualOrder(List entries, List owners) { var sequences = new List>(); var sequenceByOwner = new Dictionary(StringComparer.Ordinal); for (var index = 0; index < entries.Count; index++) { var owner = index < owners.Count ? owners[index] ?? string.Empty : string.Empty; int sequenceIndex; if (!sequenceByOwner.TryGetValue(owner, out sequenceIndex)) { sequenceIndex = sequences.Count; sequenceByOwner[owner] = sequenceIndex; sequences.Add(new List()); } sequences[sequenceIndex].Add(index); } foreach (var sequence in sequences) { sequence.Sort((leftIndex, rightIndex) => { var left = entries[leftIndex]; var right = entries[rightIndex]; var leftOrder = left.ManualOrder <= 0 ? long.MaxValue : left.ManualOrder; var rightOrder = right.ManualOrder <= 0 ? long.MaxValue : right.ManualOrder; var comparison = leftOrder.CompareTo(rightOrder); if (comparison != 0) return comparison; comparison = left.CreatedUnixMs.CompareTo(right.CreatedUnixMs); if (comparison != 0) return comparison; return string.Compare(left.Id ?? string.Empty, right.Id ?? string.Empty, StringComparison.Ordinal); }); } var offsets = new int[sequences.Count]; var ordered = new List(entries.Count); while (ordered.Count < entries.Count) { var chosen = -1; var chosenCreated = long.MaxValue; for (var index = 0; index < sequences.Count; index++) { if (offsets[index] >= sequences[index].Count) continue; var entry = entries[sequences[index][offsets[index]]]; var created = entry.CreatedUnixMs <= 0 ? long.MaxValue : entry.CreatedUnixMs; if (chosen >= 0 && created >= chosenCreated) continue; chosen = index; chosenCreated = created; } if (chosen < 0) break; var entryIndex = sequences[chosen][offsets[chosen]++]; var selected = entries[entryIndex]; selected.ManualOrder = ordered.Count + 1; ordered.Add(selected); } return ordered; } private static bool TextMarkerSuppresses(DeletedClipEntry marker, ClipEntry entry) { if (marker == null || entry == null) return false; if (string.IsNullOrEmpty(marker.TextHash) || string.IsNullOrEmpty(entry.Text)) return false; if (!string.Equals(marker.TextHash, ComputeTextHash(entry.Text), StringComparison.OrdinalIgnoreCase)) return false; var entryChangedUnixMs = Math.Max(Math.Max(entry.CreatedUnixMs, entry.LastUsedUnixMs), entry.ModifiedUnixMs); return marker.DeletedUnixMs <= 0 || entryChangedUnixMs <= marker.DeletedUnixMs; } private static string ComparableId(string value) { return (value ?? string.Empty).Trim().ToLowerInvariant(); } private static DeletedClipEntry CloneMarker(DeletedClipEntry marker) { return new DeletedClipEntry { Id = marker.Id ?? string.Empty, TextHash = marker.TextHash ?? string.Empty, DeletedUnixMs = marker.DeletedUnixMs, SourceMachine = marker.SourceMachine ?? string.Empty }; } private static Dictionary MarkersById(List markers) { var byId = new Dictionary(StringComparer.Ordinal); foreach (var marker in markers ?? new List()) { if (marker == null) continue; byId[ComparableId(marker.Id)] = CloneMarker(marker); } return byId; } /// /// The dirty-detection hash of spec section 5, upload step 4: SHA-256 over the deterministic /// plaintext with the database-level UpdatedUnixMs zeroed, which is what makes the hash /// durable across poll cycles that restamp that field. /// private static string PlainHash(ClipDatabase target) { if (target == null) return string.Empty; var stamp = target.UpdatedUnixMs; target.UpdatedUnixMs = 0; try { using (var sha = SHA256.Create()) { return BitConverter .ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(JsonUtil.SerializePretty(target)))) .Replace("-", string.Empty); } } finally { target.UpdatedUnixMs = stamp; } } private static List ListFor(Dictionary> map, string key) { List list; if (!map.TryGetValue(key ?? string.Empty, out list)) { list = new List(); map[key ?? string.Empty] = list; } return list; } private static List ListOrEmpty(Dictionary> map, string key) { List list; return map.TryGetValue(key ?? string.Empty, out list) ? list : new List(); } private void SaveChannelsLocked(bool forceServerUpload) { if (channelSlots.Count == 0) { // Rules are in effect but the channel state could not be loaded, which a storage // failure during the last load can leave behind. Routing against no channels at all // would treat even core as unsubscribed and write the whole history through to a // phantom bucket, so the view is persisted whole to the history file instead and // the next successful load re-partitions it. SaveSingleDatabaseLocked(); return; } database.UpdatedUnixMs = TimeUtil.NowUnixMs(); channelWriteOrder = new List(); channelsAdoptedRemoteState = false; try { if (storageUnavailable) { MergeExistingChannelsIfAvailableLocked(); } NormalizeDeletedEntriesLocked(); ApplyDeletedEntriesLocked(); NormalizeManualOrderLocked(); CommitRoutedChannelsLocked(TimeUtil.NowUnixMs(), forceServerUpload); SetStorageStateLocked(false, string.Empty); if (watcher == null) { ResetWatcherLocked(); } } catch (Exception ex) { if (!IsStorageAccessException(ex) && !IsRecoverableServerException(ex)) throw; SetStorageStateLocked(true, ex.Message); } } private void MergeExistingChannelsIfAvailableLocked() { var password = CurrentPassword(); foreach (var slot in channelSlots) { if (!File.Exists(slot.CacheFilePath)) continue; var existing = ClipDatabaseFile.Load(slot.CacheFilePath, password); if (existing == null || existing.Entries == null || existing.Entries.Count == 0) continue; MergeDatabaseIntoLocked(database, existing); } NormalizeManualOrderLocked(); } /// /// The upload half of spec section 5: route every entry, rebuild one database per channel, /// write entries bound for unsubscribed channels straight through, and commit only the /// channels whose plaintext actually changed, in the add-then-remove two-phase order. /// private void CommitRoutedChannelsLocked(long now, bool forceServerUpload) { var subscribed = new HashSet(StringComparer.Ordinal); foreach (var slot in channelSlots) subscribed.Add(slot.Key); var fetched = FetchedEntriesByChannelLocked(); var previousMarkers = assembledMarkers; var routed = new Dictionary>(StringComparer.Ordinal); var departures = new Dictionary>(StringComparer.Ordinal); var relocations = new Dictionary>(StringComparer.Ordinal); var pending = new Dictionary>(StringComparer.Ordinal); var pendingKeys = new List(); // A future-version document is read-only: this client cannot fully evaluate its rules, // so it must not fight better-informed clients over placement. Every entry that already // lives somewhere stays there - no relocation markers, no migration - and only new // captures are routed (spec section 4). var readOnlyRules = SyncRuleEngine.ReadOnly(syncRules); foreach (var entry in database.Entries) { if (entry == null) continue; var target = SyncRuleEngine.RouteEntry(syncRules, entry); string source; var resident = residence.TryGetValue(entry.Id ?? string.Empty, out source); if (readOnlyRules && resident) target = source; if (resident && !string.Equals(source, target, StringComparison.Ordinal)) { // Leaving a channel leaves a relocation marker behind: an empty TextHash // distinguishes "moved" from "deleted" (spec section 5, upload step 3). ListFor(departures, source).Add(entry); ListFor(relocations, source).Add(new DeletedClipEntry { Id = entry.Id, TextHash = string.Empty, DeletedUnixMs = now, SourceMachine = CurrentMachineName() }); } if (subscribed.Contains(target)) { ListFor(routed, target).Add(entry); continue; } if (!pending.ContainsKey(target)) pendingKeys.Add(target); ListFor(pending, target).Add(entry); } // First save: create the core container before anything else so every container beside // it copies its salt and one key derivation serves them all. if (!File.Exists(DatabasePath)) { var initial = BuildChannelDatabasesLocked( WithDepartures(routed, departures, fetched), AssembleMarkersLocked(previousMarkers, subscribed, null), now); var writes = pendingKeys.Count > 0; for (var index = 0; index < channelSlots.Count && !writes; index++) { if (!string.Equals(PlainHash(initial[index]), channelSlots[index].PlainHash, StringComparison.Ordinal)) { writes = true; } } if (writes) { CommitChannelLocked(channelSlots[0], initial[0], now, true); } } // Write-through (spec section 6) is committed first: its targets gain entries that // their source channels are about to lose. When one fails the entry is not taken away // from where it already lives - the departure and its marker are cancelled - and the // entry is parked for retry after the next successful poll. pendingKeys.Sort(StringComparer.Ordinal); var delivered = new List(); foreach (var channelKey in pendingKeys) { if (WriteThroughLocked(channelKey, pending[channelKey], now)) { AnnounceChannelWriteLocked(channelKey); foreach (var entry in pending[channelKey]) { delivered.Add(entry.Id ?? string.Empty); } continue; } ParkPendingWritesLocked(channelKey, pending[channelKey]); foreach (var entry in pending[channelKey]) { string source; if (!residence.TryGetValue(entry.Id ?? string.Empty, out source)) continue; ListFor(routed, source).Add(entry); RemoveEntryById(ListFor(departures, source), entry.Id); RemoveMarkerById(ListFor(relocations, source), entry.Id); } } // Phase 1: every channel keeps the entries it is about to lose and withholds its new // relocation markers, so this pass only ever adds. A departing entry is carried in the // copy the channel was fetched with, so the target's higher ModifiedUnixMs wins view // assembly for the window in which both channels hold the id. var phaseOne = BuildChannelDatabasesLocked( WithDepartures(routed, departures, fetched), AssembleMarkersLocked(previousMarkers, subscribed, null), now); for (var index = 0; index < channelSlots.Count; index++) { var slot = channelSlots[index]; var dirty = !string.Equals(PlainHash(phaseOne[index]), slot.PlainHash, StringComparison.Ordinal); if (!dirty && !forceServerUpload && !slot.NeedsUpload) continue; CommitChannelLocked(slot, phaseOne[index], now, dirty); } // Phase 2: with every addition committed, the losing channels drop their departures and // gain their relocation markers. A failure here is safe: the entry exists in both // channels and view assembly resolves the duplicate until the next save repairs it. var phaseTwo = BuildChannelDatabasesLocked( routed, AssembleMarkersLocked(previousMarkers, subscribed, relocations), now); for (var index = 0; index < channelSlots.Count; index++) { var slot = channelSlots[index]; if (ListOrEmpty(departures, slot.Key).Count == 0) continue; if (string.Equals(PlainHash(phaseTwo[index]), slot.PlainHash, StringComparison.Ordinal)) continue; CommitChannelLocked(slot, phaseTwo[index], now, true); } // Entries handed to an unsubscribed channel do not appear in the local view. if (delivered.Count > 0) { var deliveredIds = new HashSet(delivered, StringComparer.Ordinal); database.Entries.RemoveAll(entry => entry != null && deliveredIds.Contains(entry.Id ?? string.Empty)); foreach (var id in delivered) residence.Remove(id); } if (channelsAdoptedRemoteState) { // A conflict or read-before-mutate merged entries another device had added into a // channel. They are committed but absent from the view, and rebuilding that channel // from the view on the next save would delete them again, so the view is re-derived // from the channel state that actually committed. AssembleViewLocked(); return; } foreach (var slot in channelSlots) { foreach (var entry in ListOrEmpty(routed, slot.Key)) { residence[entry.Id ?? string.Empty] = slot.Key; } } assembledMarkers = MarkersById(database.DeletedEntries); } private Dictionary> FetchedEntriesByChannelLocked() { var byChannel = new Dictionary>(StringComparer.Ordinal); foreach (var slot in channelSlots) { var entries = new Dictionary(StringComparer.Ordinal); if (slot.Database != null && slot.Database.Entries != null) { foreach (var entry in slot.Database.Entries) { if (entry != null) entries[ComparableId(entry.Id)] = entry; } } byChannel[slot.Key] = entries; } return byChannel; } private static Dictionary> WithDepartures( Dictionary> routed, Dictionary> departures, Dictionary> fetched) { if (departures.Count == 0) return routed; var combined = new Dictionary>(StringComparer.Ordinal); foreach (var pair in routed) combined[pair.Key] = new List(pair.Value); foreach (var pair in departures) { if (pair.Value.Count == 0) continue; var staying = ListFor(combined, pair.Key); foreach (var entry in pair.Value) { Dictionary byId; ClipEntry before; if (fetched.TryGetValue(pair.Key, out byId) && byId.TryGetValue(ComparableId(entry.Id), out before)) { staying.Add(before); continue; } staying.Add(entry); } } return combined; } private static void RemoveEntryById(List entries, string id) { entries.RemoveAll(entry => entry != null && string.Equals(ComparableId(entry.Id), ComparableId(id), StringComparison.Ordinal)); } private static void RemoveMarkerById(List markers, string id) { markers.RemoveAll(marker => marker != null && string.Equals(ComparableId(marker.Id), ComparableId(id), StringComparison.Ordinal)); } /// /// Files tombstones per channel: each channel keeps the markers it already carried, markers /// this mutation created or refreshed go to the channel their entry lived in, and relocation /// markers are added last for the channels their entries left. /// private Dictionary> AssembleMarkersLocked( Dictionary previousMarkers, HashSet subscribed, Dictionary> relocations) { var markers = new Dictionary>(StringComparer.Ordinal); foreach (var slot in channelSlots) { var carried = new List(); if (slot.Database != null && slot.Database.DeletedEntries != null) { foreach (var marker in slot.Database.DeletedEntries) { if (marker != null) carried.Add(CloneMarker(marker)); } } markers[slot.Key] = carried; } foreach (var marker in database.DeletedEntries ?? new List()) { if (marker == null) continue; DeletedClipEntry before; if (previousMarkers.TryGetValue(ComparableId(marker.Id), out before) && before.DeletedUnixMs == marker.DeletedUnixMs && string.Equals(before.TextHash ?? string.Empty, marker.TextHash ?? string.Empty, StringComparison.Ordinal)) { continue; } string home; if (!residence.TryGetValue(marker.Id ?? string.Empty, out home) || !subscribed.Contains(home)) { home = string.Empty; } ListFor(markers, home).Add(CloneMarker(marker)); } if (relocations != null) { foreach (var pair in relocations) { if (!subscribed.Contains(pair.Key)) continue; foreach (var marker in pair.Value) ListFor(markers, pair.Key).Add(CloneMarker(marker)); } } return markers; } private List BuildChannelDatabasesLocked( Dictionary> routed, Dictionary> markers, long now) { var databases = new List(); foreach (var slot in channelSlots) { var entries = ListOrEmpty(routed, slot.Key); var built = new ClipDatabase(); built.Version = slot.Database == null || slot.Database.Version < 1 ? 1 : slot.Database.Version; built.UpdatedUnixMs = now; built.Entries = new List(); foreach (var entry in entries) built.Entries.Add(Clone(entry)); built.DeletedEntries = DropMarkersForEntries(ListOrEmpty(markers, slot.Key), entries); NormalizeDeletedEntries(built); NormalizeChannelManualOrder(built); databases.Add(built); } return databases; } /// /// Numbers manual order within one channel database. Each channel is normalized on its own, /// so two devices subscribed to different sets of channels write the same bytes for a shared /// channel instead of stamping it with their own whole-view numbering and fighting over it /// on every poll. /// private static void NormalizeChannelManualOrder(ClipDatabase target) { var next = 1L; foreach (var entry in target.Entries .OrderBy(e => e.ManualOrder <= 0 ? long.MaxValue : e.ManualOrder) .ThenBy(e => e.CreatedUnixMs)) { if (entry.CreatedUnixMs == 0) entry.CreatedUnixMs = TimeUtil.NowUnixMs(); if (entry.LastUsedUnixMs == 0) entry.LastUsedUnixMs = entry.CreatedUnixMs; if (entry.Name == null) entry.Name = string.Empty; if (entry.Group == null) entry.Group = string.Empty; if (entry.SourceMachine == null) entry.SourceMachine = string.Empty; entry.ManualOrder = next++; } } /// /// Drops markers naming an entry being written into the same channel, so a relocation marker /// left by an earlier move cannot delete the entry again when a rule change moves it back. /// private static List DropMarkersForEntries(List markers, List entries) { if (markers.Count == 0 || entries.Count == 0) return markers; var resident = new HashSet(StringComparer.Ordinal); foreach (var entry in entries) { if (entry != null) resident.Add(ComparableId(entry.Id)); } var kept = new List(); foreach (var marker in markers) { if (marker == null || resident.Contains(ComparableId(marker.Id))) continue; kept.Add(marker); } return kept; } private void CommitChannelLocked(ChannelSlot slot, ClipDatabase built, long now, bool writeFile) { var password = CurrentPassword(); if (writeFile || !File.Exists(slot.CacheFilePath)) { ClipDatabaseFile.SaveAtomic(slot.CacheFilePath, built, password, DatabasePath); } channelWriteOrder.Add(slot.Key); var committed = built; if (slot.Client != null && slot.Client.IsConfigured) { committed = UploadChannelLocked(slot, built, now); } slot.Database = committed; slot.PlainHash = PlainHash(committed); slot.Exists = true; slot.NeedsUpload = false; if (slot.Key.Length == 0) serverRevision = slot.Revision; } /// /// Uploads one channel with its own If-Match, re-reading and merging that channel alone on a /// conflict (spec section 5, upload step 5). Returns the database that actually committed. /// private ClipDatabase UploadChannelLocked(ChannelSlot slot, ClipDatabase built, long now) { var password = CurrentPassword(); var target = built; // Read before mutate: a slot whose revision is unknown - a fresh start, or a channel // whose last transfer failed - would otherwise PUT unconditionally and overwrite // whatever another device wrote in the meantime. if (slot.Exists && string.IsNullOrWhiteSpace(slot.Revision)) { target = AdoptRemoteChannelLocked(slot, target, password, now); } for (var attempt = 0; ; attempt++) { try { // A bucket that does not exist yet is created with If-None-Match, so a channel // two devices reach for at the same time is never silently overwritten. var metadata = slot.Client.Upload(File.ReadAllBytes(slot.CacheFilePath), slot.Revision, !slot.Exists); slot.Revision = metadata == null ? string.Empty : metadata.Revision; slot.Exists = true; MarkServerSuccessLocked(true); return target; } catch (WebException ex) { if (!slot.Client.IsConflict(ex) || attempt >= 3) { slot.Revision = string.Empty; MarkServerFailureLocked(); throw; } } target = AdoptRemoteChannelLocked(slot, target, password, now); } } /// /// Re-reads one channel and merges the build about to be uploaded into the server's copy. /// Entries the merge brings back are not in the view yet, so the caller must reassemble /// before the next save rebuilds this channel and PUTs them away again. /// private ClipDatabase AdoptRemoteChannelLocked(ChannelSlot slot, ClipDatabase built, string password, long now) { string revision; bool exists; var remote = DownloadChannelLocked(slot.Client, slot.CacheFilePath, password, out revision, out exists); slot.Revision = revision; slot.Exists = exists; if (!exists) return built; MergeDatabaseIntoLocked(remote, built); NormalizeChannelDatabase(remote); remote.UpdatedUnixMs = now; channelsAdoptedRemoteState = true; ClipDatabaseFile.SaveAtomic(slot.CacheFilePath, remote, password, DatabasePath); return remote; } private ClipDatabase DownloadChannelLocked( ServerStorageClient client, string nearbyPath, string password, out string revision, out bool exists) { revision = string.Empty; exists = false; ServerDatabaseDownload download; try { download = client.Download(); } catch (WebException ex) { if (client.IsNotFound(ex)) return new ClipDatabase(); throw; } if (download.Data == null || download.Data.Length == 0) return new ClipDatabase(); var tempPath = nearbyPath + ".server-download.tmp"; WriteBytesAtomic(tempPath, download.Data); var downloaded = ClipDatabaseFile.Load(tempPath, password); TryDelete(tempPath); NormalizeChannelDatabase(downloaded); revision = download.Metadata == null ? string.Empty : download.Metadata.Revision; exists = true; return downloaded; } /// /// The one-shot fetch-merge-put of spec section 6 for a channel this device does not /// subscribe to. The channel is discarded again afterwards, so it never reaches the view. /// private bool WriteThroughLocked(string channelKey, List entries, long now) { if (entries == null || entries.Count == 0) return true; // Core is always subscribed, so it is never a write-through target. Treating it as one // would send the whole history to a bucket derived as if core were a channel. if (string.IsNullOrEmpty(channelKey)) return false; var password = CurrentPassword(); var channelPath = ChannelPathLocked(channelKey); var client = ChannelClientLocked(channelKey); var throughServer = client != null && client.IsConfigured; var scratchPath = throughServer ? Path.Combine(DatabaseDirectoryLocked(), "writethrough-" + SyncRuleEngine.ChannelStorageName(channelKey) + ClipDatabaseFile.CompressedExtension) : channelPath; try { var slot = new ChannelSlot(); slot.Key = channelKey; slot.CacheFilePath = scratchPath; slot.Client = client; ClipDatabase target; if (throughServer) { string revision; bool exists; target = DownloadChannelLocked(client, scratchPath, password, out revision, out exists); slot.Revision = revision; slot.Exists = exists; } else { ResolveChannelConflictsLocked(channelPath, password); target = LoadChannelDatabase(channelPath, password); } var source = new ClipDatabase(); source.UpdatedUnixMs = now; foreach (var entry in entries) { if (entry != null) source.Entries.Add(Clone(entry)); } target.DeletedEntries = DropMarkersForEntries( target.DeletedEntries ?? new List(), source.Entries); MergeDatabaseIntoLocked(target, source); target.UpdatedUnixMs = now; ClipDatabaseFile.SaveAtomic(scratchPath, target, password, DatabasePath); if (throughServer) { UploadChannelLocked(slot, target, now); } return true; } catch (Exception ex) { if (!IsStorageAccessException(ex) && !IsRecoverableServerException(ex)) throw; return false; } finally { if (throughServer) TryDelete(scratchPath); } } private void ParkPendingWritesLocked(string channelKey, List entries) { try { var path = PendingWritesPathLocked(); var store = JsonUtil.Load(path); if (store.Channels == null) store.Channels = new List(); PendingChannelWrite bucket = null; foreach (var candidate in store.Channels) { if (candidate == null) continue; if (string.Equals(candidate.ChannelKey, channelKey, StringComparison.Ordinal)) { bucket = candidate; break; } } if (bucket == null) { bucket = new PendingChannelWrite { ChannelKey = channelKey }; store.Channels.Add(bucket); } if (bucket.Entries == null) bucket.Entries = new List(); foreach (var entry in entries) { if (entry == null) continue; var known = false; foreach (var existing in bucket.Entries) { if (existing != null && string.Equals(ComparableId(existing.Id), ComparableId(entry.Id), StringComparison.Ordinal)) { known = true; break; } } if (!known) bucket.Entries.Add(Clone(entry)); } JsonUtil.SaveAtomic(path, store); } catch (Exception) { } } /// /// Retries the entries parked by a failed write-through (spec section 6). A parked entry was /// left in the channel it already lived in, so a successful delivery leaves it in two /// places: it is dropped from the view here and its source channel is marked for the repair /// save, which rebuilds that channel without it. Returns whether the view changed. /// private bool RetryPendingWritesLocked(long now) { var path = PendingWritesPathLocked(); if (!File.Exists(path)) return false; PendingChannelWrites store; try { store = JsonUtil.Load(path); } catch (Exception) { return false; } if (store.Channels == null || store.Channels.Count == 0) { TryDelete(path); return false; } var remaining = new List(); var deliveredIds = new List(); foreach (var bucket in store.Channels) { if (bucket == null || bucket.Entries == null || bucket.Entries.Count == 0) continue; if (WriteThroughLocked(bucket.ChannelKey, bucket.Entries, now)) { AnnounceChannelWriteLocked(bucket.ChannelKey); foreach (var entry in bucket.Entries) { if (entry != null) deliveredIds.Add(entry.Id ?? string.Empty); } continue; } remaining.Add(bucket); } if (deliveredIds.Count == 0) return false; try { if (remaining.Count == 0) { TryDelete(path); } else { JsonUtil.SaveAtomic(path, new PendingChannelWrites { Channels = remaining }); } } catch (Exception) { } var changed = false; foreach (var id in deliveredIds) { string source; if (residence.TryGetValue(id, out source)) { var slot = FindChannelSlotLocked(source); if (slot != null) slot.NeedsUpload = true; residence.Remove(id); } } var removedIds = new HashSet(deliveredIds, StringComparer.Ordinal); if (database.Entries.RemoveAll(entry => entry != null && removedIds.Contains(entry.Id ?? string.Empty)) > 0) { changed = true; } return changed; } private ChannelSlot FindChannelSlotLocked(string channelKey) { foreach (var slot in channelSlots) { if (string.Equals(slot.Key, channelKey ?? string.Empty, StringComparison.Ordinal)) return slot; } return null; } private bool AnyChannelNeedsUploadLocked() { foreach (var slot in channelSlots) { if (slot.NeedsUpload) return true; } return false; } /// /// Download half of the poll for a channel-partitioned history: HEAD every subscribed /// channel, GET only the ones whose revision changed, merge each into its own channel, and /// reassemble the view (spec section 5, download steps 1-3). /// private bool SyncChannelsFromServerLocked(bool uploadLocalWhenMissing) { if (serverClient == null || !serverClient.IsConfigured) return false; if (serverSyncInProgress) return false; serverSyncInProgress = true; try { var password = CurrentPassword(); var changed = false; try { DownloadChannelsLocked(password, uploadLocalWhenMissing, ref changed); } finally { // A channel that failed part way through must not leave the view describing // channels that have already moved on. The notification is latched because the // failure propagates past the caller's own return value. serverRevision = channelSlots.Count == 0 ? string.Empty : channelSlots[0].Revision; if (changed) { AssembleViewLocked(); viewChangedNotificationPending = true; } } SetStorageStateLocked(false, string.Empty); MarkServerSuccessLocked(false); return changed; } finally { serverSyncInProgress = false; } } /// /// HEADs every subscribed channel and GETs only the ones whose revision changed, merging /// each into its own channel. is written through as the pass /// proceeds so a failure part way still tells the caller the view needs reassembling. /// private void DownloadChannelsLocked(string password, bool uploadLocalWhenMissing, ref bool changed) { foreach (var slot in channelSlots) { if (slot.Client == null || !slot.Client.IsConfigured) continue; try { var head = slot.Client.GetMetadata(); if (!string.IsNullOrWhiteSpace(head.Revision) && string.Equals(head.Revision, slot.Revision, StringComparison.Ordinal)) { continue; } } catch (WebException ex) { if (!slot.Client.IsNotFound(ex)) throw; MarkChannelBucketMissingLocked(slot, uploadLocalWhenMissing); continue; } string revision; bool exists; var remote = DownloadChannelLocked(slot.Client, slot.CacheFilePath, password, out revision, out exists); if (!exists) { MarkChannelBucketMissingLocked(slot, uploadLocalWhenMissing); continue; } if (HasLocalStateMissingFromServer(remote, slot.Database)) slot.NeedsUpload = true; var channelChanged = MergeDatabaseIntoLocked(slot.Database, remote); if (channelChanged) changed = true; NormalizeChannelDatabase(slot.Database); slot.Revision = revision; slot.Exists = true; var nextPlainHash = PlainHash(slot.Database); // Server responses can omit a stable revision, but an identical poll must not // rewrite the cache and wake the history FileSystemWatcher every two seconds. if (DownloadedCacheNeedsWrite(slot.PlainHash, nextPlainHash, File.Exists(slot.CacheFilePath))) { ClipDatabaseFile.SaveAtomic(slot.CacheFilePath, slot.Database, password, DatabasePath); } slot.PlainHash = nextPlainHash; } } internal static bool DownloadedCacheNeedsWrite(string previousPlainHash, string nextPlainHash, bool cacheExists) { return !cacheExists || !string.Equals(previousPlainHash, nextPlainHash, StringComparison.Ordinal); } private static void MarkChannelBucketMissingLocked(ChannelSlot slot, bool uploadLocalWhenMissing) { slot.Revision = string.Empty; slot.Exists = false; if (uploadLocalWhenMissing && slot.Database != null && slot.Database.Entries.Count > 0) { slot.NeedsUpload = true; } } /// /// Spec section 5, download step 1. A missing bucket falls back to the local cache and /// re-uploads it create-only; a damaged document leaves rules untouched rather than /// stopping history from syncing. /// private bool RefreshSyncRulesFromServerLocked() { if (serverClient == null || !serverClient.IsConfigured) return false; var now = TimeUtil.NowUnixMs(); if (syncRulesNextCheckUnixMs > now) return false; EnsureSyncRulesClientLocked(); if (syncRulesClient == null || !syncRulesClient.IsConfigured) return false; try { var head = syncRulesClient.GetMetadata(); syncRulesNextCheckUnixMs = 0; if (syncRules != null && !string.IsNullOrWhiteSpace(head.Revision) && string.Equals(head.Revision, syncRulesRevision, StringComparison.Ordinal)) { return false; } var download = syncRulesClient.Download(); var tempPath = SyncRulesPathLocked() + ".server-download.tmp"; WriteBytesAtomic(tempPath, download.Data); var remote = ClipDatabaseFile.Load(tempPath, CurrentPassword()); TryDelete(tempPath); if (!SyncRuleEngine.IsUsable(remote)) return false; var merged = SyncRuleEngine.MergeDocuments(syncRules, remote); // Equal cached and downloaded documents represent the same server state even when // last-writer-wins returns the cached object. A genuinely newer cache must not claim // a revision belonging to different server content. syncRulesRevision = RevisionForMergedRules( merged, remote, download.Metadata == null ? string.Empty : download.Metadata.Revision); var changed = !SameSyncRules(syncRules, merged); syncRules = merged; return changed; } catch (WebException ex) { syncRulesNextCheckUnixMs = now + SyncRulesAbsentRecheckMs; // A future-version document is display-only: this client applies what it understands // but must never write its own interpretation back, restore included. if (syncRulesClient.IsNotFound(ex) && syncRules != null && !SyncRuleEngine.ReadOnly(syncRules)) { UploadCachedSyncRulesLocked(); } return false; } catch (Exception) { syncRulesNextCheckUnixMs = now + SyncRulesAbsentRecheckMs; return false; } } private static bool SameSyncRules(SyncRulesDocument left, SyncRulesDocument right) { if (ReferenceEquals(left, right)) return true; if (left == null || right == null) return false; return string.Equals(JsonUtil.SerializePretty(left), JsonUtil.SerializePretty(right), StringComparison.Ordinal); } internal static string RevisionForMergedRules( SyncRulesDocument merged, SyncRulesDocument downloaded, string downloadedRevision) { return SameSyncRules(merged, downloaded) ? (downloadedRevision ?? string.Empty) : string.Empty; } private void UploadCachedSyncRulesLocked() { // The caller checks this too; keeping it here makes "a read-only document is never // written back" a property of the only method that writes the cache to the bucket. if (syncRules == null || SyncRuleEngine.ReadOnly(syncRules)) return; try { var path = SyncRulesPathLocked(); ClipDatabaseFile.SaveAtomic(path, syncRules, CurrentPassword(), DatabasePath); var metadata = syncRulesClient.Upload(File.ReadAllBytes(path), string.Empty, true); syncRulesRevision = metadata == null ? string.Empty : metadata.Revision; } catch (Exception) { } } private void PublishSyncRulesLocked(SyncRulesDocument document) { var path = SyncRulesPathLocked(); var published = document; ClipDatabaseFile.SaveAtomic(path, published, CurrentPassword(), DatabasePath); EnsureSyncRulesClientLocked(); if (syncRulesClient == null || !syncRulesClient.IsConfigured) { syncRules = published; return; } for (var attempt = 0; ; attempt++) { try { var metadata = syncRulesClient.Upload(File.ReadAllBytes(path), syncRulesRevision); syncRulesRevision = metadata == null ? string.Empty : metadata.Revision; break; } catch (WebException ex) { if (!syncRulesClient.IsConflict(ex) || attempt >= 2) throw; var download = syncRulesClient.Download(); var tempPath = path + ".server-download.tmp"; WriteBytesAtomic(tempPath, download.Data); var remote = ClipDatabaseFile.Load(tempPath, CurrentPassword()); TryDelete(tempPath); syncRulesRevision = download.Metadata == null ? string.Empty : download.Metadata.Revision; if (SyncRuleEngine.IsUsable(remote) && SameSyncRules(SyncRuleEngine.MergeDocuments(published, remote), remote)) { // The concurrent edit is newer, so it wins the whole document. published = remote; ClipDatabaseFile.SaveAtomic(path, published, CurrentPassword(), DatabasePath); break; } } } syncRules = published; } /// /// Spec section 4, Registry behavior: an updated client whose device name is missing from /// Devices adds itself with all channels on its next successful sync. One best-effort /// attempt per configuration. /// private void SelfRegisterDeviceLocked() { if (selfRegistrationAttempted) return; if (!RulesActiveLocked() || SyncRuleEngine.ReadOnly(syncRules)) return; var deviceName = CurrentMachineName(); var normalized = deviceName.Trim().ToLowerInvariant(); foreach (var device in syncRules.Devices ?? new List()) { if (device == null) continue; if (string.Equals((device.Name ?? string.Empty).Trim().ToLowerInvariant(), normalized, StringComparison.Ordinal)) { selfRegistrationAttempted = true; return; } } selfRegistrationAttempted = true; var updated = SyncRuleEngine.Copy(syncRules); updated.Devices.Add(new SyncDevice { Name = deviceName, Channels = new List { "*" } }); updated.UpdatedUnixMs = TimeUtil.NowUnixMs(); updated.UpdatedBy = deviceName; if (SyncRuleEngine.Validate(updated) != null) return; try { PublishSyncRulesLocked(updated); } catch (Exception) { } } private void ReconfigureChannelsLocked() { PersistSyncRulesCacheLocked(); selfRegistrationAttempted = false; LoadLocked(); ResetWatcherLocked(); } private static bool IsStorageAccessException(Exception ex) { return ex is IOException || ex is UnauthorizedAccessException || ex is DirectoryNotFoundException || ex is PathTooLongException || ex is NotSupportedException || ex is System.Security.SecurityException; } private string CurrentMachineName() { return machineName; } private static string NormalizeMachineName(string value) { var normalized = (value ?? string.Empty).Trim(); return normalized.Length == 0 ? (Environment.MachineName ?? string.Empty).Trim() : normalized; } private void SetStorageStateLocked(bool unavailable, string error) { var nextError = unavailable ? (error ?? string.Empty) : string.Empty; var wasUnavailable = storageUnavailable || !string.IsNullOrWhiteSpace(LastStorageError); var isUnavailable = unavailable || !string.IsNullOrWhiteSpace(nextError); storageUnavailable = isUnavailable; LastStorageError = nextError; if (wasUnavailable == isUnavailable) return; if (!storageStateChangedNotificationPending) { storageStateChangedNotificationPending = true; pendingStorageOriginalUnavailable = wasUnavailable; } pendingStorageUnavailable = isUnavailable; pendingStorageError = nextError; if (pendingStorageOriginalUnavailable == isUnavailable) { storageStateChangedNotificationPending = false; } } private void RaiseStorageStateChangedIfPending() { EventHandler handler; StorageStateChangedEventArgs args; lock (sync) { if (!storageStateChangedNotificationPending) return; storageStateChangedNotificationPending = false; handler = StorageStateChanged; args = new StorageStateChangedEventArgs(pendingStorageUnavailable, pendingStorageError); } if (handler != null) { handler(this, args); } } private void OnChanged() { OnChanged(false); } private void OnChanged(bool external) { RaiseStorageStateChangedIfPending(); lock (sync) { lastChangeWasExternal = external; } var handler = Changed; if (handler != null) { handler(this, EventArgs.Empty); } } public void Dispose() { if (watcher != null) watcher.Dispose(); if (reloadTimer != null) reloadTimer.Dispose(); lock (sync) { serverPollGeneration++; if (serverPollTimer != null) serverPollTimer.Dispose(); serverPollTimer = null; } } private static bool IsRecoverableServerException(Exception ex) { return ex is WebException || ex is IOException || ex is UnauthorizedAccessException || ex is DatabasePasswordRequiredException || ex is InvalidOperationException; } } }