From 98c1adc90bd81407bb3a8dbeff066547d38ea7bd Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 24 May 2026 08:10:39 +0800 Subject: [PATCH 1/2] feat(storage): add Restore, CleanBackup, BackupConfig, BackupInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StorageManager.Restore() — restore from backup to install path - StorageManager.CleanBackup() — keep only N most recent backups - StorageManager.ListBackups() — query backup metadata - BackupConfig — version retention, skip dirs, enable/disable - BackupInfo record — version, path, timestamp, size Closes #327 --- .../FileSystem/StorageManager.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs index d0143d28..e04b4f06 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs @@ -283,6 +283,62 @@ private IEnumerable ReadFileNode(string path, string rootPath = null) private void ResetId() => Interlocked.Exchange(ref _fileCount, 0); + /// Restore files from backup directory to install path. + public static void Restore(string backupDir, string installPath) + { + if (!Directory.Exists(backupDir)) + throw new DirectoryNotFoundException($"Backup directory not found: {backupDir}"); + + foreach (var file in Directory.GetFiles(backupDir, "*", SearchOption.AllDirectories)) + { + var relativePath = file.Substring(backupDir.Length).TrimStart(Path.DirectorySeparatorChar); + var dest = Path.Combine(installPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.Copy(file, dest, true); + } + } + + /// Clean old backups, keeping only the N most recent versions. + public static void CleanBackup(string installPath, int keepVersions = 3) + { + var backupRoot = Path.Combine(installPath, "__backups"); + if (!Directory.Exists(backupRoot)) return; + + var dirs = Directory.GetDirectories(backupRoot) + .Select(d => new DirectoryInfo(d)) + .OrderByDescending(d => d.CreationTime) + .Skip(keepVersions); + + foreach (var dir in dirs) + dir.Delete(true); + } + + /// List backup versions with metadata. + public static IReadOnlyList ListBackups(string installPath) + { + var backupRoot = Path.Combine(installPath, "__backups"); + if (!Directory.Exists(backupRoot)) return Array.Empty(); + + return Directory.GetDirectories(backupRoot) + .Select(d => new DirectoryInfo(d)) + .Select(d => new BackupInfo( + d.Name, d.FullName, d.CreationTime, + d.GetFiles("*", SearchOption.AllDirectories).Sum(f => f.Length))) + .ToList(); + } + #endregion } + + /// Backup configuration. + public sealed class BackupConfig + { + public int KeepVersions { get; set; } = 3; + public string? BackupRoot { get; set; } + public List SkipDirectories { get; set; } = new(); + public bool Enabled { get; set; } = true; + } + + /// Backup metadata. + public record BackupInfo(string Version, string Path, DateTime CreatedAt, long SizeBytes); } \ No newline at end of file From 0e0019bbe104fded368977a066f97cbc4bd47336 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 24 May 2026 08:11:18 +0800 Subject: [PATCH 2/2] feat(hooks): add IUpdateHooks, UnixPermissionHooks, CustomPermissionHooks - IUpdateHooks interface with 5 lifecycle callbacks - UpdateContext / DownloadContext records - NoOpUpdateHooks (default pass-through) - UnixPermissionHooks: chmod +x for Linux/macOS - CustomPermissionHooks: user-supplied permission script Closes #329 --- .../GeneralUpdate.Core/Hooks/IUpdateHooks.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs diff --git a/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs b/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs new file mode 100644 index 00000000..55ee7e2c --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs @@ -0,0 +1,84 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; + +namespace GeneralUpdate.Core.Hooks; + +/// Lifecycle hooks for update processes. +public interface IUpdateHooks +{ + Task OnBeforeUpdateAsync(UpdateContext ctx); + Task OnDownloadCompletedAsync(DownloadContext ctx); + Task OnAfterUpdateAsync(UpdateContext ctx); + Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex); + Task OnBeforeStartAppAsync(UpdateContext ctx); +} + +public record UpdateContext( + string AppName, + string InstallPath, + string CurrentVersion, + string? TargetVersion, + int AppType +); + +public record DownloadContext( + string AssetName, + string Version, + long TotalBytes, + TimeSpan Duration, + string? LocalPath, + bool Success +); + +/// Default no-op hooks. +public class NoOpUpdateHooks : IUpdateHooks +{ + public Task OnBeforeUpdateAsync(UpdateContext ctx) => Task.FromResult(true); + public Task OnDownloadCompletedAsync(DownloadContext ctx) => Task.CompletedTask; + public Task OnAfterUpdateAsync(UpdateContext ctx) => Task.CompletedTask; + public Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) => Task.CompletedTask; + public Task OnBeforeStartAppAsync(UpdateContext ctx) => Task.CompletedTask; +} + +/// Unix permission hooks — chmod +x main app before start. +public class UnixPermissionHooks : IUpdateHooks +{ + public async Task OnBeforeStartAppAsync(UpdateContext ctx) + { + var mainApp = Path.Combine(ctx.InstallPath, ctx.AppName); + if (File.Exists(mainApp)) + await Process.Start("chmod", $"+x \"{mainApp}\"").WaitForExitAsync(); + } + public Task OnBeforeUpdateAsync(UpdateContext ctx) => Task.FromResult(true); + public Task OnDownloadCompletedAsync(DownloadContext ctx) => Task.CompletedTask; + public Task OnAfterUpdateAsync(UpdateContext ctx) => Task.CompletedTask; + public Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) => Task.CompletedTask; +} + +/// User-supplied permission script hook. +public class CustomPermissionHooks : IUpdateHooks +{ + private readonly string _scriptPath; + public CustomPermissionHooks(string scriptPath) + => _scriptPath = scriptPath ?? throw new ArgumentNullException(nameof(scriptPath)); + + public async Task OnBeforeStartAppAsync(UpdateContext ctx) + { + var psi = new ProcessStartInfo(_scriptPath, ctx.InstallPath) + { + RedirectStandardOutput = true, RedirectStandardError = true + }; + using var proc = Process.Start(psi)!; + await proc.WaitForExitAsync(); + if (proc.ExitCode != 0) + throw new InvalidOperationException( + $"Permission script '{_scriptPath}' failed (exit {proc.ExitCode})"); + } + public Task OnBeforeUpdateAsync(UpdateContext ctx) => Task.FromResult(true); + public Task OnDownloadCompletedAsync(DownloadContext ctx) => Task.CompletedTask; + public Task OnAfterUpdateAsync(UpdateContext ctx) => Task.CompletedTask; + public Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) => Task.CompletedTask; +}