Skip to content

Commit 36dee89

Browse files
committed
feat(settings): add ZIP backup restore and simplify data management
1 parent 1f3e95a commit 36dee89

8 files changed

Lines changed: 424 additions & 80 deletions

File tree

src/inputor.WinUI/App.cs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using Microsoft.UI.Xaml.Controls;
88
using Microsoft.UI.Xaml.Markup;
99
using Microsoft.UI.Xaml.XamlTypeInfo;
10+
using DialogResult = System.Windows.Forms.DialogResult;
11+
using OpenFileDialog = System.Windows.Forms.OpenFileDialog;
12+
using SaveFileDialog = System.Windows.Forms.SaveFileDialog;
1013
using Windows.UI.ViewManagement;
1114

1215
namespace Inputor.WinUI;
@@ -44,7 +47,11 @@ public App()
4447
StatsStore = new StatsStore(_dataDirectory);
4548
StatsStore.SetStatus(StatusText.StatisticsSourceFallbackToDefault(), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
4649
}
50+
51+
MigrateLegacyStatisticsSourceToDefault();
52+
4753
Exporter = new CsvExportService(AppVariant.GetExportDirectory());
54+
BackupArchives = new BackupArchiveService();
4855
AutoStartService = new AutoStartService(AppVariant.AutoStartEntryName);
4956
AutoStartService.Apply(Settings.StartWithWindows);
5057
MonitoringService = new MonitoringService(StatsStore, Settings);
@@ -66,6 +73,8 @@ public App()
6673

6774
public CsvExportService Exporter { get; }
6875

76+
public BackupArchiveService BackupArchives { get; }
77+
6978
public AutoStartService AutoStartService { get; }
7079

7180
public MonitoringService MonitoringService { get; }
@@ -229,6 +238,106 @@ public void ClearIconCache()
229238
}
230239
}
231240

