ascii-chat 0.11.33
Video chat in your terminal
Loading...
Searching...
No Matches
update_checker.c
Go to the documentation of this file.
1
17#include <ascii-chat/log/log.h>
18#include <ascii-chat/common.h>
19
20#include <yyjson.h>
21
22#include <stdio.h>
23#include <stdlib.h>
24#include <string.h>
25#include <time.h>
26#include <errno.h>
27
28// Cache file is stored in ~/.config/ascii-chat/last_update_check
29#define UPDATE_CHECK_CACHE_FILENAME "last_update_check"
30
31// Check is fresh if < 7 days old
32#define UPDATE_CHECK_CACHE_MAX_AGE_SECONDS (7 * 24 * 60 * 60)
33
34// DNS timeout for connectivity check
35#define DNS_TIMEOUT_SECONDS 2
36
37// GitHub API endpoint
38#define GITHUB_API_HOSTNAME "api.github.com"
39#define GITHUB_RELEASES_PATH "/repos/zfogg/ascii-chat/releases/latest"
40
41static void trim_line_ending(char *line) {
42 if (!line) {
43 return;
44 }
45
46 size_t len = strlen(line);
47 while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
48 line[--len] = '\0';
49 }
50}
51
56static char *get_cache_file_path(void) {
57 // Get config directory
58 char *config_dir = get_config_dir();
59 if (!config_dir) {
60 log_error("Failed to get config directory for update check cache");
61 return NULL;
62 }
63
64 // The config directory may not exist on a first run. Create it before any
65 // caller attempts to open the cache file for writing.
66 if (platform_mkdir_recursive(config_dir, 0700) != ASCIICHAT_OK) {
67 log_warn("Failed to create update check config directory: %s", config_dir);
68 }
69
70 // Build cache file path
71 size_t path_len = strlen(config_dir) + strlen(UPDATE_CHECK_CACHE_FILENAME) + 2;
72 char *cache_path = SAFE_MALLOC(path_len, char *);
73 snprintf(cache_path, path_len, "%s%s", config_dir, UPDATE_CHECK_CACHE_FILENAME);
74 SAFE_FREE(config_dir);
75
76 return cache_path;
77}
78
80 if (!result) {
81 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
82 }
83
84 memset(result, 0, sizeof(*result));
85
86 char *cache_path = get_cache_file_path();
87 if (!cache_path) {
88 return SET_ERRNO(ERROR_FILE_OPERATION, "Could not determine cache file path");
89 }
90
91 FILE *f = platform_fopen("file_stream", cache_path, "r");
92 if (!f) {
93 const char *error_msg = file_read_error_message(cache_path);
94 SAFE_FREE(cache_path);
95 return SET_ERRNO(ERROR_FILE_OPERATION, "%s", error_msg);
96 }
97
98 // Read line 1: timestamp
99 char line[512];
100 if (!fgets(line, sizeof(line), f)) {
101 fclose(f);
102 SAFE_FREE(cache_path);
103 return SET_ERRNO(ERROR_FILE_OPERATION, "Failed to read timestamp from cache");
104 }
105
106 // Parse timestamp safely with error checking
107 errno = 0;
108 char *endptr = NULL;
109 long long timestamp_ll = strtoll(line, &endptr, 10);
110 if (errno != 0 || endptr == line) {
111 log_error("Failed to parse timestamp from cache: invalid value '%s'", line);
112 fclose(f);
113 SAFE_FREE(cache_path);
114 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid timestamp in cache file");
115 }
116 result->last_check_time = (time_t)timestamp_ll;
117
118 // Read line 2: latest version (may be empty if check failed)
119 if (fgets(line, sizeof(line), f)) {
120 trim_line_ending(line);
121 SAFE_STRNCPY(result->latest_version, line, sizeof(result->latest_version));
122 }
123
124 // Read line 3: latest SHA (may be empty if check failed)
125 if (fgets(line, sizeof(line), f)) {
126 trim_line_ending(line);
127 SAFE_STRNCPY(result->latest_sha, line, sizeof(result->latest_sha));
128 }
129
130 // Read line 4: release URL. This field is required in the current cache
131 // format; an older or partially-written cache must be refreshed.
132 if (!fgets(line, sizeof(line), f)) {
133 fclose(f);
134 // Treat a legacy cache exactly like a missing cache. Close the file before
135 // removing it so this also works on Windows, where open files cannot be
136 // deleted.
137 (void)remove(cache_path);
138 SAFE_FREE(cache_path);
139 return SET_ERRNO(ERROR_FORMAT, "Update cache format is obsolete");
140 }
141 trim_line_ending(line);
142 SAFE_STRNCPY(result->release_url, line, sizeof(result->release_url));
143
144 fclose(f);
145
146 // Fill in current version/SHA
149
150 // Determine if update is available using version comparison (if we have cached data)
151 if (result->latest_version[0] != '\0') {
152 semantic_version_t current_ver = version_parse(result->current_version);
153 semantic_version_t latest_ver = version_parse(result->latest_version);
154
155 if (current_ver.valid && latest_ver.valid) {
156 int cmp = version_compare(latest_ver, current_ver);
157 result->update_available = (cmp > 0); // Update available if latest > current
158 result->check_succeeded = true;
159 } else {
160 // Cache contains invalid version data - delete it
161 log_warn("Update cache contains invalid version data (current:%s, latest:%s) - deleting corrupted cache",
162 result->current_version, result->latest_version);
163 if (remove(cache_path) != 0) {
164 log_warn("Failed to delete corrupted cache file: %s", cache_path);
165 }
166 SAFE_FREE(cache_path);
167 return SET_ERRNO(ERROR_FORMAT, "Corrupted cache file deleted");
168 }
169 }
170
171 SAFE_FREE(cache_path);
172 return ASCIICHAT_OK;
173}
174
176 if (!result) {
177 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
178 }
179
180 char *cache_path = get_cache_file_path();
181 if (!cache_path) {
182 return SET_ERRNO(ERROR_FILE_OPERATION, "Could not determine cache file path");
183 }
184
185 FILE *f = platform_fopen("file_stream", cache_path, "w");
186 if (!f) {
187 const char *error_msg = file_write_error_message(cache_path);
188 SAFE_FREE(cache_path);
189 return SET_ERRNO(ERROR_FILE_OPERATION, "%s", error_msg);
190 }
191
192 // Write timestamp
193 fprintf(f, "%lld\n", (long long)result->last_check_time);
194
195 // Write version (may be empty if check failed)
196 fprintf(f, "%s\n", result->latest_version);
197
198 // Write SHA (may be empty if check failed)
199 fprintf(f, "%s\n", result->latest_sha);
200
201 // Write the release URL so cached notifications retain the exact GitHub
202 // source returned by the API.
203 fprintf(f, "%s\n", result->release_url);
204
205 fclose(f);
206 SAFE_FREE(cache_path);
207
208 return ASCIICHAT_OK;
209}
210
212 if (!result || result->last_check_time == 0) {
213 return false;
214 }
215
216 time_t now = time(NULL);
217 time_t age = now - result->last_check_time;
218
220}
221
231static bool parse_github_release_json(const char *json, char *tag_name, size_t tag_size, char *html_url,
232 size_t url_size) {
233 if (!json) {
234 return false;
235 }
236
237 yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
238 if (!doc) {
239 log_error("Failed to parse GitHub API response as JSON");
240 return false;
241 }
242
243 yyjson_val *root = yyjson_doc_get_root(doc);
244 yyjson_val *tag = yyjson_obj_get(root, "tag_name");
245 yyjson_val *url = yyjson_obj_get(root, "html_url");
246
247 bool ok = false;
248 if (yyjson_is_str(tag) && yyjson_is_str(url)) {
249 SAFE_STRNCPY(tag_name, yyjson_get_str(tag), tag_size);
250 SAFE_STRNCPY(html_url, yyjson_get_str(url), url_size);
251 ok = true;
252 } else {
253 if (!yyjson_is_str(tag)) {
254 log_error("Failed to parse tag_name from GitHub API response");
255 }
256 if (!yyjson_is_str(url)) {
257 log_error("Failed to parse html_url from GitHub API response");
258 }
259 }
260
261 yyjson_doc_free(doc);
262 return ok;
263}
264
266 if (!result) {
267 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
268 }
269
270 memset(result, 0, sizeof(*result));
271
272 // Fill in current version and SHA
275 result->last_check_time = time(NULL);
276
277 // Test DNS connectivity first
279 log_warn("No internet connectivity detected, skipping update check");
280 // Don't update cache - we'll retry when online
281 return SET_ERRNO(ERROR_NETWORK, "DNS connectivity test failed");
282 }
283
284 // Fetch latest release from GitHub API
285 log_info("Checking for updates from GitHub releases...");
287 if (!response) {
288 log_warn("Failed to fetch GitHub releases API (timeout or network error)");
289 // Don't save cache on network failure — retry next startup
290 result->check_succeeded = false;
291 return SET_ERRNO(ERROR_NETWORK, "Failed to fetch GitHub releases");
292 }
293
294 // Parse JSON response
295 char latest_tag[64] = {0};
296 char release_url[512] = {0};
297
298 if (!parse_github_release_json(response, latest_tag, sizeof(latest_tag), release_url, sizeof(release_url))) {
299 log_error("Failed to parse GitHub API response");
300 SAFE_FREE(response);
301 // Don't save cache on parse failure — retry next startup
302 result->check_succeeded = false;
303 return SET_ERRNO(ERROR_FORMAT, "Failed to parse GitHub API JSON");
304 }
305
306 SAFE_FREE(response);
307
308 // Fill in result
309 SAFE_STRNCPY(result->latest_version, latest_tag, sizeof(result->latest_version));
310 SAFE_STRNCPY(result->release_url, release_url, sizeof(result->release_url));
311 result->latest_sha[0] = '\0';
312 result->check_succeeded = true;
313
314 // Compare versions semantically
315 semantic_version_t current_ver = version_parse(result->current_version);
316 semantic_version_t latest_ver = version_parse(result->latest_version);
317
318 if (!current_ver.valid || !latest_ver.valid) {
319 log_warn("Failed to parse version strings for comparison (current: %s, latest: %s)", result->current_version,
320 result->latest_version);
321 result->update_available = false;
322 } else {
323 int cmp = version_compare(latest_ver, current_ver);
324 result->update_available = (cmp > 0); // Update available if latest > current
325 }
326
327 if (result->update_available) {
328 log_info("Update available: %s → %s", result->current_version, result->latest_version);
329 } else {
330 log_info("Already on latest version: %s", result->current_version);
331 }
332
333 // Save to cache
335
336 return ASCIICHAT_OK;
337}
338
342static bool is_homebrew_install(void) {
343 // Check if binary is in Homebrew Cellar directory
344 char exe_path[1024];
345 if (!platform_get_executable_path(exe_path, sizeof(exe_path))) {
346 return false;
347 }
348
349 return (strstr(exe_path, "/Cellar/ascii-chat") != NULL || strstr(exe_path, "/opt/homebrew/") != NULL ||
350 strstr(exe_path, "/usr/local/Cellar/") != NULL);
351}
352
356static bool is_arch_linux(void) {
357#ifdef __linux__
358 // Check /etc/os-release for Arch
359 FILE *f = platform_fopen("file_stream", "/etc/os-release", "r");
360 if (!f) {
361 return false;
362 }
363
364 char line[256];
365 bool is_arch = false;
366 while (fgets(line, sizeof(line), f)) {
367 if (strstr(line, "ID=arch") || strstr(line, "ID=\"arch\"")) {
368 is_arch = true;
369 break;
370 }
371 }
372
373 fclose(f);
374 return is_arch;
375#else
376 return false;
377#endif
378}
379
381 if (is_homebrew_install()) {
383 }
384
385 if (is_arch_linux()) {
387 }
388
389 // Default to GitHub releases
391}
392
393void update_check_get_upgrade_suggestion(install_method_t method, const char *latest_version, char *buffer,
394 size_t buffer_size) {
395 if (!buffer || buffer_size == 0) {
396 return;
397 }
398
399 switch (method) {
401 snprintf(buffer, buffer_size, "brew upgrade ascii-chat");
402 break;
403
405 // Cache AUR helper detection to avoid spawning subprocesses on every call
406 static int aur_helper = -1; // -1 = unknown, 0 = none, 1 = paru, 2 = yay
407 if (aur_helper == -1) {
408 const char *argv_paru[] = {"paru", "--version", NULL};
409 const char *argv_yay[] = {"yay", "--version", NULL};
410 if (platform_execute_subprocess("paru", argv_paru, NULL, 0) == 0) {
411 aur_helper = 1;
412 } else if (platform_execute_subprocess("yay", argv_yay, NULL, 0) == 0) {
413 aur_helper = 2;
414 } else {
415 aur_helper = 0;
416 }
417 }
418 if (aur_helper == 1) {
419 snprintf(buffer, buffer_size, "paru -S ascii-chat");
420 } else if (aur_helper == 2) {
421 snprintf(buffer, buffer_size, "yay -S ascii-chat");
422 } else {
423 snprintf(buffer, buffer_size, "<your-aur-helper> -Ss ascii-chat");
424 }
425 break;
426 }
427
430 default:
431 snprintf(buffer, buffer_size, "https://github.com/zfogg/ascii-chat/releases/tag/%s",
432 latest_version ? latest_version : "latest");
433 break;
434 }
435}
436
437void update_check_format_notification(const update_check_result_t *result, char *buffer, size_t buffer_size) {
438 if (!result || !buffer || buffer_size == 0) {
439 return;
440 }
441
442 // Get upgrade suggestion
444 char suggestion[512];
445 update_check_get_upgrade_suggestion(method, result->latest_version, suggestion, sizeof(suggestion));
446
447 // Keep the notification useful even when it is displayed in a log line:
448 // identify the versions, provide an actionable command or download URL, and
449 // point to the exact GitHub release that was returned by the API.
450 snprintf(buffer, buffer_size, "Update available: %s → %s. %s%s. Release notes: %s", result->current_version,
451 result->latest_version,
452 (method == INSTALL_METHOD_GITHUB || method == INSTALL_METHOD_UNKNOWN) ? "Download: " : "Run: ", suggestion,
453 result->release_url);
454}
455
457 update_check_result_t local_result;
458 update_check_result_t *target = result ? result : &local_result;
459
460 // Try to load from cache first
461 asciichat_error_t cache_err = update_check_load_cache(target);
462 if (cache_err == ASCIICHAT_OK && update_check_is_cache_fresh(target)) {
463 // Cache is fresh, use it
464 log_debug("Using cached update check result (age: %.1f days)",
465 (time(NULL) - target->last_check_time) / (double)SEC_PER_DAY);
466 return ASCIICHAT_OK;
467 }
468
469 // Cache is stale or missing, perform fresh check
470 log_debug("Performing automatic update check (cache %s)", cache_err == ASCIICHAT_OK ? "stale" : "missing");
471 asciichat_error_t check_err = update_check_perform(target);
472 if (check_err != ASCIICHAT_OK) {
473 // Check failed, but don't fail startup
474 log_debug("Automatic update check failed (continuing startup)");
475 return check_err;
476 }
477
478 return ASCIICHAT_OK;
479}
⚠️‼️ Comprehensive thread-local error context system for ascii-chat
⚙️ Common definitions, error codes, macros, and types shared throughout the application
🌐 DNS resolution utilities
Cross-platform error handling utilities.
Cross-platform filesystem operations.
int buffer_size
Size of circular buffer.
Definition grep.c:90
#define SAFE_STRNCPY(dst, src, size)
Definition common.h:414
#define SAFE_FREE(ptr)
Definition common.h:376
#define SAFE_MALLOC(size, cast)
Definition common.h:264
#define SET_ERRNO(code, context_msg,...)
Set error code with custom context message and log it, returning the error code.
asciichat_error_t
Error and exit codes - unified status values (0-255)
Definition error_codes.h:49
@ ERROR_NETWORK
Definition error_codes.h:77
@ ASCIICHAT_OK
Definition error_codes.h:51
@ ERROR_FILE_OPERATION
@ ERROR_INVALID_PARAM
@ ERROR_FORMAT
#define log_warn(...)
Log a WARN message.
Definition log/log.h:574
#define log_error(...)
Log an ERROR message.
Definition log/log.h:587
#define log_info(...)
Log an INFO message.
Definition log/log.h:561
#define log_debug(...)
Log a DEBUG message.
Definition log/log.h:548
#define SEC_PER_DAY
Definition time.h:169
asciichat_error_t update_check_startup(update_check_result_t *result)
Perform startup update check with caching.
install_method_t
Installation method for suggesting upgrade command.
bool dns_test_connectivity(const char *hostname)
Test DNS connectivity by resolving a hostname.
Definition dns.c:11
char * https_get(const char *hostname, const char *path)
Perform HTTPS GET request.
void update_check_get_upgrade_suggestion(install_method_t method, const char *latest_version, char *buffer, size_t buffer_size)
Get upgrade suggestion string.
bool update_check_is_cache_fresh(const update_check_result_t *result)
Check if cache is fresh (< 7 days old)
asciichat_error_t update_check_perform(update_check_result_t *result)
Check for updates from GitHub releases API.
asciichat_error_t update_check_save_cache(const update_check_result_t *result)
Save update check result to cache.
asciichat_error_t update_check_load_cache(update_check_result_t *result)
Load cached update check result.
install_method_t update_check_detect_install_method(void)
Detect installation method.
void update_check_format_notification(const update_check_result_t *result, char *buffer, size_t buffer_size)
Format update notification message.
@ INSTALL_METHOD_ARCH_AUR
Installed via Arch AUR (paru/yay)
@ INSTALL_METHOD_UNKNOWN
Unknown installation method.
@ INSTALL_METHOD_GITHUB
Manual install from GitHub releases.
@ INSTALL_METHOD_HOMEBREW
Installed via Homebrew.
const char * file_write_error_message(const char *path)
Get human-readable error message for file write failure.
Definition filesystem.c:43
bool platform_get_executable_path(char *exe_path, size_t path_size)
Get the path to the current executable.
Definition filesystem.c:191
asciichat_error_t platform_mkdir_recursive(const char *path, int mode)
Create directories recursively (mkdir -p equivalent)
int platform_execute_subprocess(const char *executable, const char **argv, char *output_buffer, size_t output_size)
Execute a subprocess and optionally capture its output.
FILE * platform_fopen(const char *name, const char *filename, const char *mode)
Safe file open stream (fopen replacement)
int errno
const char * file_read_error_message(const char *path)
Get human-readable error message for file read failure.
Definition filesystem.c:24
char * get_config_dir(void)
Get configuration directory path with XDG_CONFIG_HOME support.
Definition path.c:527
Simple HTTPS client for fetching public keys from GitHub/GitLab.
📝 Logging API with multiple log levels and terminal output control
📂 Path Manipulation Utilities
Cross-platform network headers and socket definitions.
Cross-platform socket interface for ascii-chat.
Parsed semantic version structure.
Definition version.h:47
bool valid
True if parsing succeeded.
Definition version.h:51
Update check result data.
char latest_version[64]
Latest version tag (e.g., "v0.9.0")
char current_sha[41]
Current commit SHA.
char current_version[64]
Current version string.
time_t last_check_time
Timestamp of last check.
bool update_available
True if newer version exists.
bool check_succeeded
True if check completed (even if no update)
char release_url[512]
GitHub release page URL.
char latest_sha[41]
Latest release commit SHA (full 40 chars + null)
Cross-platform system functions interface for ascii-chat.
⏱️ High-precision timing utilities using sokol_time.h and uthash
#define GITHUB_API_HOSTNAME
#define UPDATE_CHECK_CACHE_FILENAME
#define GITHUB_RELEASES_PATH
#define UPDATE_CHECK_CACHE_MAX_AGE_SECONDS
🔄 Update checker for GitHub releases with DNS connectivity test and cache
int version_compare(semantic_version_t a, semantic_version_t b)
Compare two semantic versions.
Definition version.c:112
semantic_version_t version_parse(const char *version_string)
Parse a version string into semantic version components.
Definition version.c:49
#define ASCII_CHAT_GIT_COMMIT_HASH
Definition version.h:12
#define ASCII_CHAT_VERSION_STRING
Definition version.h:10