mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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 <cursoragent@cursor.com>
911 lines
29 KiB
C
911 lines
29 KiB
C
#include "maturita.h"
|
|
|
|
#ifdef _WIN32
|
|
#include <process.h>
|
|
#define update_getpid() ((long)_getpid())
|
|
#else
|
|
#include <unistd.h>
|
|
#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/<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/<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);
|
|
}
|