241+
public void OpenDataDirectory()
242+
{
243+
try
244+
{
245+
var path = AppVariant.GetDataDirectory();
246+
Directory.CreateDirectory(path);
247+
Process.Start(new ProcessStartInfo
248+
{
249+
FileName = path,
250+
UseShellExecute = true
251+
});
252+
StatsStore.SetStatus(StatusText.DataDirectoryOpened(path), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
253+
}
254+
catch (Exception exception)
255+
{
256+
StartupDiagnostics.Log($"OpenDataDirectory failed: {exception}");
257+
StatsStore.SetStatus(StatusText.DataDirectoryOpenFailed(exception.Message), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
258+
}
259+
}
260+
261+
public void ExportBackupArchive()
262+
{
263+
try
264+
{
265+
Directory.CreateDirectory(AppVariant.GetBackupDirectory());
266+
using var dialog = new SaveFileDialog
267+
{
268+
AddExtension = true,
269+
DefaultExt = "zip",
270+
Filter = "ZIP archives (*.zip)|*.zip",
271+
InitialDirectory = AppVariant.GetBackupDirectory(),
272+
FileName = $"inputor-backup-{DateTime.Now:yyyyMMdd-HHmmss}.zip",
273+
OverwritePrompt = true,
274+
RestoreDirectory = true,
275+
Title = AppStrings.Get("Settings.Dialog.ExportBackupArchiveTitle")
276+
};
277+
278+
if (dialog.ShowDialog() != DialogResult.OK || string.IsNullOrWhiteSpace(dialog.FileName))
279+
{
280+
return;
281+
}
282+
283+
var archivePath = BackupArchives.Export(dialog.FileName, Settings, StatsStore.CurrentSourcePath);
284+
StatsStore.SetStatus(StatusText.BackupArchiveExported(archivePath), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
285+
}
286+
catch (Exception exception)
287+
{
288+
StartupDiagnostics.Log($"ExportBackupArchive failed: {exception}");
289+
StatsStore.SetStatus(StatusText.BackupArchiveExportFailed(exception.Message), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
290+
}
291+
}
292+
293+
public void RestoreBackupArchive()
294+
{
295+
try
296+
{
297+
Directory.CreateDirectory(AppVariant.GetBackupDirectory());
298+
using var dialog = new OpenFileDialog
299+
{
300+
CheckFileExists = true,
301+
Filter = "ZIP archives (*.zip)|*.zip",
302+
InitialDirectory = AppVariant.GetBackupDirectory(),
303+
RestoreDirectory = true,
304+
Title = AppStrings.Get("Settings.Dialog.RestoreBackupArchiveTitle")
305+
};
306+
307+
if (dialog.ShowDialog() != DialogResult.OK || string.IsNullOrWhiteSpace(dialog.FileName))
308+
{
309+
return;
310+
}
311+
312+
var previousSettings = CloneSettings(Settings);
313+
var previousStatsSourcePath = StatsStore.CurrentSourcePath;
314+
var previousStatsJson = File.Exists(previousStatsSourcePath)
315+
? File.ReadAllText(previousStatsSourcePath)
316+
: string.Empty;
317+
var payload = BackupArchives.Load(dialog.FileName);
318+
StatsStore.ValidateSourceJson(payload.StatsJson);
319+
320+
try
321+
{
322+
ApplySettingsSnapshot(payload.Settings, forceDefaultStatisticsSource: true);
323+
StatsStore.RestoreSource(string.Empty, payload.StatsJson);
324+
MonitoringService.ResetTrackingState();
325+
StatsStore.SetDebugCaptureEnabled(Settings.DebugCaptureEnabled);
326+
StatsStore.SetStatus(StatusText.BackupArchiveRestored(dialog.FileName), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
327+
}
328+
catch
329+
{
330+
RollbackRestoredBackup(previousSettings, previousStatsSourcePath, previousStatsJson);
331+
throw;
332+
}
333+
}
334+
catch (Exception exception)
335+
{
336+
StartupDiagnostics.Log($"RestoreBackupArchive failed: {exception}");
337+
StatsStore.SetStatus(StatusText.BackupArchiveRestoreFailed(exception.Message), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
338+
}
339+
}
340+
232341
public void SwitchStatisticsSource(string? sourcePath)
233342
{
234343
var previousSourcePath = StatsStore.CurrentSourcePath;
@@ -346,6 +455,93 @@ private void ApplyThemeMode()
346455
MainWindow?.ApplyThemeMode(AppStrings.ResolveThemeMode(Settings.ThemeMode));
347456
}
348457

458+
private void MigrateLegacyStatisticsSourceToDefault()
459+
{
460+
if (string.IsNullOrWhiteSpace(Settings.StatisticsSourcePath))
461+
{
462+
return;
463+
}
464+
465+
var currentSourcePath = StatsStore.CurrentSourcePath;
466+
var defaultSourcePath = StatsStore.DefaultSourcePath;
467+
try
468+
{
469+
if (!string.Equals(Path.GetFullPath(currentSourcePath), Path.GetFullPath(defaultSourcePath), StringComparison.OrdinalIgnoreCase))
470+
{
471+
var legacyStatsJson = File.Exists(currentSourcePath)
472+
? File.ReadAllText(currentSourcePath)
473+
: string.Empty;
474+
StatsStore.ValidateSourceJson(legacyStatsJson);
475+
StatsStore.RestoreSource(string.Empty, legacyStatsJson);
476+
}
477+
478+
Settings.StatisticsSourcePath = string.Empty;
479+
SettingsService.Save(Settings);
480+
StartupDiagnostics.Log($"Migrated legacy statistics source to default local storage: {currentSourcePath} -> {defaultSourcePath}");
481+
StatsStore.SetStatus(StatusText.LegacyStatisticsSourceMigratedToDefault(defaultSourcePath), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
482+
}
483+
catch (Exception exception)
484+
{
485+
StartupDiagnostics.Log($"Legacy statistics source migration failed: {exception}");
486+
StatsStore.SetStatus(StatusText.LegacyStatisticsSourceMigrationFailed(exception.Message), StatsStore.CurrentAppName, StatsStore.IsCurrentTargetSupported, StatsStore.CurrentProcessName);
487+
}
488+
}
489+
490+
private void ApplySettingsSnapshot(AppSettings settingsSnapshot, bool forceDefaultStatisticsSource)
491+
{
492+
Settings.StartWithWindows = settingsSnapshot.StartWithWindows;
493+
Settings.PrivacyMode = settingsSnapshot.PrivacyMode;
494+
Settings.DebugCaptureEnabled = settingsSnapshot.DebugCaptureEnabled;
495+
Settings.ThemeMode = settingsSnapshot.ThemeMode;
496+
Settings.StatisticsSourcePath = forceDefaultStatisticsSource ? string.Empty : settingsSnapshot.StatisticsSourcePath;
497+
Settings.Language = settingsSnapshot.Language;
498+
Settings.ExcludedApps = settingsSnapshot.ExcludedApps;
499+
Settings.AppTagMappings = settingsSnapshot.GetNormalizedTagMappings()
500+
.Select(mapping => new AppTagMapping
501+
{
502+
AppName = mapping.AppName,
503+
Tags = mapping.Tags.ToList()
504+
})
505+
.ToList();
506+
SaveSettings();
507+
}
508+
509+
private void RollbackRestoredBackup(AppSettings previousSettings, string previousStatsSourcePath, string previousStatsJson)
510+
{
511+
try
512+
{
513+
ApplySettingsSnapshot(previousSettings, forceDefaultStatisticsSource: false);
514+
StatsStore.RestoreSource(previousStatsSourcePath, previousStatsJson);
515+
MonitoringService.ResetTrackingState();
516+
StatsStore.SetDebugCaptureEnabled(Settings.DebugCaptureEnabled);
517+
}
518+
catch (Exception rollbackException)
519+
{
520+
StartupDiagnostics.Log($"RestoreBackupArchive rollback failed: {rollbackException}");
521+
}
522+
}
523+
524+
private static AppSettings CloneSettings(AppSettings source)
525+
{
526+
return new AppSettings
527+
{
528+
StartWithWindows = source.StartWithWindows,
529+
PrivacyMode = source.PrivacyMode,
530+
DebugCaptureEnabled = source.DebugCaptureEnabled,
531+
ThemeMode = source.ThemeMode,
532+
StatisticsSourcePath = source.StatisticsSourcePath,
533+
Language = source.Language,
534+
ExcludedApps = source.ExcludedApps,
535+
AppTagMappings = source.GetNormalizedTagMappings()
536+
.Select(mapping => new AppTagMapping
537+
{
538+
AppName = mapping.AppName,
539+
Tags = mapping.Tags.ToList()
540+
})
541+
.ToList()
542+
};
543+
}
544+
349545
private void UiSettings_ColorValuesChanged(UISettings sender, object args)
350546
{
351547
if (AppStrings.ResolveThemeMode(Settings.ThemeMode) is not AppThemeMode.FollowSystem)

src/inputor.WinUI/AppVariant.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ public static string GetDataDirectory()
2525
DataDirectoryName);
2626
}
2727

28+
public static string GetSettingsPath()
29+
{
30+
return Path.Combine(
31+
GetDataDirectory(),
32+
"settings.json");
33+
}
34+
35+
public static string GetDefaultStatsPath()
36+
{
37+
return Path.Combine(
38+
GetDataDirectory(),
39+
"stats.json");
40+
}
41+
2842
public static string GetExportDirectory()
2943
{
3044
return Path.Combine(
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.IO.Compression;
2+
using System.Text.Json;
3+
using Inputor.App.Models;
4+
using Inputor.WinUI;
5+
6+
namespace Inputor.App.Services;
7+
8+
public sealed class BackupArchiveService
9+
{
10+
private const string ManifestEntryName = "manifest.json";
11+
private const string SettingsEntryName = "settings.json";
12+
private const string StatsEntryName = "stats.json";
13+
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
14+
15+
public string Export(string archivePath, AppSettings settings, string statsSourcePath)
16+
{
17+
var normalizedArchivePath = Path.GetFullPath(archivePath);
18+
var archiveDirectory = Path.GetDirectoryName(normalizedArchivePath);
19+
if (string.IsNullOrWhiteSpace(archiveDirectory))
20+
{
21+
throw new InvalidOperationException("Backup archive path must have a parent directory.");
22+
}
23+
24+
Directory.CreateDirectory(archiveDirectory);
25+
26+
using var stream = new FileStream(normalizedArchivePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
27+
using var archive = new ZipArchive(stream, ZipArchiveMode.Create);
28+
29+
WriteEntry(archive, ManifestEntryName, JsonSerializer.Serialize(new BackupManifest
30+
{
31+
FormatVersion = 1,
32+
ExportedAt = DateTimeOffset.Now,
33+
ChannelName = AppVariant.ChannelName,
34+
OriginalStatisticsSourcePath = statsSourcePath
35+
}, JsonOptions));
36+
WriteEntry(archive, SettingsEntryName, JsonSerializer.Serialize(settings, JsonOptions));
37+
WriteEntry(archive, StatsEntryName, File.Exists(statsSourcePath) ? File.ReadAllText(statsSourcePath) : string.Empty);
38+
39+
return normalizedArchivePath;
40+
}
41+
42+
public BackupPayload Load(string archivePath)
43+
{
44+
var normalizedArchivePath = Path.GetFullPath(archivePath);
45+
using var stream = new FileStream(normalizedArchivePath, FileMode.Open, FileAccess.Read, FileShare.Read);
46+
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
47+
48+
var settingsJson = ReadRequiredEntry(archive, SettingsEntryName);
49+
var statsJson = ReadRequiredEntry(archive, StatsEntryName);
50+
var manifestJson = ReadOptionalEntry(archive, ManifestEntryName);
51+
52+
var settings = JsonSerializer.Deserialize<AppSettings>(settingsJson, JsonOptions)
53+
?? throw new InvalidDataException("Backup archive settings payload is invalid.");
54+
var manifest = string.IsNullOrWhiteSpace(manifestJson)
55+
? null
56+
: JsonSerializer.Deserialize<BackupManifest>(manifestJson, JsonOptions);
57+
58+
return new BackupPayload
59+
{
60+
Settings = settings,
61+
StatsJson = statsJson,
62+
Manifest = manifest
63+
};
64+
}
65+
66+
private static string ReadRequiredEntry(ZipArchive archive, string entryName)
67+
{
68+
return ReadOptionalEntry(archive, entryName)
69+
?? throw new InvalidDataException($"Backup archive is missing required entry '{entryName}'.");
70+
}
71+
72+
private static string? ReadOptionalEntry(ZipArchive archive, string entryName)
73+
{
74+
var entry = archive.GetEntry(entryName);
75+
if (entry is null)
76+
{
77+
return null;
78+
}
79+
80+
using var reader = new StreamReader(entry.Open());
81+
return reader.ReadToEnd();
82+
}
83+
84+
private static void WriteEntry(ZipArchive archive, string entryName, string content)
85+
{
86+
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
87+
using var writer = new StreamWriter(entry.Open());
88+
writer.Write(content);
89+
}
90+
91+
public sealed class BackupPayload
92+
{
93+
public required AppSettings Settings { get; init; }
94+
public required string StatsJson { get; init; }
95+
public BackupManifest? Manifest { get; init; }
96+
}
97+
98+
public sealed class BackupManifest
99+
{
100+
public int FormatVersion { get; init; }
101+
public DateTimeOffset ExportedAt { get; init; }
102+
public string ChannelName { get; init; } = string.Empty;
103+
public string OriginalStatisticsSourcePath { get; init; } = string.Empty;
104+
}
105+
}

0 commit comments

Comments
 (0)