src/ClipStore.cs
Rama main · réplica de lectura del repositorio original.
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<string> 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<ChannelSlot> channelSlots = new List<ChannelSlot>();
private Dictionary<string, string> residence = new Dictionary<string, string>(StringComparer.Ordinal);
private Dictionary<string, DeletedClipEntry> assembledMarkers = new Dictionary<string, DeletedClipEntry>(StringComparer.Ordinal);
private List<string> channelWriteOrder = new List<string>();
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;
/// <summary>
/// One sync channel as it stood at the last transfer. <see cref="Key"/> is the empty string
/// for the core channel, which keeps using the existing database file and bucket.
/// <see cref="PlainHash"/> is the dirty-detection hash of the plaintext with the
/// database-level UpdatedUnixMs zeroed (spec section 5, upload step 4).
/// </summary>
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<StorageStateChangedEventArgs> 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<string> passwordProvider) : this(databasePath, passwordProvider, Environment.MachineName)
{
}
public ClipStore(string databasePath, Func<string> 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<string> 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<ClipEntry> GetEntries()
{
return GetEntries("LastUsed", "All", true);
}
public List<ClipEntry> GetEntries(string sortMode)
{
return GetEntries(sortMode, "All", true);
}
public List<ClipEntry> GetEntries(string sortMode, string groupFilter)
{
return GetEntries(sortMode, groupFilter, true);
}
public List<ClipEntry> 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<ClipEntry> SortNormalEntries(IEnumerable<ClipEntry> 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<string> ids, bool keepDuplicateEntries)
{
var idSet = new HashSet<string>((ids ?? Enumerable.Empty<string>()).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<string> ids)
{
var selected = new HashSet<string>((ids ?? Enumerable.Empty<string>()).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<string> ids)
{
var idSet = new HashSet<string>((ids ?? Enumerable.Empty<string>()).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<ClipEntry> 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<ClipEntry> 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<string> ids, string groupName)
{
var idSet = new HashSet<string>((ids ?? Enumerable.Empty<string>()).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<string> 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<string> ids, int direction)
{
MoveEntries(ids, direction, null);
}
public void MoveEntries(IEnumerable<string> ids, int direction, IEnumerable<string> visibleIds)
{
var selectedIds = new HashSet<string>((ids ?? Enumerable.Empty<string>()).Where(id => !string.IsNullOrEmpty(id)));
if (selectedIds.Count == 0 || direction == 0) return;
var visibleOrder = (visibleIds ?? Enumerable.Empty<string>())
.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<string> orderedIds)
{
var order = (orderedIds ?? Enumerable.Empty<string>())
.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<ClipEntry> InsertEntriesAfter(IEnumerable<ClipEntry> entries, string afterId, bool removeDuplicates)
{
var source = (entries ?? Enumerable.Empty<ClipEntry>())
.Where(e => e != null && !string.IsNullOrEmpty(e.Text))
.ToList();
var inserted = new List<ClipEntry>();
if (source.Count == 0) return inserted;
lock (sync)
{
NormalizeManualOrderLocked();
if (removeDuplicates)
{
var texts = new HashSet<string>(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<ClipEntry> InsertEntriesAtNormalStart(IEnumerable<ClipEntry> entries, bool removeDuplicates)
{
var source = (entries ?? Enumerable.Empty<ClipEntry>())
.Where(e => e != null && !string.IsNullOrEmpty(e.Text))
.ToList();
var inserted = new List<ClipEntry>();
if (source.Count == 0) return inserted;
lock (sync)
{
NormalizeManualOrderLocked();
if (removeDuplicates)
{
var texts = new HashSet<string>(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<ClipEntry> LoadEntriesFromFile(string path)
{
return LoadEntriesFromFile(path, string.Empty);
}
public List<ClipEntry> 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<ClipEntry>())
.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<ClipEntry>();
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<ClipEntry>();
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<ClipEntry>();
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<string> GetDevices()
{
lock (sync)
{
return CanonicalLabels(database.Entries, e => e.SourceMachine);
}
}
private static List<string> CanonicalLabels(IEnumerable<ClipEntry> entries, Func<ClipEntry, string> 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<ClipEntry>();
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);
}
/// <summary>
/// Whether a container in the history folder is one this store reads: the history file
/// itself, the sync rules document, or a sync channel.
/// </summary>
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<ClipEntry> FilterByGroup(IEnumerable<ClipEntry> 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<DeletedClipEntry>();
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<DeletedClipEntry>();
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<ClipEntry>()).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<string> GetSyncChannelKeys()
{
lock (sync)
{
return AllChannelKeysLocked();
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private string RefusalForUnsubscribedRemovalLocked(SyncRulesDocument next)
{
if (syncRules == null) return null;
var currentKeys = AllChannelKeysLocked();
if (currentKeys.Count == 0) return null;
var survivingKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var channel in (next == null ? null : next.Channels) ?? new List<SyncChannel>())
{
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;
}
/// <summary>
/// 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.
/// </summary>
private void RerouteChannelsLeavingDocumentLocked(SyncRulesDocument next)
{
if (!RulesActiveLocked() || channelSlots.Count == 0) return;
var surviving = new Dictionary<string, SyncRoute>(StringComparer.Ordinal);
foreach (var channel in (next == null ? null : next.Channels) ?? new List<SyncChannel>())
{
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);
}
}
/// <summary>
/// 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).
/// </summary>
internal List<string> LastChannelWriteOrder()
{
lock (sync)
{
return new List<string>(channelWriteOrder);
}
}
/// <summary>
/// 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.
/// </summary>
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<string, string>(StringComparer.Ordinal);
assembledMarkers = new Dictionary<string, DeletedClipEntry>(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<string> AllChannelKeysLocked()
{
var keys = new List<string>();
if (syncRules == null) return keys;
foreach (var channel in syncRules.Channels ?? new List<SyncChannel>())
{
if (channel == null) continue;
var key = SyncRuleEngine.ChannelKey(channel.Name);
if (key.Length > 0 && !keys.Contains(key)) keys.Add(key);
}
return keys;
}
/// <summary>
/// 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).
/// </summary>
private List<string> SubscribedChannelKeysLocked()
{
var keys = new List<string>();
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<SyncChannel>())
{
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<SyncRulesDocument>(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;
}
}
/// <summary>
/// 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.
/// </summary>
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<SyncRulesDocument>(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<string>();
var replaced = false;
foreach (var conflict in conflicts)
{
SyncRulesDocument candidate;
try
{
candidate = ClipDatabaseFile.Load<SyncRulesDocument>(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)
{
}
}
/// <summary>
/// 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.
/// </summary>
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<string> OwnedContainerFileNamesLocked()
{
var owned = new HashSet<string>(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<ClipEntry>();
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);
}
/// <summary>
/// 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).
/// </summary>
private void AssembleViewLocked()
{
var now = TimeUtil.NowUnixMs();
var view = new ClipDatabase();
view.Version = 1;
view.UpdatedUnixMs = now;
var owners = new List<string>();
var indexById = new Dictionary<string, int>(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<ClipEntry>();
var keptOwners = new List<string>();
var live = new HashSet<string>(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<string, string>(StringComparer.Ordinal);
for (var index = 0; index < kept.Count; index++)
{
residence[kept[index].Id ?? string.Empty] = keptOwners[index];
}
assembledMarkers = MarkersById(database.DeletedEntries);
}
/// <summary>
/// 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.
/// </summary>
private static List<ClipEntry> ApplyCombinedManualOrder(List<ClipEntry> entries, List<string> owners)
{
var sequences = new List<List<int>>();
var sequenceByOwner = new Dictionary<string, int>(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<int>());
}
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<ClipEntry>(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<string, DeletedClipEntry> MarkersById(List<DeletedClipEntry> markers)
{
var byId = new Dictionary<string, DeletedClipEntry>(StringComparer.Ordinal);
foreach (var marker in markers ?? new List<DeletedClipEntry>())
{
if (marker == null) continue;
byId[ComparableId(marker.Id)] = CloneMarker(marker);
}
return byId;
}
/// <summary>
/// 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.
/// </summary>
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<TValue> ListFor<TValue>(Dictionary<string, List<TValue>> map, string key)
{
List<TValue> list;
if (!map.TryGetValue(key ?? string.Empty, out list))
{
list = new List<TValue>();
map[key ?? string.Empty] = list;
}
return list;
}
private static List<TValue> ListOrEmpty<TValue>(Dictionary<string, List<TValue>> map, string key)
{
List<TValue> list;
return map.TryGetValue(key ?? string.Empty, out list) ? list : new List<TValue>();
}
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<string>();
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();
}
/// <summary>
/// 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.
/// </summary>
private void CommitRoutedChannelsLocked(long now, bool forceServerUpload)
{
var subscribed = new HashSet<string>(StringComparer.Ordinal);
foreach (var slot in channelSlots) subscribed.Add(slot.Key);
var fetched = FetchedEntriesByChannelLocked();
var previousMarkers = assembledMarkers;
var routed = new Dictionary<string, List<ClipEntry>>(StringComparer.Ordinal);
var departures = new Dictionary<string, List<ClipEntry>>(StringComparer.Ordinal);
var relocations = new Dictionary<string, List<DeletedClipEntry>>(StringComparer.Ordinal);
var pending = new Dictionary<string, List<ClipEntry>>(StringComparer.Ordinal);
var pendingKeys = new List<string>();
// 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<string>();
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<string>(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<string, Dictionary<string, ClipEntry>> FetchedEntriesByChannelLocked()
{
var byChannel = new Dictionary<string, Dictionary<string, ClipEntry>>(StringComparer.Ordinal);
foreach (var slot in channelSlots)
{
var entries = new Dictionary<string, ClipEntry>(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<string, List<ClipEntry>> WithDepartures(
Dictionary<string, List<ClipEntry>> routed,
Dictionary<string, List<ClipEntry>> departures,
Dictionary<string, Dictionary<string, ClipEntry>> fetched)
{
if (departures.Count == 0) return routed;
var combined = new Dictionary<string, List<ClipEntry>>(StringComparer.Ordinal);
foreach (var pair in routed) combined[pair.Key] = new List<ClipEntry>(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<string, ClipEntry> 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<ClipEntry> entries, string id)
{
entries.RemoveAll(entry => entry != null && string.Equals(ComparableId(entry.Id), ComparableId(id), StringComparison.Ordinal));
}
private static void RemoveMarkerById(List<DeletedClipEntry> markers, string id)
{
markers.RemoveAll(marker => marker != null && string.Equals(ComparableId(marker.Id), ComparableId(id), StringComparison.Ordinal));
}
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, List<DeletedClipEntry>> AssembleMarkersLocked(
Dictionary<string, DeletedClipEntry> previousMarkers,
HashSet<string> subscribed,
Dictionary<string, List<DeletedClipEntry>> relocations)
{
var markers = new Dictionary<string, List<DeletedClipEntry>>(StringComparer.Ordinal);
foreach (var slot in channelSlots)
{
var carried = new List<DeletedClipEntry>();
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<DeletedClipEntry>())
{
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<ClipDatabase> BuildChannelDatabasesLocked(
Dictionary<string, List<ClipEntry>> routed,
Dictionary<string, List<DeletedClipEntry>> markers,
long now)
{
var databases = new List<ClipDatabase>();
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<ClipEntry>();
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;
}
/// <summary>
/// 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.
/// </summary>
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++;
}
}
/// <summary>
/// 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.
/// </summary>
private static List<DeletedClipEntry> DropMarkersForEntries(List<DeletedClipEntry> markers, List<ClipEntry> entries)
{
if (markers.Count == 0 || entries.Count == 0) return markers;
var resident = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in entries)
{
if (entry != null) resident.Add(ComparableId(entry.Id));
}
var kept = new List<DeletedClipEntry>();
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private bool WriteThroughLocked(string channelKey, List<ClipEntry> 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<DeletedClipEntry>(),
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<ClipEntry> entries)
{
try
{
var path = PendingWritesPathLocked();
var store = JsonUtil.Load<PendingChannelWrites>(path);
if (store.Channels == null) store.Channels = new List<PendingChannelWrite>();
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<ClipEntry>();
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)
{
}
}
/// <summary>
/// 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.
/// </summary>
private bool RetryPendingWritesLocked(long now)
{
var path = PendingWritesPathLocked();
if (!File.Exists(path)) return false;
PendingChannelWrites store;
try
{
store = JsonUtil.Load<PendingChannelWrites>(path);
}
catch (Exception)
{
return false;
}
if (store.Channels == null || store.Channels.Count == 0)
{
TryDelete(path);
return false;
}
var remaining = new List<PendingChannelWrite>();
var deliveredIds = new List<string>();
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<string>(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;
}
/// <summary>
/// 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).
/// </summary>
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;
}
}
/// <summary>
/// HEADs every subscribed channel and GETs only the ones whose revision changed, merging
/// each into its own channel. <paramref name="changed"/> is written through as the pass
/// proceeds so a failure part way still tells the caller the view needs reassembling.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<SyncRulesDocument>(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<SyncRulesDocument>(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;
}
/// <summary>
/// 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.
/// </summary>
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<SyncDevice>())
{
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<string> { "*" } });
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<StorageStateChangedEventArgs> 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;
}
}
}