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 <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-17 19:49:10 +02:00
commit 2c16cf977d
5 changed files with 180 additions and 59 deletions

View file

@ -74,11 +74,12 @@ fi
command -v curl >/dev/null 2>&1 || die "Na Macu chybí curl, bez něj nejde nic stáhnout." command -v curl >/dev/null 2>&1 || die "Na Macu chybí curl, bez něj nejde nic stáhnout."
if [ -w /Applications ]; then # ~/Applications is writable from the GUI updater later. /Applications
dest_dir="/Applications" # often is not, even when this installer can drop a .app there once.
else
dest_dir="$HOME/Applications" dest_dir="$HOME/Applications"
mkdir -p "$dest_dir" mkdir -p "$dest_dir"
if [ -e "/Applications/Maturita.app" ] && [ -w "/Applications/Maturita.app" ]; then
rm -rf "/Applications/Maturita.app"
fi fi
tmp=$(mktemp -d) tmp=$(mktemp -d)

View file

@ -903,6 +903,7 @@ void update_init(void);
void update_check_async(gboolean interactive); void update_check_async(gboolean interactive);
void update_clear_staging(void); void update_clear_staging(void);
void update_apply_lang(void); void update_apply_lang(void);
void update_on_settings_open(void);
GtkWidget *build_update_banner(void); GtkWidget *build_update_banner(void);
GtkWidget *build_update_box(void); GtkWidget *build_update_box(void);
void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr, void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr,

View file

@ -161,6 +161,7 @@ void on_settings_clicked(GtkButton *button, gpointer user_data) {
GtkPopover *popover = GTK_POPOVER(user_data); GtkPopover *popover = GTK_POPOVER(user_data);
(void)button; (void)button;
update_on_settings_open();
gtk_popover_popup(popover); gtk_popover_popup(popover);
} }

View file

