#include "downloader.h" #include "obfuscation.h" #include "debug-log.h" #include "compat.h" #include #include #include #include #include #ifdef _WIN32 #include #include #include #include #define PATH_SEP '\\' #else #include #include #define PATH_SEP '/' #endif /* ---- Error reporting ---- */ static char s_last_error[512] = ""; static void set_error(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vsnprintf(s_last_error, sizeof(s_last_error), fmt, ap); va_end(ap); } const char *downloader_last_error(void) { return s_last_error; } /* ---- cURL helpers ---- */ struct mem_buf { char *data; size_t size; }; static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) { size_t total = size * nmemb; struct mem_buf *buf = (struct mem_buf *)userp; char *tmp = (char *)realloc(buf->data, buf->size + total + 1); if (!tmp) return 0; buf->data = tmp; memcpy(buf->data + buf->size, contents, total); buf->size += total; buf->data[buf->size] = '\0'; return total; } static size_t write_file_cb(void *contents, size_t size, size_t nmemb, void *userp) { FILE *f = (FILE *)userp; return fwrite(contents, size, nmemb, f); } static void curl_set_ssl_opts(CURL *curl) { curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); #ifdef _WIN32 curl_easy_setopt(curl, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2 | CURL_SSLVERSION_MAX_TLSv1_2); curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, (long)CURLSSLOPT_NO_REVOKE); curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, (long)CURL_HTTP_VERSION_1_1); curl_easy_setopt(curl, CURLOPT_SSL_ENABLE_ALPN, 0L); curl_easy_setopt(curl, CURLOPT_IPRESOLVE, (long)CURL_IPRESOLVE_V4); #endif } static char *api_get(const char *path, const char *token) { CURL *curl = curl_easy_init(); if (!curl) return NULL; char url[512]; snprintf(url, sizeof(url), "%s%s%s", obf_https_prefix(), obf_stools_host(), path); struct curl_slist *headers = NULL; if (token && token[0]) { char auth_header[512]; snprintf(auth_header, sizeof(auth_header), obf_auth_bearer_fmt(), token); headers = curl_slist_append(headers, auth_header); } char ua[128]; snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION); struct mem_buf buf = {NULL, 0}; curl_easy_setopt(curl, CURLOPT_URL, url); if (headers) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_USERAGENT, ua); curl_set_ssl_opts(curl); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); if (headers) curl_slist_free_all(headers); curl_easy_cleanup(curl); if (res != CURLE_OK || http_code != 200) { dbg_log(LOG_WARNING, "[%s] API GET %s: %s (HTTP %ld)", PLUGIN_NAME, path, res != CURLE_OK ? curl_easy_strerror(res) : "error", http_code); free(buf.data); return NULL; } return buf.data; } /* ---- JSON helpers ---- */ static const char *json_find_key(const char *json, const char *key) { char search[256]; snprintf(search, sizeof(search), "\"%s\"", key); const char *pos = strstr(json, search); if (!pos) return NULL; pos += strlen(search); while (*pos == ' ' || *pos == ':') pos++; return pos; } static void json_extract_string(const char *json, const char *key, char *out, size_t out_sz) { out[0] = '\0'; const char *v = json_find_key(json, key); if (!v || *v != '"') return; v++; const char *end = strchr(v, '"'); if (!end) return; size_t len = (size_t)(end - v); if (len >= out_sz) len = out_sz - 1; memcpy(out, v, len); out[len] = '\0'; } static int json_extract_int(const char *json, const char *key) { const char *v = json_find_key(json, key); if (!v) return -1; return atoi(v); } /* ---- OBS plugin directory ---- */ static void ensure_dir_exists(const char *path) { #ifdef _WIN32 char tmp[512]; snprintf(tmp, sizeof(tmp), "%s", path); for (char *p = tmp + 3; *p; p++) { if (*p == '\\' || *p == '/') { *p = '\0'; CreateDirectoryA(tmp, NULL); *p = '\\'; } } CreateDirectoryA(tmp, NULL); #else char cmd[1024]; snprintf(cmd, sizeof(cmd), "mkdir -p '%s'", path); system(cmd); #endif } bool downloader_get_obs_plugin_dir(char *buf, size_t sz) { #ifdef _WIN32 char exe_path[MAX_PATH]; GetModuleFileNameA(NULL, exe_path, MAX_PATH); /* Go up from 64bit to obs-studio root */ char *last_sep = strrchr(exe_path, '\\'); if (last_sep) *last_sep = '\0'; /* remove obs64.exe */ last_sep = strrchr(exe_path, '\\'); if (last_sep) *last_sep = '\0'; /* remove 64bit */ last_sep = strrchr(exe_path, '\\'); if (last_sep) *last_sep = '\0'; /* remove bin */ snprintf(buf, sz, "%s\\obs-plugins\\64bit", exe_path); ensure_dir_exists(buf); return true; #elif defined(__APPLE__) const char *home = getenv("HOME"); if (!home) return false; snprintf(buf, sz, "%s/Library/Application Support/obs-studio/obs-plugins", home); mkdir(buf, 0755); return true; #else const char *home = getenv("HOME"); if (!home) return false; snprintf(buf, sz, "%s/.config/obs-studio/plugins", home); mkdir(buf, 0755); return true; #endif } /* ---- Parse plugin list from /api/products ---- */ static const char *platform_suffix(void) { #ifdef _WIN32 return "windows"; #elif defined(__APPLE__) return "macos"; #else return "linux"; #endif } bool downloader_fetch_plugin_list(const char *token, struct plugin_list *out) { memset(out, 0, sizeof(*out)); /* /api/products is public, but send token if available */ char *json = api_get(obf_api_products_path(), token); if (!json) return false; /* * Response: array of product objects * [{"id":"easy-irl-stream","name":"Easy IRL Stream", * "type":"download","accessLevel":"free",...}, ...] * * We only show products of type "download" */ const char *pos = json; while (out->count < MAX_PLUGINS) { const char *obj_start = strchr(pos, '{'); if (!obj_start) break; /* Find matching closing brace (simplified: first } works for * flat objects; nested JSON could break this but products are flat) */ int depth = 0; const char *p = obj_start; const char *obj_end = NULL; while (*p) { if (*p == '{') depth++; else if (*p == '}') { depth--; if (depth == 0) { obj_end = p; break; } } p++; } if (!obj_end) break; size_t obj_len = (size_t)(obj_end - obj_start + 1); char *obj_buf = (char *)malloc(obj_len + 1); if (!obj_buf) break; memcpy(obj_buf, obj_start, obj_len); obj_buf[obj_len] = '\0'; char type[64] = ""; json_extract_string(obj_buf, "type", type, sizeof(type)); /* Only include downloadable plugins */ if (strcmp(type, "download") == 0) { struct plugin_info *pi = &out->items[out->count]; json_extract_string(obj_buf, "id", pi->slug, sizeof(pi->slug)); json_extract_string(obj_buf, "name", pi->name, sizeof(pi->name)); if (pi->slug[0] && pi->name[0]) out->count++; } free(obj_buf); pos = obj_end + 1; } free(json); /* Now fetch latest version for each plugin from /api/releases/{slug} */ for (int i = 0; i < out->count; i++) { struct plugin_info *pi = &out->items[i]; char path[256]; snprintf(path, sizeof(path), obf_api_releases_fmt(), pi->slug); char *rel_json = api_get(path, token); if (!rel_json) continue; /* * Response: {"product":{...},"releases":[{...},...]}} * First release is latest. Has "version":"vX.Y.Z" and * "assets":[{"id":123,"filename":"...","platform":"windows",...}] */ const char *releases_key = strstr(rel_json, "\"releases\""); if (!releases_key) { free(rel_json); continue; } /* Find first release object */ const char *first_rel = strchr(releases_key, '{'); if (first_rel) { char version[64] = ""; json_extract_string(first_rel, "version", version, sizeof(version)); /* Strip leading 'v' if present */ const char *ver = version; if (ver[0] == 'v' || ver[0] == 'V') ver++; snprintf(pi->latest_version, sizeof(pi->latest_version), "%s", ver); /* Find matching asset for our platform */ const char *assets_key = strstr(first_rel, "\"assets\""); if (assets_key) { const char *asset_pos = assets_key; const char *plat = platform_suffix(); while ((asset_pos = strchr(asset_pos, '{')) != NULL) { char asset_plat[64] = ""; char asset_fn[256] = ""; json_extract_string(asset_pos, "platform", asset_plat, sizeof(asset_plat)); json_extract_string(asset_pos, "filename", asset_fn, sizeof(asset_fn)); int asset_id = json_extract_int( asset_pos, "id"); if (strcmp(asset_plat, plat) == 0 && asset_id > 0) { pi->download_asset_id = asset_id; break; } asset_pos++; } } } free(rel_json); } dbg_log(LOG_INFO, "[%s] Fetched %d plugins", PLUGIN_NAME, out->count); return out->count > 0; } /* ---- Detect installed plugins ---- */ static bool file_exists(const char *path) { #ifdef _WIN32 DWORD attrs = GetFileAttributesA(path); return attrs != INVALID_FILE_ATTRIBUTES; #else return access(path, F_OK) == 0; #endif } static bool read_version_file(const char *dir, const char *slug, char *ver, size_t ver_sz) { char path[512]; snprintf(path, sizeof(path), "%s%c.%s.version", dir, PATH_SEP, slug); FILE *f = fopen(path, "r"); if (!f) return false; if (!fgets(ver, (int)ver_sz, f)) { fclose(f); return false; } fclose(f); size_t len = strlen(ver); while (len > 0 && (ver[len - 1] == '\n' || ver[len - 1] == '\r')) ver[--len] = '\0'; return len > 0; } static int compare_versions(const char *a, const char *b) { int a1 = 0, a2 = 0, a3 = 0, b1 = 0, b2 = 0, b3 = 0; sscanf(a, "%d.%d.%d", &a1, &a2, &a3); sscanf(b, "%d.%d.%d", &b1, &b2, &b3); if (a1 != b1) return a1 - b1; if (a2 != b2) return a2 - b2; return a3 - b3; } void downloader_detect_installed(struct plugin_list *list, const char *obs_plugin_dir) { for (int i = 0; i < list->count; i++) { struct plugin_info *pi = &list->items[i]; char dll_path[512]; #ifdef _WIN32 snprintf(dll_path, sizeof(dll_path), "%s\\%s.dll", obs_plugin_dir, pi->slug); #else snprintf(dll_path, sizeof(dll_path), "%s/%s.so", obs_plugin_dir, pi->slug); #endif pi->installed = file_exists(dll_path); if (pi->installed) { if (read_version_file(obs_plugin_dir, pi->slug, pi->installed_version, sizeof(pi->installed_version))) { pi->update_available = compare_versions(pi->latest_version, pi->installed_version) > 0; } else { snprintf(pi->installed_version, sizeof(pi->installed_version), "?"); pi->update_available = true; } } } } /* ---- Download and install a plugin ---- */ /* ---- Archive extraction via system tools ---- */ static bool is_zip(const char *path) { FILE *f = fopen(path, "rb"); if (!f) return false; unsigned char sig[4] = {0}; fread(sig, 1, 4, f); fclose(f); return sig[0] == 'P' && sig[1] == 'K' && sig[2] == 0x03 && sig[3] == 0x04; } static bool is_targz(const char *path) { FILE *f = fopen(path, "rb"); if (!f) return false; unsigned char sig[2] = {0}; fread(sig, 1, 2, f); fclose(f); return sig[0] == 0x1f && sig[1] == 0x8b; } static bool get_temp_dir(char *buf, size_t sz) { #ifdef _WIN32 GetTempPathA((DWORD)sz, buf); return true; #else snprintf(buf, sz, "/tmp"); return true; #endif } /* * Extract archive to a temp directory, then find the plugin binary. * * Release ZIP structure (Windows): * easy-irl-stream/obs-plugins/64bit/easy-irl-stream.dll * * Release tar.gz structure (Linux): * obs-plugins/easy-irl-stream.so */ static bool extract_archive(const char *archive_path, const char *extract_dir) { #ifdef _WIN32 char cmd[2048]; snprintf(cmd, sizeof(cmd), "powershell -NoProfile -Command \"Expand-Archive -Force " "-Path '%s' -DestinationPath '%s'\"", archive_path, extract_dir); int ret = system(cmd); return ret == 0; #else char cmd[2048]; if (is_targz(archive_path)) { snprintf(cmd, sizeof(cmd), "mkdir -p '%s' && tar xzf '%s' -C '%s'", extract_dir, archive_path, extract_dir); } else { snprintf(cmd, sizeof(cmd), "mkdir -p '%s' && unzip -o '%s' -d '%s'", extract_dir, archive_path, extract_dir); } int ret = system(cmd); return ret == 0; #endif } /* * Recursively search for the plugin binary (slug.dll/.so) in extract_dir. * This handles any nested folder structure. */ static bool find_plugin_binary(const char *dir, const char *filename, char *result, size_t result_sz) { #ifdef _WIN32 char search[512]; snprintf(search, sizeof(search), "%s\\*", dir); WIN32_FIND_DATAA fd; HANDLE hFind = FindFirstFileA(search, &fd); if (hFind == INVALID_HANDLE_VALUE) return false; do { if (fd.cFileName[0] == '.') continue; char full[512]; snprintf(full, sizeof(full), "%s\\%s", dir, fd.cFileName); if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { if (find_plugin_binary(full, filename, result, result_sz)) { FindClose(hFind); return true; } } else if (_stricmp(fd.cFileName, filename) == 0) { snprintf(result, result_sz, "%s", full); FindClose(hFind); return true; } } while (FindNextFileA(hFind, &fd)); FindClose(hFind); return false; #else char cmd[1024]; snprintf(cmd, sizeof(cmd), "find '%s' -name '%s' -type f 2>/dev/null | head -1", dir, filename); FILE *p = popen(cmd, "r"); if (!p) return false; if (fgets(result, (int)result_sz, p)) { size_t len = strlen(result); while (len > 0 && (result[len-1] == '\n' || result[len-1] == '\r')) result[--len] = '\0'; pclose(p); return len > 0; } pclose(p); return false; #endif } static void remove_directory(const char *dir) { #ifdef _WIN32 char cmd[1024]; snprintf(cmd, sizeof(cmd), "rmdir /s /q \"%s\"", dir); system(cmd); #else char cmd[1024]; snprintf(cmd, sizeof(cmd), "rm -rf '%s'", dir); system(cmd); #endif } /* * Copy a file with UAC elevation on Windows. * Launches a hidden PowerShell process as Administrator. */ #ifdef _WIN32 static bool copy_elevated(const char *src, const char *dst) { char ps_args[2048]; snprintf(ps_args, sizeof(ps_args), "-NoProfile -WindowStyle Hidden -Command \"" "Copy-Item -Force -Path '%s' -Destination '%s'\"", src, dst); SHELLEXECUTEINFOA sei; memset(&sei, 0, sizeof(sei)); sei.cbSize = sizeof(sei); sei.fMask = SEE_MASK_NOCLOSEPROCESS; sei.lpVerb = "runas"; sei.lpFile = "powershell.exe"; sei.lpParameters = ps_args; sei.nShow = SW_HIDE; if (!ShellExecuteExA(&sei)) { dbg_log(LOG_ERROR, "[%s] UAC elevation denied or failed", PLUGIN_NAME); return false; } HANDLE proc = sei.hProcess; WaitForSingleObject(proc, 30000); DWORD exit_code = 1; GetExitCodeProcess(proc, &exit_code); CloseHandle(proc); return exit_code == 0; } #endif static void hide_file(const char *path) { #ifdef _WIN32 SetFileAttributesA(path, FILE_ATTRIBUTE_HIDDEN); #else (void)path; #endif } bool downloader_write_version_file(const char *obs_plugin_dir, const char *slug, const char *version) { char ver_path[512]; snprintf(ver_path, sizeof(ver_path), "%s%c.%s.version", obs_plugin_dir, PATH_SEP, slug); FILE *f = fopen(ver_path, "w"); if (f) { fputs(version, f); fclose(f); hide_file(ver_path); return true; } #ifdef _WIN32 /* Try elevated write */ char tmp_ver[512]; char tmp_d[512]; get_temp_dir(tmp_d, sizeof(tmp_d)); snprintf(tmp_ver, sizeof(tmp_ver), "%s%cst_pm_%s.version", tmp_d, PATH_SEP, slug); f = fopen(tmp_ver, "w"); if (!f) return false; fputs(version, f); fclose(f); bool ok = copy_elevated(tmp_ver, ver_path); remove(tmp_ver); return ok; #else return false; #endif } /* ---- Download and install a plugin ---- */ bool downloader_install_plugin(const char *token, const char *slug, int asset_id, const char *obs_plugin_dir) { s_last_error[0] = '\0'; if (asset_id <= 0) { set_error("No download asset found for %s", slug); dbg_log(LOG_ERROR, "[%s] No asset ID for %s", PLUGIN_NAME, slug); return false; } char path[512]; snprintf(path, sizeof(path), obf_api_downloads_fmt(), slug, asset_id); char url[512]; snprintf(url, sizeof(url), "%s%s%s", obf_https_prefix(), obf_stools_host(), path); const char *ext = #ifdef _WIN32 ".dll"; #else ".so"; #endif /* Download to temp file */ char tmp_dir[512]; get_temp_dir(tmp_dir, sizeof(tmp_dir)); char archive_path[512]; snprintf(archive_path, sizeof(archive_path), "%s%cst_pm_%s_download", tmp_dir, PATH_SEP, slug); FILE *f = fopen(archive_path, "wb"); if (!f) { dbg_log(LOG_ERROR, "[%s] Cannot open %s for writing", PLUGIN_NAME, archive_path); return false; } CURL *curl = curl_easy_init(); if (!curl) { fclose(f); remove(archive_path); return false; } struct curl_slist *headers = NULL; char auth_header[512]; snprintf(auth_header, sizeof(auth_header), obf_auth_bearer_fmt(), token); headers = curl_slist_append(headers, auth_header); char ua[128]; snprintf(ua, sizeof(ua), "%s%s", obf_ua_prefix(), PLUGIN_VERSION); curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, f); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L); curl_easy_setopt(curl, CURLOPT_USERAGENT, ua); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_set_ssl_opts(curl); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_slist_free_all(headers); curl_easy_cleanup(curl); fclose(f); if (res != CURLE_OK || http_code != 200) { set_error("Download failed: %s (HTTP %ld)", res != CURLE_OK ? curl_easy_strerror(res) : "server error", http_code); dbg_log(LOG_ERROR, "[%s] Download %s failed: %s (HTTP %ld)", PLUGIN_NAME, slug, curl_easy_strerror(res), http_code); remove(archive_path); return false; } /* Check if it's an archive or a raw binary */ bool success = false; char final_path[512]; snprintf(final_path, sizeof(final_path), "%s%c%s%s", obs_plugin_dir, PATH_SEP, slug, ext); if (is_zip(archive_path) || is_targz(archive_path)) { /* Extract to temp, then find the binary */ char extract_dir[512]; snprintf(extract_dir, sizeof(extract_dir), "%s%cst_pm_%s_extract", tmp_dir, PATH_SEP, slug); remove_directory(extract_dir); dbg_log(LOG_INFO, "[%s] Extracting archive for %s", PLUGIN_NAME, slug); if (!extract_archive(archive_path, extract_dir)) { set_error("Failed to extract archive for %s", slug); dbg_log(LOG_ERROR, "[%s] Failed to extract archive for %s", PLUGIN_NAME, slug); remove(archive_path); remove_directory(extract_dir); return false; } /* Find the DLL/SO inside the extracted tree */ char dll_filename[128]; snprintf(dll_filename, sizeof(dll_filename), "%s%s", slug, ext); char found_path[512] = ""; if (!find_plugin_binary(extract_dir, dll_filename, found_path, sizeof(found_path))) { set_error("Could not find %s in archive", dll_filename); dbg_log(LOG_ERROR, "[%s] Could not find %s in extracted archive", PLUGIN_NAME, dll_filename); remove(archive_path); remove_directory(extract_dir); return false; } dbg_log(LOG_INFO, "[%s] Found binary at: %s", PLUGIN_NAME, found_path); /* Ensure target dir exists */ ensure_dir_exists(obs_plugin_dir); /* Copy to OBS plugin dir (try normal, then elevated) */ remove(final_path); #ifdef _WIN32 success = CopyFileA(found_path, final_path, FALSE); if (!success) { DWORD copy_err = GetLastError(); dbg_log(LOG_INFO, "[%s] Normal copy failed (err %lu), trying elevated", PLUGIN_NAME, copy_err); success = copy_elevated(found_path, final_path); } #else { char cp_cmd[1024]; snprintf(cp_cmd, sizeof(cp_cmd), "cp '%s' '%s'", found_path, final_path); success = system(cp_cmd) == 0; if (!success) { snprintf(cp_cmd, sizeof(cp_cmd), "pkexec cp '%s' '%s'", found_path, final_path); success = system(cp_cmd) == 0; } } #endif remove_directory(extract_dir); } else { /* Raw binary - just move to final location */ remove(final_path); #ifdef _WIN32 success = CopyFileA(archive_path, final_path, FALSE); if (!success) { dbg_log(LOG_INFO, "[%s] Normal copy failed, trying elevated", PLUGIN_NAME); success = copy_elevated(archive_path, final_path); } #else success = rename(archive_path, final_path) == 0; if (!success) { char cp_cmd[1024]; snprintf(cp_cmd, sizeof(cp_cmd), "pkexec cp '%s' '%s'", archive_path, final_path); success = system(cp_cmd) == 0; } #endif } remove(archive_path); if (success) { dbg_log(LOG_INFO, "[%s] Installed %s to %s", PLUGIN_NAME, slug, final_path); } else { #ifdef _WIN32 DWORD err = GetLastError(); set_error("Failed to copy to %s (error %lu)", final_path, err); #else set_error("Failed to copy to %s", final_path); #endif dbg_log(LOG_ERROR, "[%s] Failed to install %s to %s", PLUGIN_NAME, slug, final_path); } return success; }