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;