From 91a848b408cd8663dafad68a14b78f2b2536305a Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Thu, 17 Sep 2026 15:04:55 +0200 Subject: [PATCH] Add a self-updater that installs new releases. Bake the release tag into the binary so the app knows what it is running; plain builds keep the "dev" default and never offer an update. The CI workflow already exported the tag on Linux and macOS, so only the Windows job needed it. Read the newest version from the redirect GitHub serves for /releases/latest, which avoids the REST API and its unauthenticated rate limit and needs no JSON parser. Networking goes through the curl command rather than a linked library: curl handles TLS, redirects and proxies, it ships on all three platforms, and it keeps the bundling scripts free of another shared library. An application cannot reliably replace its own files while running, so every platform follows one shape: unpack beside the current install, write a script that waits for this process to exit before swapping, then launch it detached and quit. Staging beside the install keeps the move on one filesystem and fails early when the location is read-only. The Windows script copies without mirroring so the progress files kept inside the install directory survive. Check quietly a few seconds after startup and offer the result in a banner above the pages, with a manual check in the settings popover. Co-authored-by: Cursor --- .github/workflows/release.yml | 2 + CMakeLists.txt | 5 + Makefile | 8 + i18n.c | 30 ++ main.c | 14 +- maturita.h | 16 + settings.c | 12 +- style.css | 86 ++++ update.c | 911 ++++++++++++++++++++++++++++++++++ util.c | 4 +- 10 files changed, 1084 insertions(+), 4 deletions(-) create mode 100644 update.c diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22b1b37..5a209f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,8 @@ jobs: mingw-w64-ucrt-x86_64-make - name: Build and bundle + env: + VERSION: ${{ github.ref_name }} run: | make bundle cd dist diff --git a/CMakeLists.txt b/CMakeLists.txt index 2fd957d..943bfde 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,11 @@ else() add_executable(maturita ${MATURITA_SOURCES}) endif() +# Mirrors VERSION in the Makefile: bakes the release tag in for the updater. +if(DEFINED APP_VERSION AND NOT APP_VERSION STREQUAL "") + target_compile_definitions(maturita PRIVATE APP_VERSION="${APP_VERSION}") +endif() + if(MSVC) target_compile_options(maturita PRIVATE ${GTK4_CFLAGS}) target_link_options(maturita PRIVATE ${GTK4_LIBS}) diff --git a/Makefile b/Makefile index 8ed138f..64e2fb6 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,14 @@ GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4) CFLAGS = -Wall -Wextra -Wno-deprecated-declarations $(GTK_CFLAGS) LIBS = $(GTK_LIBS) -lm +# Release builds pass the git tag (the CI workflow exports it), which bakes the +# version into the binary for the updater to compare against. Without it the +# build keeps the "dev" default from maturita.h and never offers an update. +VERSION ?= +ifneq ($(VERSION),) +CFLAGS += -DAPP_VERSION='"$(VERSION)"' +endif + # Windows (MSYS2 / MinGW): .exe suffix, no console window, embed .ico. ifeq ($(OS),Windows_NT) TARGET = maturita.exe diff --git a/i18n.c b/i18n.c index f28b3b3..5ca67af 100644 --- a/i18n.c +++ b/i18n.c @@ -19,6 +19,35 @@ const TrEntry tr_ui[] = { {"mode_light", "Světlý", "Light"}, {"theme", "TÉMA", "THEME"}, {"language", "JAZYK", "LANGUAGE"}, + {"updates", "AKTUALIZACE", "UPDATES"}, + {"update_current", "Verze %s", "Version %s"}, + {"update_checking", "Hledám aktualizace…", "Checking for updates…"}, + {"update_uptodate", "Máte nejnovější verzi (%s).", + "You are up to date (%s)."}, + {"update_available", "Je dostupná verze %s.", "Version %s is available."}, + {"update_downloading", "Stahuji aktualizaci…", "Downloading the update…"}, + {"update_staged", "Aktualizace je připravená — restartujte aplikaci.", + "The update is ready — restart the app to finish."}, + {"update_check", "Zkontrolovat", "Check"}, + {"update_install", "Aktualizovat", "Update"}, + {"update_restart", "Restartovat", "Restart"}, + {"update_later", "Později", "Later"}, + {"update_failed", "Aktualizace se nepovedla.", "The update failed."}, + {"update_err_network", "Nepodařilo se zjistit nejnovější verzi.", + "Could not find out the latest version."}, + {"update_err_download", "Stahování se nepovedlo.", "The download failed."}, + {"update_err_curl", "V systému chybí curl, aktualizace nejsou dostupné.", + "curl is missing, so updates are unavailable."}, + {"update_err_unsupported", "Pro tuto platformu se aktualizace nevydává.", + "No update is published for this platform."}, + {"update_err_readonly", + "Do umístění aplikace nelze zapisovat. Přesuňte ji třeba do Aplikací.", + "The app's location is not writable. Move it somewhere else, for " + "example to Applications."}, + {"update_err_stage", "Aktualizaci nešlo připravit.", + "Could not prepare the update."}, + {"update_err_devbuild", "Tento build nemá číslo verze (dev).", + "This build carries no version number (dev)."}, {"welcome_body", "maturita.C je vzdělávací program pro studenty středních škol a " "gymnázií na přípravu k maturitě.\n\n" @@ -1474,6 +1503,7 @@ void apply_language(void) { i18n_apply_one(&g_array_index(i18n_binds, I18nBind, i)); refresh_welcome_heading(); refresh_stats_ui(); + update_apply_lang(); net_lessons_apply_lang(); hw_lessons_apply_lang(); } diff --git a/main.c b/main.c index f30150f..ae5623a 100644 --- a/main.c +++ b/main.c @@ -348,7 +348,17 @@ void activate(GtkApplication *app, gpointer user_data) { main_stack = GTK_STACK(gtk_stack_new()); gtk_stack_set_transition_type(GTK_STACK(main_stack), GTK_STACK_TRANSITION_TYPE_CROSSFADE); - gtk_window_set_child(window, GTK_WIDGET(main_stack)); + + /* The update banner sits above the pages and stays hidden until there is + * something to report, so it never shifts the layout unprompted. */ + { + GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + gtk_box_append(GTK_BOX(root), build_update_banner()); + gtk_widget_set_vexpand(GTK_WIDGET(main_stack), TRUE); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(main_stack)); + gtk_window_set_child(window, root); + } welcome_page = build_welcome_page(); roadmap_page = build_roadmap_page(); @@ -469,6 +479,8 @@ void activate(GtkApplication *app, gpointer user_data) { gtk_widget_add_tick_callback(GTK_WIDGET(window), ui_scale_tick, NULL, NULL); + update_init(); + gtk_window_present(window); } diff --git a/maturita.h b/maturita.h index 7c833d1..d6e10b0 100644 --- a/maturita.h +++ b/maturita.h @@ -21,6 +21,13 @@ #define CZ_SUBJ 3 /* index of "Český jazyk a literatura" */ #define CSS_FILE "style.css" #define APP_ID "org.maturita.Maturita" +/* Release builds bake the git tag in (see VERSION in the Makefile), so the + * updater can tell what it is running. Plain builds stay at "dev" and never + * report an update as available. */ +#ifndef APP_VERSION +#define APP_VERSION "dev" +#endif +#define UPDATE_REPO "pepaskopec-blip/maturita.c" #define ICON_NAME "maturita" #define ICON_FILE "assets/app-icon.png" #define ICON_THEME_DIR "assets/icons" /* cwd when run from source tree */ @@ -883,6 +890,15 @@ gboolean on_window_key_pressed(GtkEventControllerKey *controller, GdkModifierType state, gpointer user_data); GtkWidget *build_welcome_page(void); +/* util.c */ +char *app_executable_path(void); +/* update.c */ +void update_init(void); +void update_check_async(gboolean interactive); +void update_clear_staging(void); +void update_apply_lang(void); +GtkWidget *build_update_banner(void); +GtkWidget *build_update_box(void); void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer data); void road_point(double t, double *ox, double *oy); diff --git a/settings.c b/settings.c index f42577e..47879f8 100644 --- a/settings.c +++ b/settings.c @@ -324,11 +324,21 @@ GtkWidget *build_settings_button(void) { gtk_box_append(GTK_BOX(lang_row), cs_btn); gtk_box_append(GTK_BOX(lang_row), en_btn); + label = gtk_label_new(NULL); + i18n_bind(label, "updates", 0); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_widget_add_css_class(label, "settings-label"); + gtk_widget_set_margin_top(label, 4); + gtk_box_append(GTK_BOX(box), label); + gtk_box_append(GTK_BOX(box), build_update_box()); + { GtkWidget *sw = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sw), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); - gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(sw), 470); + /* Tall enough for every section, including updates at the bottom; GTK + * still shrinks the popover to fit the window and scrolls the rest. */ + gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(sw), 820); gtk_scrolled_window_set_has_frame(GTK_SCROLLED_WINDOW(sw), FALSE); gtk_scrolled_window_set_propagate_natural_width(GTK_SCROLLED_WINDOW(sw), TRUE); gtk_scrolled_window_set_propagate_natural_height(GTK_SCROLLED_WINDOW(sw), TRUE); diff --git a/style.css b/style.css index 6167f6c..fd60088 100644 --- a/style.css +++ b/style.css @@ -743,6 +743,92 @@ flowboxchild { font-weight: 600; } +/* ------------------------------------------------------------------ */ +/* Updates */ +/* ------------------------------------------------------------------ */ + +.update-banner { + background-color: alpha(@accent, 0.12); + border-bottom: 1px solid alpha(@accent, 0.3); + padding: 8px 16px; +} +.update-banner-text { + color: @fg_text; + font-size: 13px; + font-weight: 500; +} +.update-banner-btn { + background-image: none; + background-color: @accent; + color: @on_accent; + border: none; + border-radius: 8px; + padding: 5px 14px; + font-size: 13px; + font-weight: 600; + box-shadow: none; + transition: background-color 120ms ease; +} +.update-banner-btn:hover { + background-color: shade(@accent, 1.1); +} +.update-banner-later { + background-image: none; + background-color: transparent; + color: @fg_subtext; + border: none; + border-radius: 8px; + padding: 5px 10px; + font-size: 13px; + font-weight: 500; + box-shadow: none; +} +.update-banner-later:hover { + background-color: alpha(@fg_text, 0.09); + color: @fg_text; +} +.update-status { + color: @fg_subtext; + font-size: 12px; +} +.update-btn { + background-image: none; + background-color: mix(@bg_base, @bg_surface0, 0.7); + color: @fg_text; + border: 1px solid alpha(@fg_text, 0.12); + border-radius: 8px; + padding: 5px 12px; + font-size: 12px; + font-weight: 600; + box-shadow: none; + transition: background-color 120ms ease, border-color 120ms ease; +} +.update-btn:hover { + background-image: none; + background-color: mix(@bg_base, @bg_surface0, 1.0); + border-color: alpha(@accent, 0.7); +} +.update-btn:disabled { + color: @fg_overlay; +} +.update-progress, +.update-progress trough { + min-height: 6px; + min-width: 6px; + border: none; + border-radius: 3px; + padding: 0; + background-color: alpha(@fg_text, 0.11); +} +.update-progress progress { + min-height: 6px; + min-width: 6px; + border: none; + border-radius: 3px; + background-image: none; + background-color: @accent; +} + /* ------------------------------------------------------------------ */ /* Statistics */ /* ------------------------------------------------------------------ */ diff --git a/update.c b/update.c new file mode 100644 index 0000000..9eecf0b --- /dev/null +++ b/update.c @@ -0,0 +1,911 @@ +#include "maturita.h" + +#ifdef _WIN32 +#include +#define update_getpid() ((long)_getpid()) +#else +#include +#define update_getpid() ((long)getpid()) +#endif + +/* ------------------------------------------------------------------ */ +/* Self-update */ +/* ------------------------------------------------------------------ */ + +/* Networking runs through the `curl` command rather than a linked HTTP + * library. curl already handles TLS, redirects and proxies, it ships with + * macOS, with every desktop Linux and with Windows 10 and later, and using it + * keeps the three bundling scripts free of another shared library to ship. + * Every call is asynchronous, so a slow or unreachable network never blocks + * the interface. + * + * The newest version is read from the redirect GitHub serves for + * /releases/latest, which points at /releases/tag/. That avoids the REST + * API together with its unauthenticated rate limit, and needs no JSON parser. + * + * An application cannot reliably replace its own files while it is running, so + * all three platforms follow the same shape: unpack the new version into a + * staging directory beside the current install, write a small script that + * waits for this process to exit before swapping it in, then launch that + * script detached and quit. Staging beside the install keeps the final move on + * one filesystem and proves up front that the location is writable. */ + +#define UPDATE_LATEST_URL "https://github.com/" UPDATE_REPO "/releases/latest" +#define UPDATE_ASSET_URL "https://github.com/" UPDATE_REPO \ + "/releases/latest/download/" +#define UPDATE_STAGING_DIR ".maturita-update" + +#ifdef _WIN32 +#define NULL_DEVICE "NUL" +#else +#define NULL_DEVICE "/dev/null" +#endif + +typedef enum { + UPDATE_IDLE, + UPDATE_CHECKING, + UPDATE_UP_TO_DATE, + UPDATE_AVAILABLE, + UPDATE_DOWNLOADING, + UPDATE_STAGED, + UPDATE_FAILED, +} UpdateState; + +/* How this copy is installed, which decides what the update has to replace: a + * macOS bundle, a Windows install directory, or a single AppImage file. Builds + * run straight from the source tree cannot update themselves. */ +typedef enum { + INSTALL_UNSUPPORTED, + INSTALL_MACOS_BUNDLE, + INSTALL_WINDOWS_DIR, + INSTALL_APPIMAGE, +} InstallKind; + +static struct { + UpdateState state; + gboolean interactive; /* user asked, so also report "up to date" */ + char *latest; /* newest release version, without any "v" */ + char *staging; /* scratch directory beside the install */ + char *archive; /* asset downloaded into the staging directory */ + char *source; /* unpacked version the swap script moves in */ + char *script; /* swap script, run after this process exits */ + char *error; + goffset total; /* asset size, 0 when the server did not say */ + guint poll_id; + GtkWidget *banner; + GtkWidget *banner_label; + GtkWidget *banner_action; + GtkWidget *status; /* status line in the settings popover */ + GtkWidget *action; /* check / update / restart button */ + GtkWidget *progress; +} up; + +/* ------------------------------------------------------------------ */ +/* Versions */ +/* ------------------------------------------------------------------ */ + +static const char *strip_v(const char *v) { + return (v && (v[0] == 'v' || v[0] == 'V')) ? v + 1 : v; +} + +/* Component-wise numeric comparison, so 1.0.10 sorts above 1.0.9. Missing + * components count as zero, which makes 1.1 and 1.1.0 equal. */ +static int version_cmp(const char *a, const char *b) { + char **pa = g_strsplit(strip_v(a), ".", -1); + char **pb = g_strsplit(strip_v(b), ".", -1); + guint na = g_strv_length(pa); + guint nb = g_strv_length(pb); + guint n = MAX(na, nb); + int result = 0; + + for (guint i = 0; i < n && result == 0; i++) { + gint64 va = i < na ? g_ascii_strtoll(pa[i], NULL, 10) : 0; + gint64 vb = i < nb ? g_ascii_strtoll(pb[i], NULL, 10) : 0; + + if (va != vb) + result = va < vb ? -1 : 1; + } + + g_strfreev(pa); + g_strfreev(pb); + return result; +} + +/* Only tagged builds know their version; see APP_VERSION in maturita.h. */ +static gboolean is_release_build(void) { + return g_ascii_isdigit(strip_v(APP_VERSION)[0]); +} + +/* ------------------------------------------------------------------ */ +/* Install layout */ +/* ------------------------------------------------------------------ */ + +/* The asset published for this platform, or NULL when the release carries + * none. Only arm64 macOS and x86_64 Linux / Windows are built. */ +static const char *asset_name(void) { +#if defined(_WIN32) + return "maturita-windows-x64.zip"; +#elif defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + return "maturita-macos-arm64.zip"; +#else + return NULL; +#endif +#elif defined(__x86_64__) + return "maturita-linux-x86_64.AppImage"; +#else + return NULL; +#endif +} + +/* What the update replaces, plus the directory holding it. */ +static InstallKind install_target(char **target_out, char **parent_out) { + char *exe = app_executable_path(); + InstallKind kind = INSTALL_UNSUPPORTED; + char *target = NULL; + + *target_out = NULL; + *parent_out = NULL; + +#ifndef _WIN32 + { + const char *appimage = g_getenv("APPIMAGE"); + + if (appimage && appimage[0]) { + target = g_strdup(appimage); + kind = INSTALL_APPIMAGE; + } + } +#endif + +#ifdef __APPLE__ + /* .../Maturita.app/Contents/MacOS/maturita -> .../Maturita.app */ + if (!target && exe) { + char *macos_dir = g_path_get_dirname(exe); + char *contents = g_path_get_dirname(macos_dir); + char *bundle = g_path_get_dirname(contents); + + if (g_str_has_suffix(macos_dir, "MacOS") && + g_str_has_suffix(bundle, ".app")) { + target = g_strdup(bundle); + kind = INSTALL_MACOS_BUNDLE; + } + g_free(bundle); + g_free(contents); + g_free(macos_dir); + } +#endif + +#ifdef _WIN32 + /* The Windows package is a plain directory holding the exe and the GTK + * runtime, so the directory itself is what gets refreshed. */ + if (!target && exe) { + target = g_path_get_dirname(exe); + kind = INSTALL_WINDOWS_DIR; + } +#endif + + if (target) { + *target_out = target; + *parent_out = g_path_get_dirname(target); + } + g_free(exe); + return kind; +} + +/* An update is only worth offering when a release carries an asset for this + * platform and the running copy is one this code knows how to replace. */ +static gboolean update_supported(void) { + char *target = NULL; + char *parent = NULL; + gboolean ok = install_target(&target, &parent) != INSTALL_UNSUPPORTED; + + g_free(target); + g_free(parent); + return ok && asset_name() != NULL; +} + +/* Depth-first delete; GLib has no recursive remove. */ +static void remove_tree(const char *path) { + GDir *dir; + + if (!path || !g_file_test(path, G_FILE_TEST_EXISTS)) + return; + + if (g_file_test(path, G_FILE_TEST_IS_DIR) && + !g_file_test(path, G_FILE_TEST_IS_SYMLINK)) { + const char *name; + + dir = g_dir_open(path, 0, NULL); + if (dir) { + while ((name = g_dir_read_name(dir))) { + char *child = g_build_filename(path, name, NULL); + + remove_tree(child); + g_free(child); + } + g_dir_close(dir); + } + g_rmdir(path); + } else { + g_remove(path); + } +} + +/* An interrupted swap leaves the staging directory behind; drop it so it does + * not sit next to the install forever. */ +void update_clear_staging(void) { + char *target = NULL; + char *parent = NULL; + + if (install_target(&target, &parent) != INSTALL_UNSUPPORTED) { + char *stale = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); + + remove_tree(stale); + g_free(stale); + } + g_free(target); + g_free(parent); +} + +/* ------------------------------------------------------------------ */ +/* Interface state */ +/* ------------------------------------------------------------------ */ + +static void update_set_state(UpdateState state); + +static void update_fail(const char *message) { + g_free(up.error); + up.error = g_strdup(message); + update_set_state(UPDATE_FAILED); +} + +static void update_refresh_ui(void) { + gboolean busy = up.state == UPDATE_CHECKING || + up.state == UPDATE_DOWNLOADING; + gboolean banner = up.state == UPDATE_AVAILABLE || + up.state == UPDATE_DOWNLOADING || + up.state == UPDATE_STAGED; + const char *status = ""; + const char *action = "update_check"; + char *owned = NULL; + + switch (up.state) { + case UPDATE_IDLE: + owned = g_strdup_printf(tr("update_current"), APP_VERSION); + status = owned; + break; + case UPDATE_CHECKING: + status = tr("update_checking"); + break; + case UPDATE_UP_TO_DATE: + owned = g_strdup_printf(tr("update_uptodate"), APP_VERSION); + status = owned; + break; + case UPDATE_AVAILABLE: + owned = g_strdup_printf(tr("update_available"), + up.latest ? up.latest : ""); + status = owned; + action = "update_install"; + break; + case UPDATE_DOWNLOADING: + status = tr("update_downloading"); + action = "update_install"; + break; + case UPDATE_STAGED: + status = tr("update_staged"); + action = "update_restart"; + break; + case UPDATE_FAILED: + status = up.error ? up.error : tr("update_failed"); + break; + } + + if (up.status) + gtk_label_set_text(GTK_LABEL(up.status), status); + if (up.action) { + gtk_button_set_label(GTK_BUTTON(up.action), tr(action)); + gtk_widget_set_sensitive(up.action, !busy); + } + if (up.progress) + gtk_widget_set_visible(up.progress, up.state == UPDATE_DOWNLOADING); + + if (up.banner) { + gtk_widget_set_visible(up.banner, banner); + if (banner) { + gtk_label_set_text(GTK_LABEL(up.banner_label), status); + gtk_button_set_label(GTK_BUTTON(up.banner_action), tr(action)); + gtk_widget_set_sensitive(up.banner_action, !busy); + } + } + + g_free(owned); +} + +static void update_set_state(UpdateState state) { + up.state = state; + update_refresh_ui(); +} + +/* Called by apply_language() so switching language relabels the banner. */ +void update_apply_lang(void) { + update_refresh_ui(); +} + +/* ------------------------------------------------------------------ */ +/* Running curl */ +/* ------------------------------------------------------------------ */ + +/* Spawn curl and hand its stdout to `done`. FALSE means curl itself could not + * be started, the one failure worth its own message. */ +static gboolean curl_async(const char *const *argv, GAsyncReadyCallback done) { + GSubprocess *proc; + + proc = g_subprocess_newv(argv, + G_SUBPROCESS_FLAGS_STDOUT_PIPE | + G_SUBPROCESS_FLAGS_STDERR_SILENCE, + NULL); + if (!proc) + return FALSE; + g_subprocess_communicate_utf8_async(proc, NULL, NULL, done, NULL); + return TRUE; +} + +/* ------------------------------------------------------------------ */ +/* Staging and the swap script */ +/* ------------------------------------------------------------------ */ + +/* The macOS asset holds a single .app whose name we should not assume. */ +static char *find_app_bundle(const char *dir) { + GDir *d = g_dir_open(dir, 0, NULL); + const char *name; + char *found = NULL; + + if (!d) + return NULL; + while (!found && (name = g_dir_read_name(d))) { + if (g_str_has_suffix(name, ".app")) + found = g_build_filename(dir, name, NULL); + } + g_dir_close(d); + return found; +} + +/* Each script takes: pid target source staging. They all wait for this + * process to exit, swap the new version in, relaunch and clean up after + * themselves. Rolling back on a failed move keeps a broken half-update from + * leaving the user with nothing to start. */ +static const char *swap_script_body(InstallKind kind) { + switch (kind) { + case INSTALL_MACOS_BUNDLE: + return "#!/bin/sh\n" + "pid=$1; target=$2; source=$3; staging=$4\n" + "n=0\n" + "while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n" + " sleep 0.2; n=$((n+1))\n" + "done\n" + "rm -rf \"$target.old\"\n" + "mv \"$target\" \"$target.old\" 2>/dev/null\n" + "if mv \"$source\" \"$target\" 2>/dev/null; then\n" + " xattr -cr \"$target\" 2>/dev/null\n" + " rm -rf \"$target.old\"\n" + "else\n" + " mv \"$target.old\" \"$target\" 2>/dev/null\n" + "fi\n" + "open -n \"$target\"\n" + "rm -rf \"$staging\"\n" + "rm -f \"$0\"\n"; + case INSTALL_APPIMAGE: + return "#!/bin/sh\n" + "pid=$1; target=$2; source=$3; staging=$4\n" + "n=0\n" + "while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n" + " sleep 0.2; n=$((n+1))\n" + "done\n" + "if mv \"$source\" \"$target\" 2>/dev/null; then\n" + " chmod +x \"$target\"\n" + "fi\n" + "\"$target\" >/dev/null 2>&1 &\n" + "rm -rf \"$staging\"\n" + "rm -f \"$0\"\n"; + case INSTALL_WINDOWS_DIR: + /* Copies over the install without mirroring, so the progress files + * kept inside it survive. ping is the sleep that works without a + * console window. */ + return "@echo off\r\n" + "set PID=%~1\r\n" + "set TARGET=%~2\r\n" + "set SOURCE=%~3\r\n" + "set STAGING=%~4\r\n" + ":wait\r\n" + "tasklist /FI \"PID eq %PID%\" 2>nul" + " | find \"%PID%\" >nul\r\n" + "if not errorlevel 1 (\r\n" + " ping -n 2 127.0.0.1 >nul\r\n" + " goto wait\r\n" + ")\r\n" + "xcopy \"%SOURCE%\\*\" \"%TARGET%\\\" /E /I /Y /Q >nul\r\n" + "start \"\" \"%TARGET%\\maturita.exe\"\r\n" + "rmdir /s /q \"%STAGING%\"\r\n" + "del \"%~f0\"\r\n"; + case INSTALL_UNSUPPORTED: + default: + return NULL; + } +} + +static gboolean write_swap_script(InstallKind kind) { + const char *body = swap_script_body(kind); + const char *name = kind == INSTALL_WINDOWS_DIR ? "maturita-update.cmd" + : "maturita-update.sh"; + + if (!body) + return FALSE; + + g_free(up.script); + up.script = g_build_filename(g_get_tmp_dir(), name, NULL); + if (!g_file_set_contents(up.script, body, -1, NULL)) + return FALSE; + g_chmod(up.script, 0755); + return TRUE; +} + +/* Unpack the asset and write the swap script. Nothing outside the staging + * directory is touched until the user restarts. */ +static void install_staged(void) { + char *target = NULL; + char *parent = NULL; + InstallKind kind = install_target(&target, &parent); + char *source = NULL; + gboolean ok; + + if (kind == INSTALL_APPIMAGE) { + /* The asset already is the executable; there is nothing to unpack. */ + source = g_strdup(up.archive); + ok = TRUE; + } else { + char *unpacked = g_build_filename(up.staging, "new", NULL); + const char *argv[] = {"tar", "-xf", up.archive, "-C", unpacked, NULL}; + GSubprocess *proc; + + /* bsdtar reads zip archives and ships with both macOS and Windows 10 + * and later, so no separate unzip tool is needed. */ + ok = g_mkdir_with_parents(unpacked, 0755) == 0; + if (ok) { + proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE, + NULL); + ok = proc && g_subprocess_wait_check(proc, NULL, NULL); + g_clear_object(&proc); + } + if (ok && kind == INSTALL_MACOS_BUNDLE) { + source = find_app_bundle(unpacked); + ok = source != NULL; + } else if (ok) { + source = g_strdup(unpacked); + } + g_free(unpacked); + } + + if (ok) + ok = write_swap_script(kind); + + g_free(up.source); + up.source = source; + g_free(target); + g_free(parent); + + if (ok) + update_set_state(UPDATE_STAGED); + else + update_fail(tr("update_err_stage")); +} + +/* ------------------------------------------------------------------ */ +/* Download */ +/* ------------------------------------------------------------------ */ + +/* curl writes straight to disk, so the partial file's size is the progress. */ +static gboolean download_poll(gpointer data) { + GStatBuf st; + + (void)data; + if (up.state != UPDATE_DOWNLOADING) { + up.poll_id = 0; + return G_SOURCE_REMOVE; + } + if (up.progress && up.archive && g_stat(up.archive, &st) == 0) { + if (up.total > 0) + gtk_progress_bar_set_fraction( + GTK_PROGRESS_BAR(up.progress), + CLAMP((double)st.st_size / (double)up.total, 0.0, 1.0)); + else + gtk_progress_bar_pulse(GTK_PROGRESS_BAR(up.progress)); + } + return G_SOURCE_CONTINUE; +} + +static void download_done(GObject *src, GAsyncResult *res, gpointer data) { + GSubprocess *proc = G_SUBPROCESS(src); + gboolean ok; + + (void)data; + g_subprocess_communicate_utf8_finish(proc, res, NULL, NULL, NULL); + ok = g_subprocess_get_successful(proc); + g_object_unref(proc); + + if (up.poll_id) { + g_source_remove(up.poll_id); + up.poll_id = 0; + } + + if (!ok) { + update_fail(tr("update_err_download")); + return; + } + install_staged(); +} + +/* Content-Length of the asset, read from the headers of every redirect hop. + * The last one is the file itself; the hops before it report zero. curl has no + * --write-out variable for this, since a HEAD downloads no body. */ +static goffset content_length_from_headers(const char *headers) { + char **lines = g_strsplit(headers ? headers : "", "\n", -1); + goffset total = 0; + + for (guint i = 0; lines[i]; i++) { + if (g_ascii_strncasecmp(lines[i], "content-length:", 15) == 0) { + goffset v = (goffset)g_ascii_strtoll(lines[i] + 15, NULL, 10); + + if (v > 0) + total = v; + } + } + g_strfreev(lines); + return total; +} + +/* The asset size only makes the progress bar meaningful, so a failure here + * costs nothing: the bar falls back to pulsing. */ +static void size_probe_done(GObject *src, GAsyncResult *res, gpointer data) { + GSubprocess *proc = G_SUBPROCESS(src); + char *out = NULL; + char *url; + const char *argv[8]; + + (void)data; + g_subprocess_communicate_utf8_finish(proc, res, &out, NULL, NULL); + up.total = content_length_from_headers(out); + g_free(out); + g_object_unref(proc); + + if (up.state != UPDATE_DOWNLOADING) + return; + + url = g_strconcat(UPDATE_ASSET_URL, asset_name(), NULL); + argv[0] = "curl"; + argv[1] = "-fsSL"; + argv[2] = "--max-time"; + argv[3] = "1800"; + argv[4] = "-o"; + argv[5] = up.archive; + argv[6] = url; + argv[7] = NULL; + + if (!curl_async(argv, download_done)) + update_fail(tr("update_err_curl")); + else + up.poll_id = g_timeout_add(250, download_poll, NULL); + g_free(url); +} + +static void update_start_download(void) { + char *target = NULL; + char *parent = NULL; + char *url; + const char *argv[10]; + + if (install_target(&target, &parent) == INSTALL_UNSUPPORTED || + !asset_name()) { + g_free(target); + g_free(parent); + update_fail(tr("update_err_unsupported")); + return; + } + + /* Staging beside the install keeps the final move on one filesystem, and + * fails here, before anything is downloaded, if we cannot write there. */ + g_free(up.staging); + up.staging = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); + remove_tree(up.staging); + g_free(target); + g_free(parent); + if (g_mkdir_with_parents(up.staging, 0755) != 0) { + update_fail(tr("update_err_readonly")); + return; + } + + g_free(up.archive); + up.archive = g_build_filename(up.staging, asset_name(), NULL); + up.total = 0; + update_set_state(UPDATE_DOWNLOADING); + + /* HEAD the asset with its headers on stdout, to size the progress bar. */ + url = g_strconcat(UPDATE_ASSET_URL, asset_name(), NULL); + argv[0] = "curl"; + argv[1] = "-fsSLI"; + argv[2] = "--max-time"; + argv[3] = "20"; + argv[4] = "-D"; + argv[5] = "-"; + argv[6] = "-o"; + argv[7] = NULL_DEVICE; + argv[8] = url; + argv[9] = NULL; + + if (!curl_async(argv, size_probe_done)) + update_fail(tr("update_err_curl")); + g_free(url); +} + +/* ------------------------------------------------------------------ */ +/* Check */ +/* ------------------------------------------------------------------ */ + +/* The effective URL ends in /releases/tag/. */ +static char *tag_from_url(const char *url) { + const char *slash; + + if (!url || !*url) + return NULL; + slash = g_strrstr(url, "/"); + if (!slash || !slash[1]) + return NULL; + return g_strdup(slash + 1); +} + +/* A failed background check stays silent; only an explicit one reports. */ +static void check_giveup(void) { + if (up.interactive) + update_fail(tr("update_err_network")); + else + update_set_state(UPDATE_IDLE); +} + +static void check_done(GObject *src, GAsyncResult *res, gpointer data) { + GSubprocess *proc = G_SUBPROCESS(src); + char *out = NULL; + char *tag; + gboolean ok; + + (void)data; + g_subprocess_communicate_utf8_finish(proc, res, &out, NULL, NULL); + ok = g_subprocess_get_successful(proc); + g_object_unref(proc); + + tag = ok ? tag_from_url(g_strstrip(out ? out : "")) : NULL; + g_free(out); + if (!tag) { + check_giveup(); + return; + } + + g_free(up.latest); + up.latest = g_strdup(strip_v(tag)); + g_free(tag); + + if (version_cmp(APP_VERSION, up.latest) >= 0) + update_set_state(up.interactive ? UPDATE_UP_TO_DATE : UPDATE_IDLE); + else if (update_supported()) + update_set_state(UPDATE_AVAILABLE); + else if (up.interactive) + /* Newer release exists, but this copy cannot swap itself out. */ + update_fail(tr("update_err_unsupported")); + else + update_set_state(UPDATE_IDLE); +} + +void update_check_async(gboolean interactive) { + const char *argv[10]; + + if (up.state == UPDATE_CHECKING || up.state == UPDATE_DOWNLOADING || + up.state == UPDATE_STAGED) + return; + + up.interactive = interactive; + + /* Nothing to compare against without a baked-in version. */ + if (!is_release_build()) { + if (interactive) + update_fail(tr("update_err_devbuild")); + return; + } + + update_set_state(UPDATE_CHECKING); + + argv[0] = "curl"; + argv[1] = "-fsSLI"; + argv[2] = "--max-time"; + argv[3] = "20"; + argv[4] = "-o"; + argv[5] = NULL_DEVICE; + argv[6] = "-w"; + argv[7] = "%{url_effective}"; + argv[8] = UPDATE_LATEST_URL; + argv[9] = NULL; + + if (!curl_async(argv, check_done)) { + if (interactive) + update_fail(tr("update_err_curl")); + else + update_set_state(UPDATE_IDLE); + } +} + +/* ------------------------------------------------------------------ */ +/* Restart into the new version */ +/* ------------------------------------------------------------------ */ + +static void update_restart(void) { + char *target = NULL; + char *parent = NULL; + InstallKind kind = install_target(&target, &parent); + char *pid = g_strdup_printf("%ld", update_getpid()); + GSubprocess *proc; + const char *argv[8]; + int i = 0; + + if (kind == INSTALL_UNSUPPORTED || !up.script || !up.source || + !up.staging) { + g_free(pid); + g_free(target); + g_free(parent); + update_fail(tr("update_err_stage")); + return; + } + +#ifdef _WIN32 + argv[i++] = "cmd"; + argv[i++] = "/c"; +#else + argv[i++] = "/bin/sh"; +#endif + argv[i++] = up.script; + argv[i++] = pid; + argv[i++] = target; + argv[i++] = up.source; + argv[i++] = up.staging; + argv[i] = NULL; + + /* The child outlives us on purpose: it is what puts the new version in + * place once this process is gone. */ + proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_NONE, NULL); + g_free(pid); + g_free(target); + g_free(parent); + + if (!proc) { + update_fail(tr("update_err_stage")); + return; + } + g_object_unref(proc); + + if (main_window) { + GtkApplication *app = gtk_window_get_application(main_window); + + if (app) + g_application_quit(G_APPLICATION(app)); + else + gtk_window_close(main_window); + } +} + +/* ------------------------------------------------------------------ */ +/* Widgets */ +/* ------------------------------------------------------------------ */ + +static void on_update_action(GtkButton *btn, gpointer data) { + (void)btn; + (void)data; + + switch (up.state) { + case UPDATE_AVAILABLE: + update_start_download(); + break; + case UPDATE_STAGED: + update_restart(); + break; + case UPDATE_DOWNLOADING: + case UPDATE_CHECKING: + break; + default: + update_check_async(TRUE); + break; + } +} + +static void on_update_dismiss(GtkButton *btn, gpointer data) { + (void)btn; + (void)data; + if (up.banner) + gtk_widget_set_visible(up.banner, FALSE); +} + +/* Strip shown above the page stack once an update is waiting. */ +GtkWidget *build_update_banner(void) { + GtkWidget *bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + GtkWidget *label = gtk_label_new(""); + GtkWidget *action = gtk_button_new_with_label(""); + GtkWidget *later = gtk_button_new(); + + gtk_widget_add_css_class(bar, "update-banner"); + gtk_widget_set_visible(bar, FALSE); + + gtk_widget_set_hexpand(label, TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.0); + gtk_widget_add_css_class(label, "update-banner-text"); + gtk_box_append(GTK_BOX(bar), label); + + gtk_widget_add_css_class(action, "update-banner-btn"); + gtk_widget_set_valign(action, GTK_ALIGN_CENTER); + g_signal_connect(action, "clicked", G_CALLBACK(on_update_action), NULL); + gtk_box_append(GTK_BOX(bar), action); + + i18n_bind(later, "update_later", 1); + gtk_widget_add_css_class(later, "update-banner-later"); + gtk_widget_set_valign(later, GTK_ALIGN_CENTER); + g_signal_connect(later, "clicked", G_CALLBACK(on_update_dismiss), NULL); + gtk_box_append(GTK_BOX(bar), later); + + up.banner = bar; + up.banner_label = label; + up.banner_action = action; + return bar; +} + +/* "Updates" section of the settings popover. */ +GtkWidget *build_update_box(void) { + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget *status = gtk_label_new(""); + GtkWidget *action = gtk_button_new_with_label(""); + GtkWidget *progress = gtk_progress_bar_new(); + + gtk_widget_set_hexpand(status, TRUE); + gtk_label_set_xalign(GTK_LABEL(status), 0.0); + gtk_label_set_wrap(GTK_LABEL(status), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(status), 30); + gtk_widget_add_css_class(status, "update-status"); + gtk_box_append(GTK_BOX(row), status); + + gtk_widget_add_css_class(action, "update-btn"); + gtk_widget_set_valign(action, GTK_ALIGN_CENTER); + g_signal_connect(action, "clicked", G_CALLBACK(on_update_action), NULL); + gtk_box_append(GTK_BOX(row), action); + gtk_box_append(GTK_BOX(box), row); + + gtk_widget_add_css_class(progress, "update-progress"); + gtk_widget_set_visible(progress, FALSE); + gtk_box_append(GTK_BOX(box), progress); + + up.status = status; + up.action = action; + up.progress = progress; + update_refresh_ui(); + return box; +} + +/* ------------------------------------------------------------------ */ +/* Startup */ +/* ------------------------------------------------------------------ */ + +static gboolean update_startup_check(gpointer data) { + (void)data; + update_check_async(FALSE); + return G_SOURCE_REMOVE; +} + +/* Leaves the first seconds to the interface, then looks for a new release. */ +void update_init(void) { + update_clear_staging(); + g_timeout_add_seconds(3, update_startup_check, NULL); +} diff --git a/util.c b/util.c index bd68ccc..842367a 100644 --- a/util.c +++ b/util.c @@ -35,7 +35,7 @@ static char *dup_dirname(const char *path) { } /* Absolute path of this process's executable, or NULL. */ -static char *executable_path(void) { +char *app_executable_path(void) { #ifdef _WIN32 wchar_t wbuf[MAX_PATH]; DWORD n = GetModuleFileNameW(NULL, wbuf, MAX_PATH); @@ -161,7 +161,7 @@ static void set_progress_paths(const char *write_root) { void setup_portable_paths(void) { const char *appdir = g_getenv("APPDIR"); const char *appimage = g_getenv("APPIMAGE"); - char *exe = executable_path(); + char *exe = app_executable_path(); char *exe_dir = exe ? dup_dirname(exe) : NULL; char *data_dir = NULL; char *write_root = NULL;