ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
update_checker.c
Go to the documentation of this file.
1
6#include <ascii-chat/network/update_checker.h>
7#include <ascii-chat/network/http_client.h>
8#include <ascii-chat/network/dns.h>
9#include <ascii-chat/version.h>
10#include <ascii-chat/asciichat_errno.h>
11#include <ascii-chat/platform/socket.h>
12#include <ascii-chat/platform/network.h>
13#include <ascii-chat/platform/system.h>
14#include <ascii-chat/platform/filesystem.h>
15#include <ascii-chat/platform/util.h>
16#include <ascii-chat/util/path.h>
17#include <ascii-chat/util/time.h>
18#include <ascii-chat/log/logging.h>
19#include <ascii-chat/common.h>
20
21#include <stdio.h>
22#include <stdlib.h>
23#include <string.h>
24#include <time.h>
25#include <errno.h>
26
27// Cache file is stored in ~/.config/ascii-chat/last_update_check
28#define UPDATE_CHECK_CACHE_FILENAME "last_update_check"
29
30// Check is fresh if < 7 days old
31#define UPDATE_CHECK_CACHE_MAX_AGE_SECONDS (7 * 24 * 60 * 60)
32
33// DNS timeout for connectivity check
34#define DNS_TIMEOUT_SECONDS 2
35
36// GitHub API endpoint
37#define GITHUB_API_HOSTNAME "api.github.com"
38#define GITHUB_RELEASES_PATH "/repos/zfogg/ascii-chat/releases/latest"
39
44static char *get_cache_file_path(void) {
45 // Get config directory
46 char *config_dir = get_config_dir();
47 if (!config_dir) {
48 log_error("Failed to get config directory for update check cache");
49 return NULL;
50 }
51
52 // Build cache file path
53 size_t path_len = strlen(config_dir) + strlen(UPDATE_CHECK_CACHE_FILENAME) + 2;
54 char *cache_path = SAFE_MALLOC(path_len, char *);
55 snprintf(cache_path, path_len, "%s%s", config_dir, UPDATE_CHECK_CACHE_FILENAME);
56 SAFE_FREE(config_dir);
57
58 return cache_path;
59}
60
61asciichat_error_t update_check_load_cache(update_check_result_t *result) {
62 if (!result) {
63 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
64 }
65
66 memset(result, 0, sizeof(*result));
67
68 char *cache_path = get_cache_file_path();
69 if (!cache_path) {
70 return SET_ERRNO(ERROR_FILE_OPERATION, "Could not determine cache file path");
71 }
72
73 FILE *f = platform_fopen(cache_path, "r");
74 if (!f) {
75 const char *error_msg = file_read_error_message(cache_path);
76 SAFE_FREE(cache_path);
77 return SET_ERRNO(ERROR_FILE_OPERATION, "%s", error_msg);
78 }
79
80 // Read line 1: timestamp
81 char line[512];
82 if (!fgets(line, sizeof(line), f)) {
83 fclose(f);
84 SAFE_FREE(cache_path);
85 return SET_ERRNO(ERROR_FILE_OPERATION, "Failed to read timestamp from cache");
86 }
87 result->last_check_time = (time_t)atoll(line);
88
89 // Read line 2: latest version (may be empty if check failed)
90 if (fgets(line, sizeof(line), f)) {
91 // Remove newline
92 size_t len = strlen(line);
93 if (len > 0 && line[len - 1] == '\n') {
94 line[len - 1] = '\0';
95 }
96 SAFE_STRNCPY(result->latest_version, line, sizeof(result->latest_version));
97 }
98
99 // Read line 3: latest SHA (may be empty if check failed)
100 if (fgets(line, sizeof(line), f)) {
101 // Remove newline
102 size_t len = strlen(line);
103 if (len > 0 && line[len - 1] == '\n') {
104 line[len - 1] = '\0';
105 }
106 SAFE_STRNCPY(result->latest_sha, line, sizeof(result->latest_sha));
107 }
108
109 fclose(f);
110
111 // Fill in current version/SHA
112 SAFE_STRNCPY(result->current_version, ASCII_CHAT_VERSION_STRING, sizeof(result->current_version));
113 SAFE_STRNCPY(result->current_sha, ASCII_CHAT_GIT_COMMIT_HASH, sizeof(result->current_sha));
114
115 // Determine if update is available using version comparison (if we have cached data)
116 if (result->latest_version[0] != '\0') {
117 semantic_version_t current_ver = version_parse(result->current_version);
118 semantic_version_t latest_ver = version_parse(result->latest_version);
119
120 if (current_ver.valid && latest_ver.valid) {
121 int cmp = version_compare(latest_ver, current_ver);
122 result->update_available = (cmp > 0); // Update available if latest > current
123 result->check_succeeded = true;
124 } else {
125 // Cache contains invalid version data - delete it
126 log_warn("Update cache contains invalid version data (current:%s, latest:%s) - deleting corrupted cache",
127 result->current_version, result->latest_version);
128 if (remove(cache_path) != 0) {
129 log_warn("Failed to delete corrupted cache file: %s", cache_path);
130 }
131 SAFE_FREE(cache_path);
132 return SET_ERRNO(ERROR_FORMAT, "Corrupted cache file deleted");
133 }
134 }
135
136 SAFE_FREE(cache_path);
137 return ASCIICHAT_OK;
138}
139
140asciichat_error_t update_check_save_cache(const update_check_result_t *result) {
141 if (!result) {
142 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
143 }
144
145 char *cache_path = get_cache_file_path();
146 if (!cache_path) {
147 return SET_ERRNO(ERROR_FILE_OPERATION, "Could not determine cache file path");
148 }
149
150 FILE *f = platform_fopen(cache_path, "w");
151 if (!f) {
152 const char *error_msg = file_write_error_message(cache_path);
153 SAFE_FREE(cache_path);
154 return SET_ERRNO(ERROR_FILE_OPERATION, "%s", error_msg);
155 }
156
157 // Write timestamp
158 fprintf(f, "%lld\n", (long long)result->last_check_time);
159
160 // Write version (may be empty if check failed)
161 fprintf(f, "%s\n", result->latest_version);
162
163 // Write SHA (may be empty if check failed)
164 fprintf(f, "%s\n", result->latest_sha);
165
166 fclose(f);
167 SAFE_FREE(cache_path);
168
169 return ASCIICHAT_OK;
170}
171
172bool update_check_is_cache_fresh(const update_check_result_t *result) {
173 if (!result || result->last_check_time == 0) {
174 return false;
175 }
176
177 time_t now = time(NULL);
178 time_t age = now - result->last_check_time;
179
181}
182
191static bool parse_json_string_field(const char *json, const char *field, char *output, size_t output_size) {
192 if (!json || !field || !output || output_size == 0) {
193 return false;
194 }
195
196 // Build search pattern: "field":"value"
197 char pattern[128];
198 snprintf(pattern, sizeof(pattern), "\"%s\"", field);
199
200 const char *field_start = strstr(json, pattern);
201 if (!field_start) {
202 return false;
203 }
204
205 // Skip past field name and find opening quote
206 const char *value_start = strchr(field_start + strlen(pattern), '"');
207 if (!value_start) {
208 return false;
209 }
210 value_start++; // Skip opening quote
211
212 // Find closing quote
213 const char *value_end = strchr(value_start, '"');
214 if (!value_end) {
215 return false;
216 }
217
218 // Copy value
219 size_t value_len = value_end - value_start;
220 if (value_len >= output_size) {
221 value_len = output_size - 1;
222 }
223
224 memcpy(output, value_start, value_len);
225 output[value_len] = '\0';
226
227 return true;
228}
229
241static bool parse_github_release_json(const char *json, char *tag_name, size_t tag_size, char *commit_sha,
242 size_t sha_size, char *html_url, size_t url_size) {
243 if (!json) {
244 return false;
245 }
246
247 // Extract tag_name
248 if (!parse_json_string_field(json, "tag_name", tag_name, tag_size)) {
249 log_error("Failed to parse tag_name from GitHub API response");
250 return false;
251 }
252
253 // Extract target_commitish (the SHA)
254 if (!parse_json_string_field(json, "target_commitish", commit_sha, sha_size)) {
255 log_error("Failed to parse target_commitish from GitHub API response");
256 return false;
257 }
258
259 // Extract html_url
260 if (!parse_json_string_field(json, "html_url", html_url, url_size)) {
261 log_error("Failed to parse html_url from GitHub API response");
262 return false;
263 }
264
265 return true;
266}
267
268asciichat_error_t update_check_perform(update_check_result_t *result) {
269 if (!result) {
270 return SET_ERRNO(ERROR_INVALID_PARAM, "NULL result pointer");
271 }
272
273 memset(result, 0, sizeof(*result));
274
275 // Fill in current version and SHA
276 SAFE_STRNCPY(result->current_version, ASCII_CHAT_VERSION_STRING, sizeof(result->current_version));
277 SAFE_STRNCPY(result->current_sha, ASCII_CHAT_GIT_COMMIT_HASH, sizeof(result->current_sha));
278 result->last_check_time = time(NULL);
279
280 // Test DNS connectivity first
282 log_warn("No internet connectivity detected, skipping update check");
283 // Don't update cache - we'll retry when online
284 return SET_ERRNO(ERROR_NETWORK, "DNS connectivity test failed");
285 }
286
287 // Fetch latest release from GitHub API
288 log_info("Checking for updates from GitHub releases...");
290 if (!response) {
291 log_warn("Failed to fetch GitHub releases API (timeout or network error)");
292 // Mark as checked even though it failed (prevents repeated offline attempts)
293 result->check_succeeded = false;
295 return SET_ERRNO(ERROR_NETWORK, "Failed to fetch GitHub releases");
296 }
297
298 // Parse JSON response
299 char latest_tag[64] = {0};
300 char latest_sha[41] = {0};
301 char release_url[512] = {0};
302
303 if (!parse_github_release_json(response, latest_tag, sizeof(latest_tag), latest_sha, sizeof(latest_sha), release_url,
304 sizeof(release_url))) {
305 log_error("Failed to parse GitHub API response");
306 SAFE_FREE(response);
307 result->check_succeeded = false;
309 return SET_ERRNO(ERROR_FORMAT, "Failed to parse GitHub API JSON");
310 }
311
312 SAFE_FREE(response);
313
314 // Fill in result
315 SAFE_STRNCPY(result->latest_version, latest_tag, sizeof(result->latest_version));
316 SAFE_STRNCPY(result->latest_sha, latest_sha, sizeof(result->latest_sha));
317 SAFE_STRNCPY(result->release_url, release_url, sizeof(result->release_url));
318 result->check_succeeded = true;
319
320 // Compare versions semantically
321 semantic_version_t current_ver = version_parse(result->current_version);
322 semantic_version_t latest_ver = version_parse(result->latest_version);
323
324 if (!current_ver.valid || !latest_ver.valid) {
325 log_warn("Failed to parse version strings for comparison (current: %s, latest: %s)", result->current_version,
326 result->latest_version);
327 result->update_available = false;
328 } else {
329 int cmp = version_compare(latest_ver, current_ver);
330 result->update_available = (cmp > 0); // Update available if latest > current
331 }
332
333 if (result->update_available) {
334 log_info("Update available: %s (%.*s) → %s (%.*s)", result->current_version, 8, result->current_sha,
335 result->latest_version, 8, result->latest_sha);
336 } else {
337 log_info("Already on latest version: %s (%.*s)", result->current_version, 8, result->current_sha);
338 }
339
340 // Save to cache
342
343 return ASCIICHAT_OK;
344}
345
349static bool is_homebrew_install(void) {
350 // Check if binary is in Homebrew Cellar directory
351 char exe_path[1024];
352 if (!platform_get_executable_path(exe_path, sizeof(exe_path))) {
353 return false;
354 }
355
356 return (strstr(exe_path, "/Cellar/ascii-chat") != NULL || strstr(exe_path, "/opt/homebrew/") != NULL ||
357 strstr(exe_path, "/usr/local/Cellar/") != NULL);
358}
359
363static bool is_arch_linux(void) {
364#ifdef __linux__
365 // Check /etc/os-release for Arch
366 FILE *f = platform_fopen("/etc/os-release", "r");
367 if (!f) {
368 return false;
369 }
370
371 char line[256];
372 bool is_arch = false;
373 while (fgets(line, sizeof(line), f)) {
374 if (strstr(line, "ID=arch") || strstr(line, "ID=\"arch\"")) {
375 is_arch = true;
376 break;
377 }
378 }
379
380 fclose(f);
381 return is_arch;
382#else
383 return false;
384#endif
385}
386
388 if (is_homebrew_install()) {
389 return INSTALL_METHOD_HOMEBREW;
390 }
391
392 if (is_arch_linux()) {
393 return INSTALL_METHOD_ARCH_AUR;
394 }
395
396 // Default to GitHub releases
397 return INSTALL_METHOD_GITHUB;
398}
399
400void update_check_get_upgrade_suggestion(install_method_t method, const char *latest_version, char *buffer,
401 size_t buffer_size) {
402 if (!buffer || buffer_size == 0) {
403 return;
404 }
405
406 switch (method) {
407 case INSTALL_METHOD_HOMEBREW:
408 snprintf(buffer, buffer_size, "brew upgrade ascii-chat");
409 break;
410
411 case INSTALL_METHOD_ARCH_AUR:
412 // Check if paru is available, otherwise suggest yay
413 if (system("command -v paru >/dev/null 2>&1") == 0) {
414 snprintf(buffer, buffer_size, "paru -S ascii-chat");
415 } else {
416 snprintf(buffer, buffer_size, "yay -S ascii-chat");
417 }
418 break;
419
420 case INSTALL_METHOD_GITHUB:
421 case INSTALL_METHOD_UNKNOWN:
422 default:
423 snprintf(buffer, buffer_size, "https://github.com/zfogg/ascii-chat/releases/tag/%s",
424 latest_version ? latest_version : "latest");
425 break;
426 }
427}
428
429void update_check_format_notification(const update_check_result_t *result, char *buffer, size_t buffer_size) {
430 if (!result || !buffer || buffer_size == 0) {
431 return;
432 }
433
434 // Get upgrade suggestion
435 install_method_t method = update_check_detect_install_method();
436 char suggestion[512];
437 update_check_get_upgrade_suggestion(method, result->latest_version, suggestion, sizeof(suggestion));
438
439 // Format: "Update available: v0.8.1 (f8dc35e1) → v0.9.0 (a1b2c3d4). Run: brew upgrade ascii-chat"
440 snprintf(buffer, buffer_size, "Update available: %s (%.8s) → %s (%.8s). %s%s", result->current_version,
441 result->current_sha, result->latest_version, result->latest_sha,
442 (method == INSTALL_METHOD_GITHUB || method == INSTALL_METHOD_UNKNOWN) ? "Download: " : "Run: ", suggestion);
443}
444
445asciichat_error_t update_check_startup(update_check_result_t *result) {
446 update_check_result_t local_result;
447 update_check_result_t *target = result ? result : &local_result;
448
449 // Try to load from cache first
450 asciichat_error_t cache_err = update_check_load_cache(target);
451 if (cache_err == ASCIICHAT_OK && update_check_is_cache_fresh(target)) {
452 // Cache is fresh, use it
453 log_debug("Using cached update check result (age: %.1f days)",
454 (time(NULL) - target->last_check_time) / (double)SEC_PER_DAY);
455 return ASCIICHAT_OK;
456 }
457
458 // Cache is stale or missing, perform fresh check
459 log_debug("Performing automatic update check (cache %s)", cache_err == ASCIICHAT_OK ? "stale" : "missing");
460 asciichat_error_t check_err = update_check_perform(target);
461 if (check_err != ASCIICHAT_OK) {
462 // Check failed, but don't fail startup
463 log_debug("Automatic update check failed (continuing startup)");
464 return check_err;
465 }
466
467 return ASCIICHAT_OK;
468}
bool dns_test_connectivity(const char *hostname)
Definition dns.c:11
const char * file_write_error_message(const char *path)
Definition filesystem.c:33
const char * file_read_error_message(const char *path)
Definition filesystem.c:15
int buffer_size
Size of circular buffer.
Definition grep.c:84
char * https_get(const char *hostname, const char *path)
char * get_config_dir(void)
Definition path.c:493
bool platform_get_executable_path(char *exe_path, size_t path_size)
Get the path to the current executable.
Definition system.c:393
#define GITHUB_API_HOSTNAME
#define UPDATE_CHECK_CACHE_FILENAME
asciichat_error_t update_check_startup(update_check_result_t *result)
void update_check_get_upgrade_suggestion(install_method_t method, const char *latest_version, char *buffer, size_t buffer_size)
#define GITHUB_RELEASES_PATH
bool update_check_is_cache_fresh(const update_check_result_t *result)
asciichat_error_t update_check_perform(update_check_result_t *result)
asciichat_error_t update_check_save_cache(const update_check_result_t *result)
#define UPDATE_CHECK_CACHE_MAX_AGE_SECONDS
asciichat_error_t update_check_load_cache(update_check_result_t *result)
install_method_t update_check_detect_install_method(void)
void update_check_format_notification(const update_check_result_t *result, char *buffer, size_t buffer_size)
int version_compare(semantic_version_t a, semantic_version_t b)
Definition version.c:112
semantic_version_t version_parse(const char *version_string)
Definition version.c:49
FILE * platform_fopen(const char *filename, const char *mode)