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>
28#define UPDATE_CHECK_CACHE_FILENAME "last_update_check"
31#define UPDATE_CHECK_CACHE_MAX_AGE_SECONDS (7 * 24 * 60 * 60)
34#define DNS_TIMEOUT_SECONDS 2
37#define GITHUB_API_HOSTNAME "api.github.com"
38#define GITHUB_RELEASES_PATH "/repos/zfogg/ascii-chat/releases/latest"
44static char *get_cache_file_path(
void) {
48 log_error(
"Failed to get config directory for update check cache");
54 char *cache_path = SAFE_MALLOC(path_len,
char *);
56 SAFE_FREE(config_dir);
63 return SET_ERRNO(ERROR_INVALID_PARAM,
"NULL result pointer");
66 memset(result, 0,
sizeof(*result));
68 char *cache_path = get_cache_file_path();
70 return SET_ERRNO(ERROR_FILE_OPERATION,
"Could not determine cache file path");
76 SAFE_FREE(cache_path);
77 return SET_ERRNO(ERROR_FILE_OPERATION,
"%s", error_msg);
82 if (!fgets(line,
sizeof(line), f)) {
84 SAFE_FREE(cache_path);
85 return SET_ERRNO(ERROR_FILE_OPERATION,
"Failed to read timestamp from cache");
87 result->last_check_time = (time_t)atoll(line);
90 if (fgets(line,
sizeof(line), f)) {
92 size_t len = strlen(line);
93 if (len > 0 && line[len - 1] ==
'\n') {
96 SAFE_STRNCPY(result->latest_version, line,
sizeof(result->latest_version));
100 if (fgets(line,
sizeof(line), f)) {
102 size_t len = strlen(line);
103 if (len > 0 && line[len - 1] ==
'\n') {
104 line[len - 1] =
'\0';
106 SAFE_STRNCPY(result->latest_sha, line,
sizeof(result->latest_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));
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);
120 if (current_ver.valid && latest_ver.valid) {
122 result->update_available = (cmp > 0);
123 result->check_succeeded =
true;
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);
131 SAFE_FREE(cache_path);
132 return SET_ERRNO(ERROR_FORMAT,
"Corrupted cache file deleted");
136 SAFE_FREE(cache_path);
142 return SET_ERRNO(ERROR_INVALID_PARAM,
"NULL result pointer");
145 char *cache_path = get_cache_file_path();
147 return SET_ERRNO(ERROR_FILE_OPERATION,
"Could not determine cache file path");
153 SAFE_FREE(cache_path);
154 return SET_ERRNO(ERROR_FILE_OPERATION,
"%s", error_msg);
158 fprintf(f,
"%lld\n", (
long long)result->last_check_time);
161 fprintf(f,
"%s\n", result->latest_version);
164 fprintf(f,
"%s\n", result->latest_sha);
167 SAFE_FREE(cache_path);
173 if (!result || result->last_check_time == 0) {
177 time_t now = time(NULL);
178 time_t age = now - result->last_check_time;
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) {
198 snprintf(pattern,
sizeof(pattern),
"\"%s\"", field);
200 const char *field_start = strstr(json, pattern);
206 const char *value_start = strchr(field_start + strlen(pattern),
'"');
213 const char *value_end = strchr(value_start,
'"');
219 size_t value_len = value_end - value_start;
220 if (value_len >= output_size) {
221 value_len = output_size - 1;
224 memcpy(output, value_start, value_len);
225 output[value_len] =
'\0';
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) {
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");
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");
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");
270 return SET_ERRNO(ERROR_INVALID_PARAM,
"NULL result pointer");
273 memset(result, 0,
sizeof(*result));
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);
282 log_warn(
"No internet connectivity detected, skipping update check");
284 return SET_ERRNO(ERROR_NETWORK,
"DNS connectivity test failed");
288 log_info(
"Checking for updates from GitHub releases...");
291 log_warn(
"Failed to fetch GitHub releases API (timeout or network error)");
293 result->check_succeeded =
false;
295 return SET_ERRNO(ERROR_NETWORK,
"Failed to fetch GitHub releases");
299 char latest_tag[64] = {0};
300 char latest_sha[41] = {0};
301 char release_url[512] = {0};
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");
307 result->check_succeeded =
false;
309 return SET_ERRNO(ERROR_FORMAT,
"Failed to parse GitHub API JSON");
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;
321 semantic_version_t current_ver =
version_parse(result->current_version);
322 semantic_version_t latest_ver =
version_parse(result->latest_version);
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;
330 result->update_available = (cmp > 0);
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);
337 log_info(
"Already on latest version: %s (%.*s)", result->current_version, 8, result->current_sha);
349static bool is_homebrew_install(
void) {
356 return (strstr(exe_path,
"/Cellar/ascii-chat") != NULL || strstr(exe_path,
"/opt/homebrew/") != NULL ||
357 strstr(exe_path,
"/usr/local/Cellar/") != NULL);
363static bool is_arch_linux(
void) {
372 bool is_arch =
false;
373 while (fgets(line,
sizeof(line), f)) {
374 if (strstr(line,
"ID=arch") || strstr(line,
"ID=\"arch\"")) {
388 if (is_homebrew_install()) {
389 return INSTALL_METHOD_HOMEBREW;
392 if (is_arch_linux()) {
393 return INSTALL_METHOD_ARCH_AUR;
397 return INSTALL_METHOD_GITHUB;
407 case INSTALL_METHOD_HOMEBREW:
408 snprintf(buffer,
buffer_size,
"brew upgrade ascii-chat");
411 case INSTALL_METHOD_ARCH_AUR:
413 if (system(
"command -v paru >/dev/null 2>&1") == 0) {
414 snprintf(buffer,
buffer_size,
"paru -S ascii-chat");
416 snprintf(buffer,
buffer_size,
"yay -S ascii-chat");
420 case INSTALL_METHOD_GITHUB:
421 case INSTALL_METHOD_UNKNOWN:
423 snprintf(buffer,
buffer_size,
"https://github.com/zfogg/ascii-chat/releases/tag/%s",
424 latest_version ? latest_version :
"latest");
436 char suggestion[512];
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);
446 update_check_result_t local_result;
447 update_check_result_t *target = result ? result : &local_result;
453 log_debug(
"Using cached update check result (age: %.1f days)",
454 (time(NULL) - target->last_check_time) / (
double)SEC_PER_DAY);
459 log_debug(
"Performing automatic update check (cache %s)", cache_err == ASCIICHAT_OK ?
"stale" :
"missing");
461 if (check_err != ASCIICHAT_OK) {
463 log_debug(
"Automatic update check failed (continuing startup)");
bool dns_test_connectivity(const char *hostname)
const char * file_write_error_message(const char *path)
const char * file_read_error_message(const char *path)
int buffer_size
Size of circular buffer.
char * https_get(const char *hostname, const char *path)
char * get_config_dir(void)
bool platform_get_executable_path(char *exe_path, size_t path_size)
Get the path to the current executable.
#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)
semantic_version_t version_parse(const char *version_string)
FILE * platform_fopen(const char *filename, const char *mode)