@ -36,10 +36,10 @@
* *
* An application cannot reliably replace its own files while it is running, so * 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 * all three platforms follow the same shape: unpack the new version into a
* staging directory beside the current install, write a small script that * writable staging directory, write a small script that waits for this process
* waits for this process to exit before swapping it in, then launch that * to exit before swapping it in, then launch that script detached and quit.
* script detached and quit. Staging beside the install keeps the final move on * Staging lives in the user cache first, because /Applications is often not
* one filesystem and proves up front that the location is writable. */ * writable from a GUI app even when the installer could put a .app there. */
#define UPDATE_ATOM_URL "https://github.com/" UPDATE_REPO \ #define UPDATE_ATOM_URL "https://github.com/" UPDATE_REPO \
"/commits/" UPDATE_BRANCH ".atom" "/commits/" UPDATE_BRANCH ".atom"
@ -51,6 +51,12 @@
#define NULL_DEVICE "/dev/null" #define NULL_DEVICE "/dev/null"
#endif #endif
#ifdef __APPLE__
#define CURL_BIN "/usr/bin/curl"
#else
#define CURL_BIN "curl"
#endif
typedef enum { typedef enum {
UPDATE_IDLE, UPDATE_IDLE,
UPDATE_CHECKING, UPDATE_CHECKING,
@ -76,6 +82,7 @@ static struct {
gboolean interactive; /* user asked, so also report "up to date" */ gboolean interactive; /* user asked, so also report "up to date" */
char *tip; /* current commit of the builds branch itself */ char *tip; /* current commit of the builds branch itself */
char *latest; /* commit the published packages were built from */ char *latest; /* commit the published packages were built from */
char *latest_hint; /* "Build from <sha>" parsed from the Atom title */
char *staging; /* scratch directory beside the install */ char *staging; /* scratch directory beside the install */
char *archive; /* asset downloaded into the staging directory */ char *archive; /* asset downloaded into the staging directory */
char *source; /* unpacked version the swap script moves in */ char *source; /* unpacked version the swap script moves in */
@ -141,6 +148,27 @@ static char *parse_atom_commit(const char *text) {
return NULL; return NULL;
} }
/* CI commit message on the builds branch: "Build from <main-sha>". */
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 /* 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. */ * branch-named file. Falls back to the branch name if the tip is unknown. */
static char *builds_raw_url(const char *name) { static char *builds_raw_url(const char *name) {
@ -300,7 +328,11 @@ static void remove_tree(const char *path) {
void update_clear_staging(void) { void update_clear_staging(void) {
char *target = NULL; char *target = NULL;
char *parent = 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) { if (install_target(&target, &parent) != INSTALL_UNSUPPORTED) {
char *stale = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); 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_set_state(UpdateState state);
static void update_start_download(void);
static void update_fail(const char *message) { static void update_fail(const char *message) {
g_free(up.error); g_free(up.error);
@ -405,15 +438,34 @@ void update_apply_lang(void) {
/* Running curl */ /* 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 /* Spawn curl and hand its stdout to `done`. FALSE means curl itself could not
* be started, the one failure worth its own message. */ * be started, the one failure worth its own message. */
static gboolean curl_async(const char *const *argv, GAsyncReadyCallback done) { static gboolean curl_async(const char *const *argv, GAsyncReadyCallback done) {
GSubprocess *proc; GSubprocess *proc;
proc = g_subprocess_newv(argv, proc = spawn_host(argv, G_SUBPROCESS_FLAGS_STDOUT_PIPE |
G_SUBPROCESS_FLAGS_STDOUT_PIPE | G_SUBPROCESS_FLAGS_STDERR_SILENCE);
G_SUBPROCESS_FLAGS_STDERR_SILENCE,
NULL);
if (!proc) if (!proc)
return FALSE; return FALSE;
g_subprocess_communicate_utf8_async(proc, NULL, NULL, done, NULL); 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) { static const char *swap_script_body(InstallKind kind) {
switch (kind) { switch (kind) {
case INSTALL_MACOS_BUNDLE: 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" 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" "pid=$1; target=$2; source=$3; staging=$4\n"
"n=0\n" "n=0\n"
"while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n" "while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n"
" sleep 0.2; n=$((n+1))\n" " sleep 0.2; n=$((n+1))\n"
"done\n" "done\n"
"rm -rf \"$target.old\"\n" "place=$target\n"
"mv \"$target\" \"$target.old\" 2>/dev/null\n" "if /usr/bin/ditto \"$source\" \"$target\" 2>/dev/null; then\n"
"if mv \"$source\" \"$target\" 2>/dev/null; then\n"
" xattr -cr \"$target\" 2>/dev/null\n" " xattr -cr \"$target\" 2>/dev/null\n"
" rm -rf \"$target.old\"\n"
"else\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" "fi\n"
"open -n \"$place\"\n"
"rm -rf \"$staging\"\n" "rm -rf \"$staging\"\n"
"rm -f \"$0\"\n"; "rm -f \"$0\"\n";
case INSTALL_APPIMAGE: case INSTALL_APPIMAGE:
return "#!/bin/sh\n" 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" "pid=$1; target=$2; source=$3; staging=$4\n"
"n=0\n" "n=0\n"
"while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\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: case INSTALL_WINDOWS_DIR:
/* Copies over the install without mirroring, so the progress files /* Copies over the install without mirroring, so the progress files
* kept inside it survive. ping is the sleep that works without a * 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" 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 PID=%~1\r\n"
"set TARGET=%~2\r\n" "set TARGET=%~2\r\n"
"set SOURCE=%~3\r\n" "set SOURCE=%~3\r\n"
@ -534,15 +611,18 @@ static void install_staged(void) {
ok = TRUE; ok = TRUE;
} else { } else {
char *unpacked = g_build_filename(up.staging, "new", NULL); 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}; const char *argv[] = {"tar", "-xf", up.archive, "-C", unpacked, NULL};
#endif
GSubprocess *proc; GSubprocess *proc;
/* bsdtar reads zip archives and ships with both macOS and Windows 10 /* macOS: ditto keeps the .app layout. Elsewhere bsdtar reads zip. */
* and later, so no separate unzip tool is needed. */
ok = g_mkdir_with_parents(unpacked, 0755) == 0; ok = g_mkdir_with_parents(unpacked, 0755) == 0;
if (ok) { if (ok) {
proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE, proc = spawn_host(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE);
NULL);
ok = proc && g_subprocess_wait_check(proc, NULL, NULL); ok = proc && g_subprocess_wait_check(proc, NULL, NULL);
g_clear_object(&proc); g_clear_object(&proc);
} }
@ -651,7 +731,7 @@ static void size_probe_done(GObject *src, GAsyncResult *res, gpointer data) {
return; return;
url = builds_raw_url(asset_name()); url = builds_raw_url(asset_name());
argv[0] = "curl"; argv[0] = CURL_BIN;
argv[1] = "-fsSL"; argv[1] = "-fsSL";
argv[2] = "--max-time"; argv[2] = "--max-time";
argv[3] = "1800"; argv[3] = "1800";
@ -681,11 +761,17 @@ static void update_start_download(void) {
return; return;
} }
/* Staging beside the install keeps the final move on one filesystem, and /* Stage in the user cache first: /Applications is often not writable
* fails here, before anything is downloaded, if we cannot write there. */ * from a GUI app even when the installer could put a .app there. */
g_free(up.staging);
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); g_free(up.staging);
up.staging = g_build_filename(parent, UPDATE_STAGING_DIR, NULL); up.staging = g_build_filename(parent, UPDATE_STAGING_DIR, NULL);
remove_tree(up.staging); remove_tree(up.staging);
}
g_free(target); g_free(target);
g_free(parent); g_free(parent);
if (g_mkdir_with_parents(up.staging, 0755) != 0) { 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. */ /* HEAD the asset with its headers on stdout, to size the progress bar. */
url = builds_raw_url(asset_name()); url = builds_raw_url(asset_name());
argv[0] = "curl"; argv[0] = CURL_BIN;
argv[1] = "-fsSLI"; argv[1] = "-fsSLI";
argv[2] = "--max-time"; argv[2] = "--max-time";
argv[3] = "20"; argv[3] = "20";
@ -728,6 +814,25 @@ static void check_giveup(void) {
update_set_state(UPDATE_IDLE); 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) { static void check_done(GObject *src, GAsyncResult *res, gpointer data) {
GSubprocess *proc = G_SUBPROCESS(src); GSubprocess *proc = G_SUBPROCESS(src);
char *out = NULL; char *out = NULL;
@ -740,29 +845,12 @@ static void check_done(GObject *src, GAsyncResult *res, gpointer data) {
if (out) if (out)
g_strstrip(out); g_strstrip(out);
if (!ok || !out || !*out) {
g_free(out);
check_giveup();
return;
}
g_free(up.latest); g_free(up.latest);
up.latest = parse_commit(out); up.latest = (ok && out) ? parse_commit(out) : NULL;
g_free(out); g_free(out);
if (!up.latest) { if (!up.latest && up.latest_hint)
check_giveup(); up.latest = g_strdup(up.latest_hint);
return; finish_check();
}
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);
} }
static void tip_done(GObject *src, GAsyncResult *res, gpointer data) { 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_object_unref(proc);
g_free(up.tip); g_free(up.tip);
g_free(up.latest_hint);
up.tip = (ok && out) ? parse_atom_commit(out) : NULL; up.tip = (ok && out) ? parse_atom_commit(out) : NULL;
up.latest_hint = (ok && out) ? parse_build_from(out) : NULL;
g_free(out); 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; return;
} }
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"); url = builds_raw_url("VERSION");
argv[0] = "curl"; }
argv[0] = CURL_BIN;
argv[1] = "-fsSL"; argv[1] = "-fsSL";
argv[2] = "--max-time"; argv[2] = "--max-time";
argv[3] = "20"; argv[3] = "20";
@ -816,7 +917,7 @@ void update_check_async(gboolean interactive) {
update_set_state(UPDATE_CHECKING); update_set_state(UPDATE_CHECKING);
argv[0] = "curl"; argv[0] = CURL_BIN;
argv[1] = "-fsSL"; argv[1] = "-fsSL";
argv[2] = "--max-time"; argv[2] = "--max-time";
argv[3] = "20"; argv[3] = "20";
@ -866,14 +967,15 @@ static void update_restart(void) {
argv[i++] = up.staging; argv[i++] = up.staging;
argv[i] = NULL; argv[i] = NULL;
/* The child outlives us on purpose: it is what puts the new version in /* The script detaches itself and exits; wait for that so we do not
* place once this process is gone. */ * quit before the grandchild is running. */
proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_NONE, NULL); proc = spawn_host(argv, G_SUBPROCESS_FLAGS_NONE);
g_free(pid); g_free(pid);
g_free(target); g_free(target);
g_free(parent); g_free(parent);
if (!proc) { if (!proc || !g_subprocess_wait_check(proc, NULL, NULL)) {
g_clear_object(&proc);
update_fail(tr("update_err_stage")); update_fail(tr("update_err_stage"));
return; return;
} }
@ -994,8 +1096,24 @@ static gboolean update_startup_check(gpointer data) {
return G_SOURCE_REMOVE; 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. */ /* Leaves the first seconds to the interface, then looks for a new release. */
void update_init(void) { void update_init(void) {
update_clear_staging(); update_clear_staging();
g_timeout_add_seconds(3, update_startup_check, NULL); g_timeout_add_seconds(3, update_startup_check, NULL);
g_timeout_add_seconds(120, update_periodic_check, NULL);
} }