diff --git a/.editorconfig b/.editorconfig index 50a5b61..8fd7bd6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -46,3 +46,7 @@ dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_style.prefix_underscore.required_prefix = _ dotnet_naming_style.prefix_underscore.capitalization = camel_case + +# --- Suppressed Rules --- +dotnet_diagnostic.CA1031.severity = suggestion +dotnet_diagnostic.CA2007.severity = none diff --git a/CopyPaste.Core.Tests/BackupServiceTests.cs b/CopyPaste.Core.Tests/BackupServiceTests.cs new file mode 100644 index 0000000..2408f58 --- /dev/null +++ b/CopyPaste.Core.Tests/BackupServiceTests.cs @@ -0,0 +1,463 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class BackupServiceTests : IDisposable +{ + private readonly string _basePath; + + public BackupServiceTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + } + + #region CreateBackup Tests + + [Fact] + public void CreateBackup_WritesValidZip() + { + SeedDatabase(); + + using var output = new MemoryStream(); + BackupService.CreateBackup(output, "1.0.0"); + + output.Position = 0; + using var archive = new ZipArchive(output, ZipArchiveMode.Read); + Assert.NotNull(archive.GetEntry("manifest.json")); + Assert.NotNull(archive.GetEntry("clipboard.db")); + } + + [Fact] + public void CreateBackup_ManifestContainsCorrectItemCount() + { + SeedDatabaseWithItems(5); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "2.0.0"); + + Assert.Equal(5, manifest.ItemCount); + } + + [Fact] + public void CreateBackup_ManifestHasCorrectVersion() + { + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.5.0"); + + Assert.Equal(BackupService.CurrentVersion, manifest.Version); + Assert.Equal("1.5.0", manifest.AppVersion); + } + + [Fact] + public void CreateBackup_IncludesImages() + { + // Create a fake image file + var imagePath = Path.Combine(StorageConfig.ImagesPath, "test-image.png"); + File.WriteAllBytes(imagePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(1, manifest.ImageCount); + + output.Position = 0; + using var archive = new ZipArchive(output, ZipArchiveMode.Read); + Assert.NotNull(archive.GetEntry("images/test-image.png")); + } + + [Fact] + public void CreateBackup_IncludesThumbnails() + { + var thumbPath = Path.Combine(StorageConfig.ThumbnailsPath, "thumb_t.png"); + File.WriteAllBytes(thumbPath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(1, manifest.ThumbnailCount); + } + + [Fact] + public void CreateBackup_IncludesConfig() + { + var configPath = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); + File.WriteAllText(configPath, """{"PreferredLanguage":"es-CL"}"""); + + using var output = new MemoryStream(); + BackupService.CreateBackup(output, "1.0.0"); + + output.Position = 0; + using var archive = new ZipArchive(output, ZipArchiveMode.Read); + Assert.NotNull(archive.GetEntry("config/MyM.json")); + } + + [Fact] + public void CreateBackup_ManifestHasMachineName() + { + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(Environment.MachineName, manifest.MachineName); + } + + [Fact] + public void CreateBackup_ManifestHasTimestamp() + { + var before = DateTime.UtcNow.AddSeconds(-1); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + var after = DateTime.UtcNow.AddSeconds(1); + Assert.InRange(manifest.CreatedAtUtc, before, after); + } + + [Fact] + public void CreateBackup_EmptyDatabase_Succeeds() + { + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(0, manifest.ItemCount); + Assert.False(manifest.HasPinnedItems); + } + + [Fact] + public void CreateBackup_DetectsPinnedItems() + { + SeedDatabaseWithPinnedItem(); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.True(manifest.HasPinnedItems); + } + + [Fact] + public void CreateBackup_ThrowsOnNullStream() + { + Assert.Throws(() => BackupService.CreateBackup(null!, "1.0.0")); + } + + #endregion + + #region ValidateBackup Tests + + [Fact] + public void ValidateBackup_ValidZip_ReturnsManifest() + { + using var backupStream = CreateTestBackup(); + backupStream.Position = 0; + + var manifest = BackupService.ValidateBackup(backupStream); + + Assert.NotNull(manifest); + Assert.Equal(BackupService.CurrentVersion, manifest.Version); + } + + [Fact] + public void ValidateBackup_InvalidZip_ReturnsNull() + { + using var invalidStream = new MemoryStream(Encoding.UTF8.GetBytes("not a zip file")); + + var manifest = BackupService.ValidateBackup(invalidStream); + + Assert.Null(manifest); + } + + [Fact] + public void ValidateBackup_ZipWithoutManifest_ReturnsNull() + { + using var zipStream = new MemoryStream(); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("random.txt"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("hello"); + } + + zipStream.Position = 0; + var manifest = BackupService.ValidateBackup(zipStream); + + Assert.Null(manifest); + } + + [Fact] + public void ValidateBackup_ThrowsOnNullStream() + { + Assert.Throws(() => BackupService.ValidateBackup(null!)); + } + + #endregion + + #region GetBackupInfo Tests + + [Fact] + public void GetBackupInfo_ReturnsManifestData() + { + using var backupStream = CreateTestBackup(itemCount: 10); + backupStream.Position = 0; + + var info = BackupService.GetBackupInfo(backupStream); + + Assert.NotNull(info); + Assert.Equal("1.0.0", info.AppVersion); + } + + #endregion + + #region RestoreBackup Tests + + [Fact] + public void RestoreBackup_RestoresDatabase() + { + // Seed and backup + SeedDatabaseWithItems(3); + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Clear current data (release SQLite shared cache first) + SqliteConnection.ClearAllPools(); + File.Delete(StorageConfig.DatabasePath); + Assert.False(File.Exists(StorageConfig.DatabasePath)); + + // Restore + backupStream.Position = 0; + var manifest = BackupService.RestoreBackup(backupStream); + + Assert.NotNull(manifest); + Assert.True(File.Exists(StorageConfig.DatabasePath)); + } + + [Fact] + public void RestoreBackup_RestoresImages() + { + // Create image and backup + var imagePath = Path.Combine(StorageConfig.ImagesPath, "restored-image.png"); + File.WriteAllBytes(imagePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Delete image + File.Delete(imagePath); + Assert.False(File.Exists(imagePath)); + + // Restore + backupStream.Position = 0; + BackupService.RestoreBackup(backupStream); + + Assert.True(File.Exists(imagePath)); + } + + [Fact] + public void RestoreBackup_RestoresConfig() + { + // Create config and backup + var configFile = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); + File.WriteAllText(configFile, """{"RetentionDays":60}"""); + + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Delete config + File.Delete(configFile); + Assert.False(File.Exists(configFile)); + + // Restore + backupStream.Position = 0; + BackupService.RestoreBackup(backupStream); + + Assert.True(File.Exists(configFile)); + Assert.Contains("60", File.ReadAllText(configFile), StringComparison.Ordinal); + } + + [Fact] + public void RestoreBackup_CleansOrphanFilesInImageDirectory() + { + // Backup with one image + var imagePath = Path.Combine(StorageConfig.ImagesPath, "good.png"); + File.WriteAllBytes(imagePath, new byte[] { 0x01 }); + + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Add an orphan image that shouldn't exist after restore + var orphanPath = Path.Combine(StorageConfig.ImagesPath, "orphan.png"); + File.WriteAllBytes(orphanPath, new byte[] { 0x02 }); + + // Restore + backupStream.Position = 0; + BackupService.RestoreBackup(backupStream); + + Assert.True(File.Exists(imagePath)); + Assert.False(File.Exists(orphanPath)); + } + + [Fact] + public void RestoreBackup_InvalidStream_ReturnsNull() + { + using var invalidStream = new MemoryStream(Encoding.UTF8.GetBytes("not a zip")); + + var result = BackupService.RestoreBackup(invalidStream); + + Assert.Null(result); + } + + [Fact] + public void RestoreBackup_ThrowsOnNullStream() + { + Assert.Throws(() => BackupService.RestoreBackup(null!)); + } + + #endregion + + #region Roundtrip Tests + + [Fact] + public void Roundtrip_BackupAndRestore_PreservesAllData() + { + // Setup: DB + images + thumbs + config + SeedDatabaseWithItems(5); + + var img = Path.Combine(StorageConfig.ImagesPath, "roundtrip.png"); + File.WriteAllBytes(img, new byte[] { 0x89, 0x50 }); + + var thumb = Path.Combine(StorageConfig.ThumbnailsPath, "roundtrip_t.png"); + File.WriteAllBytes(thumb, new byte[] { 0xAA, 0xBB }); + + var config = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); + File.WriteAllText(config, """{"PreferredLanguage":"ja-JP"}"""); + + // Backup + using var backupStream = new MemoryStream(); + var backupManifest = BackupService.CreateBackup(backupStream, "3.0.0"); + + // Simulate data loss (release SQLite shared cache first) + SqliteConnection.ClearAllPools(); + File.Delete(StorageConfig.DatabasePath); + File.Delete(img); + File.Delete(thumb); + File.Delete(config); + + // Restore + backupStream.Position = 0; + var restoredManifest = BackupService.RestoreBackup(backupStream); + + // Verify + Assert.NotNull(restoredManifest); + Assert.Equal(backupManifest.ItemCount, restoredManifest.ItemCount); + Assert.True(File.Exists(StorageConfig.DatabasePath)); + Assert.True(File.Exists(img)); + Assert.True(File.Exists(thumb)); + Assert.True(File.Exists(config)); + Assert.Contains("ja-JP", File.ReadAllText(config), StringComparison.Ordinal); + } + + #endregion + + #region BackupManifest Serialization Tests + + [Fact] + public void BackupManifest_Serialization_Roundtrip() + { + var manifest = new BackupManifest + { + Version = 1, + AppVersion = "1.2.3", + CreatedAtUtc = new DateTime(2026, 2, 12, 10, 0, 0, DateTimeKind.Utc), + ItemCount = 42, + ImageCount = 10, + ThumbnailCount = 10, + HasPinnedItems = true, + MachineName = "TEST-PC" + }; + + var json = JsonSerializer.Serialize(manifest, BackupManifestJsonContext.Default.BackupManifest); + var deserialized = JsonSerializer.Deserialize(json, BackupManifestJsonContext.Default.BackupManifest); + + Assert.NotNull(deserialized); + Assert.Equal(manifest.Version, deserialized.Version); + Assert.Equal(manifest.AppVersion, deserialized.AppVersion); + Assert.Equal(manifest.ItemCount, deserialized.ItemCount); + Assert.Equal(manifest.ImageCount, deserialized.ImageCount); + Assert.Equal(manifest.ThumbnailCount, deserialized.ThumbnailCount); + Assert.True(deserialized.HasPinnedItems); + Assert.Equal("TEST-PC", deserialized.MachineName); + } + + #endregion + + #region Helpers + + private static void SeedDatabase() + { + using var repo = new SqliteRepository(StorageConfig.DatabasePath); + repo.Save(new ClipboardItem + { + Content = "test content", + Type = ClipboardContentType.Text + }); + } + + private static void SeedDatabaseWithItems(int count) + { + using var repo = new SqliteRepository(StorageConfig.DatabasePath); + for (int i = 0; i < count; i++) + { + repo.Save(new ClipboardItem + { + Content = $"item {i}", + Type = ClipboardContentType.Text + }); + } + } + + private static void SeedDatabaseWithPinnedItem() + { + using var repo = new SqliteRepository(StorageConfig.DatabasePath); + repo.Save(new ClipboardItem + { + Content = "pinned item", + Type = ClipboardContentType.Text, + IsPinned = true + }); + } + + private static MemoryStream CreateTestBackup(int itemCount = 0) + { + if (itemCount > 0) + SeedDatabaseWithItems(itemCount); + else + SeedDatabase(); + + var stream = new MemoryStream(); + BackupService.CreateBackup(stream, "1.0.0"); + return stream; + } + + public void Dispose() + { + try + { + if (Directory.Exists(_basePath)) + { + Directory.Delete(_basePath, recursive: true); + } + } + catch + { + // Best-effort cleanup for temp test data. + } + } + + #endregion +} diff --git a/CopyPaste.Core.Tests/CleanupServiceTests.cs b/CopyPaste.Core.Tests/CleanupServiceTests.cs index b76358f..9323f5d 100644 --- a/CopyPaste.Core.Tests/CleanupServiceTests.cs +++ b/CopyPaste.Core.Tests/CleanupServiceTests.cs @@ -205,7 +205,6 @@ private static string GetLastCleanupFile() return Path.Combine(directory, "last_cleanup.txt"); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best-effort cleanup of temp test data should not fail tests")] public void Dispose() { try diff --git a/CopyPaste.Core.Tests/ClipboardServiceTests.cs b/CopyPaste.Core.Tests/ClipboardServiceTests.cs index 132a073..9e5df8d 100644 --- a/CopyPaste.Core.Tests/ClipboardServiceTests.cs +++ b/CopyPaste.Core.Tests/ClipboardServiceTests.cs @@ -674,7 +674,6 @@ public void AddFiles_WithFileSize_IncludesFileSizeInMetadata() #endregion - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best-effort cleanup of temp test data should not fail tests")] public void Dispose() { try diff --git a/CopyPaste.Core.Tests/SqliteRepositoryTests.cs b/CopyPaste.Core.Tests/SqliteRepositoryTests.cs index 177ce46..e0d4618 100644 --- a/CopyPaste.Core.Tests/SqliteRepositoryTests.cs +++ b/CopyPaste.Core.Tests/SqliteRepositoryTests.cs @@ -629,7 +629,6 @@ public void Constructor_CreatesTablesAndIndexes() #endregion - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best-effort cleanup of temp test data should not fail tests")] public void Dispose() { _repository?.Dispose(); diff --git a/CopyPaste.Core.Tests/StorageConfigTests.cs b/CopyPaste.Core.Tests/StorageConfigTests.cs index dd4f974..a8387b6 100644 --- a/CopyPaste.Core.Tests/StorageConfigTests.cs +++ b/CopyPaste.Core.Tests/StorageConfigTests.cs @@ -254,7 +254,6 @@ public void SetBasePath_UpdatesAllPaths() #endregion - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best-effort cleanup of temp test data should not fail tests")] public void Dispose() { try diff --git a/CopyPaste.Core/AppLogger.cs b/CopyPaste.Core/AppLogger.cs index 69b3507..ee7dbd7 100644 --- a/CopyPaste.Core/AppLogger.cs +++ b/CopyPaste.Core/AppLogger.cs @@ -9,7 +9,6 @@ namespace CopyPaste.Core; /// Simple file logger optimized for AOT and minimal allocations. /// Logs are written to the application data folder. /// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Logger must never crash the app")] public static class AppLogger { private static readonly string _logDirectory = Path.Combine( diff --git a/CopyPaste.Core/BackupManifest.cs b/CopyPaste.Core/BackupManifest.cs new file mode 100644 index 0000000..e2cb6e7 --- /dev/null +++ b/CopyPaste.Core/BackupManifest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace CopyPaste.Core; + +public sealed class BackupManifest +{ + public int Version { get; set; } = 1; + public string AppVersion { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public int ItemCount { get; set; } + public int ImageCount { get; set; } + public int ThumbnailCount { get; set; } + public bool HasPinnedItems { get; set; } + public string MachineName { get; set; } = string.Empty; +} + +[JsonSerializable(typeof(BackupManifest))] +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNameCaseInsensitive = true)] +public partial class BackupManifestJsonContext : JsonSerializerContext +{ +} diff --git a/CopyPaste.Core/BackupService.cs b/CopyPaste.Core/BackupService.cs new file mode 100644 index 0000000..26e9a91 --- /dev/null +++ b/CopyPaste.Core/BackupService.cs @@ -0,0 +1,345 @@ +using System.IO.Compression; +using System.Text.Json; +using Microsoft.Data.Sqlite; + +namespace CopyPaste.Core; + +public static class BackupService +{ + private const string _manifestFileName = "manifest.json"; + private const string _databaseFileName = "clipboard.db"; + private const string _imagesFolderName = "images"; + private const string _thumbsFolderName = "thumbs"; + private const string _configFolderName = "config"; + + public const int CurrentVersion = 1; + + public static BackupManifest CreateBackup(Stream output, string appVersion) + { + ArgumentNullException.ThrowIfNull(output); + + CheckpointDatabase(); + + var manifest = new BackupManifest + { + Version = CurrentVersion, + AppVersion = appVersion, + CreatedAtUtc = DateTime.UtcNow, + MachineName = Environment.MachineName + }; + + using var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true); + + var dbPath = StorageConfig.DatabasePath; + if (File.Exists(dbPath)) + { + AddFileToArchive(archive, dbPath, _databaseFileName); + manifest.ItemCount = CountDatabaseItems(dbPath); + manifest.HasPinnedItems = HasPinnedItems(dbPath); + } + + manifest.ImageCount = AddDirectoryToArchive(archive, StorageConfig.ImagesPath, _imagesFolderName); + manifest.ThumbnailCount = AddDirectoryToArchive(archive, StorageConfig.ThumbnailsPath, _thumbsFolderName); + AddDirectoryToArchive(archive, StorageConfig.ConfigPath, _configFolderName); + + var manifestEntry = archive.CreateEntry(_manifestFileName, CompressionLevel.Optimal); + using (var manifestStream = manifestEntry.Open()) + { + JsonSerializer.Serialize(manifestStream, manifest, BackupManifestJsonContext.Default.BackupManifest); + } + + AppLogger.Info($"Backup created: {manifest.ItemCount} items, {manifest.ImageCount} images, {manifest.ThumbnailCount} thumbs"); + return manifest; + } + + public static BackupManifest? RestoreBackup(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + + var manifest = ReadManifestFromStream(input); + if (manifest == null) + { + AppLogger.Error("Restore failed: invalid backup file (missing or corrupt manifest)"); + return null; + } + + if (manifest.Version > CurrentVersion) + { + AppLogger.Error($"Restore failed: backup version {manifest.Version} is newer than supported version {CurrentVersion}"); + return null; + } + + var snapshotPath = CreatePreRestoreSnapshot(); + + try + { + SqliteConnection.ClearAllPools(); + + input.Position = 0; + using var archive = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: true); + + RestoreFile(archive, _databaseFileName, StorageConfig.DatabasePath); + RestoreDirectory(archive, _imagesFolderName, StorageConfig.ImagesPath); + RestoreDirectory(archive, _thumbsFolderName, StorageConfig.ThumbnailsPath); + RestoreDirectory(archive, _configFolderName, StorageConfig.ConfigPath); + + AppLogger.Info($"Restore completed: {manifest.ItemCount} items from backup dated {manifest.CreatedAtUtc:O}"); + CleanupSnapshot(snapshotPath); + + return manifest; + } + catch (Exception ex) + { + AppLogger.Exception(ex, "Restore failed, attempting rollback from snapshot"); + RollbackFromSnapshot(snapshotPath); + return null; + } + } + + public static BackupManifest? ValidateBackup(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + return ReadManifestFromStream(input); + } + + public static BackupManifest? GetBackupInfo(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + return ReadManifestFromStream(input); + } + + private static BackupManifest? ReadManifestFromStream(Stream input) + { + try + { + input.Position = 0; + using var archive = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: true); + var manifestEntry = archive.GetEntry(_manifestFileName); + if (manifestEntry == null) return null; + + using var stream = manifestEntry.Open(); + return JsonSerializer.Deserialize(stream, BackupManifestJsonContext.Default.BackupManifest); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to read backup manifest: {ex.Message}"); + return null; + } + } + + private static int AddDirectoryToArchive(ZipArchive archive, string sourcePath, string entryPrefix) + { + if (!Directory.Exists(sourcePath)) return 0; + + var files = Directory.GetFiles(sourcePath); + foreach (var file in files) + { + var entryName = $"{entryPrefix}/{Path.GetFileName(file)}"; + AddFileToArchive(archive, file, entryName); + } + + return files.Length; + } + + private static void AddFileToArchive(ZipArchive archive, string filePath, string entryName) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + fileStream.CopyTo(entryStream); + } + + private static void RestoreFile(ZipArchive archive, string entryName, string destinationPath) + { + var entry = archive.GetEntry(entryName); + if (entry == null) return; + + var directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + if (entryName == _databaseFileName) + { + TryDeleteFile(destinationPath + "-wal"); + TryDeleteFile(destinationPath + "-shm"); + } + + using var source = entry.Open(); + using var destination = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + source.CopyTo(destination); + } + + private static void RestoreDirectory(ZipArchive archive, string entryPrefix, string destinationPath) + { + if (Directory.Exists(destinationPath)) + { + foreach (var file in Directory.GetFiles(destinationPath)) + { + TryDeleteFile(file); + } + } + else + { + Directory.CreateDirectory(destinationPath); + } + + var prefix = entryPrefix + "/"; + foreach (var entry in archive.Entries) + { + if (!entry.FullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + + var fileName = Path.GetFileName(entry.FullName); + if (string.IsNullOrEmpty(fileName) || fileName.Contains("..", StringComparison.Ordinal)) + continue; + + var destinationFile = Path.Combine(destinationPath, fileName); + entry.ExtractToFile(destinationFile, overwrite: true); + } + } + + private static void CheckpointDatabase() + { + var dbPath = StorageConfig.DatabasePath; + if (!File.Exists(dbPath)) return; + + try + { + using var connection = new SqliteConnection($"Data Source={dbPath};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);"; + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + AppLogger.Warn($"WAL checkpoint before backup failed (non-critical): {ex.Message}"); + } + } + + private static int CountDatabaseItems(string dbPath) + { + try + { + using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM ClipboardItems"; + return Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return 0; + } + } + + private static bool HasPinnedItems(string dbPath) + { + try + { + using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM ClipboardItems WHERE IsPinned = 1"; + return Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture) > 0; + } + catch + { + return false; + } + } + + private static string CreatePreRestoreSnapshot() + { + var snapshotPath = Path.Combine( + Path.GetDirectoryName(StorageConfig.DatabasePath) ?? string.Empty, + $".pre-restore-{DateTime.UtcNow:yyyyMMddHHmmss}"); + + try + { + Directory.CreateDirectory(snapshotPath); + + if (File.Exists(StorageConfig.DatabasePath)) + File.Copy(StorageConfig.DatabasePath, Path.Combine(snapshotPath, _databaseFileName), overwrite: true); + + CopyDirectoryFlat(StorageConfig.ImagesPath, Path.Combine(snapshotPath, _imagesFolderName)); + CopyDirectoryFlat(StorageConfig.ThumbnailsPath, Path.Combine(snapshotPath, _thumbsFolderName)); + CopyDirectoryFlat(StorageConfig.ConfigPath, Path.Combine(snapshotPath, _configFolderName)); + + AppLogger.Info($"Pre-restore snapshot created at: {snapshotPath}"); + } + catch (Exception ex) + { + AppLogger.Warn($"Failed to create pre-restore snapshot (restore will proceed): {ex.Message}"); + } + + return snapshotPath; + } + + private static void RollbackFromSnapshot(string snapshotPath) + { + if (!Directory.Exists(snapshotPath)) return; + + try + { + var snapshotDb = Path.Combine(snapshotPath, _databaseFileName); + if (File.Exists(snapshotDb)) + File.Copy(snapshotDb, StorageConfig.DatabasePath, overwrite: true); + + RestoreDirectoryFromSnapshot(Path.Combine(snapshotPath, _imagesFolderName), StorageConfig.ImagesPath); + RestoreDirectoryFromSnapshot(Path.Combine(snapshotPath, _thumbsFolderName), StorageConfig.ThumbnailsPath); + RestoreDirectoryFromSnapshot(Path.Combine(snapshotPath, _configFolderName), StorageConfig.ConfigPath); + + AppLogger.Info("Rollback from pre-restore snapshot completed"); + } + catch (Exception ex) + { + AppLogger.Exception(ex, "Rollback from snapshot failed - manual recovery may be needed"); + } + } + + private static void RestoreDirectoryFromSnapshot(string snapshotDir, string targetDir) + { + if (!Directory.Exists(snapshotDir)) return; + + Directory.CreateDirectory(targetDir); + foreach (var file in Directory.GetFiles(snapshotDir)) + { + File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), overwrite: true); + } + } + + private static void CleanupSnapshot(string snapshotPath) + { + try + { + if (Directory.Exists(snapshotPath)) + Directory.Delete(snapshotPath, recursive: true); + } + catch (Exception ex) + { + AppLogger.Warn($"Failed to clean up snapshot at {snapshotPath}: {ex.Message}"); + } + } + + private static void CopyDirectoryFlat(string source, string destination) + { + if (!Directory.Exists(source)) return; + + Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/CopyPaste.Core/ClipboardHelper.cs b/CopyPaste.Core/ClipboardHelper.cs index 23f1ec9..098421a 100644 --- a/CopyPaste.Core/ClipboardHelper.cs +++ b/CopyPaste.Core/ClipboardHelper.cs @@ -4,8 +4,6 @@ namespace CopyPaste.Core; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Clipboard operations must not crash app - failures are logged")] public static class ClipboardHelper { public static bool SetClipboardContent(ClipboardItem item, bool plainText = false) diff --git a/CopyPaste.Core/ClipboardService.cs b/CopyPaste.Core/ClipboardService.cs index 164d42f..2e00eed 100644 --- a/CopyPaste.Core/ClipboardService.cs +++ b/CopyPaste.Core/ClipboardService.cs @@ -237,8 +237,6 @@ private void ProcessImageFileBackground(ClipboardItem item, string filePath) } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Media thumbnail processing is best-effort - failures should not crash app")] private void ProcessMediaThumbnailBackground(ClipboardItem item, string filePath, ClipboardContentType type) { var meta = ParseExistingMetadata(item.Metadata); @@ -365,8 +363,6 @@ private static Dictionary ParseExistingMetadata(string? metadata return meta; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Media metadata extraction is best-effort - failures should not crash app")] private static void ExtractMediaMetadata(string filePath, ClipboardContentType type, Dictionary meta) { try diff --git a/CopyPaste.Core/ConfigLoader.cs b/CopyPaste.Core/ConfigLoader.cs index 5590894..77bf943 100644 --- a/CopyPaste.Core/ConfigLoader.cs +++ b/CopyPaste.Core/ConfigLoader.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace CopyPaste.Core; @@ -31,8 +30,6 @@ public static class ConfigLoader /// /// Loads configuration from MyM.json. Missing properties use MyMConfig defaults. /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Config loading should not crash app - any error falls back to defaults")] public static MyMConfig Load() { if (_cachedConfig != null) @@ -68,8 +65,6 @@ public static MyMConfig Load() /// /// Saves configuration to MyM.json. /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Config saving should not crash app - errors are logged")] public static bool Save(MyMConfig config) { try diff --git a/CopyPaste.Core/MyMConfig.cs b/CopyPaste.Core/MyMConfig.cs index 07fd5f8..0dd5c74 100644 --- a/CopyPaste.Core/MyMConfig.cs +++ b/CopyPaste.Core/MyMConfig.cs @@ -116,6 +116,12 @@ public sealed class MyMConfig /// public int RetentionDays { get; set; } = 30; + /// + /// UTC timestamp of the last successful backup. + /// Null if the user has never created a backup. + /// + public DateTime? LastBackupDateUtc { get; set; } + // ═══════════════════════════════════════════════════════════════ // Paste Behavior Configuration // ═══════════════════════════════════════════════════════════════ diff --git a/CopyPaste.Core/PackageHelper.cs b/CopyPaste.Core/PackageHelper.cs index 9564e00..b3fb193 100644 --- a/CopyPaste.Core/PackageHelper.cs +++ b/CopyPaste.Core/PackageHelper.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace CopyPaste.Core; /// @@ -19,8 +17,6 @@ public static class PackageHelper /// public static bool IsPackaged => _isPackaged.Value; - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Package.Current throws when unpackaged - catching is the expected detection pattern")] private static bool DetectIsPackaged() { try diff --git a/CopyPaste.Core/StartupHelper.cs b/CopyPaste.Core/StartupHelper.cs index ec2476b..177c39a 100644 --- a/CopyPaste.Core/StartupHelper.cs +++ b/CopyPaste.Core/StartupHelper.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace CopyPaste.Core; /// @@ -26,8 +24,6 @@ public static class StartupHelper /// All exceptions are handled internally - this method never throws. /// /// True to enable auto-start, false to disable. - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Startup registration is non-critical - failures should not prevent app from running")] public static async Task ApplyStartupSettingAsync(bool runOnStartup) { try @@ -47,8 +43,6 @@ public static async Task ApplyStartupSettingAsync(bool runOnStartup) } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "StartupTask API can fail for various reasons - all non-critical")] private static async Task ApplyPackagedStartupAsync(bool enable) { try diff --git a/CopyPaste.Core/UpdateChecker.cs b/CopyPaste.Core/UpdateChecker.cs index 15db1c9..83077a4 100644 --- a/CopyPaste.Core/UpdateChecker.cs +++ b/CopyPaste.Core/UpdateChecker.cs @@ -39,8 +39,6 @@ public sealed class UpdateChecker : IDisposable /// public event EventHandler? OnUpdateAvailable; - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Fire-and-forget task must never throw — unobserved exception would crash the process")] public UpdateChecker() { _httpClient = new HttpClient { Timeout = _httpTimeout }; @@ -88,8 +86,6 @@ public static string GetCurrentVersion() return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0"; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Update check is non-critical — unexpected failures must be logged, not crash the app")] internal async Task CheckForUpdateAsync() { if (_isDisposed) return; @@ -236,8 +232,6 @@ public static void DismissVersion(string version) /// /// Checks if the user has dismissed a specific version notification. /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Dismissed version check is non-critical - any failure should return false")] private static bool IsVersionDismissed(string version) { try diff --git a/CopyPaste.Core/WindowsThumbnailExtractor.cs b/CopyPaste.Core/WindowsThumbnailExtractor.cs index b40581a..38b362c 100644 --- a/CopyPaste.Core/WindowsThumbnailExtractor.cs +++ b/CopyPaste.Core/WindowsThumbnailExtractor.cs @@ -3,7 +3,6 @@ namespace CopyPaste.Core; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types")] public static partial class WindowsThumbnailExtractor { private static readonly Guid _iShellItemImageFactoryGuid = new("bcc18b79-ba16-442f-80c4-8a59c30c463b"); diff --git a/CopyPaste.Listener.Tests/WindowsClipboardListenerTests.cs b/CopyPaste.Listener.Tests/WindowsClipboardListenerTests.cs index 8c3ee24..e4d5ed8 100644 --- a/CopyPaste.Listener.Tests/WindowsClipboardListenerTests.cs +++ b/CopyPaste.Listener.Tests/WindowsClipboardListenerTests.cs @@ -420,7 +420,6 @@ public void DetectFileCollectionType_HiddenFile_ReturnsFile() #endregion - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Best-effort cleanup of temp test data should not fail tests")] public void Dispose() { try diff --git a/CopyPaste.UI/App.xaml.cs b/CopyPaste.UI/App.xaml.cs index 92683e0..91b980a 100644 --- a/CopyPaste.UI/App.xaml.cs +++ b/CopyPaste.UI/App.xaml.cs @@ -174,8 +174,6 @@ private void Dispose(bool disposing) _isDisposed = true; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Single instance check is non-critical - any failure should not prevent app from running")] private bool TryAcquireSingleInstance() { try @@ -199,8 +197,6 @@ private bool TryAcquireSingleInstance() } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Message display is non-critical - failures should not prevent app from running")] private static void ShowInstanceAlreadyRunningMessage() { try @@ -222,8 +218,6 @@ private static void ShowInstanceAlreadyRunningMessage() [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Launcher signaling is non-critical - failures should not prevent app from running")] private static void SignalLauncherReady() { try @@ -247,8 +241,6 @@ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExce e.Handled = true; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Update check is non-critical - failures should not prevent app from running")] private void InitializeUpdateChecker() { try @@ -266,8 +258,6 @@ private void InitializeUpdateChecker() } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Update notification is non-critical - failures should not prevent app from running")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1863:Use CompositeFormat", Justification = "Format string is dynamic (localized) and used at most once per session")] private void OnUpdateAvailable(object? sender, UpdateAvailableEventArgs e) diff --git a/CopyPaste.UI/CopyPaste.UI.csproj b/CopyPaste.UI/CopyPaste.UI.csproj index 458bd7d..faac68e 100644 --- a/CopyPaste.UI/CopyPaste.UI.csproj +++ b/CopyPaste.UI/CopyPaste.UI.csproj @@ -30,7 +30,7 @@ app.manifest None - true + true true false false diff --git a/CopyPaste.UI/Localization/Languages/en-US.json b/CopyPaste.UI/Localization/Languages/en-US.json index f3b0b20..b330649 100644 --- a/CopyPaste.UI/Localization/Languages/en-US.json +++ b/CopyPaste.UI/Localization/Languages/en-US.json @@ -33,6 +33,7 @@ }, "tabs": { "general": "General", + "backup": "Backup", "theme": "Theme" }, "themeSelector": { @@ -120,6 +121,28 @@ "retentionDaysDesc": "Days to keep history (0=unlimited)", "retentionTooltip": "Days to keep clipboard history. 0 = unlimited. Pinned items are never deleted." }, + "backup": { + "heading": "BACKUP & RESTORE", + "desc": "Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.", + "lastBackup": "Last backup: {date}", + "noBackupYet": "No backup created yet.", + "createLabel": "Create backup", + "createDesc": "Export all data to a ZIP file", + "createButton": "Backup", + "restoreLabel": "Restore backup", + "restoreDesc": "Import data from a backup file", + "restoreButton": "Restore", + "creating": "Creating backup...", + "createSuccess": "Backup created: {count} items, {images} images.", + "createError": "Failed to create backup. Check permissions.", + "restoring": "Restoring backup...", + "restoreSuccess": "Restore complete. Restarting...", + "restoreError": "Restore failed. Your previous data has been preserved.", + "invalidFile": "Invalid backup file. Select a valid CopyPaste backup (.zip).", + "restoreConfirmTitle": "Restore backup?", + "restoreConfirmMessage": "This will replace all current data with the backup ({count} items, {date}). This action cannot be undone.", + "restoreConfirmYes": "Restore" + }, "paste": { "heading": "PASTE", "intro": "Automatic paste speed. Normal/Safe recommended for most computers.", diff --git a/CopyPaste.UI/Localization/Languages/es-CL.json b/CopyPaste.UI/Localization/Languages/es-CL.json index e970aad..bf30e91 100644 --- a/CopyPaste.UI/Localization/Languages/es-CL.json +++ b/CopyPaste.UI/Localization/Languages/es-CL.json @@ -33,6 +33,7 @@ }, "tabs": { "general": "General", + "backup": "Respaldo", "theme": "Tema" }, "themeSelector": { @@ -120,6 +121,28 @@ "retentionDaysDesc": "Días para mantener historial (0=sin límite)", "retentionTooltip": "Días para mantener historial del portapapeles. 0 = sin límite. Los elementos anclados nunca se borran." }, + "backup": { + "heading": "RESPALDO Y RESTAURACIÓN", + "desc": "Crea un respaldo de tu historial del portapapeles, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.", + "lastBackup": "Último respaldo: {date}", + "noBackupYet": "Aún no se ha creado un respaldo.", + "createLabel": "Crear respaldo", + "createDesc": "Exportar todos los datos a un archivo ZIP", + "createButton": "Respaldar", + "restoreLabel": "Restaurar respaldo", + "restoreDesc": "Importar datos desde un archivo de respaldo", + "restoreButton": "Restaurar", + "creating": "Creando respaldo...", + "createSuccess": "Respaldo creado: {count} elementos, {images} imágenes.", + "createError": "Error al crear respaldo. Verifica los permisos.", + "restoring": "Restaurando respaldo...", + "restoreSuccess": "Restauración completa. Reiniciando...", + "restoreError": "Error al restaurar. Tus datos anteriores se han preservado.", + "invalidFile": "Archivo de respaldo inválido. Selecciona un respaldo CopyPaste válido (.zip).", + "restoreConfirmTitle": "¿Restaurar respaldo?", + "restoreConfirmMessage": "Esto reemplazará todos los datos actuales con el respaldo ({count} elementos, {date}). Esta acción no se puede deshacer.", + "restoreConfirmYes": "Restaurar" + }, "paste": { "heading": "PEGADO", "intro": "Velocidad de pegado automático. Normal/Seguro recomendados para mayoría de equipos.", diff --git a/CopyPaste.UI/Localization/LocalizationService.cs b/CopyPaste.UI/Localization/LocalizationService.cs index 1d599e1..349796d 100644 --- a/CopyPaste.UI/Localization/LocalizationService.cs +++ b/CopyPaste.UI/Localization/LocalizationService.cs @@ -85,8 +85,6 @@ private static string GetGlobalFallback() return config.TryGetValue("globalFallback", out var fallback) ? fallback : "en-US"; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Config loading is best-effort - failures are logged and defaults returned")] private static Dictionary LoadConfig() { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -124,8 +122,6 @@ private static FrozenDictionary BuildAndFreeze(string language) return merged.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Language loading is best-effort - failures are logged and English fallback used")] private static void LoadLanguageInto(string lang, Dictionary target) { try diff --git a/CopyPaste.UI/Shell/ConfigWindow.xaml b/CopyPaste.UI/Shell/ConfigWindow.xaml index cbcbb39..2bc91f7 100644 --- a/CopyPaste.UI/Shell/ConfigWindow.xaml +++ b/CopyPaste.UI/Shell/ConfigWindow.xaml @@ -35,6 +35,11 @@ + + + + + @@ -307,6 +312,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CopyPaste.UI/Shell/ConfigWindow.xaml.cs b/CopyPaste.UI/Shell/ConfigWindow.xaml.cs index ab518b1..5bb0d14 100644 --- a/CopyPaste.UI/Shell/ConfigWindow.xaml.cs +++ b/CopyPaste.UI/Shell/ConfigWindow.xaml.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using Windows.Storage.Pickers; namespace CopyPaste.UI.Shell; @@ -57,6 +59,7 @@ public ConfigWindow(ITheme theme, IReadOnlyList availableThemes) // Navigation items GeneralNavItem.Content = L.Get("config.tabs.general", "General"); + BackupNavItem.Content = L.Get("config.tabs.backup", "Backup"); ThemeNavItem.Content = $"{L.Get("config.tabs.theme", "Theme")}: {_theme.Name}"; UseCtrlCheck.Checked += OnHotkeyChanged; @@ -89,9 +92,15 @@ private void OnNavSelectionChanged(NavigationView sender, NavigationViewSelectio { if (args.SelectedItem is NavigationViewItem item) { - var isGeneral = item.Tag?.ToString() == "general"; - GeneralContent.Visibility = isGeneral ? Visibility.Visible : Visibility.Collapsed; - ThemeContent.Visibility = isGeneral ? Visibility.Collapsed : Visibility.Visible; + var tag = item.Tag?.ToString(); + GeneralContent.Visibility = tag == "general" ? Visibility.Visible : Visibility.Collapsed; + BackupContent.Visibility = tag == "backup" ? Visibility.Visible : Visibility.Collapsed; + ThemeContent.Visibility = tag == "theme" ? Visibility.Visible : Visibility.Collapsed; + + if (tag == "backup") + { + UpdateLastBackupDisplay(); + } } } @@ -203,6 +212,17 @@ private void ApplyLocalizedStrings() RetentionDesc.Text = L.Get("config.storage.retentionDaysDesc"); ToolTipService.SetToolTip(RetentionGrid, L.Get("config.storage.retentionTooltip")); + // Backup + BackupHeading.Text = L.Get("config.backup.heading"); + BackupDesc.Text = L.Get("config.backup.desc"); + BackupCreateLabel.Text = L.Get("config.backup.createLabel"); + BackupCreateDesc.Text = L.Get("config.backup.createDesc"); + BackupCreateButtonText.Text = L.Get("config.backup.createButton"); + BackupRestoreLabel.Text = L.Get("config.backup.restoreLabel"); + BackupRestoreDesc.Text = L.Get("config.backup.restoreDesc"); + BackupRestoreButtonText.Text = L.Get("config.backup.restoreButton"); + UpdateLastBackupDisplay(); + // Paste PasteHeading.Text = L.Get("config.paste.heading"); PasteIntro.Text = L.Get("config.paste.intro"); @@ -538,8 +558,6 @@ private void ResetButton_Click(object sender, RoutedEventArgs e) private void CancelButton_Click(object sender, RoutedEventArgs e) => Close(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Save operation should not crash - errors are logged")] private void SaveButton_Click(object sender, RoutedEventArgs e) { try @@ -612,8 +630,6 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Restart is best-effort")] private static void RestartApplication() { try @@ -648,4 +664,139 @@ private async void ShowErrorDialog(string message) }; await dialog.ShowAsync(); } + + private void UpdateLastBackupDisplay() + { + var config = ConfigLoader.Config; + if (config.LastBackupDateUtc.HasValue) + { + var local = config.LastBackupDateUtc.Value.ToLocalTime(); + BackupLastDate.Text = L.Get("config.backup.lastBackup") + .Replace("{date}", local.ToString("g", System.Globalization.CultureInfo.CurrentCulture), StringComparison.Ordinal); + BackupLastDate.Visibility = Visibility.Visible; + } + else + { + BackupLastDate.Text = L.Get("config.backup.noBackupYet"); + BackupLastDate.Visibility = Visibility.Visible; + } + } + + private async void BackupCreateButton_Click(object sender, RoutedEventArgs e) + { + try + { + var picker = new FileSavePicker(); + picker.SuggestedStartLocation = PickerLocationId.DocumentsLibrary; + picker.FileTypeChoices.Add("CopyPaste Backup", new List { ".zip" }); + picker.SuggestedFileName = $"CopyPaste_Backup_{DateTime.Now:yyyy-MM-dd}"; + + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + + var file = await picker.PickSaveFileAsync(); + if (file == null) return; + + BackupCreateButton.IsEnabled = false; + BackupStatusText.Text = L.Get("config.backup.creating"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray); + BackupStatusText.Visibility = Visibility.Visible; + + using var stream = await file.OpenStreamForWriteAsync(); + stream.SetLength(0); + var manifest = BackupService.CreateBackup(stream, UpdateChecker.GetCurrentVersion()); + + var config = ConfigLoader.Load(); + config.LastBackupDateUtc = manifest.CreatedAtUtc; + ConfigLoader.Save(config); + + BackupStatusText.Text = L.Get("config.backup.createSuccess") + .Replace("{count}", manifest.ItemCount.ToString(System.Globalization.CultureInfo.CurrentCulture), StringComparison.Ordinal) + .Replace("{images}", manifest.ImageCount.ToString(System.Globalization.CultureInfo.CurrentCulture), StringComparison.Ordinal); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Green); + UpdateLastBackupDisplay(); + } + catch (Exception ex) + { + AppLogger.Exception(ex, "Backup creation failed"); + BackupStatusText.Text = L.Get("config.backup.createError"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red); + BackupStatusText.Visibility = Visibility.Visible; + } + finally + { + BackupCreateButton.IsEnabled = true; + } + } + + private async void BackupRestoreButton_Click(object sender, RoutedEventArgs e) + { + try + { + var picker = new FileOpenPicker(); + picker.SuggestedStartLocation = PickerLocationId.DocumentsLibrary; + picker.FileTypeFilter.Add(".zip"); + + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + + var file = await picker.PickSingleFileAsync(); + if (file == null) return; + + using var validationStream = await file.OpenStreamForReadAsync(); + var info = BackupService.ValidateBackup(validationStream); + if (info == null) + { + ShowErrorDialog(L.Get("config.backup.invalidFile")); + return; + } + + var confirmDialog = new ContentDialog + { + Title = L.Get("config.backup.restoreConfirmTitle"), + Content = L.Get("config.backup.restoreConfirmMessage") + .Replace("{count}", info.ItemCount.ToString(System.Globalization.CultureInfo.CurrentCulture), StringComparison.Ordinal) + .Replace("{date}", info.CreatedAtUtc.ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture), StringComparison.Ordinal), + PrimaryButtonText = L.Get("config.backup.restoreConfirmYes"), + CloseButtonText = L.Get("config.buttons.cancel"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot + }; + + var result = await confirmDialog.ShowAsync(); + if (result != ContentDialogResult.Primary) return; + + BackupRestoreButton.IsEnabled = false; + BackupStatusText.Text = L.Get("config.backup.restoring"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray); + BackupStatusText.Visibility = Visibility.Visible; + + using var restoreStream = await file.OpenStreamForReadAsync(); + var manifest = BackupService.RestoreBackup(restoreStream); + + if (manifest != null) + { + BackupStatusText.Text = L.Get("config.backup.restoreSuccess"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Green); + ConfigLoader.ClearCache(); + RestartApplication(); + } + else + { + BackupStatusText.Text = L.Get("config.backup.restoreError"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red); + } + } + catch (Exception ex) + { + AppLogger.Exception(ex, "Backup restore failed"); + BackupStatusText.Text = L.Get("config.backup.restoreError"); + BackupStatusText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Red); + BackupStatusText.Visibility = Visibility.Visible; + } + finally + { + BackupRestoreButton.IsEnabled = true; + } + } } diff --git a/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs b/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs index 3ce2e9f..6bfaac7 100644 --- a/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs +++ b/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs @@ -222,8 +222,6 @@ private void OnDeleteItem(ClipboardItemViewModel itemVM) UpdateIsEmpty(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Paste errors must not crash UI")] private async void OnPasteItem(ClipboardItemViewModel itemVM, bool plain) { try diff --git a/CopyPaste.UI/Themes/Compact/CompactSettings.cs b/CopyPaste.UI/Themes/Compact/CompactSettings.cs index 3889953..ab39f24 100644 --- a/CopyPaste.UI/Themes/Compact/CompactSettings.cs +++ b/CopyPaste.UI/Themes/Compact/CompactSettings.cs @@ -23,8 +23,6 @@ public sealed class CompactSettings private static string FilePath => Path.Combine(StorageConfig.ConfigPath, _fileName); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Settings loading should not crash app - failures are logged and defaults returned")] public static CompactSettings Load() { try @@ -52,8 +50,6 @@ public static CompactSettings Load() return new CompactSettings(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Settings saving should not crash app - failures are logged")] public static bool Save(CompactSettings settings) { try diff --git a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs index ee55635..8d652f0 100644 --- a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs +++ b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs @@ -277,8 +277,6 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) => private void HelpButton_Click(object sender, RoutedEventArgs e) => _context.OpenHelp(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:ConfigureAwait", - Justification = "UI event handler must remain on UI thread")] private async void ReportBugButton_Click(object sender, RoutedEventArgs e) => await ViewModel.OpenRepoCommand.ExecuteAsync(null); @@ -544,8 +542,6 @@ private void LoadClipboardImage(ListViewBase sender, ContainerContentChangingEve } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Image loading must not crash UI - failures are silently handled")] private void LoadImageSource(Image image, string? imagePath) { if (string.IsNullOrEmpty(imagePath)) return; diff --git a/CopyPaste.UI/Themes/Default/DefaultThemeSettings.cs b/CopyPaste.UI/Themes/Default/DefaultThemeSettings.cs index 8b7560d..9746e06 100644 --- a/CopyPaste.UI/Themes/Default/DefaultThemeSettings.cs +++ b/CopyPaste.UI/Themes/Default/DefaultThemeSettings.cs @@ -24,8 +24,6 @@ public sealed class DefaultThemeSettings private static string FilePath => Path.Combine(StorageConfig.ConfigPath, _fileName); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Settings loading should not crash app - failures are logged and defaults returned")] public static DefaultThemeSettings Load() { try @@ -53,8 +51,6 @@ public static DefaultThemeSettings Load() return new DefaultThemeSettings(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Settings saving should not crash app - failures are logged")] public static bool Save(DefaultThemeSettings settings) { try diff --git a/CopyPaste.UI/Themes/ThemeRegistry.cs b/CopyPaste.UI/Themes/ThemeRegistry.cs index 1939f7c..c83b5bd 100644 --- a/CopyPaste.UI/Themes/ThemeRegistry.cs +++ b/CopyPaste.UI/Themes/ThemeRegistry.cs @@ -23,8 +23,6 @@ internal sealed class ThemeRegistry _factories[probe.Id] = static () => new T(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "Community theme loading must not crash app - failures are logged")] public void DiscoverCommunityThemes() { var themesDir = StorageConfig.ThemesPath; diff --git a/CopyPaste.slnx b/CopyPaste.slnx index cd243c8..b3871a2 100644 --- a/CopyPaste.slnx +++ b/CopyPaste.slnx @@ -1,6 +1,5 @@ -