From 2c16cf977d7cb6112a6c18471b5dd2194b1b2c8c Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Thu, 17 Sep 2026 19:49:10 +0200 Subject: [PATCH] Let the updater apply a new build after restart. The swap helper now detaches from the app, downloads start when a newer VERSION appears, and checks keep running so a just-published build is not missed. Co-authored-by: Cursor --- installers/maturita-installer-macos.command | 11 +- installers/maturita-installer-macos.zip | Bin 3010 -> 3093 bytes src/maturita.h | 1 + src/settings.c | 1 + src/update.c | 226 +++++++++++++++----- 5 files changed, 180 insertions(+), 59 deletions(-) diff --git a/installers/maturita-installer-macos.command b/installers/maturita-installer-macos.command index f79a19e..8d516d8 100755 --- a/installers/maturita-installer-macos.command +++ b/installers/maturita-installer-macos.command @@ -74,11 +74,12 @@ fi command -v curl >/dev/null 2>&1 || die "Na Macu chybí curl, bez něj nejde nic stáhnout." -if [ -w /Applications ]; then - dest_dir="/Applications" -else - dest_dir="$HOME/Applications" - mkdir -p "$dest_dir" +# ~/Applications is writable from the GUI updater later. /Applications +# often is not, even when this installer can drop a .app there once. +dest_dir="$HOME/Applications" +mkdir -p "$dest_dir" +if [ -e "/Applications/Maturita.app" ] && [ -w "/Applications/Maturita.app" ]; then + rm -rf "/Applications/Maturita.app" fi tmp=$(mktemp -d) diff --git a/installers/maturita-installer-macos.zip b/installers/maturita-installer-macos.zip index 86806c937d4bda0df872d0cf26960f5fd94173d7..5c05064c49f64e37ac8d5752584026fa249f7836 100644 GIT binary patch delta 2008 zcma)-c{tRI8pnsR%rIFeVw6Hmq%4EUGO{m~VaiTACRsueljU4L+eAjTv1BPJlyJou z#n?ucb1`Iu%w$WJNpiA_I6UXv=brn=eV+S1&-=&s{eGYC`{$=kGV2J;_5zqk0CX^{ zN9iz4d6H6v-@%5434*wI*0?|*9{9fbUolVp|0_l}=KHga4CO+IvL30sNArPA;`u-z zm@o*0N)JMkMFd3wCWNuHjrGSJyLGo24g=%fuC!? zuUCd4?fpCH0eQi$5&&}_B?V5RodTLo)V?2r+7>vA* z8m&fM&oG|N)~Hmv*DWK=>zqq-G8DRyTQX~b{-j(257J~@2Ed3w=+#mtv&!(Nps&lP zཙ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" parsed from the Atom title */ char *staging; /* scratch directory beside the install */ char *archive; /* asset downloaded into the staging directory */ char *source; /* unpacked version the swap script moves in */ @@ -141,6 +148,27 @@ static char *parse_atom_commit(const char *text) { return NULL; } +/* CI commit message on the builds branch: "Build from ". */ +static char *parse_build_from(const char *text) { + const char *p; + + if (!text) + return NULL; + p = strstr(text, "Build from "); + if (!p) + return NULL; + p += 11; + if (strlen(p) >= 40) { + size_t n = 0; + + while (n < 40 && g_ascii_isxdigit(p[n])) + n++; + if (n == 40) + return g_strndup(p, 40); + } + return parse_commit(p); +} + /* Pin raw.githubusercontent.com to a commit so Fastly cannot serve a stale * branch-named file. Falls back to the branch name if the tip is unknown. */ static char *builds_raw_url(const char *name) { @@ -300,7 +328,11 @@ static void remove_tree(const char *path) { void update_clear_staging(void) { char *target = NULL; char *parent = NULL; + char *cache = g_build_filename(g_get_user_cache_dir(), + "maturita-update", NULL); + remove_tree(cache); + g_free(cache); if (install_target(&target, &parent) != INSTALL_UNSUPPORTED) { char *stale = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); @@ -316,6 +348,7 @@ void update_clear_staging(void) { /* ------------------------------------------------------------------ */ static void update_set_state(UpdateState state); +static void update_start_download(void); static void update_fail(const char *message) { g_free(up.error); @@ -405,15 +438,34 @@ void update_apply_lang(void) { /* Running curl */ /* ------------------------------------------------------------------ */ +/* Host tools (curl, tar, ditto, the swap script) must not inherit the + * bundle's DYLD/LD library path — that is for GTK, and it can stop system + * curl from talking to the network. */ +static GSubprocess *spawn_host(const char *const *argv, GSubprocessFlags flags) { + GSubprocessLauncher *launcher = g_subprocess_launcher_new(flags); + GSubprocess *proc; + +#ifdef __APPLE__ + g_subprocess_launcher_unsetenv(launcher, "DYLD_LIBRARY_PATH"); + g_subprocess_launcher_unsetenv(launcher, "DYLD_FALLBACK_LIBRARY_PATH"); + g_subprocess_launcher_unsetenv(launcher, "DYLD_INSERT_LIBRARIES"); +#endif +#ifdef __linux__ + if (g_getenv("APPIMAGE")) + g_subprocess_launcher_unsetenv(launcher, "LD_LIBRARY_PATH"); +#endif + proc = g_subprocess_launcher_spawnv(launcher, argv, NULL); + g_object_unref(launcher); + return proc; +} + /* 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); + proc = spawn_host(argv, G_SUBPROCESS_FLAGS_STDOUT_PIPE | + G_SUBPROCESS_FLAGS_STDERR_SILENCE); if (!proc) return FALSE; g_subprocess_communicate_utf8_async(proc, NULL, NULL, done, NULL); @@ -447,25 +499,44 @@ static char *find_app_bundle(const char *dir) { static const char *swap_script_body(InstallKind kind) { switch (kind) { case INSTALL_MACOS_BUNDLE: + /* Detach first so dropping the GSubprocess (or quitting) cannot + * take the swap down with us. /Applications is often not writable + * from a GUI app, so a failed in-place copy goes to ~/Applications. + * ditto keeps the .app layout and works across volumes. */ return "#!/bin/sh\n" + "if [ -z \"$MATURITA_UPDATE_DETACHED\" ]; then\n" + " MATURITA_UPDATE_DETACHED=1\n" + " export MATURITA_UPDATE_DETACHED\n" + " nohup /bin/sh \"$0\" \"$@\" >/dev/null 2>&1 &\n" + " exit 0\n" + "fi\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" + "place=$target\n" + "if /usr/bin/ditto \"$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" + " alt=\"$HOME/Applications/$(basename \"$target\")\"\n" + " mkdir -p \"$HOME/Applications\"\n" + " if /usr/bin/ditto \"$source\" \"$alt\" 2>/dev/null; then\n" + " xattr -cr \"$alt\" 2>/dev/null\n" + " place=$alt\n" + " fi\n" "fi\n" - "open -n \"$target\"\n" + "open -n \"$place\"\n" "rm -rf \"$staging\"\n" "rm -f \"$0\"\n"; case INSTALL_APPIMAGE: return "#!/bin/sh\n" + "if [ -z \"$MATURITA_UPDATE_DETACHED\" ]; then\n" + " MATURITA_UPDATE_DETACHED=1\n" + " export MATURITA_UPDATE_DETACHED\n" + " nohup /bin/sh \"$0\" \"$@\" >/dev/null 2>&1 &\n" + " exit 0\n" + "fi\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" @@ -480,8 +551,14 @@ static const char *swap_script_body(InstallKind kind) { 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. */ + * console window. start /b detaches so quitting this process + * cannot terminate the swap. */ return "@echo off\r\n" + "if not defined MATURITA_UPDATE_DETACHED (\r\n" + " set MATURITA_UPDATE_DETACHED=1\r\n" + " start \"\" /b cmd /c \"%~f0\" %*\r\n" + " exit /b 0\r\n" + ")\r\n" "set PID=%~1\r\n" "set TARGET=%~2\r\n" "set SOURCE=%~3\r\n" @@ -534,15 +611,18 @@ static void install_staged(void) { ok = TRUE; } else { char *unpacked = g_build_filename(up.staging, "new", NULL); +#ifdef __APPLE__ + const char *argv[] = {"/usr/bin/ditto", "-x", "-k", up.archive, + unpacked, NULL}; +#else const char *argv[] = {"tar", "-xf", up.archive, "-C", unpacked, NULL}; +#endif GSubprocess *proc; - /* bsdtar reads zip archives and ships with both macOS and Windows 10 - * and later, so no separate unzip tool is needed. */ + /* macOS: ditto keeps the .app layout. Elsewhere bsdtar reads zip. */ ok = g_mkdir_with_parents(unpacked, 0755) == 0; if (ok) { - proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE, - NULL); + proc = spawn_host(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE); ok = proc && g_subprocess_wait_check(proc, NULL, NULL); g_clear_object(&proc); } @@ -651,7 +731,7 @@ static void size_probe_done(GObject *src, GAsyncResult *res, gpointer data) { return; url = builds_raw_url(asset_name()); - argv[0] = "curl"; + argv[0] = CURL_BIN; argv[1] = "-fsSL"; argv[2] = "--max-time"; argv[3] = "1800"; @@ -681,11 +761,17 @@ static void update_start_download(void) { return; } - /* Staging beside the install keeps the final move on one filesystem, and - * fails here, before anything is downloaded, if we cannot write there. */ + /* Stage in the user cache first: /Applications is often not writable + * from a GUI app even when the installer could put a .app there. */ g_free(up.staging); - up.staging = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); + up.staging = g_build_filename(g_get_user_cache_dir(), + "maturita-update", NULL); remove_tree(up.staging); + if (g_mkdir_with_parents(up.staging, 0755) != 0 && parent) { + 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) { @@ -700,7 +786,7 @@ static void update_start_download(void) { /* HEAD the asset with its headers on stdout, to size the progress bar. */ url = builds_raw_url(asset_name()); - argv[0] = "curl"; + argv[0] = CURL_BIN; argv[1] = "-fsSLI"; argv[2] = "--max-time"; argv[3] = "20"; @@ -728,6 +814,25 @@ static void check_giveup(void) { update_set_state(UPDATE_IDLE); } +static void finish_check(void) { + if (!up.latest) { + check_giveup(); + return; + } + + if (same_commit(APP_COMMIT, up.latest)) + update_set_state(up.interactive ? UPDATE_UP_TO_DATE : UPDATE_IDLE); + else if (update_supported()) { + update_set_state(UPDATE_AVAILABLE); + /* Download without another click: the remaining action is Restart. */ + update_start_download(); + } else if (up.interactive) + /* A newer build exists, but this copy cannot swap itself out. */ + update_fail(tr("update_err_unsupported")); + else + update_set_state(UPDATE_IDLE); +} + static void check_done(GObject *src, GAsyncResult *res, gpointer data) { GSubprocess *proc = G_SUBPROCESS(src); char *out = NULL; @@ -740,29 +845,12 @@ static void check_done(GObject *src, GAsyncResult *res, gpointer data) { if (out) g_strstrip(out); - if (!ok || !out || !*out) { - g_free(out); - check_giveup(); - return; - } - g_free(up.latest); - up.latest = parse_commit(out); + up.latest = (ok && out) ? parse_commit(out) : NULL; g_free(out); - if (!up.latest) { - check_giveup(); - return; - } - - if (same_commit(APP_COMMIT, up.latest)) - update_set_state(up.interactive ? UPDATE_UP_TO_DATE : UPDATE_IDLE); - else if (update_supported()) - update_set_state(UPDATE_AVAILABLE); - else if (up.interactive) - /* A newer build exists, but this copy cannot swap itself out. */ - update_fail(tr("update_err_unsupported")); - else - update_set_state(UPDATE_IDLE); + if (!up.latest && up.latest_hint) + up.latest = g_strdup(up.latest_hint); + finish_check(); } static void tip_done(GObject *src, GAsyncResult *res, gpointer data) { @@ -778,15 +866,28 @@ static void tip_done(GObject *src, GAsyncResult *res, gpointer data) { g_object_unref(proc); g_free(up.tip); + g_free(up.latest_hint); up.tip = (ok && out) ? parse_atom_commit(out) : NULL; + up.latest_hint = (ok && out) ? parse_build_from(out) : NULL; g_free(out); - if (!up.tip) { - check_giveup(); + + /* The Atom title already names the main commit; that is enough to decide + * whether we are behind, even if the VERSION file fetch fails. */ + if (up.latest_hint) { + g_free(up.latest); + up.latest = g_strdup(up.latest_hint); + finish_check(); return; } - url = builds_raw_url("VERSION"); - argv[0] = "curl"; + if (!up.tip) { + url = g_strdup_printf( + "https://raw.githubusercontent.com/%s/%s/VERSION?t=%ld", + UPDATE_REPO, UPDATE_BRANCH, (long)g_get_real_time()); + } else { + url = builds_raw_url("VERSION"); + } + argv[0] = CURL_BIN; argv[1] = "-fsSL"; argv[2] = "--max-time"; argv[3] = "20"; @@ -816,7 +917,7 @@ void update_check_async(gboolean interactive) { update_set_state(UPDATE_CHECKING); - argv[0] = "curl"; + argv[0] = CURL_BIN; argv[1] = "-fsSL"; argv[2] = "--max-time"; argv[3] = "20"; @@ -866,14 +967,15 @@ static void update_restart(void) { 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); + /* The script detaches itself and exits; wait for that so we do not + * quit before the grandchild is running. */ + proc = spawn_host(argv, G_SUBPROCESS_FLAGS_NONE); g_free(pid); g_free(target); g_free(parent); - if (!proc) { + if (!proc || !g_subprocess_wait_check(proc, NULL, NULL)) { + g_clear_object(&proc); update_fail(tr("update_err_stage")); return; } @@ -994,8 +1096,24 @@ static gboolean update_startup_check(gpointer data) { return G_SOURCE_REMOVE; } +/* The first check can race CI publishing a new build after launch. */ +static gboolean update_periodic_check(gpointer data) { + (void)data; + if (up.state == UPDATE_IDLE || up.state == UPDATE_UP_TO_DATE || + up.state == UPDATE_FAILED) + update_check_async(FALSE); + return G_SOURCE_CONTINUE; +} + +void update_on_settings_open(void) { + if (up.state == UPDATE_IDLE || up.state == UPDATE_UP_TO_DATE || + up.state == UPDATE_FAILED) + update_check_async(FALSE); +} + /* 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); + g_timeout_add_seconds(120, update_periodic_check, NULL); }