commit 3673cc41a822f7a822fa2f084a910415290cae2d Author: Nils <34674720+nils-kt@users.noreply.github.com> Date: Mon May 4 19:39:38 2026 +0200 Initial commit: stools Plugin Manager for OBS Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e27c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +build/ +deps/ +*.dll +*.so +*.dylib +*.obj +*.lib +*.exp +*.pdb +*.ilk +.vs/ +.vscode/ +CMakeCache.txt +CMakeFiles/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..cba9068 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,135 @@ +cmake_minimum_required(VERSION 3.16...3.28) + +project(st-pluginmanager VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC OFF) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE RelWithDebInfo) +endif() + +add_library(st-pluginmanager MODULE + src/plugin-main.c + src/auth.c + src/downloader.c + src/obfuscation.cpp + src/manager-dialog.cpp +) + +if(WIN32) + set(OBS_SOURCE_DIR "" CACHE PATH "Path to obs-studio source tree") + set(OBS_LIB_DIR "" CACHE PATH "Path to OBS import libraries") + set(QT6_DIR "" CACHE PATH "Path to Qt6 dev package") + + add_library(obs_lib SHARED IMPORTED) + set_target_properties(obs_lib PROPERTIES + IMPORTED_IMPLIB "${OBS_LIB_DIR}/obs.lib" + ) + + add_library(obs_frontend SHARED IMPORTED) + set_target_properties(obs_frontend PROPERTIES + IMPORTED_IMPLIB "${OBS_LIB_DIR}/obs-frontend-api.lib" + ) + + add_library(w32_pthreads SHARED IMPORTED) + set_target_properties(w32_pthreads PROPERTIES + IMPORTED_IMPLIB "${OBS_LIB_DIR}/w32-pthreads.lib" + ) + + foreach(_qt_mod Core Gui Widgets) + add_library(Qt6::${_qt_mod} SHARED IMPORTED) + set_target_properties(Qt6::${_qt_mod} PROPERTIES + IMPORTED_IMPLIB "${QT6_DIR}/lib/Qt6${_qt_mod}.lib" + ) + endforeach() + + target_include_directories(st-pluginmanager PRIVATE + "${OBS_SOURCE_DIR}/libobs" + "${OBS_SOURCE_DIR}/frontend/api" + "${OBS_SOURCE_DIR}/deps/w32-pthreads" + "${QT6_DIR}/include" + "${QT6_DIR}/include/QtCore" + "${QT6_DIR}/include/QtGui" + "${QT6_DIR}/include/QtWidgets" + ) + + # Find curl from obs-deps or system + set(CURL_DIR "" CACHE PATH "Path to curl dev package (include + lib)") + if(CURL_DIR) + target_include_directories(st-pluginmanager PRIVATE "${CURL_DIR}/include") + set(_CURL_LIB "${CURL_DIR}/lib/libcurl_imp.lib") + else() + set(_CURL_LIB "libcurl_imp.lib") + endif() + + target_link_libraries(st-pluginmanager + obs_lib + obs_frontend + w32_pthreads + shell32 + Qt6::Core + Qt6::Gui + Qt6::Widgets + "${_CURL_LIB}" + ) + + target_compile_definitions(st-pluginmanager PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_options(st-pluginmanager PRIVATE /Zc:__cplusplus /permissive- /utf-8) + +else() + find_package(PkgConfig REQUIRED) + + pkg_check_modules(LIBOBS REQUIRED IMPORTED_TARGET libobs) + + find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) + find_package(CURL REQUIRED) + + pkg_check_modules(OBS_FRONTEND IMPORTED_TARGET obs-frontend-api) + if(OBS_FRONTEND_FOUND) + set(_OBS_FRONTEND_TARGET PkgConfig::OBS_FRONTEND) + else() + find_library(OBS_FRONTEND_LIB obs-frontend-api) + if(OBS_FRONTEND_LIB) + add_library(obs_frontend_imported SHARED IMPORTED) + set_target_properties(obs_frontend_imported PROPERTIES + IMPORTED_LOCATION "${OBS_FRONTEND_LIB}" + ) + set(_OBS_FRONTEND_TARGET obs_frontend_imported) + else() + set(_OBS_FRONTEND_TARGET obs-frontend-api) + endif() + endif() + + target_link_libraries(st-pluginmanager + PkgConfig::LIBOBS + ${_OBS_FRONTEND_TARGET} + Qt6::Core + Qt6::Gui + Qt6::Widgets + CURL::libcurl + pthread + ) + + include(GNUInstallDirs) + install(TARGETS st-pluginmanager + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}/obs-plugins") +endif() + +if(DEFINED PLUGIN_VERSION_OVERRIDE) + set(_PLUGIN_VER "${PLUGIN_VERSION_OVERRIDE}") +else() + set(_PLUGIN_VER "${PROJECT_VERSION}") +endif() + +option(DEBUG_BUILD "Enable debug logging" OFF) + +target_compile_definitions(st-pluginmanager PRIVATE + PLUGIN_VERSION="${_PLUGIN_VER}" + $<$:DEBUG_BUILD> +) + +set_target_properties(st-pluginmanager PROPERTIES PREFIX "") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8cf7d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,280 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..c38d96d --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# stools Plugin Manager + +OBS Studio plugin that manages stools plugins. Automatically downloads, installs and updates plugins with login authentication. + +## Features + +- Login via stools.cc API token +- Automatic plugin download and installation +- Auto-update check on OBS startup +- Plugin version tracking +- Cross-platform (Windows, macOS, Linux) + +## Building + +```bash +# Windows (requires Visual Studio + OBS installed) +powershell -ExecutionPolicy Bypass -File build.ps1 +``` + +## License + +This project is licensed under the [GNU General Public License v2.0](LICENSE). diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..2b38d5f --- /dev/null +++ b/build.ps1 @@ -0,0 +1,115 @@ +$ErrorActionPreference = "Stop" +$ROOT = $PSScriptRoot + +# --- VS Developer Environment --- +$vsPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath +if (-not $vsPath) { throw "Visual Studio not found" } + +Import-Module "$vsPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" +Enter-VsDevShell -VsInstallPath $vsPath -DevCmdArguments "-arch=x64 -host_arch=x64" -SkipAutomaticLocation + +$OBS_BIN = "C:\Program Files\obs-studio\bin\64bit" +$DEPS_DIR = "$ROOT\deps" +$OBS_SRC = "$DEPS_DIR\obs-studio" +$OBS_DEPS = "$DEPS_DIR\obs-deps" +$QT6_DIR = "$DEPS_DIR\qt6" +$OBS_LIBS = "$DEPS_DIR\obs-libs" +$BUILD_DIR = "$ROOT\build" +$OBS_VERSION = "32.1.0" + +# --- 1. OBS headers (sparse clone) --- +if (-not (Test-Path "$OBS_SRC\libobs\obs-module.h")) { + Write-Host "[1/5] Cloning OBS Studio headers..." + git clone --depth 1 --branch $OBS_VERSION --filter=blob:none --sparse ` + "https://github.com/obsproject/obs-studio.git" $OBS_SRC + Push-Location $OBS_SRC + git sparse-checkout set libobs frontend/api deps/w32-pthreads + Pop-Location + + @" +#pragma once +#define OBS_DATA_PATH "data" +#define OBS_PLUGIN_PATH "obs-plugins/64bit" +#define OBS_PLUGIN_DESTINATION "obs-plugins/64bit" +#define OBS_INSTALL_PREFIX "C:/Program Files/obs-studio" +#define OBS_RELEASE_CANDIDATE 0 +#define OBS_BETA 0 +"@ | Out-File -Encoding ASCII "$OBS_SRC\libobs\obsconfig.h" +} else { + Write-Host "[1/5] OBS headers: OK" +} + +# --- 2. OBS deps (curl headers + libs) --- +$depsZip = "$DEPS_DIR\windows-deps-x64.zip" +if (-not (Test-Path "$OBS_DEPS\lib\libcurl_imp.lib")) { + Write-Host "[2/5] Downloading OBS deps..." + New-Item -ItemType Directory -Force -Path $DEPS_DIR | Out-Null + $depsUrl = "https://github.com/obsproject/obs-deps/releases/download/2025-08-23/windows-deps-2025-08-23-x64.zip" + Invoke-WebRequest -Uri $depsUrl -OutFile $depsZip -UseBasicParsing + Expand-Archive -Path $depsZip -DestinationPath $OBS_DEPS -Force +} else { + Write-Host "[2/5] OBS deps: OK" +} + +# --- 3. Qt6 deps --- +$qt6Zip = "$DEPS_DIR\windows-deps-qt6-x64.zip" +if (-not (Test-Path "$QT6_DIR\include\QtWidgets")) { + Write-Host "[3/5] Downloading Qt6 deps..." + New-Item -ItemType Directory -Force -Path $QT6_DIR | Out-Null + $qt6Url = "https://github.com/obsproject/obs-deps/releases/download/2025-08-23/windows-deps-qt6-2025-08-23-x64.zip" + Invoke-WebRequest -Uri $qt6Url -OutFile $qt6Zip -UseBasicParsing + Expand-Archive -Path $qt6Zip -DestinationPath $QT6_DIR -Force +} else { + Write-Host "[3/5] Qt6 deps: OK" +} + +# --- 4. Generate OBS import libraries --- +if (-not (Test-Path "$OBS_LIBS\obs.lib") -or (Get-Item "$OBS_LIBS\obs.lib").Length -lt 10000) { + Write-Host "[4/5] Generating import libraries..." + New-Item -ItemType Directory -Force -Path $OBS_LIBS | Out-Null + + foreach ($name in @("obs", "obs-frontend-api", "w32-pthreads")) { + $raw = (& dumpbin /exports "$OBS_BIN\$name.dll" 2>&1) | Out-String + $lines = $raw -split "`r?`n" + $defLines = @("LIBRARY `"$name`"", "EXPORTS") + $capture = $false + foreach ($line in $lines) { + if ($line -match "ordinal\s+hint\s+RVA\s+name") { $capture = $true; continue } + if ($capture -and $line -match "Summary") { break } + if ($capture -and $line -match "^\s+(\d+)\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\S+)") { + $defLines += " $($Matches[2])" + } + } + $defLines -join "`n" | Out-File -Encoding ASCII "$OBS_LIBS\$name.def" -NoNewline + & lib /nologo /def:"$OBS_LIBS\$name.def" /out:"$OBS_LIBS\$name.lib" /machine:x64 2>$null | Out-Null + } +} else { + Write-Host "[4/5] Import libraries: OK" +} + +# --- 5. Build --- +Write-Host "[5/5] Building..." +$debugFlag = if ($env:DEBUG_BUILD -eq "1") { "-DDEBUG_BUILD=ON" } else { "-DDEBUG_BUILD=OFF" } + +cmake -S $ROOT -B $BUILD_DIR -G "Ninja" ` + -DCMAKE_BUILD_TYPE=RelWithDebInfo ` + -DCMAKE_C_COMPILER=cl ` + -DCMAKE_CXX_COMPILER=cl ` + -DOBS_SOURCE_DIR="$OBS_SRC" ` + -DOBS_LIB_DIR="$OBS_LIBS" ` + -DCURL_DIR="$OBS_DEPS" ` + -DQT6_DIR="$QT6_DIR" ` + $debugFlag + +cmake --build $BUILD_DIR --config RelWithDebInfo + +$dll = "$BUILD_DIR\st-pluginmanager.dll" +if (Test-Path $dll) { + $size = [math]::Round((Get-Item $dll).Length / 1KB, 1) + Write-Host "" + Write-Host "BUILD SUCCESSFUL: st-pluginmanager.dll ($size KB)" -ForegroundColor Green + Write-Host "Output: $dll" +} else { + Write-Host "BUILD FAILED" -ForegroundColor Red + exit 1 +} diff --git a/src/auth.c b/src/auth.c new file mode 100644 index 0000000..6488d9a --- /dev/null +++ b/src/auth.c @@ -0,0 +1,283 @@ +#include "auth.h" +#include "obfuscation.h" +#include "debug-log.h" +#include "compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +#define MAX_TOKEN_LEN 512 +#define MAX_USERNAME_LEN 128 + +static char g_token[MAX_TOKEN_LEN] = ""; +static char g_username[MAX_USERNAME_LEN] = ""; +static bool g_logged_in = false; + +/* ---- Token file path ---- */ + +static void get_token_path(char *buf, size_t sz) +{ +#ifdef _WIN32 + char appdata[MAX_PATH]; + SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata); + snprintf(buf, sz, "%s\\stools\\pluginmanager_token", appdata); +#else + const char *home = getenv("HOME"); + if (!home) { + struct passwd *pw = getpwuid(getuid()); + home = pw ? pw->pw_dir : "/tmp"; + } + snprintf(buf, sz, "%s/.config/stools/pluginmanager_token", home); +#endif +} + +static void ensure_dir(const char *path) +{ + char dir[512]; + snprintf(dir, sizeof(dir), "%s", path); + char *last_sep = strrchr(dir, '/'); +#ifdef _WIN32 + char *last_bs = strrchr(dir, '\\'); + if (last_bs && (!last_sep || last_bs > last_sep)) + last_sep = last_bs; +#endif + if (last_sep) { + *last_sep = '\0'; +#ifdef _WIN32 + CreateDirectoryA(dir, NULL); +#else + mkdir(dir, 0700); +#endif + } +} + +static void save_token(const char *token) +{ + char path[512]; + get_token_path(path, sizeof(path)); + ensure_dir(path); + FILE *f = fopen(path, "w"); + if (f) { + fputs(token, f); + fclose(f); + } +} + +static bool load_token(char *buf, size_t sz) +{ + char path[512]; + get_token_path(path, sizeof(path)); + FILE *f = fopen(path, "r"); + if (!f) return false; + if (!fgets(buf, (int)sz, f)) { + fclose(f); + return false; + } + fclose(f); + size_t len = strlen(buf); + while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) + buf[--len] = '\0'; + return len > 0; +} + +static void delete_token(void) +{ + char path[512]; + get_token_path(path, sizeof(path)); + remove(path); +} + +/* ---- 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 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; + 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); + 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, 15L); + 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); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 200) { + 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 char *json_get_string_val(const char *json, const char *key) +{ + const char *v = json_find_key(json, key); + if (!v || *v != '"') return NULL; + v++; + const char *end = strchr(v, '"'); + if (!end) return NULL; + size_t len = (size_t)(end - v); + char *result = (char *)malloc(len + 1); + memcpy(result, v, len); + result[len] = '\0'; + return result; +} + +/* ---- Public API ---- */ + +bool auth_validate_token(const char *token) +{ + if (!token || !token[0]) return false; + + char *json = api_get(obf_api_me_path(), token); + if (!json) return false; + + char *name = json_get_string_val(json, "username"); + if (!name) + name = json_get_string_val(json, "name"); + + free(json); + + if (name) { + snprintf(g_username, sizeof(g_username), "%s", name); + free(name); + return true; + } + + return false; +} + +bool auth_login(const char *token) +{ + if (!auth_validate_token(token)) + return false; + + snprintf(g_token, sizeof(g_token), "%s", token); + g_logged_in = true; + save_token(token); + + dbg_log(LOG_INFO, "[%s] Logged in as %s", PLUGIN_NAME, g_username); + return true; +} + +void auth_logout(void) +{ + g_token[0] = '\0'; + g_username[0] = '\0'; + g_logged_in = false; + delete_token(); + dbg_log(LOG_INFO, "[%s] Logged out", PLUGIN_NAME); +} + +void auth_init(void) +{ + char token[MAX_TOKEN_LEN]; + if (load_token(token, sizeof(token))) { + if (auth_validate_token(token)) { + snprintf(g_token, sizeof(g_token), "%s", token); + g_logged_in = true; + dbg_log(LOG_INFO, "[%s] Auto-login as %s", + PLUGIN_NAME, g_username); + } else { + dbg_log(LOG_WARNING, + "[%s] Saved token invalid, login required", + PLUGIN_NAME); + } + } +} + +void auth_shutdown(void) +{ + /* nothing to clean up */ +} + +bool auth_is_logged_in(void) +{ + return g_logged_in; +} + +const char *auth_get_token(void) +{ + return g_token; +} + +const char *auth_get_username(void) +{ + return g_username; +} diff --git a/src/auth.h b/src/auth.h new file mode 100644 index 0000000..3f89ccc --- /dev/null +++ b/src/auth.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void auth_init(void); +void auth_shutdown(void); + +bool auth_is_logged_in(void); +const char *auth_get_token(void); +const char *auth_get_username(void); + +bool auth_login(const char *token); +void auth_logout(void); + +bool auth_validate_token(const char *token); + +#ifdef __cplusplus +} +#endif diff --git a/src/compat.h b/src/compat.h new file mode 100644 index 0000000..6544d87 --- /dev/null +++ b/src/compat.h @@ -0,0 +1,124 @@ +#pragma once + +/* + * Platform compatibility layer replacing OBS internal utilities + * (util/platform.h, util/threading.h, bmem.h) with standard C equivalents. + */ + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#endif + +/* ---- Memory (replaces bmem.h) ---- */ + +#ifdef _MSC_VER +#define portable_strdup _strdup +#else +#define portable_strdup strdup +#endif + +/* ---- Time (replaces util/platform.h os_gettime_ns / os_sleep_ms) ---- */ + +static inline uint64_t os_gettime_ns(void) +{ +#ifdef _WIN32 + static LARGE_INTEGER freq = {0}; + if (freq.QuadPart == 0) + QueryPerformanceFrequency(&freq); + LARGE_INTEGER counter; + QueryPerformanceCounter(&counter); + return (uint64_t)((double)counter.QuadPart / (double)freq.QuadPart * + 1000000000.0); +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; +#endif +} + +static inline void os_sleep_ms(uint32_t ms) +{ +#ifdef _WIN32 + Sleep(ms); +#else + usleep((useconds_t)ms * 1000); +#endif +} + +/* ---- Thread naming (replaces util/platform.h os_set_thread_name) ---- */ + +static inline void os_set_thread_name(const char *name) +{ +#ifdef _WIN32 + /* SetThreadDescription available on Windows 10 1607+ */ + typedef HRESULT(WINAPI * SetThreadDescriptionFunc)(HANDLE, PCWSTR); + static SetThreadDescriptionFunc fn = NULL; + static bool resolved = false; + if (!resolved) { + HMODULE mod = GetModuleHandleW(L"kernel32.dll"); + if (mod) + fn = (SetThreadDescriptionFunc)GetProcAddress( + mod, "SetThreadDescription"); + resolved = true; + } + if (fn) { + wchar_t wname[64]; + MultiByteToWideChar(CP_UTF8, 0, name, -1, wname, 64); + fn(GetCurrentThread(), wname); + } +#elif defined(__APPLE__) + pthread_setname_np(name); +#else + pthread_setname_np(pthread_self(), name); +#endif +} + +/* ---- Atomics (replaces util/platform.h os_atomic_*) ---- */ + +#ifdef _WIN32 + +static inline long os_atomic_set_long(volatile long *ptr, long val) +{ + return InterlockedExchange(ptr, val); +} + +static inline long os_atomic_load_long(volatile long *ptr) +{ + return InterlockedCompareExchange(ptr, 0, 0); +} + +static inline long os_atomic_exchange_long(volatile long *ptr, long val) +{ + return InterlockedExchange(ptr, val); +} + +#else + +static inline long os_atomic_set_long(volatile long *ptr, long val) +{ + return __sync_lock_test_and_set(ptr, val); +} + +static inline long os_atomic_load_long(volatile long *ptr) +{ + return __sync_add_and_fetch(ptr, 0); +} + +static inline long os_atomic_exchange_long(volatile long *ptr, long val) +{ + return __sync_lock_test_and_set(ptr, val); +} + +#endif diff --git a/src/debug-log.h b/src/debug-log.h new file mode 100644 index 0000000..db0e0a6 --- /dev/null +++ b/src/debug-log.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#ifndef PLUGIN_NAME +#define PLUGIN_NAME "stools Plugin Manager" +#endif + +#ifdef DEBUG_BUILD +#define dbg_log(level, ...) do { fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } while (0) +#else +#define dbg_log(...) ((void)0) +#endif + +#ifndef LOG_ERROR +#define LOG_ERROR 100 +#define LOG_WARNING 200 +#define LOG_INFO 300 +#define LOG_DEBUG 400 +#endif diff --git a/src/downloader.c b/src/downloader.c new file mode 100644 index 0000000..5df5264 --- /dev/null +++ b/src/downloader.c @@ -0,0 +1,381 @@ +#include "downloader.h" +#include "obfuscation.h" +#include "debug-log.h" +#include "compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#define PATH_SEP '\\' +#else +#include +#include +#define PATH_SEP '/' +#endif + +/* ---- 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; + 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); + 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); + 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'; +} + +/* ---- OBS plugin directory ---- */ + +bool downloader_get_obs_plugin_dir(char *buf, size_t sz) +{ +#ifdef _WIN32 + char appdata[MAX_PATH]; + SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata); + snprintf(buf, sz, "%s\\obs-studio\\obs-plugins\\64bit", appdata); + CreateDirectoryA(buf, NULL); + 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 ---- */ + +bool downloader_fetch_plugin_list(const char *token, struct plugin_list *out) +{ + memset(out, 0, sizeof(*out)); + + char *json = api_get(obf_api_plugins_path(), token); + if (!json) return false; + + /* + * Expected JSON format: + * [{"slug":"easy-irl-stream","name":"Easy IRL Stream", + * "version":"1.1.4","platform":{"windows":".dll","linux":".so"}}, ...] + */ + const char *pos = json; + while (out->count < MAX_PLUGINS) { + const char *obj_start = strchr(pos, '{'); + if (!obj_start) break; + + const char *obj_end = strchr(obj_start, '}'); + if (!obj_end) break; + + size_t obj_len = (size_t)(obj_end - obj_start + 1); + char obj_buf[2048]; + if (obj_len >= sizeof(obj_buf)) { + pos = obj_end + 1; + continue; + } + memcpy(obj_buf, obj_start, obj_len); + obj_buf[obj_len] = '\0'; + + struct plugin_info *pi = &out->items[out->count]; + json_extract_string(obj_buf, "slug", pi->slug, sizeof(pi->slug)); + json_extract_string(obj_buf, "name", pi->name, sizeof(pi->name)); + json_extract_string(obj_buf, "version", pi->latest_version, + sizeof(pi->latest_version)); + + if (pi->slug[0] && pi->name[0]) + out->count++; + + pos = obj_end + 1; + } + + free(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); +#elif defined(__APPLE__) + snprintf(dll_path, sizeof(dll_path), "%s/%s.so", + 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 ---- */ + +bool downloader_install_plugin(const char *token, const char *slug, + const char *obs_plugin_dir) +{ + const char *platform = +#ifdef _WIN32 + "windows"; +#elif defined(__APPLE__) + "macos"; +#else + "linux"; +#endif + + char path[512]; + snprintf(path, sizeof(path), obf_api_plugin_download_fmt(), + slug, platform); + + 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 + + char tmp_path[512], final_path[512], ver_path[512]; + snprintf(tmp_path, sizeof(tmp_path), "%s%c%s%s.tmp", + obs_plugin_dir, PATH_SEP, slug, ext); + snprintf(final_path, sizeof(final_path), "%s%c%s%s", + obs_plugin_dir, PATH_SEP, slug, ext); + snprintf(ver_path, sizeof(ver_path), "%s%c%s.version", + obs_plugin_dir, PATH_SEP, slug); + + FILE *f = fopen(tmp_path, "wb"); + if (!f) { + dbg_log(LOG_ERROR, "[%s] Cannot open %s for writing", + PLUGIN_NAME, tmp_path); + return false; + } + + CURL *curl = curl_easy_init(); + if (!curl) { + fclose(f); + remove(tmp_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) { + dbg_log(LOG_ERROR, "[%s] Download %s failed: %s (HTTP %ld)", + PLUGIN_NAME, slug, curl_easy_strerror(res), http_code); + remove(tmp_path); + return false; + } + + /* Atomic replace: delete old, rename tmp */ + remove(final_path); + if (rename(tmp_path, final_path) != 0) { + dbg_log(LOG_ERROR, "[%s] Failed to rename %s -> %s", + PLUGIN_NAME, tmp_path, final_path); + remove(tmp_path); + return false; + } + + /* Write version file from server response header or plugin list */ + dbg_log(LOG_INFO, "[%s] Installed %s to %s", PLUGIN_NAME, slug, + final_path); + return true; +} diff --git a/src/downloader.h b/src/downloader.h new file mode 100644 index 0000000..c9bb9da --- /dev/null +++ b/src/downloader.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAX_PLUGINS 32 +#define MAX_NAME_LEN 128 +#define MAX_VER_LEN 32 +#define MAX_SLUG_LEN 64 + +struct plugin_info { + char slug[MAX_SLUG_LEN]; + char name[MAX_NAME_LEN]; + char latest_version[MAX_VER_LEN]; + char installed_version[MAX_VER_LEN]; + bool installed; + bool update_available; +}; + +struct plugin_list { + struct plugin_info items[MAX_PLUGINS]; + int count; +}; + +bool downloader_fetch_plugin_list(const char *token, struct plugin_list *out); +bool downloader_install_plugin(const char *token, const char *slug, + const char *obs_plugin_dir); +void downloader_detect_installed(struct plugin_list *list, + const char *obs_plugin_dir); +bool downloader_get_obs_plugin_dir(char *buf, size_t sz); + +#ifdef __cplusplus +} +#endif diff --git a/src/dstr-compat.h b/src/dstr-compat.h new file mode 100644 index 0000000..455f958 --- /dev/null +++ b/src/dstr-compat.h @@ -0,0 +1,98 @@ +#pragma once + +/* + * Minimal dynamic string builder (replaces OBS util/dstr.h). + */ + +#include +#include +#include +#include + +struct dstr { + char *array; + size_t len; + size_t capacity; +}; + +static inline void dstr_init(struct dstr *dst) +{ + dst->array = NULL; + dst->len = 0; + dst->capacity = 0; +} + +static inline void dstr_free(struct dstr *dst) +{ + free(dst->array); + dst->array = NULL; + dst->len = 0; + dst->capacity = 0; +} + +static inline void dstr_ensure_capacity(struct dstr *dst, size_t new_cap) +{ + if (new_cap <= dst->capacity) + return; + size_t cap = dst->capacity ? dst->capacity : 64; + while (cap < new_cap) + cap *= 2; + dst->array = (char *)realloc(dst->array, cap); + dst->capacity = cap; +} + +static inline void dstr_cat(struct dstr *dst, const char *str) +{ + size_t slen = strlen(str); + dstr_ensure_capacity(dst, dst->len + slen + 1); + memcpy(dst->array + dst->len, str, slen + 1); + dst->len += slen; +} + +static inline void dstr_ncat(struct dstr *dst, const char *str, size_t len) +{ + dstr_ensure_capacity(dst, dst->len + len + 1); + memcpy(dst->array + dst->len, str, len); + dst->len += len; + dst->array[dst->len] = '\0'; +} + +static inline void dstr_printf(struct dstr *dst, const char *fmt, ...) +{ + va_list args, args2; + va_start(args, fmt); + va_copy(args2, args); + + int needed = vsnprintf(NULL, 0, fmt, args); + va_end(args); + + if (needed < 0) { + va_end(args2); + return; + } + + dstr_ensure_capacity(dst, (size_t)needed + 1); + vsnprintf(dst->array, (size_t)needed + 1, fmt, args2); + dst->len = (size_t)needed; + va_end(args2); +} + +static inline void dstr_catf(struct dstr *dst, const char *fmt, ...) +{ + va_list args, args2; + va_start(args, fmt); + va_copy(args2, args); + + int needed = vsnprintf(NULL, 0, fmt, args); + va_end(args); + + if (needed < 0) { + va_end(args2); + return; + } + + dstr_ensure_capacity(dst, dst->len + (size_t)needed + 1); + vsnprintf(dst->array + dst->len, (size_t)needed + 1, fmt, args2); + dst->len += (size_t)needed; + va_end(args2); +} diff --git a/src/manager-dialog.cpp b/src/manager-dialog.cpp new file mode 100644 index 0000000..d7202fd --- /dev/null +++ b/src/manager-dialog.cpp @@ -0,0 +1,341 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +extern "C" { +#include "auth.h" +#include "downloader.h" +} + +#include "manager-dialog.hpp" + +static bool is_de(const char *locale) +{ + return locale && locale[0] == 'd' && locale[1] == 'e'; +} + +class ManagerDialog : public QDialog { +public: + ManagerDialog(QWidget *parent, const char *locale) + : QDialog(parent), m_locale(locale ? locale : "en") + { + bool de = is_de(locale); + setWindowTitle("stools Plugin Manager"); + setMinimumSize(600, 450); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(16, 16, 16, 16); + mainLayout->setSpacing(12); + + /* ---- Header ---- */ + auto *header = new QLabel("stools Plugin Manager"); + QFont headerFont = header->font(); + headerFont.setPointSize(14); + headerFont.setBold(true); + header->setFont(headerFont); + mainLayout->addWidget(header); + + /* ---- Auth section ---- */ + m_authFrame = new QFrame(); + auto *authLayout = new QHBoxLayout(m_authFrame); + authLayout->setContentsMargins(0, 0, 0, 0); + + m_statusLabel = new QLabel(); + authLayout->addWidget(m_statusLabel, 1); + + m_tokenInput = new QLineEdit(); + m_tokenInput->setEchoMode(QLineEdit::Password); + m_tokenInput->setPlaceholderText("API Token"); + m_tokenInput->setMinimumWidth(200); + authLayout->addWidget(m_tokenInput); + + m_loginBtn = new QPushButton(de ? "Anmelden" : "Login"); + connect(m_loginBtn, &QPushButton::clicked, this, + &ManagerDialog::onLogin); + authLayout->addWidget(m_loginBtn); + + m_logoutBtn = new QPushButton(de ? "Abmelden" : "Logout"); + connect(m_logoutBtn, &QPushButton::clicked, this, + &ManagerDialog::onLogout); + authLayout->addWidget(m_logoutBtn); + + mainLayout->addWidget(m_authFrame); + + /* ---- Separator ---- */ + auto *sep = new QFrame(); + sep->setFrameShape(QFrame::HLine); + mainLayout->addWidget(sep); + + /* ---- Plugin table ---- */ + m_table = new QTableWidget(0, 4); + m_table->setHorizontalHeaderLabels( + {de ? "Plugin" : "Plugin", + de ? "Installiert" : "Installed", + de ? "Verfügbar" : "Available", ""}); + m_table->horizontalHeader()->setStretchLastSection(false); + m_table->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + m_table->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode( + 2, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode( + 3, QHeaderView::ResizeToContents); + m_table->verticalHeader()->setVisible(false); + m_table->setSelectionMode(QAbstractItemView::NoSelection); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + mainLayout->addWidget(m_table, 1); + + /* ---- Refresh button ---- */ + auto *bottomLayout = new QHBoxLayout(); + m_refreshBtn = new QPushButton( + de ? "Aktualisieren" : "Refresh"); + connect(m_refreshBtn, &QPushButton::clicked, this, + &ManagerDialog::onRefresh); + bottomLayout->addStretch(); + bottomLayout->addWidget(m_refreshBtn); + mainLayout->addLayout(bottomLayout); + + /* ---- Info label ---- */ + m_infoLabel = new QLabel( + de ? "Änderungen werden nach OBS-Neustart wirksam." + : "Changes take effect after restarting OBS."); + QFont infoFont = m_infoLabel->font(); + infoFont.setItalic(true); + m_infoLabel->setFont(infoFont); + m_infoLabel->setStyleSheet("color: gray;"); + mainLayout->addWidget(m_infoLabel); + + updateAuthUI(); + + if (auth_is_logged_in()) + onRefresh(); + } + +private: + QString m_locale; + QFrame *m_authFrame; + QLabel *m_statusLabel; + QLineEdit *m_tokenInput; + QPushButton *m_loginBtn; + QPushButton *m_logoutBtn; + QTableWidget *m_table; + QPushButton *m_refreshBtn; + QLabel *m_infoLabel; + struct plugin_list m_plugins = {}; + + void updateAuthUI() + { + bool de = is_de(m_locale.toUtf8().constData()); + bool logged_in = auth_is_logged_in(); + + m_tokenInput->setVisible(!logged_in); + m_loginBtn->setVisible(!logged_in); + m_logoutBtn->setVisible(logged_in); + m_refreshBtn->setEnabled(logged_in); + m_table->setEnabled(logged_in); + + if (logged_in) { + m_statusLabel->setText( + QString(de ? "Angemeldet als: %1" + : "Logged in as: %1") + .arg(auth_get_username())); + m_statusLabel->setStyleSheet("color: green;"); + } else { + m_statusLabel->setText( + de ? "Nicht angemeldet" + : "Not logged in"); + m_statusLabel->setStyleSheet("color: red;"); + } + } + + void onLogin() + { + QString token = m_tokenInput->text().trimmed(); + if (token.isEmpty()) return; + + m_loginBtn->setEnabled(false); + m_loginBtn->setText("..."); + QApplication::processEvents(); + + bool ok = auth_login(token.toUtf8().constData()); + bool de = is_de(m_locale.toUtf8().constData()); + + m_loginBtn->setEnabled(true); + m_loginBtn->setText(de ? "Anmelden" : "Login"); + + if (ok) { + m_tokenInput->clear(); + updateAuthUI(); + onRefresh(); + } else { + QMessageBox::warning(this, "stools Plugin Manager", + de ? "Ungültiger Token." + : "Invalid token."); + } + } + + void onLogout() + { + auth_logout(); + m_table->setRowCount(0); + m_plugins.count = 0; + updateAuthUI(); + } + + void onRefresh() + { + if (!auth_is_logged_in()) return; + + m_refreshBtn->setEnabled(false); + m_refreshBtn->setText("..."); + QApplication::processEvents(); + + bool de = is_de(m_locale.toUtf8().constData()); + + if (downloader_fetch_plugin_list(auth_get_token(), &m_plugins)) { + char obs_dir[512]; + if (downloader_get_obs_plugin_dir(obs_dir, sizeof(obs_dir))) + downloader_detect_installed(&m_plugins, obs_dir); + populateTable(); + } else { + QMessageBox::warning( + this, "stools Plugin Manager", + de ? "Plugin-Liste konnte nicht geladen werden." + : "Failed to fetch plugin list."); + } + + m_refreshBtn->setEnabled(true); + m_refreshBtn->setText(de ? "Aktualisieren" : "Refresh"); + } + + void populateTable() + { + bool de = is_de(m_locale.toUtf8().constData()); + m_table->setRowCount(m_plugins.count); + + for (int i = 0; i < m_plugins.count; i++) { + struct plugin_info *pi = &m_plugins.items[i]; + + m_table->setItem(i, 0, + new QTableWidgetItem(pi->name)); + + QString installed_ver = + pi->installed + ? QString(pi->installed_version) + : (de ? "Nicht installiert" + : "Not installed"); + m_table->setItem(i, 1, + new QTableWidgetItem(installed_ver)); + + m_table->setItem( + i, 2, + new QTableWidgetItem(pi->latest_version)); + + QString btn_text; + if (!pi->installed) + btn_text = de ? "Installieren" : "Install"; + else if (pi->update_available) + btn_text = de ? "Aktualisieren" : "Update"; + else + btn_text = de ? "Aktuell" : "Up to date"; + + auto *btn = new QPushButton(btn_text); + btn->setEnabled(!pi->installed || + pi->update_available); + btn->setProperty("slug", + QString(pi->slug)); + btn->setProperty("version", + QString(pi->latest_version)); + connect(btn, &QPushButton::clicked, this, + &ManagerDialog::onInstallClicked); + m_table->setCellWidget(i, 3, btn); + } + } + + void onInstallClicked() + { + auto *btn = qobject_cast(sender()); + if (!btn) return; + + QString slug = btn->property("slug").toString(); + QString version = btn->property("version").toString(); + bool de = is_de(m_locale.toUtf8().constData()); + + btn->setEnabled(false); + btn->setText("..."); + QApplication::processEvents(); + + char obs_dir[512]; + if (!downloader_get_obs_plugin_dir(obs_dir, sizeof(obs_dir))) { + QMessageBox::critical( + this, "stools Plugin Manager", + de ? "OBS Plugin-Verzeichnis nicht gefunden." + : "OBS plugin directory not found."); + btn->setEnabled(true); + return; + } + + bool ok = downloader_install_plugin( + auth_get_token(), slug.toUtf8().constData(), obs_dir); + + if (ok) { + /* Write version file */ + char ver_path[512]; + snprintf(ver_path, sizeof(ver_path), "%s%c%s.version", + obs_dir, +#ifdef _WIN32 + '\\', +#else + '/', +#endif + slug.toUtf8().constData()); + FILE *f = fopen(ver_path, "w"); + if (f) { + fputs(version.toUtf8().constData(), f); + fclose(f); + } + + btn->setText(de ? "Aktuell" : "Up to date"); + btn->setEnabled(false); + + QMessageBox::information( + this, "stools Plugin Manager", + QString(de ? "%1 wurde installiert. Bitte OBS neu starten." + : "%1 has been installed. Please restart OBS.") + .arg(slug)); + + onRefresh(); + } else { + btn->setText(de ? "Fehlgeschlagen" : "Failed"); + QTimer::singleShot(2000, [btn, de]() { + btn->setText(de ? "Erneut versuchen" : "Retry"); + btn->setEnabled(true); + }); + } + } +}; + +void manager_dialog_show(const char *locale) +{ + QMainWindow *main_window = + (QMainWindow *)obs_frontend_get_main_window(); + auto *dialog = new ManagerDialog(main_window, locale); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); +} diff --git a/src/manager-dialog.hpp b/src/manager-dialog.hpp new file mode 100644 index 0000000..02d276c --- /dev/null +++ b/src/manager-dialog.hpp @@ -0,0 +1,11 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +void manager_dialog_show(const char *locale); + +#ifdef __cplusplus +} +#endif diff --git a/src/obfuscation.cpp b/src/obfuscation.cpp new file mode 100644 index 0000000..ca939ba --- /dev/null +++ b/src/obfuscation.cpp @@ -0,0 +1,21 @@ +#include "obfuscation.h" +#include + +#define OBF_FUNC(name, literal) \ + static const std::string &name##_storage() \ + { \ + static const std::string s{literal}; \ + return s; \ + } \ + extern "C" const char *name(void) \ + { \ + return name##_storage().c_str(); \ + } + +OBF_FUNC(obf_https_prefix, "https://") +OBF_FUNC(obf_stools_host, "stools.cc") +OBF_FUNC(obf_auth_bearer_fmt, "Authorization: Bearer %s") +OBF_FUNC(obf_ua_prefix, "stools-pluginmanager/") +OBF_FUNC(obf_api_me_path, "/api/me") +OBF_FUNC(obf_api_plugins_path, "/api/plugins") +OBF_FUNC(obf_api_plugin_download_fmt, "/api/plugins/%s/download?platform=%s") diff --git a/src/obfuscation.h b/src/obfuscation.h new file mode 100644 index 0000000..8a97cf9 --- /dev/null +++ b/src/obfuscation.h @@ -0,0 +1,17 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +const char *obf_https_prefix(void); +const char *obf_stools_host(void); +const char *obf_auth_bearer_fmt(void); +const char *obf_ua_prefix(void); +const char *obf_api_me_path(void); +const char *obf_api_plugins_path(void); +const char *obf_api_plugin_download_fmt(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/plugin-main.c b/src/plugin-main.c new file mode 100644 index 0000000..92edc25 --- /dev/null +++ b/src/plugin-main.c @@ -0,0 +1,126 @@ +#include +#include +#include +#include +#include +#include + +#include "compat.h" +#include "debug-log.h" +#include "auth.h" +#include "downloader.h" +#include "manager-dialog.hpp" + +OBS_DECLARE_MODULE() + +#define PLUGIN_NAME "stools Plugin Manager" + +static const char *g_locale = NULL; + +/* ---- Background init thread ---- */ + +static pthread_t g_init_thread; +static volatile bool g_init_done = false; + +static void *init_thread_func(void *arg) +{ + (void)arg; + + os_set_thread_name("stools-pm-init"); + + auth_init(); + + if (auth_is_logged_in()) { + struct plugin_list plugins = {0}; + if (downloader_fetch_plugin_list(auth_get_token(), &plugins)) { + char obs_dir[512]; + if (downloader_get_obs_plugin_dir(obs_dir, + sizeof(obs_dir))) { + downloader_detect_installed(&plugins, obs_dir); + + for (int i = 0; i < plugins.count; i++) { + struct plugin_info *pi = + &plugins.items[i]; + if (!pi->installed || + pi->update_available) { + dbg_log(LOG_INFO, + "[%s] Auto-installing %s v%s", + PLUGIN_NAME, pi->slug, + pi->latest_version); + if (downloader_install_plugin( + auth_get_token(), + pi->slug, + obs_dir)) { + char vp[512]; + snprintf(vp, + sizeof(vp), + "%s" +#ifdef _WIN32 + "\\" +#else + "/" +#endif + "%s.version", + obs_dir, + pi->slug); + FILE *f = fopen(vp, + "w"); + if (f) { + fputs(pi->latest_version, + f); + fclose(f); + } + } + } + } + } + } + } + + g_init_done = true; + return NULL; +} + +/* ---- Tools menu ---- */ + +static void tools_menu_cb(void *private_data) +{ + (void)private_data; + manager_dialog_show(g_locale); +} + +/* ---- Module lifecycle ---- */ + +bool obs_module_load(void) +{ + curl_global_init(CURL_GLOBAL_DEFAULT); + + g_locale = obs_get_locale(); + + if (pthread_create(&g_init_thread, NULL, init_thread_func, NULL) != 0) { + dbg_log(LOG_ERROR, "[%s] Failed to create init thread", + PLUGIN_NAME); + } + + dbg_log(LOG_INFO, "[%s] Plugin loaded (v%s)", PLUGIN_NAME, + PLUGIN_VERSION); + return true; +} + +void obs_module_post_load(void) +{ + bool de = g_locale && g_locale[0] == 'd' && g_locale[1] == 'e'; + obs_frontend_add_tools_menu_item( + de ? "stools Plugin Manager" : "stools Plugin Manager", + tools_menu_cb, NULL); +} + +void obs_module_unload(void) +{ + if (!g_init_done) { + pthread_join(g_init_thread, NULL); + } + auth_shutdown(); + curl_global_cleanup(); + dbg_log(LOG_INFO, "[%s] Plugin unloaded", PLUGIN_NAME); +}