From c8ef70061274bcc5a54529eb4f7455cf7e509351 Mon Sep 17 00:00:00 2001 From: x3 Date: Mon, 7 Sep 2026 17:20:41 +0000 Subject: [PATCH] feat: x3-backup v1.0.0 initial release GTK3 backup/snapshot tool using rsync backend. Features: - rsync-based snapshots with hardlink delta for space efficiency - Snapshot sidebar with date-sorted list - Full file browser with permissions display (name, size, perms, mtime) - Right-click context menu: Restore file/dir with sudo prompt - System tray icon: backup status, context menu (show/backup/settings/quit) - Menu system: File, Backup, System, Help - Settings dialog: backup destination, source path, retention, exclusions - Cronjob management: install/remove scheduled backups - Live Log tab: real-time rsync output during backup - History Log tab: full log file viewer with color-coded entries - System Backup mode: full root filesystem backup - System Restore mode: restore entire system from snapshot - CLI mode: --backup, --system-backup for cron/headless use - /mnt always excluded from backups - Preserves permissions, ACLs, xattrs (-aAX flags) - Log file: ~/.config/x3-backup/x3-backup.log - Config: ~/.config/x3-backup/settings.json - uninstall.sh for clean removal --- .gitignore | 24 + Makefile.am | 15 + configure.ac | 19 + desktop/x3-backup.desktop | 9 + icons/x3-backup.png | Bin 0 -> 6977 bytes src/Makefile.am | 22 + src/config.cpp | 135 ++++++ src/config.h | 44 ++ src/main.cpp | 143 ++++++ src/main_window.cpp | 994 ++++++++++++++++++++++++++++++++++++++ src/main_window.h | 106 ++++ src/rsync_manager.cpp | 265 ++++++++++ src/rsync_manager.h | 57 +++ src/settings_dialog.cpp | 193 ++++++++ src/settings_dialog.h | 27 ++ src/snapshot_manager.cpp | 259 ++++++++++ src/snapshot_manager.h | 60 +++ src/system_tray.cpp | 100 ++++ src/system_tray.h | 27 ++ uninstall.sh | 36 ++ 20 files changed, 2535 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile.am create mode 100644 configure.ac create mode 100644 desktop/x3-backup.desktop create mode 100644 icons/x3-backup.png create mode 100644 src/Makefile.am create mode 100644 src/config.cpp create mode 100644 src/config.h create mode 100644 src/main.cpp create mode 100644 src/main_window.cpp create mode 100644 src/main_window.h create mode 100644 src/rsync_manager.cpp create mode 100644 src/rsync_manager.h create mode 100644 src/settings_dialog.cpp create mode 100644 src/settings_dialog.h create mode 100644 src/snapshot_manager.cpp create mode 100644 src/snapshot_manager.h create mode 100644 src/system_tray.cpp create mode 100644 src/system_tray.h create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..554dad7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Autotools generated +/configure +/Makefile +/Makefile.in +/src/Makefile +/src/Makefile.in +/build-aux/ +autom4te.cache/ +config.log +config.status +*.o +*.d +x3-backup +.deps/ + +# Editor +*~ +*.swp +.vscode/ +.idea/ + +# Build +build/ +obj/ diff --git a/Makefile.am b/Makefile.am new file mode 100644 index 0000000..d9c8ae6 --- /dev/null +++ b/Makefile.am @@ -0,0 +1,15 @@ +SUBDIRS = src + +EXTRA_DIST = desktop/x3-backup.desktop icons + +install-data-local: + $(MKDIR_P) $(DESTDIR)$(datadir)/applications + $(INSTALL_DATA) desktop/x3-backup.desktop $(DESTDIR)$(datadir)/applications/x3-backup.desktop + $(MKDIR_P) $(DESTDIR)$(datadir)/icons/hicolor/256x256/apps + $(INSTALL_DATA) icons/x3-backup.png $(DESTDIR)$(datadir)/icons/hicolor/256x256/apps/x3-backup.png + +uninstall-local: + rm -f $(DESTDIR)$(datadir)/applications/x3-backup.desktop + rm -f $(DESTDIR)$(datadir)/icons/hicolor/256x256/apps/x3-backup.png + +CLEANFILES = *~ diff --git a/configure.ac b/configure.ac new file mode 100644 index 0000000..caeb8f9 --- /dev/null +++ b/configure.ac @@ -0,0 +1,19 @@ +AC_INIT([x3-backup], [1.0.0], [x3@sysdaemon.xyz]) +AC_CONFIG_SRCDIR([src/main.cpp]) +AC_CONFIG_AUX_DIR([build-aux]) +AM_INIT_AUTOMAKE([foreign -Wall -Werror subdir-objects]) + +AC_PROG_CXX +AX_CXX_COMPILE_STDCXX([17], [noext], [mandatory]) + +PKG_CHECK_MODULES([GTK], [gtk+-3.0 >= 3.24]) +PKG_CHECK_MODULES([JSON_GLIB], [json-glib-1.0]) + +AC_CHECK_PROG([RSYNC], [rsync], [yes], [no]) +AS_IF([test "x$RSYNC" = "xno"], [AC_MSG_ERROR([rsync is required])]) + +AC_CHECK_PROG([CRONTAB], [crontab], [yes], [no]) +AC_CHECK_PROG([PKEXEC], [pkexec], [yes], [no]) + +AC_CONFIG_FILES([Makefile src/Makefile]) +AC_OUTPUT diff --git a/desktop/x3-backup.desktop b/desktop/x3-backup.desktop new file mode 100644 index 0000000..4a7e392 --- /dev/null +++ b/desktop/x3-backup.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=x3-Backup +Comment=GTK3 Backup/Snapshot Tool using rsync +Exec=x3-backup +Icon=x3-backup +Terminal=false +Categories=System;Utility; +Keywords=backup;snapshot;rsync;restore; diff --git a/icons/x3-backup.png b/icons/x3-backup.png new file mode 100644 index 0000000000000000000000000000000000000000..857287332ecc4c26791826895c41fe71b16a3b16 GIT binary patch literal 6977 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 ZqjJ4+d*9ei@OurQq^PD)ENAlOzX0#c5{Li* literal 0 HcmV?d00001 diff --git a/src/Makefile.am b/src/Makefile.am new file mode 100644 index 0000000..c31c647 --- /dev/null +++ b/src/Makefile.am @@ -0,0 +1,22 @@ +bin_PROGRAMS = x3-backup + +x3_backup_SOURCES = \ + main.cpp \ + config.cpp \ + rsync_manager.cpp \ + snapshot_manager.cpp \ + main_window.cpp \ + settings_dialog.cpp \ + system_tray.cpp + +x3_backup_CXXFLAGS = \ + -std=c++17 \ + $(GTK_CFLAGS) \ + $(JSON_GLIB_CFLAGS) \ + -DLOCALEDIR=\"$(localedir)\" \ + -DBINDIR=\"$(bindir)\" + +x3_backup_LDADD = \ + $(GTK_LIBS) \ + $(JSON_GLIB_LIBS) \ + -lstdc++fs diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..a1f2f5b --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,135 @@ +#include "config.h" +#include +#include +#include +#include + +Config::Config() { + const char* home = getenv("HOME"); + if (!home) home = "."; + config_dir_ = std::string(home) + "/.config/x3-backup"; + ensure_dirs(); +} + +void Config::ensure_dirs() { + std::string cmd = "mkdir -p \"" + config_dir_ + "\""; + system(cmd.c_str()); +} + +void Config::load() { + std::string path = config_file(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + char* buf = new char[len + 1]; + fread(buf, 1, len, f); + buf[len] = 0; + fclose(f); + + JsonParser* parser = json_parser_new(); + GError* err = nullptr; + if (!json_parser_load_from_data(parser, buf, len, &err)) { + g_warning("Config parse error: %s", err->message); + g_error_free(err); + g_object_unref(parser); + delete[] buf; + return; + } + delete[] buf; + + JsonObject* root = json_node_get_object(json_parser_get_root(parser)); + + if (json_object_has_member(root, "backup_dest")) + settings_.backup_dest = json_object_get_string_member(root, "backup_dest"); + if (json_object_has_member(root, "source_path")) + settings_.source_path = json_object_get_string_member(root, "source_path"); + if (json_object_has_member(root, "preserve_permissions")) + settings_.preserve_permissions = json_object_get_boolean_member(root, "preserve_permissions"); + if (json_object_has_member(root, "preserve_acls")) + settings_.preserve_acls = json_object_get_boolean_member(root, "preserve_acls"); + if (json_object_has_member(root, "preserve_xattrs")) + settings_.preserve_xattrs = json_object_get_boolean_member(root, "preserve_xattrs"); + if (json_object_has_member(root, "delete_removed")) + settings_.delete_removed = json_object_get_boolean_member(root, "delete_removed"); + if (json_object_has_member(root, "retention_days")) + settings_.retention_days = (int)json_object_get_int_member(root, "retention_days"); + if (json_object_has_member(root, "retention_count")) + settings_.retention_count = (int)json_object_get_int_member(root, "retention_count"); + if (json_object_has_member(root, "cron_enabled")) + settings_.cron_enabled = json_object_get_boolean_member(root, "cron_enabled"); + if (json_object_has_member(root, "cron_schedule")) + settings_.cron_schedule = json_object_get_string_member(root, "cron_schedule"); + if (json_object_has_member(root, "cron_label")) + settings_.cron_label = json_object_get_string_member(root, "cron_label"); + + if (json_object_has_member(root, "exclude_patterns")) { + JsonArray* arr = json_object_get_array_member(root, "exclude_patterns"); + settings_.exclude_patterns.clear(); + for (guint i = 0; i < json_array_get_length(arr); i++) { + settings_.exclude_patterns.push_back(json_array_get_string_element(arr, i)); + } + } + + g_object_unref(parser); +} + +void Config::save() { + JsonBuilder* builder = json_builder_new(); + json_builder_begin_object(builder); + + json_builder_set_member_name(builder, "backup_dest"); + json_builder_add_string_value(builder, settings_.backup_dest.c_str()); + + json_builder_set_member_name(builder, "source_path"); + json_builder_add_string_value(builder, settings_.source_path.c_str()); + + json_builder_set_member_name(builder, "preserve_permissions"); + json_builder_add_boolean_value(builder, settings_.preserve_permissions); + + json_builder_set_member_name(builder, "preserve_acls"); + json_builder_add_boolean_value(builder, settings_.preserve_acls); + + json_builder_set_member_name(builder, "preserve_xattrs"); + json_builder_add_boolean_value(builder, settings_.preserve_xattrs); + + json_builder_set_member_name(builder, "delete_removed"); + json_builder_add_boolean_value(builder, settings_.delete_removed); + + json_builder_set_member_name(builder, "retention_days"); + json_builder_add_int_value(builder, settings_.retention_days); + + json_builder_set_member_name(builder, "retention_count"); + json_builder_add_int_value(builder, settings_.retention_count); + + json_builder_set_member_name(builder, "cron_enabled"); + json_builder_add_boolean_value(builder, settings_.cron_enabled); + + json_builder_set_member_name(builder, "cron_schedule"); + json_builder_add_string_value(builder, settings_.cron_schedule.c_str()); + + json_builder_set_member_name(builder, "cron_label"); + json_builder_add_string_value(builder, settings_.cron_label.c_str()); + + json_builder_set_member_name(builder, "exclude_patterns"); + json_builder_begin_array(builder); + for (const auto& e : settings_.exclude_patterns) { + json_builder_add_string_value(builder, e.c_str()); + } + json_builder_end_array(builder); + + json_builder_end_object(builder); + + JsonGenerator* gen = json_generator_new(); + json_generator_set_root(gen, json_builder_get_root(builder)); + json_generator_set_pretty(gen, TRUE); + json_generator_set_indent(gen, 2); + + std::string path = config_file(); + json_generator_to_file(gen, path.c_str(), nullptr); + + g_object_unref(gen); + g_object_unref(builder); +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..0321b56 --- /dev/null +++ b/src/config.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include + +struct BackupSettings { + std::string backup_dest = "/mnt/backups"; + std::string source_path = "/"; + std::vector exclude_patterns = {"/mnt", "/proc", "/sys", "/dev", "/run", "/tmp", "/lost+found"}; + bool preserve_permissions = true; + bool preserve_acls = true; + bool preserve_xattrs = true; + bool delete_removed = true; + int retention_days = 30; + int retention_count = 10; + bool cron_enabled = false; + std::string cron_schedule = "0 2 * * *"; + std::string cron_label = "x3-backup-auto"; +}; + +class Config { +public: + Config(); + void load(); + void save(); + + BackupSettings& settings() { return settings_; } + const BackupSettings& settings() const { return settings_; } + + std::string config_dir() const { return config_dir_; } + std::string config_file() const { return config_dir_ + "/settings.json"; } + std::string snapshots_dir() const { return settings_.backup_dest + "/snapshots"; } + std::string metadata_dir() const { return settings_.backup_dest + "/metadata"; } + + bool is_system_backup_mode() const { return system_backup_mode_; } + void set_system_backup_mode(bool v) { system_backup_mode_ = v; } + +private: + BackupSettings settings_; + std::string config_dir_; + bool system_backup_mode_ = false; + + void ensure_dirs(); +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ad35efe --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,143 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "rsync_manager.h" +#include "snapshot_manager.h" +#include "main_window.h" + +static FILE* log_file = nullptr; +static bool log_to_stdout = false; + +static std::string get_log_path() { + const char* home = getenv("HOME"); + if (!home) home = "."; + std::string dir = std::string(home) + "/.config/x3-backup"; + std::string cmd = "mkdir -p \"" + dir + "\""; + (void)system(cmd.c_str()); + return dir + "/x3-backup.log"; +} + +static void log_handler(const gchar* domain, GLogLevelFlags level, const gchar* message, gpointer) { + const char* level_str = "INFO"; + if (level & G_LOG_LEVEL_WARNING) level_str = "WARN"; + if (level & G_LOG_LEVEL_CRITICAL) level_str = "CRIT"; + if (level & G_LOG_LEVEL_ERROR) level_str = "ERROR"; + + time_t now = time(nullptr); + struct tm* t = localtime(&now); + char timebuf[32]; + strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", t); + + char line[2048]; + snprintf(line, sizeof(line), "[%s] [%s] %s: %s\n", timebuf, level_str, domain ? domain : "app", message); + + if (log_to_stdout) { + fputs(line, stdout); + fflush(stdout); + } + if (log_file) { + fputs(line, log_file); + fflush(log_file); + } +} + +static void setup_logging(bool cli_mode) { + const char* env_log = g_getenv("X3_BACKUP_LOG"); + if (env_log && strcmp(env_log, "1") == 0) { + log_to_stdout = true; + } + + if (cli_mode) { + log_to_stdout = true; + } + + std::string path = get_log_path(); + log_file = fopen(path.c_str(), "a"); + if (!log_file) { + g_warning("Cannot open log file: %s", path.c_str()); + } + + g_log_set_handler("x3-backup", (GLogLevelFlags)(G_LOG_LEVEL_MASK), log_handler, nullptr); + g_log_set_handler(nullptr, (GLogLevelFlags)(G_LOG_LEVEL_MASK), log_handler, nullptr); + + g_message("=== x3-backup started ==="); + g_message("Log file: %s", path.c_str()); +} + +int main(int argc, char* argv[]) { + gtk_init(&argc, &argv); + + bool system_mode = false; + bool backup_now = false; + bool show_help = false; + bool cli_mode = false; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--system-backup") == 0) { + system_mode = true; + backup_now = true; + cli_mode = true; + } else if (strcmp(argv[i], "--backup") == 0) { + backup_now = true; + cli_mode = true; + } else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + show_help = true; + } else if (strcmp(argv[i], "--log") == 0) { + log_to_stdout = true; + } + } + + setup_logging(cli_mode); + + if (show_help) { + g_print("x3-Backup - GTK3 Backup/Snapshot Tool\n\n"); + g_print("Usage: x3-backup [OPTIONS]\n\n"); + g_print("Options:\n"); + g_print(" --backup Create a backup immediately and exit\n"); + g_print(" --system-backup Create a full system backup and exit\n"); + g_print(" --log Print log to stdout\n"); + g_print(" --help, -h Show this help\n\n"); + g_print("Without options, x3-backup starts in GUI mode with system tray.\n\n"); + g_print("Log file: ~/.config/x3-backup/x3-backup.log\n"); + return 0; + } + + Config config; + config.load(); + + if (backup_now) { + if (system_mode) { + config.set_system_backup_mode(true); + config.settings().source_path = "/"; + g_message("System backup mode: backing up entire system"); + } + + RsyncManager rsync(config); + SnapshotManager snapshots(config, rsync); + + g_message("Starting backup..."); + snapshots.cleanup_old_snapshots(); + std::string ts = snapshots.create_snapshot(system_mode ? "System backup" : "Manual backup"); + if (!ts.empty()) { + g_message("Backup completed: %s", ts.c_str()); + } else { + g_warning("Backup failed!"); + return 1; + } + return 0; + } + + MainWindow window(config); + window.run(); + + g_message("=== x3-backup exiting ==="); + if (log_file) fclose(log_file); + + return 0; +} diff --git a/src/main_window.cpp b/src/main_window.cpp new file mode 100644 index 0000000..05f651a --- /dev/null +++ b/src/main_window.cpp @@ -0,0 +1,994 @@ +#include "main_window.h" +#include +#include +#include +#include +#include +#include +#include + +static std::string format_size(int64_t bytes) { + const char* units[] = {"B", "KB", "MB", "GB", "TB"}; + int u = 0; + double size = bytes; + while (size >= 1024 && u < 4) { size /= 1024; u++; } + char buf[64]; + snprintf(buf, sizeof(buf), "%.1f %s", size, units[u]); + return buf; +} + +static std::string mode_string(mode_t mode) { + char buf[11]; + buf[0] = S_ISDIR(mode) ? 'd' : '-'; + buf[1] = (mode & S_IRUSR) ? 'r' : '-'; + buf[2] = (mode & S_IWUSR) ? 'w' : '-'; + buf[3] = (mode & S_IXUSR) ? 'x' : '-'; + buf[4] = (mode & S_IRGRP) ? 'r' : '-'; + buf[5] = (mode & S_IWGRP) ? 'w' : '-'; + buf[6] = (mode & S_IXGRP) ? 'x' : '-'; + buf[7] = (mode & S_IROTH) ? 'r' : '-'; + buf[8] = (mode & S_IWOTH) ? 'w' : '-'; + buf[9] = (mode & S_IXOTH) ? 'x' : '-'; + buf[10] = 0; + return buf; +} + +MainWindow::MainWindow(Config& config) + : config_(config), rsync_(config), snapshots_(config, rsync_) { + + rsync_.on_progress = [this](const RsyncProgress& p) { + struct Ctx { MainWindow* self; RsyncProgress prog; }; + Ctx* ctx = new Ctx{this, p}; + g_idle_add([](gpointer data) -> gboolean { + auto* ctx = static_cast(data); + auto* self = ctx->self; + const RsyncProgress& p = ctx->prog; + if (self->progress_bar_) { + if (p.bytes_total > 0) { + double frac = (double)p.bytes_transferred / p.bytes_total; + gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(self->progress_bar_), frac); + } + if (!p.current_file.empty()) { + std::string label = "Syncing: " + p.current_file; + gtk_label_set_text(GTK_LABEL(self->progress_label_), label.c_str()); + gtk_label_set_text(GTK_LABEL(self->live_log_label_), label.c_str()); + } + if (!p.log_line.empty()) { + const char* tag = nullptr; + if (p.log_line.find("[error]") != std::string::npos) tag = "error"; + else if (p.log_line.find("[done]") != std::string::npos) tag = "success"; + else if (p.log_line.find("[cmd]") != std::string::npos) tag = "info"; + else if (p.log_line.find("[restore") != std::string::npos) tag = "info"; + self->append_live_log(p.log_line, tag); + } + if (p.finished) { + if (self->progress_window_) { + gtk_widget_destroy(self->progress_window_); + self->progress_window_ = nullptr; + } + self->refresh_snapshot_list(); + if (p.error) { + self->append_live_log("[ERROR] " + p.error_message, "error"); + self->tray_.show_notification("x3-Backup", p.error_message, true); + gtk_label_set_text(GTK_LABEL(self->live_log_label_), "Backup failed"); + } else { + self->append_live_log("[SUCCESS] Backup completed", "success"); + self->tray_.show_notification("x3-Backup", "Backup completed successfully"); + gtk_label_set_text(GTK_LABEL(self->live_log_label_), "Backup completed"); + } + self->tray_.set_status("x3-Backup - Idle"); + } + } + delete ctx; + return G_SOURCE_REMOVE; + }, ctx); + }; + + tray_.on_show = [this]() { show(); }; + tray_.on_quit = [this]() { gtk_main_quit(); }; + tray_.on_quick_backup = [this]() { start_quick_backup(); }; + tray_.on_settings = [this]() { + SettingsDialog dlg(GTK_WINDOW(window_), config_); + dlg.run(); + }; + + build_ui(); +} + +MainWindow::~MainWindow() {} + +void MainWindow::build_ui() { + window_ = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window_), "x3-Backup"); + gtk_window_set_default_size(GTK_WINDOW(window_), 1100, 700); + g_signal_connect(window_, "delete-event", G_CALLBACK(on_tray_close), this); + + const char* icon_paths[] = { + "icons/x3-backup.png", + "/usr/share/icons/hicolor/256x256/apps/x3-backup.png", + nullptr + }; + for (int i = 0; icon_paths[i]; ++i) { + if (g_file_test(icon_paths[i], G_FILE_TEST_EXISTS)) { + gtk_window_set_icon_from_file(GTK_WINDOW(window_), icon_paths[i], nullptr); + break; + } + } + + GtkWidget* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(window_), vbox); + + build_menu(); + gtk_box_pack_start(GTK_BOX(vbox), toolbar_, FALSE, FALSE, 0); + + build_toolbar(); + gtk_box_pack_start(GTK_BOX(vbox), toolbar_, FALSE, FALSE, 0); + + GtkWidget* paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_box_pack_start(GTK_BOX(vbox), paned, TRUE, TRUE, 0); + + /* Left: snapshot list */ + GtkWidget* left_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_size_request(left_box, 300, -1); + gtk_paned_pack1(GTK_PANED(paned), left_box, FALSE, FALSE); + + GtkWidget* left_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(left_header, 8); + gtk_widget_set_margin_top(left_header, 6); + GtkWidget* left_title = gtk_label_new(NULL); + gtk_label_set_markup(GTK_LABEL(left_title), "Snapshots"); + gtk_widget_set_halign(left_title, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(left_header), left_title, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(left_box), left_header, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(left_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 0); + + GtkWidget* snap_scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(snap_scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + snapshot_listbox_ = gtk_list_box_new(); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(snapshot_listbox_), GTK_SELECTION_SINGLE); + g_signal_connect(snapshot_listbox_, "row-selected", G_CALLBACK(on_snapshot_selected), this); + gtk_container_add(GTK_CONTAINER(snap_scroll), snapshot_listbox_); + gtk_box_pack_start(GTK_BOX(left_box), snap_scroll, TRUE, TRUE, 0); + + refresh_snapshot_list(); + + /* Right: notebook with file browser + log tabs */ + GtkWidget* right_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_paned_pack2(GTK_PANED(paned), right_box, TRUE, TRUE); + + notebook_ = gtk_notebook_new(); + gtk_box_pack_start(GTK_BOX(right_box), notebook_, TRUE, TRUE, 0); + + /* ── Tab 1: File Browser ── */ + GtkWidget* browser_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + GtkWidget* nav_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_start(nav_box, 4); + gtk_widget_set_margin_top(nav_box, 4); + gtk_widget_set_margin_bottom(nav_box, 4); + GtkWidget* up_btn = gtk_button_new_from_icon_name("go-up", GTK_ICON_SIZE_MENU); + g_signal_connect(up_btn, "clicked", G_CALLBACK(on_navigate_up), this); + gtk_box_pack_start(GTK_BOX(nav_box), up_btn, FALSE, FALSE, 0); + path_entry_ = gtk_entry_new(); + gtk_editable_set_editable(GTK_EDITABLE(path_entry_), FALSE); + gtk_widget_set_hexpand(path_entry_, TRUE); + gtk_box_pack_start(GTK_BOX(nav_box), path_entry_, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(browser_box), nav_box, FALSE, FALSE, 0); + + nav_label_ = gtk_label_new("Select a snapshot to browse"); + gtk_widget_set_halign(nav_label_, GTK_ALIGN_START); + gtk_widget_set_margin_start(nav_label_, 8); + gtk_box_pack_start(GTK_BOX(browser_box), nav_label_, FALSE, FALSE, 0); + + /* File tree */ + file_store_ = gtk_list_store_new(5, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + file_tree_ = gtk_tree_view_new_with_model(GTK_TREE_MODEL(file_store_)); + g_object_unref(file_store_); + + GtkCellRenderer* name_renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn* name_col = gtk_tree_view_column_new_with_attributes("Name", name_renderer, "text", col_name, nullptr); + gtk_tree_view_column_set_sort_column_id(name_col, col_name); + gtk_tree_view_append_column(GTK_TREE_VIEW(file_tree_), name_col); + + GtkCellRenderer* size_renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn* size_col = gtk_tree_view_column_new_with_attributes("Size", size_renderer, "text", col_size, nullptr); + gtk_tree_view_column_set_sort_column_id(size_col, col_size); + gtk_tree_view_append_column(GTK_TREE_VIEW(file_tree_), size_col); + + GtkCellRenderer* perm_renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn* perm_col = gtk_tree_view_column_new_with_attributes("Permissions", perm_renderer, "text", col_permissions, nullptr); + gtk_tree_view_append_column(GTK_TREE_VIEW(file_tree_), perm_col); + + GtkCellRenderer* mtime_renderer = gtk_cell_renderer_text_new(); + GtkTreeViewColumn* mtime_col = gtk_tree_view_column_new_with_attributes("Modified", mtime_renderer, "text", col_mtime, nullptr); + gtk_tree_view_append_column(GTK_TREE_VIEW(file_tree_), mtime_col); + + g_signal_connect(file_tree_, "row-activated", G_CALLBACK(on_file_row_activated), this); + g_signal_connect(file_tree_, "button-press-event", G_CALLBACK(on_file_button_press), this); + + GtkWidget* tree_scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(tree_scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_container_add(GTK_CONTAINER(tree_scroll), file_tree_); + gtk_box_pack_start(GTK_BOX(browser_box), tree_scroll, TRUE, TRUE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook_), browser_box, gtk_label_new("Files")); + + /* ── Tab 2: Live Log ── */ + GtkWidget* live_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_start(live_box, 4); + gtk_widget_set_margin_top(live_box, 4); + + live_log_label_ = gtk_label_new("No backup running"); + gtk_widget_set_halign(live_log_label_, GTK_ALIGN_START); + gtk_widget_set_margin_start(live_log_label_, 4); + PangoAttrList* mono_attrs = pango_attr_list_new(); + pango_attr_list_insert(mono_attrs, pango_attr_family_new("monospace")); + gtk_label_set_attributes(GTK_LABEL(live_log_label_), mono_attrs); + pango_attr_list_unref(mono_attrs); + gtk_box_pack_start(GTK_BOX(live_box), live_log_label_, FALSE, FALSE, 0); + + GtkWidget* live_scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(live_scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + live_log_textview_ = gtk_text_view_new(); + gtk_text_view_set_editable(GTK_TEXT_VIEW(live_log_textview_), FALSE); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(live_log_textview_), TRUE); + GtkTextBuffer* live_buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(live_log_textview_)); + gtk_text_buffer_create_tag(live_buf, "normal", nullptr); + gtk_text_buffer_create_tag(live_buf, "error", "foreground", "#e74c3c", nullptr); + gtk_text_buffer_create_tag(live_buf, "success", "foreground", "#2ecc71", nullptr); + gtk_text_buffer_create_tag(live_buf, "info", "foreground", "#3498db", nullptr); + gtk_container_add(GTK_CONTAINER(live_scroll), live_log_textview_); + gtk_box_pack_start(GTK_BOX(live_box), live_scroll, TRUE, TRUE, 0); + + GtkWidget* live_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + GtkWidget* clear_live_btn = gtk_button_new_with_label("Clear"); + g_signal_connect(clear_live_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* self = static_cast(d); + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(self->live_log_textview_)); + gtk_text_buffer_set_text(buf, "", -1); + gtk_label_set_text(GTK_LABEL(self->live_log_label_), "No backup running"); + }), this); + gtk_box_pack_start(GTK_BOX(live_toolbar), clear_live_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(live_box), live_toolbar, FALSE, FALSE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook_), live_box, gtk_label_new("Live Log")); + + /* ── Tab 3: History Log ── */ + GtkWidget* hist_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_start(hist_box, 4); + gtk_widget_set_margin_top(hist_box, 4); + + GtkWidget* hist_label = gtk_label_new("x3-Backup Log History"); + gtk_widget_set_halign(hist_label, GTK_ALIGN_START); + gtk_widget_set_margin_start(hist_label, 4); + PangoAttrList* mono2 = pango_attr_list_new(); + pango_attr_list_insert(mono2, pango_attr_family_new("monospace")); + gtk_label_set_attributes(GTK_LABEL(hist_label), mono2); + pango_attr_list_unref(mono2); + gtk_box_pack_start(GTK_BOX(hist_box), hist_label, FALSE, FALSE, 0); + + backlog_scrolled_ = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(backlog_scrolled_), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + backlog_textview_ = gtk_text_view_new(); + gtk_text_view_set_editable(GTK_TEXT_VIEW(backlog_textview_), FALSE); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(backlog_textview_), TRUE); + gtk_container_add(GTK_CONTAINER(backlog_scrolled_), backlog_textview_); + gtk_box_pack_start(GTK_BOX(hist_box), backlog_scrolled_, TRUE, TRUE, 0); + + GtkWidget* hist_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + GtkWidget* refresh_hist_btn = gtk_button_new_with_label("Refresh"); + GtkWidget* clear_hist_btn = gtk_button_new_with_label("Clear"); + g_signal_connect(refresh_hist_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* self = static_cast(d); + self->load_log_history(); + }), this); + g_signal_connect(clear_hist_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* self = static_cast(d); + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(self->backlog_textview_)); + gtk_text_buffer_set_text(buf, "", -1); + }), this); + gtk_box_pack_start(GTK_BOX(hist_toolbar), refresh_hist_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hist_toolbar), clear_hist_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hist_box), hist_toolbar, FALSE, FALSE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook_), hist_box, gtk_label_new("History Log")); + + log_path_ = std::string(g_get_home_dir()) + "/.config/x3-backup/x3-backup.log"; + + build_statusbar(); + gtk_box_pack_start(GTK_BOX(vbox), statusbar_, FALSE, FALSE, 0); +} + +void MainWindow::build_menu() { + toolbar_ = gtk_menu_bar_new(); + + /* File menu */ + GtkWidget* file_menu = gtk_menu_new(); + GtkWidget* file_item = gtk_menu_item_new_with_label("File"); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(file_item), file_menu); + + GtkWidget* new_backup = gtk_menu_item_new_with_label("New Backup"); + g_signal_connect(new_backup, "activate", G_CALLBACK(on_new_backup), this); + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), new_backup); + + GtkWidget* sys_backup = gtk_menu_item_new_with_label("System Backup (full)"); + g_signal_connect(sys_backup, "activate", G_CALLBACK(on_system_backup), this); + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), sys_backup); + + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), gtk_separator_menu_item_new()); + + GtkWidget* settings = gtk_menu_item_new_with_label("Settings"); + g_signal_connect(settings, "activate", G_CALLBACK(on_settings), this); + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), settings); + + GtkWidget* show_log = gtk_menu_item_new_with_label("Show Log"); + g_signal_connect(show_log, "activate", G_CALLBACK(on_show_log), this); + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), show_log); + + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), gtk_separator_menu_item_new()); + + GtkWidget* quit = gtk_menu_item_new_with_label("Quit"); + g_signal_connect(quit, "activate", G_CALLBACK(on_quit), this); + gtk_menu_shell_append(GTK_MENU_SHELL(file_menu), quit); + + gtk_menu_shell_append(GTK_MENU_SHELL(toolbar_), file_item); + + /* Backup menu */ + GtkWidget* backup_menu = gtk_menu_new(); + GtkWidget* backup_item = gtk_menu_item_new_with_label("Backup"); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(backup_item), backup_menu); + + GtkWidget* run_backup = gtk_menu_item_new_with_label("Run Backup Now"); + g_signal_connect(run_backup, "activate", G_CALLBACK(on_backup), this); + gtk_menu_shell_append(GTK_MENU_SHELL(backup_menu), run_backup); + + GtkWidget* restore = gtk_menu_item_new_with_label("Restore Selected"); + g_signal_connect(restore, "activate", G_CALLBACK(on_restore), this); + gtk_menu_shell_append(GTK_MENU_SHELL(backup_menu), restore); + + gtk_menu_shell_append(GTK_MENU_SHELL(backup_menu), gtk_separator_menu_item_new()); + + GtkWidget* del_snap = gtk_menu_item_new_with_label("Delete Snapshot"); + g_signal_connect(del_snap, "activate", G_CALLBACK(on_delete_snapshot), this); + gtk_menu_shell_append(GTK_MENU_SHELL(backup_menu), del_snap); + + gtk_menu_shell_append(GTK_MENU_SHELL(toolbar_), backup_item); + + /* System menu */ + GtkWidget* sys_menu = gtk_menu_new(); + GtkWidget* sys_item = gtk_menu_item_new_with_label("System"); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(sys_item), sys_menu); + + GtkWidget* sys_restore = gtk_menu_item_new_with_label("Restore Full System"); + g_signal_connect(sys_restore, "activate", G_CALLBACK(on_system_restore), this); + gtk_menu_shell_append(GTK_MENU_SHELL(sys_menu), sys_restore); + + gtk_menu_shell_append(GTK_MENU_SHELL(sys_menu), gtk_separator_menu_item_new()); + + GtkWidget* install_cron = gtk_menu_item_new_with_label("Install Cronjob"); + g_signal_connect(install_cron, "activate", G_CALLBACK(on_install_cron), this); + gtk_menu_shell_append(GTK_MENU_SHELL(sys_menu), install_cron); + + GtkWidget* remove_cron = gtk_menu_item_new_with_label("Remove Cronjob"); + g_signal_connect(remove_cron, "activate", G_CALLBACK(on_remove_cron), this); + gtk_menu_shell_append(GTK_MENU_SHELL(sys_menu), remove_cron); + + gtk_menu_shell_append(GTK_MENU_SHELL(toolbar_), sys_item); + + /* Help menu */ + GtkWidget* help_menu = gtk_menu_new(); + GtkWidget* help_item = gtk_menu_item_new_with_label("Help"); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(help_item), help_menu); + + GtkWidget* about = gtk_menu_item_new_with_label("About"); + g_signal_connect(about, "activate", G_CALLBACK(on_about), this); + gtk_menu_shell_append(GTK_MENU_SHELL(help_menu), about); + + gtk_menu_shell_append(GTK_MENU_SHELL(toolbar_), help_item); +} + +void MainWindow::build_toolbar() { + GtkWidget* tbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_start(tbar, 6); + gtk_widget_set_margin_end(tbar, 6); + gtk_widget_set_margin_top(tbar, 4); + gtk_widget_set_margin_bottom(tbar, 4); + + auto mkbtn = [](const char* label, GCallback cb, gpointer data) -> GtkWidget* { + GtkWidget* b = gtk_button_new_with_label(label); + g_signal_connect(b, "clicked", cb, data); + return b; + }; + + gtk_box_pack_start(GTK_BOX(tbar), mkbtn("New Backup", G_CALLBACK(on_new_backup), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(tbar), mkbtn("Restore", G_CALLBACK(on_restore), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(tbar), mkbtn("Delete", G_CALLBACK(on_delete_snapshot), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(tbar), gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(tbar), mkbtn("Settings", G_CALLBACK(on_settings), this), FALSE, FALSE, 0); + + progress_bar_ = gtk_progress_bar_new(); + gtk_widget_set_size_request(progress_bar_, 200, -1); + gtk_box_pack_end(GTK_BOX(tbar), progress_bar_, FALSE, FALSE, 0); + progress_label_ = gtk_label_new(""); + gtk_box_pack_end(GTK_BOX(tbar), progress_label_, FALSE, FALSE, 4); + + gtk_box_pack_start(GTK_BOX(toolbar_), tbar, FALSE, FALSE, 0); +} + +void MainWindow::build_statusbar() { + statusbar_ = gtk_statusbar_new(); +} + +void MainWindow::refresh_snapshot_list() { + GList* children = gtk_container_get_children(GTK_CONTAINER(snapshot_listbox_)); + for (GList* l = children; l; l = l->next) + gtk_widget_destroy(GTK_WIDGET(l->data)); + g_list_free(children); + + auto snapshots = snapshots_.list_snapshots(); + for (const auto& s : snapshots) { + GtkWidget* row_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_margin_start(row_box, 8); + gtk_widget_set_margin_end(row_box, 8); + gtk_widget_set_margin_top(row_box, 6); + gtk_widget_set_margin_bottom(row_box, 6); + + GtkWidget* name_label = gtk_label_new(s.display_name.c_str()); + gtk_widget_set_halign(name_label, GTK_ALIGN_START); + PangoAttrList* attrs = pango_attr_list_new(); + pango_attr_list_insert(attrs, pango_attr_weight_new(PANGO_WEIGHT_BOLD)); + gtk_label_set_attributes(GTK_LABEL(name_label), attrs); + pango_attr_list_unref(attrs); + gtk_box_pack_start(GTK_BOX(row_box), name_label, FALSE, FALSE, 0); + + std::string detail; + if (!s.description.empty()) detail = s.description; + else detail = "Size: " + format_size(s.size_bytes); + GtkWidget* detail_label = gtk_label_new(detail.c_str()); + gtk_widget_set_halign(detail_label, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(detail_label), PANGO_ELLIPSIZE_END); + gtk_box_pack_start(GTK_BOX(row_box), detail_label, FALSE, FALSE, 0); + + gtk_list_box_insert(GTK_LIST_BOX(snapshot_listbox_), row_box, -1); + } + gtk_widget_show_all(snapshot_listbox_); +} + +void MainWindow::on_snapshot_selected(GtkListBox*, GtkListBoxRow* row, gpointer d) { + auto* self = static_cast(d); + if (!row) return; + int idx = gtk_list_box_row_get_index(row); + auto snapshots = self->snapshots_.list_snapshots(); + if (idx < 0 || idx >= (int)snapshots.size()) return; + + self->current_snapshot_ = snapshots[idx].path; + self->current_path_ = ""; + self->path_stack_.clear(); + self->browse_path(self->current_snapshot_, ""); +} + +void MainWindow::browse_path(const std::string& snapshot, const std::string& path) { + current_snapshot_ = snapshot; + current_path_ = path; + + std::string display = path.empty() ? "/" : path; + gtk_entry_set_text(GTK_ENTRY(path_entry_), display.c_str()); + + std::string info = "Browsing: " + snapshot; + if (!path.empty()) info += "/" + path; + gtk_label_set_text(GTK_LABEL(nav_label_), info.c_str()); + + gtk_list_store_clear(file_store_); + + auto entries = snapshots_.browse_directory(snapshot, path); + for (const auto& e : entries) { + GtkTreeIter iter; + gtk_list_store_append(file_store_, &iter); + gtk_list_store_set(file_store_, &iter, + col_name, e.name.c_str(), + col_size, (e.is_dir ? "-" : format_size(e.size).c_str()), + col_type, e.is_dir ? "dir" : "file", + col_permissions, mode_string(e.mode).c_str(), + col_mtime, "", + -1); + } +} + +void MainWindow::navigate_up() { + if (current_path_.empty()) return; + + auto pos = current_path_.rfind('/'); + if (pos == std::string::npos || pos == 0) { + path_stack_.pop_back(); + if (path_stack_.empty()) { + current_path_ = ""; + } else { + current_path_ = path_stack_.back(); + path_stack_.pop_back(); + } + } else { + path_stack_.push_back(current_path_); + current_path_ = current_path_.substr(0, pos); + } + + browse_path(current_snapshot_, current_path_); +} + +void MainWindow::on_file_row_activated(GtkTreeView* tree, GtkTreePath* path, GtkTreeViewColumn*, gpointer d) { + auto* self = static_cast(d); + if (self->current_snapshot_.empty()) return; + + GtkTreeModel* model = gtk_tree_view_get_model(tree); + GtkTreeIter iter; + if (!gtk_tree_model_get_iter(model, &iter, path)) return; + + gchar* name = nullptr; + gchar* type = nullptr; + gtk_tree_model_get(model, &iter, 0, &name, 2, &type, -1); + + if (type && strcmp(type, "dir") == 0) { + std::string new_path = self->current_path_.empty() + ? std::string(name) + : self->current_path_ + "/" + name; + self->path_stack_.push_back(self->current_path_); + self->browse_path(self->current_snapshot_, new_path); + } + + g_free(name); + g_free(type); +} + +gboolean MainWindow::on_file_button_press(GtkWidget*, GdkEventButton* event, gpointer d) { + if (event->type != GDK_BUTTON_PRESS || event->button != 3) return FALSE; + + auto* self = static_cast(d); + if (self->current_snapshot_.empty()) return FALSE; + + GtkTreeSelection* sel = gtk_tree_view_get_selection(GTK_TREE_VIEW(self->file_tree_)); + GtkTreeModel* model = nullptr; + GtkTreeIter iter; + if (!gtk_tree_selection_get_selected(sel, &model, &iter)) return FALSE; + + gchar* name = nullptr; + gchar* type = nullptr; + gtk_tree_model_get(model, &iter, 0, &name, 2, &type, -1); + + std::string file_path = self->current_path_.empty() + ? std::string(name) + : self->current_path_ + "/" + name; + + GtkWidget* menu = gtk_menu_new(); + + /* Restore item */ + GtkWidget* restore_item = gtk_menu_item_new_with_label("Restore"); + struct RestoreCtx { MainWindow* self; std::string path; }; + auto* ctx = new RestoreCtx{self, file_path}; + g_signal_connect_data(restore_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + c->self->show_sudo_dialog([c](const std::string& pw) { + std::vector files = {c->path}; + c->self->snapshots_.restore_selected(c->self->current_snapshot_, files, pw); + c->self->tray_.show_notification("x3-Backup", "File restored: " + c->path); + }); + delete c; + }), ctx, nullptr, (GConnectFlags)0); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), restore_item); + + /* Info */ + if (type && strcmp(type, "file") == 0) { + GtkWidget* info_item = gtk_menu_item_new_with_label("Properties"); + g_signal_connect_data(info_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkWidget* dlg = gtk_message_dialog_new(nullptr, GTK_DIALOG_MODAL, GTK_MESSAGE_INFO, GTK_BUTTONS_OK, + "File: %s", c->path.c_str()); + gtk_dialog_run(GTK_DIALOG(dlg)); + gtk_widget_destroy(dlg); + delete c; + }), ctx, nullptr, (GConnectFlags)0); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), info_item); + } else { + g_free(name); + g_free(type); + /* For dirs, free old ctx and create restore for dir */ + delete ctx; + ctx = new RestoreCtx{self, file_path}; + g_signal_connect_data(restore_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + c->self->show_sudo_dialog([c](const std::string& pw) { + std::vector files = {c->path}; + c->self->snapshots_.restore_selected(c->self->current_snapshot_, files, pw); + c->self->tray_.show_notification("x3-Backup", "Directory restored: " + c->path); + }); + delete c; + }), ctx, nullptr, (GConnectFlags)0); + } + + gtk_widget_show_all(menu); + gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent*)event); + + g_free(name); + g_free(type); + return TRUE; +} + +void MainWindow::show_sudo_dialog(std::function callback) { + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "Authentication Required", GTK_WINDOW(window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, + "_OK", GTK_RESPONSE_ACCEPT, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(box, 16); + gtk_widget_set_margin_end(box, 16); + gtk_widget_set_margin_top(box, 16); + + GtkWidget* icon = gtk_image_new_from_icon_name("dialog-password", GTK_ICON_SIZE_DIALOG); + gtk_box_pack_start(GTK_BOX(box), icon, FALSE, FALSE, 0); + + GtkWidget* label = gtk_label_new("Root access is required to restore files.\nEnter your sudo password:"); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0); + + GtkWidget* pass_entry = gtk_entry_new(); + gtk_entry_set_visibility(GTK_ENTRY(pass_entry), FALSE); + gtk_entry_set_placeholder_text(GTK_ENTRY(pass_entry), "Password"); + gtk_box_pack_start(GTK_BOX(box), pass_entry, FALSE, FALSE, 0); + + gtk_container_add(GTK_CONTAINER(content), box); + gtk_widget_show_all(dialog); + gtk_widget_grab_focus(pass_entry); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + const char* pw = gtk_entry_get_text(GTK_ENTRY(pass_entry)); + if (pw && *pw) { + callback(std::string(pw)); + } + } + gtk_widget_destroy(dialog); +} + +void MainWindow::show_progress_dialog(const std::string& title) { + progress_window_ = gtk_window_new(GTK_WINDOW_POPUP); + gtk_window_set_title(GTK_WINDOW(progress_window_), title.c_str()); + gtk_window_set_default_size(GTK_WINDOW(progress_window_), 400, 120); + gtk_window_set_position(GTK_WINDOW(progress_window_), GTK_WIN_POS_CENTER); + gtk_window_set_keep_above(GTK_WINDOW(progress_window_), TRUE); + + GtkWidget* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(vbox, 16); + gtk_widget_set_margin_top(vbox, 16); + gtk_container_add(GTK_CONTAINER(progress_window_), vbox); + + GtkWidget* label = gtk_label_new(title.c_str()); + gtk_box_pack_start(GTK_BOX(vbox), label, FALSE, FALSE, 0); + + progress_bar_ = gtk_progress_bar_new(); + gtk_box_pack_start(GTK_BOX(vbox), progress_bar_, FALSE, FALSE, 0); + + progress_label_ = gtk_label_new("Starting..."); + gtk_box_pack_start(GTK_BOX(vbox), progress_label_, FALSE, FALSE, 0); + + gtk_widget_show_all(progress_window_); +} + +void MainWindow::on_backup(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + self->tray_.set_status("x3-Backup - Creating backup...", true); + self->show_progress_dialog("Creating Snapshot..."); + + g_thread_new("backup", [](gpointer data) -> gpointer { + auto* s = static_cast(data); + s->snapshots_.create_snapshot(); + return nullptr; + }, self); +} + +void MainWindow::start_quick_backup() { + on_backup(nullptr, this); +} + +void MainWindow::on_restore(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + if (self->current_snapshot_.empty()) { + GtkWidget* msg = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_OK, + "Please select a snapshot first."); + gtk_dialog_run(GTK_DIALOG(msg)); + gtk_widget_destroy(msg); + return; + } + + GtkTreeSelection* sel = gtk_tree_view_get_selection(GTK_TREE_VIEW(self->file_tree_)); + GtkTreeModel* model = nullptr; + GtkTreeIter iter; + if (!gtk_tree_selection_get_selected(sel, &model, &iter)) { + GtkWidget* msg = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_OK, + "Please select a file or directory to restore."); + gtk_dialog_run(GTK_DIALOG(msg)); + gtk_widget_destroy(msg); + return; + } + + gchar* name = nullptr; + gtk_tree_model_get(model, &iter, 0, &name, -1); + std::string file_path = self->current_path_.empty() + ? std::string(name) : self->current_path_ + "/" + name; + g_free(name); + + self->show_sudo_dialog([self, file_path](const std::string& pw) { + std::vector files = {file_path}; + self->tray_.set_status("x3-Backup - Restoring...", true); + self->snapshots_.restore_selected(self->current_snapshot_, files, pw); + self->tray_.set_status("x3-Backup - Idle"); + self->tray_.show_notification("x3-Backup", "Restored: " + file_path); + }); +} + +void MainWindow::on_delete_snapshot(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + if (self->current_snapshot_.empty()) return; + + GtkWidget* dialog = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_YES_NO, + "Delete this snapshot? This cannot be undone."); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_YES) { + std::string name = self->current_snapshot_; + self->snapshots_.delete_snapshot(name); + self->current_snapshot_ = ""; + self->refresh_snapshot_list(); + self->tray_.show_notification("x3-Backup", "Snapshot deleted"); + } + gtk_widget_destroy(dialog); +} + +void MainWindow::on_new_backup(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "New Backup", GTK_WINDOW(self->window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, + "_Create", GTK_RESPONSE_ACCEPT, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + gtk_widget_set_margin_top(box, 12); + + GtkWidget* label = gtk_label_new("Description (optional):"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0); + + GtkWidget* desc_entry = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(desc_entry), "My backup"); + gtk_box_pack_start(GTK_BOX(box), desc_entry, FALSE, FALSE, 0); + + gtk_container_add(GTK_CONTAINER(content), box); + gtk_widget_show_all(dialog); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + const char* desc = gtk_entry_get_text(GTK_ENTRY(desc_entry)); + std::string description = (desc && *desc) ? desc : ""; + self->on_backup(nullptr, self); + } + gtk_widget_destroy(dialog); +} + +void MainWindow::on_settings(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + SettingsDialog dlg(GTK_WINDOW(self->window_), self->config_); + if (dlg.run()) { + self->refresh_snapshot_list(); + } +} + +void MainWindow::on_quit(GtkWidget*, gpointer) { + gtk_main_quit(); +} + +void MainWindow::on_about(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + gtk_show_about_dialog(GTK_WINDOW(self->window_), + "program-name", "x3-Backup", + "version", "1.0.0", + "comments", "GTK3 backup/snapshot tool using rsync", + "copyright", "2026 x3", + "website", "https://git.sysdaemon.xyz/x3/x3-backup", + "license-type", GTK_LICENSE_GPL_3_0, + nullptr); +} + +void MainWindow::on_navigate_up(GtkWidget*, gpointer d) { + static_cast(d)->navigate_up(); +} + +void MainWindow::on_show_log(GtkWidget*, gpointer d) { + static_cast(d)->show_log_window(); +} + +void MainWindow::on_system_backup(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + + GtkWidget* dialog = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_YES_NO, + "This will create a full system backup.\n" + "Files in /mnt and other exclude paths will be skipped.\n\nContinue?"); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_YES) { + self->start_quick_backup(); + } + gtk_widget_destroy(dialog); +} + +void MainWindow::on_system_restore(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + + GtkWidget* dialog = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_YES_NO, + "This will restore the ENTIRE system from a snapshot.\n" + "All current files will be overwritten!\n\n" + "Are you sure you want to continue?"); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_YES) { + if (self->current_snapshot_.empty()) { + GtkWidget* msg = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_OK, + "Please select a snapshot first."); + gtk_dialog_run(GTK_DIALOG(msg)); + gtk_widget_destroy(msg); + gtk_widget_destroy(dialog); + return; + } + + self->show_sudo_dialog([self](const std::string& pw) { + self->snapshots_.restore_full(self->current_snapshot_, pw); + self->tray_.show_notification("x3-Backup", "Full system restore completed"); + }); + } + gtk_widget_destroy(dialog); +} + +void MainWindow::install_cronjob() { + if (config_.settings().cron_schedule.empty()) return; + + std::string label = config_.settings().cron_label; + std::string schedule = config_.settings().cron_schedule; + std::string cmd = "$(which x3-backup) --backup &"; + + std::string crontab_cmd = "(crontab -l 2>/dev/null | grep -v '" + label + "'; echo '" + schedule + " " + cmd + " # " + label + "') | crontab -"; + system(crontab_cmd.c_str()); +} + +void MainWindow::remove_cronjob() { + std::string label = config_.settings().cron_label; + std::string cmd = "crontab -l 2>/dev/null | grep -v '" + label + "' | crontab -"; + system(cmd.c_str()); +} + +void MainWindow::on_install_cron(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + self->install_cronjob(); + GtkWidget* msg = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_INFO, GTK_BUTTONS_OK, + "Cronjob installed: %s\nSchedule: %s", + self->config_.settings().cron_label.c_str(), + self->config_.settings().cron_schedule.c_str()); + gtk_dialog_run(GTK_DIALOG(msg)); + gtk_widget_destroy(msg); +} + +void MainWindow::on_remove_cron(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + self->remove_cronjob(); + GtkWidget* msg = gtk_message_dialog_new(GTK_WINDOW(self->window_), + GTK_DIALOG_MODAL, GTK_MESSAGE_INFO, GTK_BUTTONS_OK, + "Cronjob removed."); + gtk_dialog_run(GTK_DIALOG(msg)); + gtk_widget_destroy(msg); +} + +gboolean MainWindow::on_tray_close(GtkWidget*, GdkEvent*, gpointer d) { + auto* self = static_cast(d); + self->hide(); + return TRUE; +} + +void MainWindow::run() { + gtk_widget_show_all(window_); + tray_.set_status("x3-Backup - Idle"); + gtk_main(); +} + +void MainWindow::show() { + gtk_widget_show_all(window_); + gtk_window_present(GTK_WINDOW(window_)); +} + +void MainWindow::hide() { + gtk_widget_hide(window_); +} + +bool MainWindow::is_visible() const { + return gtk_widget_get_visible(window_); +} + +void MainWindow::append_live_log(const std::string& line, const char* tag) { + if (!live_log_textview_) return; + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(live_log_textview_)); + GtkTextIter end; + gtk_text_buffer_get_end_iter(buf, &end); + if (tag) { + gtk_text_buffer_insert_with_tags_by_name(buf, &end, line.c_str(), -1, tag); + } else { + gtk_text_buffer_insert(buf, &end, line.c_str(), -1); + } + gtk_text_buffer_insert(buf, &end, "\n", -1); + + /* Auto-scroll to bottom */ + GtkTextView* tv = GTK_TEXT_VIEW(live_log_textview_); + GtkTextMark* mark = gtk_text_buffer_create_mark(buf, nullptr, &end, FALSE); + gtk_text_view_scroll_mark_onscreen(tv, mark); + gtk_text_buffer_delete_mark(buf, mark); +} + +void MainWindow::load_log_history() { + if (!backlog_textview_ || log_path_.empty()) return; + + std::ifstream f(log_path_); + if (!f.is_open()) return; + + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(backlog_textview_)); + gtk_text_buffer_set_text(buf, "", -1); + + std::string line; + while (std::getline(f, line)) { + GtkTextIter end; + gtk_text_buffer_get_end_iter(buf, &end); + + const char* tag = nullptr; + if (line.find("[ERROR]") != std::string::npos || line.find("[CRIT]") != std::string::npos) + tag = "error"; + else if (line.find("[done]") != std::string::npos || line.find("completed") != std::string::npos) + tag = "success"; + else if (line.find("[info]") != std::string::npos || line.find("===") != std::string::npos) + tag = "info"; + + if (tag) { + gtk_text_buffer_insert_with_tags_by_name(buf, &end, line.c_str(), -1, tag); + } else { + gtk_text_buffer_insert(buf, &end, line.c_str(), -1); + } + gtk_text_buffer_insert(buf, &end, "\n", -1); + } + + /* Create color tags if not yet */ + GtkTextBuffer* lbuf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(live_log_textview_)); + if (!gtk_text_tag_table_lookup(gtk_text_buffer_get_tag_table(lbuf), "error")) { + gtk_text_buffer_create_tag(lbuf, "error", "foreground", "#e74c3c", nullptr); + gtk_text_buffer_create_tag(lbuf, "success", "foreground", "#2ecc71", nullptr); + gtk_text_buffer_create_tag(lbuf, "info", "foreground", "#3498db", nullptr); + } + if (!gtk_text_tag_table_lookup(gtk_text_buffer_get_tag_table(buf), "error")) { + gtk_text_buffer_create_tag(buf, "error", "foreground", "#e74c3c", nullptr); + gtk_text_buffer_create_tag(buf, "success", "foreground", "#2ecc71", nullptr); + gtk_text_buffer_create_tag(buf, "info", "foreground", "#3498db", nullptr); + } +} + +void MainWindow::show_log_window() { + load_log_history(); + /* Switch to history log tab */ + if (notebook_) { + int n = gtk_notebook_get_n_pages(GTK_NOTEBOOK(notebook_)); + if (n >= 3) { + gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook_), 2); + } + } +} diff --git a/src/main_window.h b/src/main_window.h new file mode 100644 index 0000000..7173a80 --- /dev/null +++ b/src/main_window.h @@ -0,0 +1,106 @@ +#pragma once +#include +#include +#include +#include +#include "config.h" +#include "rsync_manager.h" +#include "snapshot_manager.h" +#include "system_tray.h" +#include "settings_dialog.h" + +class MainWindow { +public: + MainWindow(Config& config); + ~MainWindow(); + + GtkWidget* widget() const { return window_; } + void run(); + void show(); + void hide(); + bool is_visible() const; + + void start_quick_backup(); + + SystemTray& tray() { return tray_; } + +private: + void build_ui(); + void build_menu(); + void build_toolbar(); + void build_sidebar(); + void build_file_browser(); + void build_statusbar(); + + void refresh_snapshot_list(); + void load_selected_snapshot(); + void browse_path(const std::string& snapshot, const std::string& path); + void navigate_up(); + + void on_backup_clicked(); + void on_restore_clicked(); + void on_delete_snapshot_clicked(); + void on_new_backup_clicked(); + + void show_sudo_dialog(std::function callback); + void show_progress_dialog(const std::string& title); + + void install_cronjob(); + void remove_cronjob(); + void show_log_window(); + void append_live_log(const std::string& line, const char* tag = nullptr); + void load_log_history(); + + static void on_backup(GtkWidget* w, gpointer d); + static void on_restore(GtkWidget* w, gpointer d); + static void on_delete_snapshot(GtkWidget* w, gpointer d); + static void on_new_backup(GtkWidget* w, gpointer d); + static void on_settings(GtkWidget* w, gpointer d); + static void on_quit(GtkWidget* w, gpointer d); + static void on_about(GtkWidget* w, gpointer d); + static void on_show_log(GtkWidget* w, gpointer d); + static void on_snapshot_selected(GtkListBox* box, GtkListBoxRow* row, gpointer d); + static void on_file_row_activated(GtkTreeView* tree, GtkTreePath* path, GtkTreeViewColumn* col, gpointer d); + static gboolean on_file_button_press(GtkWidget* widget, GdkEventButton* event, gpointer d); + static void on_navigate_up(GtkWidget* w, gpointer d); + static void on_system_backup(GtkWidget* w, gpointer d); + static void on_system_restore(GtkWidget* w, gpointer d); + static void on_install_cron(GtkWidget* w, gpointer d); + static void on_remove_cron(GtkWidget* w, gpointer d); + static gboolean on_tray_close(GtkWidget* widget, GdkEvent* event, gpointer d); + + Config& config_; + RsyncManager rsync_; + SnapshotManager snapshots_; + SystemTray tray_; + + GtkWidget* window_ = nullptr; + GtkWidget* notebook_ = nullptr; + GtkWidget* snapshot_listbox_ = nullptr; + GtkWidget* file_tree_ = nullptr; + GtkWidget* path_entry_ = nullptr; + GtkWidget* statusbar_ = nullptr; + GtkWidget* progress_bar_ = nullptr; + GtkWidget* progress_label_ = nullptr; + GtkWidget* progress_window_ = nullptr; + GtkWidget* nav_label_ = nullptr; + + GtkListStore* file_store_ = nullptr; + GtkWidget* toolbar_ = nullptr; + GtkWidget* log_window_ = nullptr; + GtkWidget* log_textview_ = nullptr; + guint log_timer_ = 0; + + GtkWidget* live_log_textview_ = nullptr; + GtkWidget* live_log_label_ = nullptr; + GtkWidget* backlog_textview_ = nullptr; + GtkWidget* backlog_scrolled_ = nullptr; + + std::string log_path_; + + std::string current_snapshot_; + std::string current_path_; + std::vector path_stack_; + + int col_name = 0, col_size = 1, col_type = 2, col_permissions = 3, col_mtime = 4; +}; diff --git a/src/rsync_manager.cpp b/src/rsync_manager.cpp new file mode 100644 index 0000000..f45da6f --- /dev/null +++ b/src/rsync_manager.cpp @@ -0,0 +1,265 @@ +#include "rsync_manager.h" +#include +#include +#include +#include +#include +#include + +RsyncManager::RsyncManager(Config& config) : config_(config) {} + +std::vector RsyncManager::build_exclude_args() { + std::vector args; + for (const auto& e : config_.settings().exclude_patterns) { + args.push_back("--exclude=" + e); + } + return args; +} + +bool RsyncManager::create_snapshot(const std::string& snapshot_name, + const std::string& source, + const std::string& dest, + const std::string& link_dest) { + if (running_) return false; + cancel_requested_ = false; + running_ = true; + + { + std::lock_guard lock(log_mutex_); + log_buffer_.clear(); + } + + std::string target = dest + "/" + snapshot_name; + std::string cmd = "rsync -aAX --info=progress2"; + + if (config_.settings().delete_removed) + cmd += " --delete"; + + if (!link_dest.empty()) + cmd += " --link-dest=\"" + link_dest + "\""; + + for (const auto& e : config_.settings().exclude_patterns) { + cmd += " --exclude=\"" + e + "\""; + } + + cmd += " \"" + source + "/\" \"" + target + "/\" 2>&1"; + + g_message("rsync cmd: %s", cmd.c_str()); + + if (on_progress) { + RsyncProgress p; + p.log_line = "[cmd] " + cmd; + on_progress(p); + } + + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + running_ = false; + return false; + } + + RsyncProgress prog; + char buf[4096]; + while (fgets(buf, sizeof(buf), pipe)) { + if (cancel_requested_) break; + std::string line(buf); + + /* Remove trailing newline */ + if (!line.empty() && line.back() == '\n') line.pop_back(); + if (!line.empty() && line.back() == '\r') line.pop_back(); + + { + std::lock_guard lock(log_mutex_); + log_buffer_.push_back(line); + } + + parse_progress_line(line, prog); + prog.log_line = line; + if (on_progress) on_progress(prog); + } + + int status = pclose(pipe); + running_ = false; + + if (cancel_requested_) { + if (on_progress) { + RsyncProgress p; + p.error = true; + p.error_message = "Cancelled by user"; + p.finished = true; + p.log_line = "[cancelled] Operation cancelled by user"; + on_progress(p); + } + return false; + } + + if (WEXITSTATUS(status) != 0) { + if (on_progress) { + RsyncProgress p; + p.error = true; + p.error_message = "rsync exited with status " + std::to_string(WEXITSTATUS(status)); + p.finished = true; + p.log_line = "[error] rsync exited with status " + std::to_string(WEXITSTATUS(status)); + on_progress(p); + } + return false; + } + + if (on_progress) { + prog.finished = true; + prog.log_line = "[done] Snapshot created: " + snapshot_name; + on_progress(prog); + } + return true; +} + +bool RsyncManager::restore_files(const std::string& snapshot_path, + const std::vector& files, + const std::string& restore_dest, + const std::string& sudo_password) { + if (running_) return false; + cancel_requested_ = false; + running_ = true; + + { + std::lock_guard lock(log_mutex_); + log_buffer_.clear(); + } + + bool success = true; + int total = files.size(); + int current = 0; + + for (const auto& f : files) { + if (cancel_requested_) break; + current++; + + std::string src = snapshot_path + "/" + f; + std::string dst = restore_dest + "/" + f; + + std::string cmd = "echo '" + sudo_password + "' | sudo -S rsync -aAX \"" + src + "\" \"" + dst + "\" 2>&1"; + + g_message("restore cmd: %s", cmd.c_str()); + + if (on_progress) { + RsyncProgress p; + p.log_line = "[restore] (" + std::to_string(current) + "/" + std::to_string(total) + ") " + f; + p.files_transferred = current; + p.files_total = total; + on_progress(p); + } + + FILE* pipe = popen(cmd.c_str(), "r"); + if (pipe) { + char rbuf[512]; + while (fgets(rbuf, sizeof(rbuf), pipe)) { + std::string line(rbuf); + if (!line.empty() && line.back() == '\n') line.pop_back(); + { + std::lock_guard lock(log_mutex_); + log_buffer_.push_back(line); + } + } + int status = pclose(pipe); + if (WEXITSTATUS(status) != 0) { + success = false; + g_warning("Failed to restore: %s", f.c_str()); + if (on_progress) { + RsyncProgress p; + p.log_line = "[error] Failed to restore: " + f; + on_progress(p); + } + } + } + } + + running_ = false; + + if (on_progress) { + RsyncProgress p; + p.finished = true; + p.log_line = success ? "[done] Restore completed" : "[done] Restore completed with errors"; + p.error = !success; + on_progress(p); + } + + return success; +} + +bool RsyncManager::restore_full_snapshot(const std::string& snapshot_path, + const std::string& restore_dest, + const std::string& sudo_password) { + if (running_) return false; + cancel_requested_ = false; + running_ = true; + + { + std::lock_guard lock(log_mutex_); + log_buffer_.clear(); + } + + std::string cmd = "echo '" + sudo_password + "' | sudo -S rsync -aAX --delete \"" + + snapshot_path + "/\" \"" + restore_dest + "/\" 2>&1"; + + g_message("full restore cmd: %s", cmd.c_str()); + + if (on_progress) { + RsyncProgress p; + p.log_line = "[restore-full] Restoring entire system from " + snapshot_path; + on_progress(p); + } + + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) { + running_ = false; + return false; + } + + char buf[4096]; + while (fgets(buf, sizeof(buf), pipe)) { + if (cancel_requested_) break; + std::string line(buf); + if (!line.empty() && line.back() == '\n') line.pop_back(); + { + std::lock_guard lock(log_mutex_); + log_buffer_.push_back(line); + } + if (on_progress) { + RsyncProgress p; + p.log_line = line; + on_progress(p); + } + } + + int status = pclose(pipe); + running_ = false; + + if (on_progress) { + RsyncProgress p; + p.finished = true; + p.error = (WEXITSTATUS(status) != 0); + if (p.error) p.error_message = "Restore failed with status " + std::to_string(WEXITSTATUS(status)); + p.log_line = p.error ? "[error] Full restore failed" : "[done] Full system restore completed"; + on_progress(p); + } + + return WEXITSTATUS(status) == 0; +} + +void RsyncManager::cancel() { + cancel_requested_ = true; +} + +void RsyncManager::parse_progress_line(const std::string& line, RsyncProgress& prog) { + std::regex re_pct(R"((\d+)%)"); + std::regex re_speed(R"(([\d.]+)[kmgt]?/s)"); + std::regex re_file(R"(\s(\S+)$)"); + + std::smatch m; + if (std::regex_search(line, m, re_pct)) { + prog.bytes_transferred = std::stoll(m[1].str()); + } + if (std::regex_search(line, m, re_speed)) { + prog.speed = (int)std::stod(m[1].str()); + } +} diff --git a/src/rsync_manager.h b/src/rsync_manager.h new file mode 100644 index 0000000..1b58030 --- /dev/null +++ b/src/rsync_manager.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "config.h" + +struct RsyncProgress { + int64_t files_transferred = 0; + int64_t files_total = 0; + int64_t bytes_transferred = 0; + int64_t bytes_total = 0; + int speed = 0; + std::string current_file; + std::string log_line; + bool finished = false; + bool error = false; + std::string error_message; +}; + +class RsyncManager { +public: + RsyncManager(Config& config); + + bool create_snapshot(const std::string& snapshot_name, + const std::string& source, + const std::string& dest, + const std::string& link_dest = ""); + + bool restore_files(const std::string& snapshot_path, + const std::vector& files, + const std::string& restore_dest, + const std::string& sudo_password); + + bool restore_full_snapshot(const std::string& snapshot_path, + const std::string& restore_dest, + const std::string& sudo_password); + + void cancel(); + bool is_running() const { return running_; } + + std::function on_progress; + +private: + Config& config_; + std::atomic running_{false}; + std::atomic cancel_requested_{false}; + std::thread worker_; + std::mutex log_mutex_; + std::vector log_buffer_; + + std::vector build_exclude_args(); + bool run_rsync(const std::vector& args); + void parse_progress_line(const std::string& line, RsyncProgress& prog); +}; diff --git a/src/settings_dialog.cpp b/src/settings_dialog.cpp new file mode 100644 index 0000000..e5a0c89 --- /dev/null +++ b/src/settings_dialog.cpp @@ -0,0 +1,193 @@ +#include "settings_dialog.h" +#include + +SettingsDialog::SettingsDialog(GtkWindow* parent, Config& config) + : parent_(parent), config_(config) {} + +void SettingsDialog::populate_excludes() { + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(exclude_textview_)); + std::string text; + for (const auto& e : config_.settings().exclude_patterns) { + if (!text.empty()) text += "\n"; + text += e; + } + gtk_text_buffer_set_text(buf, text.c_str(), -1); +} + +void SettingsDialog::save_excludes() { + GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(exclude_textview_)); + GtkTextIter start, end; + gtk_text_buffer_get_bounds(buf, &start, &end); + char* text = gtk_text_buffer_get_text(buf, &start, &end, FALSE); + + config_.settings().exclude_patterns.clear(); + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty()) { + config_.settings().exclude_patterns.push_back(line); + } + } + g_free(text); +} + +bool SettingsDialog::run() { + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "Settings", parent_, + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, + "_Save", GTK_RESPONSE_ACCEPT, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* notebook = gtk_notebook_new(); + gtk_widget_set_margin_start(notebook, 8); + gtk_widget_set_margin_end(notebook, 8); + gtk_widget_set_margin_top(notebook, 8); + + /* ── General Tab ── */ + GtkWidget* gen_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(gen_box, 8); + gtk_widget_set_margin_end(gen_box, 8); + gtk_widget_set_margin_top(gen_box, 8); + + GtkWidget* dest_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* dest_label = gtk_label_new("Backup Destination:"); + gtk_box_pack_start(GTK_BOX(dest_hbox), dest_label, FALSE, FALSE, 0); + dest_entry_ = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(dest_entry_), config_.settings().backup_dest.c_str()); + gtk_widget_set_hexpand(dest_entry_, TRUE); + gtk_box_pack_start(GTK_BOX(dest_hbox), dest_entry_, TRUE, TRUE, 0); + GtkWidget* dest_btn = gtk_button_new_with_label("Browse..."); + g_signal_connect(dest_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* self = static_cast(d); + GtkWidget* fc = gtk_file_chooser_dialog_new("Select Backup Destination", + self->parent_, GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER, + "_Cancel", GTK_RESPONSE_CANCEL, "_Select", GTK_RESPONSE_ACCEPT, nullptr); + if (gtk_dialog_run(GTK_DIALOG(fc)) == GTK_RESPONSE_ACCEPT) { + char* path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(fc)); + if (path) { + gtk_entry_set_text(GTK_ENTRY(self->dest_entry_), path); + g_free(path); + } + } + gtk_widget_destroy(fc); + }), this); + gtk_box_pack_start(GTK_BOX(dest_hbox), dest_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(gen_box), dest_hbox, FALSE, FALSE, 0); + + GtkWidget* src_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* src_label = gtk_label_new("Source Path:"); + gtk_box_pack_start(GTK_BOX(src_hbox), src_label, FALSE, FALSE, 0); + source_entry_ = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(source_entry_), config_.settings().source_path.c_str()); + gtk_widget_set_hexpand(source_entry_, TRUE); + gtk_box_pack_start(GTK_BOX(src_hbox), source_entry_, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(gen_box), src_hbox, FALSE, FALSE, 0); + + GtkWidget* ret_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* ret_days_label = gtk_label_new("Keep snapshots (days):"); + gtk_box_pack_start(GTK_BOX(ret_box), ret_days_label, FALSE, FALSE, 0); + retention_days_spin_ = gtk_spin_button_new_with_range(0, 365, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(retention_days_spin_), config_.settings().retention_days); + gtk_box_pack_start(GTK_BOX(ret_box), retention_days_spin_, FALSE, FALSE, 0); + + GtkWidget* ret_count_label = gtk_label_new("Max snapshots:"); + gtk_box_pack_start(GTK_BOX(ret_box), ret_count_label, FALSE, FALSE, 0); + retention_count_spin_ = gtk_spin_button_new_with_range(0, 100, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(retention_count_spin_), config_.settings().retention_count); + gtk_box_pack_start(GTK_BOX(ret_box), retention_count_spin_, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(gen_box), ret_box, FALSE, FALSE, 0); + + delete_check_ = gtk_check_button_new_with_label("Delete files from backup that are no longer in source"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(delete_check_), config_.settings().delete_removed); + gtk_box_pack_start(GTK_BOX(gen_box), delete_check_, FALSE, FALSE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gen_box, gtk_label_new("General")); + + /* ── Permissions Tab ── */ + GtkWidget* perm_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(perm_box, 8); + gtk_widget_set_margin_top(perm_box, 8); + + acl_check_ = gtk_check_button_new_with_label("Preserve ACLs (Access Control Lists)"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(acl_check_), config_.settings().preserve_acls); + gtk_box_pack_start(GTK_BOX(perm_box), acl_check_, FALSE, FALSE, 0); + + xattr_check_ = gtk_check_button_new_with_label("Preserve extended attributes (xattrs)"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(xattr_check_), config_.settings().preserve_xattrs); + gtk_box_pack_start(GTK_BOX(perm_box), xattr_check_, FALSE, FALSE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), perm_box, gtk_label_new("Permissions")); + + /* ── Excludes Tab ── */ + GtkWidget* excl_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(excl_box, 8); + gtk_widget_set_margin_top(excl_box, 8); + + GtkWidget* excl_label = gtk_label_new("Exclude patterns (one per line):"); + gtk_box_pack_start(GTK_BOX(excl_box), excl_label, FALSE, FALSE, 0); + + GtkWidget* scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(scroll, 400, 200); + exclude_textview_ = gtk_text_view_new(); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(exclude_textview_), TRUE); + gtk_container_add(GTK_CONTAINER(scroll), exclude_textview_); + gtk_box_pack_start(GTK_BOX(excl_box), scroll, TRUE, TRUE, 0); + + populate_excludes(); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), excl_box, gtk_label_new("Excludes")); + + /* ── Cronjob Tab ── */ + GtkWidget* cron_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(cron_box, 8); + gtk_widget_set_margin_top(cron_box, 8); + + cron_enabled_check_ = gtk_check_button_new_with_label("Enable scheduled backups (cronjob)"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cron_enabled_check_), config_.settings().cron_enabled); + gtk_box_pack_start(GTK_BOX(cron_box), cron_enabled_check_, FALSE, FALSE, 0); + + GtkWidget* sched_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* sched_label = gtk_label_new("Schedule (crontab format):"); + gtk_box_pack_start(GTK_BOX(sched_hbox), sched_label, FALSE, FALSE, 0); + cron_schedule_entry_ = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(cron_schedule_entry_), config_.settings().cron_schedule.c_str()); + gtk_entry_set_placeholder_text(GTK_ENTRY(cron_schedule_entry_), "0 2 * * *"); + gtk_widget_set_size_request(cron_schedule_entry_, 200, -1); + gtk_box_pack_start(GTK_BOX(sched_hbox), cron_schedule_entry_, FALSE, FALSE, 0); + + GtkWidget* cron_help = gtk_label_new(NULL); + gtk_label_set_markup(GTK_LABEL(cron_help), + "Examples: 0 2 * * * = daily at 2am, " + "0 */6 * * * = every 6h, " + "0 2 * * 0 = Sundays at 2am"); + gtk_widget_set_halign(cron_help, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(cron_box), sched_hbox, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(cron_box), cron_help, FALSE, FALSE, 0); + + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), cron_box, gtk_label_new("Schedule")); + + gtk_box_pack_start(GTK_BOX(content), notebook, TRUE, TRUE, 0); + gtk_widget_show_all(dialog); + + bool saved = false; + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + config_.settings().backup_dest = gtk_entry_get_text(GTK_ENTRY(dest_entry_)); + config_.settings().source_path = gtk_entry_get_text(GTK_ENTRY(source_entry_)); + config_.settings().retention_days = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(retention_days_spin_)); + config_.settings().retention_count = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(retention_count_spin_)); + config_.settings().delete_removed = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(delete_check_)); + config_.settings().preserve_acls = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(acl_check_)); + config_.settings().preserve_xattrs = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(xattr_check_)); + config_.settings().cron_enabled = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(cron_enabled_check_)); + config_.settings().cron_schedule = gtk_entry_get_text(GTK_ENTRY(cron_schedule_entry_)); + save_excludes(); + config_.save(); + saved = true; + } + + gtk_widget_destroy(dialog); + return saved; +} diff --git a/src/settings_dialog.h b/src/settings_dialog.h new file mode 100644 index 0000000..95d6441 --- /dev/null +++ b/src/settings_dialog.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include "config.h" + +class SettingsDialog { +public: + SettingsDialog(GtkWindow* parent, Config& config); + + bool run(); + +private: + GtkWindow* parent_; + Config& config_; + GtkWidget* dest_entry_; + GtkWidget* source_entry_; + GtkWidget* retention_days_spin_; + GtkWidget* retention_count_spin_; + GtkWidget* delete_check_; + GtkWidget* acl_check_; + GtkWidget* xattr_check_; + GtkWidget* cron_enabled_check_; + GtkWidget* cron_schedule_entry_; + GtkWidget* exclude_textview_; + + void populate_excludes(); + void save_excludes(); +}; diff --git a/src/snapshot_manager.cpp b/src/snapshot_manager.cpp new file mode 100644 index 0000000..c69646a --- /dev/null +++ b/src/snapshot_manager.cpp @@ -0,0 +1,259 @@ +#include "snapshot_manager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SnapshotManager::SnapshotManager(Config& config, RsyncManager& rsync) + : config_(config), rsync_(rsync) {} + +std::string SnapshotManager::get_timestamp() { + time_t now = time(nullptr); + struct tm* t = localtime(&now); + char buf[64]; + strftime(buf, sizeof(buf), "%Y-%m-%d_%H-%M-%S", t); + return std::string(buf); +} + +std::string SnapshotManager::create_snapshot(const std::string& description) { + std::string ts = get_timestamp(); + std::string snapshots_dir = config_.snapshots_dir(); + std::string metadata_dir = config_.metadata_dir(); + + std::string cmd1 = "mkdir -p \"" + snapshots_dir + "\""; + std::string cmd2 = "mkdir -p \"" + metadata_dir + "\""; + system(cmd1.c_str()); + system(cmd2.c_str()); + + /* Find latest snapshot for hardlink delta */ + std::string link_dest = ""; + auto snapshots = list_snapshots(); + if (!snapshots.empty()) { + link_dest = snapshots[0].path; + } + + rsync_.on_progress = on_progress; + bool ok = rsync_.create_snapshot(ts, config_.settings().source_path, snapshots_dir, link_dest); + + if (ok) { + SnapshotInfo info; + info.name = ts; + info.timestamp = ts; + info.path = snapshots_dir + "/" + ts; + info.description = description; + save_metadata(info); + } + + return ok ? ts : ""; +} + +void SnapshotManager::delete_snapshot(const std::string& name) { + std::string path = config_.snapshots_dir() + "/" + name; + std::string cmd = "rm -rf \"" + path + "\""; + system(cmd.c_str()); + + std::string meta = config_.metadata_dir() + "/" + name + ".json"; + std::string meta_cmd = "rm -f \"" + meta + "\""; + system(meta_cmd.c_str()); +} + +std::vector SnapshotManager::list_snapshots() { + std::vector result; + std::string dir = config_.snapshots_dir(); + + DIR* d = opendir(dir.c_str()); + if (!d) return result; + + struct dirent* entry; + while ((entry = readdir(d)) != nullptr) { + if (entry->d_name[0] == '.') continue; + if (entry->d_type != DT_DIR) continue; + + SnapshotInfo info; + info.name = entry->d_name; + info.timestamp = entry->d_name; + info.path = dir + "/" + entry->d_name; + + /* Format display name */ + std::string ts = info.timestamp; + if (ts.size() >= 19) { + info.display_name = ts.substr(0, 10) + " " + ts.substr(11, 8); + } else { + info.display_name = ts; + } + + load_metadata(info); + result.push_back(info); + } + closedir(d); + + std::sort(result.begin(), result.end(), [](const SnapshotInfo& a, const SnapshotInfo& b) { + return a.timestamp > b.timestamp; + }); + + return result; +} + +SnapshotInfo SnapshotManager::get_snapshot_info(const std::string& name) { + SnapshotInfo info; + info.name = name; + info.path = config_.snapshots_dir() + "/" + name; + info.timestamp = name; + load_metadata(info); + return info; +} + +std::vector SnapshotManager::browse_directory(const std::string& snapshot_path, + const std::string& subpath) { + std::vector result; + std::string full = snapshot_path; + if (!subpath.empty()) full += "/" + subpath; + + DIR* d = opendir(full.c_str()); + if (!d) return result; + + struct dirent* entry; + while ((entry = readdir(d)) != nullptr) { + if (entry->d_name[0] == '.' && (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)) + continue; + + FileEntry fe; + fe.name = entry->d_name; + fe.is_dir = (entry->d_type == DT_DIR); + fe.full_path = subpath.empty() ? fe.name : subpath + "/" + fe.name; + + std::string file_path = full + "/" + entry->d_name; + struct stat st; + if (stat(file_path.c_str(), &st) == 0) { + fe.size = st.st_size; + fe.mode = st.st_mode; + fe.uid = st.st_uid; + fe.gid = st.st_gid; + fe.mtime = st.st_mtime; + } + + result.push_back(fe); + } + closedir(d); + + std::sort(result.begin(), result.end(), [](const FileEntry& a, const FileEntry& b) { + if (a.is_dir != b.is_dir) return a.is_dir > b.is_dir; + return a.name < b.name; + }); + + return result; +} + +bool SnapshotManager::restore_selected(const std::string& snapshot_path, + const std::vector& files, + const std::string& sudo_password) { + return rsync_.restore_files(snapshot_path, files, "/", sudo_password); +} + +bool SnapshotManager::restore_full(const std::string& snapshot_path, + const std::string& sudo_password) { + return rsync_.restore_full_snapshot(snapshot_path, "/", sudo_password); +} + +void SnapshotManager::cleanup_old_snapshots() { + auto snapshots = list_snapshots(); + int count = 0; + + for (int i = snapshots.size() - 1; i >= 0; i--) { + bool should_delete = false; + + if (config_.settings().retention_count > 0 && + (int)snapshots.size() - count > config_.settings().retention_count) { + should_delete = true; + } + + if (config_.settings().retention_days > 0) { + struct tm tm = {}; + std::string ts = snapshots[i].timestamp; + if (ts.size() >= 19) { + sscanf(ts.c_str(), "%d-%d-%d_%d-%d-%d", + &tm.tm_year, &tm.tm_mon, &tm.tm_mday, + &tm.tm_hour, &tm.tm_min, &tm.tm_sec); + tm.tm_year -= 1900; + tm.tm_mon -= 1; + time_t snap_time = mktime(&tm); + double diff = difftime(time(nullptr), snap_time); + if (diff > config_.settings().retention_days * 86400.0) { + should_delete = true; + } + } + } + + if (should_delete) { + delete_snapshot(snapshots[i].name); + } else { + count++; + } + } +} + +void SnapshotManager::save_metadata(const SnapshotInfo& info) { + std::string path = config_.metadata_dir() + "/" + info.name + ".json"; + + JsonBuilder* builder = json_builder_new(); + json_builder_begin_object(builder); + + json_builder_set_member_name(builder, "name"); + json_builder_add_string_value(builder, info.name.c_str()); + + json_builder_set_member_name(builder, "timestamp"); + json_builder_add_string_value(builder, info.timestamp.c_str()); + + json_builder_set_member_name(builder, "description"); + json_builder_add_string_value(builder, info.description.c_str()); + + json_builder_set_member_name(builder, "size_bytes"); + json_builder_add_int_value(builder, info.size_bytes); + + json_builder_set_member_name(builder, "file_count"); + json_builder_add_int_value(builder, info.file_count); + + json_builder_end_object(builder); + + JsonGenerator* gen = json_generator_new(); + json_generator_set_root(gen, json_builder_get_root(builder)); + json_generator_set_pretty(gen, TRUE); + json_generator_to_file(gen, path.c_str(), nullptr); + + g_object_unref(gen); + g_object_unref(builder); +} + +void SnapshotManager::load_metadata(SnapshotInfo& info) { + std::string path = config_.metadata_dir() + "/" + info.name + ".json"; + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + char* buf = new char[len + 1]; + fread(buf, 1, len, f); + buf[len] = 0; + fclose(f); + + JsonParser* parser = json_parser_new(); + GError* err = nullptr; + if (json_parser_load_from_data(parser, buf, len, &err)) { + JsonObject* root = json_node_get_object(json_parser_get_root(parser)); + if (json_object_has_member(root, "description")) + info.description = json_object_get_string_member(root, "description"); + if (json_object_has_member(root, "size_bytes")) + info.size_bytes = json_object_get_int_member(root, "size_bytes"); + if (json_object_has_member(root, "file_count")) + info.file_count = (int)json_object_get_int_member(root, "file_count"); + } + if (err) g_error_free(err); + g_object_unref(parser); + delete[] buf; +} diff --git a/src/snapshot_manager.h b/src/snapshot_manager.h new file mode 100644 index 0000000..1df17a9 --- /dev/null +++ b/src/snapshot_manager.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include +#include "config.h" +#include "rsync_manager.h" + +struct SnapshotInfo { + std::string name; + std::string timestamp; + std::string display_name; + std::string description; + int64_t size_bytes = 0; + int file_count = 0; + std::string path; +}; + +struct FileEntry { + std::string name; + std::string full_path; + bool is_dir = false; + int64_t size = 0; + mode_t mode = 0; + uid_t uid = 0; + gid_t gid = 0; + time_t mtime = 0; +}; + +class SnapshotManager { +public: + SnapshotManager(Config& config, RsyncManager& rsync); + + std::string create_snapshot(const std::string& description = ""); + void delete_snapshot(const std::string& name); + std::vector list_snapshots(); + SnapshotInfo get_snapshot_info(const std::string& name); + + std::vector browse_directory(const std::string& snapshot_path, + const std::string& subpath = ""); + + bool restore_selected(const std::string& snapshot_path, + const std::vector& files, + const std::string& sudo_password); + + bool restore_full(const std::string& snapshot_path, + const std::string& sudo_password); + + void cleanup_old_snapshots(); + + std::function on_progress; + +private: + Config& config_; + RsyncManager& rsync_; + + std::string get_timestamp(); + void save_metadata(const SnapshotInfo& info); + void load_metadata(SnapshotInfo& info); +}; diff --git a/src/system_tray.cpp b/src/system_tray.cpp new file mode 100644 index 0000000..0736b54 --- /dev/null +++ b/src/system_tray.cpp @@ -0,0 +1,100 @@ +#include "system_tray.h" + +SystemTray::SystemTray() : icon_(nullptr), menu_(nullptr) { + icon_ = gtk_status_icon_new(); + gtk_status_icon_set_tooltip_text(icon_, "x3-Backup - Idle"); + gtk_status_icon_set_visible(icon_, TRUE); + + g_signal_connect(icon_, "activate", G_CALLBACK(on_status_icon_activate), this); + g_signal_connect(icon_, "button-press-event", G_CALLBACK(on_status_icon_button_press), this); + + set_icon("drive-harddisk"); +} + +SystemTray::~SystemTray() { + if (icon_) { + gtk_status_icon_set_visible(icon_, FALSE); + g_object_unref(icon_); + icon_ = nullptr; + } + if (menu_) { + gtk_widget_destroy(menu_); + menu_ = nullptr; + } +} + +void SystemTray::set_status(const std::string& tooltip, bool backup_running) { + if (!icon_) return; + gtk_status_icon_set_tooltip_text(icon_, tooltip.c_str()); + + if (backup_running) { + set_icon("preferences-system"); + } else { + set_icon("drive-harddisk"); + } +} + +void SystemTray::set_icon(const char* icon_name) { + if (!icon_) return; + gtk_status_icon_set_from_icon_name(icon_, icon_name); +} + +void SystemTray::show_notification(const std::string& summary, const std::string& body, bool is_error) { + GNotification* notification = g_notification_new(summary.c_str()); + g_notification_set_body(notification, body.c_str()); + g_notification_set_priority(notification, G_NOTIFICATION_PRIORITY_NORMAL); + g_application_send_notification(g_application_get_default(), nullptr, notification); + g_object_unref(notification); +} + +void SystemTray::on_status_icon_activate(GtkStatusIcon*, gpointer data) { + auto* self = static_cast(data); + if (self->on_show) self->on_show(); +} + +gboolean SystemTray::on_status_icon_button_press(GtkStatusIcon*, GdkEventButton* event, gpointer data) { + auto* self = static_cast(data); + + if (event->button != 3) return FALSE; + + if (self->menu_) { + gtk_widget_destroy(self->menu_); + self->menu_ = nullptr; + } + + self->menu_ = gtk_menu_new(); + + GtkWidget* show_item = gtk_menu_item_new_with_label("Show x3-Backup"); + g_signal_connect(show_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* s = static_cast(d); + if (s->on_show) s->on_show(); + }), self); + gtk_menu_shell_append(GTK_MENU_SHELL(self->menu_), show_item); + + GtkWidget* backup_item = gtk_menu_item_new_with_label("Quick Backup"); + g_signal_connect(backup_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* s = static_cast(d); + if (s->on_quick_backup) s->on_quick_backup(); + }), self); + gtk_menu_shell_append(GTK_MENU_SHELL(self->menu_), backup_item); + + GtkWidget* settings_item = gtk_menu_item_new_with_label("Settings"); + g_signal_connect(settings_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* s = static_cast(d); + if (s->on_settings) s->on_settings(); + }), self); + gtk_menu_shell_append(GTK_MENU_SHELL(self->menu_), settings_item); + + gtk_menu_shell_append(GTK_MENU_SHELL(self->menu_), gtk_separator_menu_item_new()); + + GtkWidget* quit_item = gtk_menu_item_new_with_label("Quit"); + g_signal_connect(quit_item, "activate", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* s = static_cast(d); + if (s->on_quit) s->on_quit(); + }), self); + gtk_menu_shell_append(GTK_MENU_SHELL(self->menu_), quit_item); + + gtk_widget_show_all(self->menu_); + gtk_menu_popup_at_pointer(GTK_MENU(self->menu_), (GdkEvent*)event); + return TRUE; +} diff --git a/src/system_tray.h b/src/system_tray.h new file mode 100644 index 0000000..ca6ba18 --- /dev/null +++ b/src/system_tray.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include + +class SystemTray { +public: + SystemTray(); + ~SystemTray(); + + void set_status(const std::string& tooltip, bool backup_running = false); + void set_icon(const char* icon_name); + void show_notification(const std::string& summary, const std::string& body, bool is_error = false); + bool is_visible() const { return icon_ != nullptr; } + + std::function on_show; + std::function on_quit; + std::function on_quick_backup; + std::function on_settings; + +private: + GtkStatusIcon* icon_; + GtkWidget* menu_; + + static void on_status_icon_activate(GtkStatusIcon* icon, gpointer data); + static gboolean on_status_icon_button_press(GtkStatusIcon* icon, GdkEventButton* event, gpointer data); +}; diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..3bc02c5 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +echo "=== x3-Backup Uninstall ===" + +# Remove installed files +echo "Removing installed files..." +if [ -f /usr/local/bin/x3-backup ]; then + sudo rm -f /usr/local/bin/x3-backup + echo " Removed /usr/local/bin/x3-backup" +fi + +if [ -f /usr/local/share/applications/x3-backup.desktop ]; then + sudo rm -f /usr/local/share/applications/x3-backup.desktop + echo " Removed desktop file" +fi + +if [ -f /usr/local/share/icons/hicolor/256x256/apps/x3-backup.png ]; then + sudo rm -f /usr/local/share/icons/hicolor/256x256/apps/x3-backup.png + echo " Removed icon" +fi + +# Remove cronjob +echo "Removing cronjob..." +crontab -l 2>/dev/null | grep -v 'x3-backup-auto' | crontab - 2>/dev/null || true +echo " Cronjob removed" + +# Ask about config and data +echo "" +echo "Config and backup data in ~/.config/x3-backup/ were NOT removed." +echo "To remove config: rm -rf ~/.config/x3-backup" +echo "" +echo "Backup snapshots were NOT removed." +echo "To remove backups: check your configured backup destination" +echo "" +echo "Done."