Files

300 lines
9.6 KiB
C++

#include <imgui.h>
#include "app_base.h"
#include "core/panel_menu.h"
#include "core/icons_tabler.h"
#include "core/logger.h"
#include "core/app_card.h"
#include "gfx/gl_texture_load.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <shellapi.h>
#endif
namespace fs = std::filesystem;
struct AppMeta {
std::string display_name;
std::string description;
ImU32 accent;
};
struct AppEntry {
std::string name;
fs::path exe_path;
AppMeta meta;
};
static std::vector<AppEntry> g_apps;
static std::unordered_map<std::string, fn::GlTexture> g_icons;
static bool g_show_main = true;
static char g_filter[128] = {0};
static std::string g_last_status;
static ImU32 hex_to_imu32(std::string const& hex_in) {
std::string h = hex_in;
if (!h.empty() && h[0] == '#') h.erase(0, 1);
if (h.size() != 6) return IM_COL32(100, 116, 139, 255);
unsigned int v = 0;
std::stringstream ss;
ss << std::hex << h;
ss >> v;
int r = (v >> 16) & 0xFF;
int g = (v >> 8) & 0xFF;
int b = v & 0xFF;
return IM_COL32(r, g, b, 255);
}
static std::string camel_case_from_snake(std::string const& s) {
std::string out;
out.reserve(s.size() + 2);
bool cap = true;
for (char c : s) {
if (c == '_' || c == '-') {
out.push_back(' ');
cap = true;
} else if (cap) {
out.push_back((char)std::toupper((unsigned char)c));
cap = false;
} else {
out.push_back((char)std::tolower((unsigned char)c));
}
}
return out;
}
static std::unordered_map<std::string, AppMeta> load_manifest() {
std::unordered_map<std::string, AppMeta> map;
fs::path path = fs::path(fn::local_path("hub_manifest.tsv"));
std::ifstream f(path);
if (!f.is_open()) {
fn_log::log_warn("hub: manifest not found at %s", path.string().c_str());
return map;
}
std::string line;
bool header = true;
while (std::getline(f, line)) {
if (header) { header = false; continue; }
if (line.empty()) continue;
std::vector<std::string> cols;
std::stringstream ss(line);
std::string item;
while (std::getline(ss, item, '\t')) cols.push_back(item);
if (cols.size() < 4) continue;
AppMeta m;
m.display_name = cols[1];
m.description = cols[2];
m.accent = hex_to_imu32(cols[3]);
map[cols[0]] = m;
}
fn_log::log_info("hub: loaded %zu manifest rows from %s",
map.size(), path.string().c_str());
return map;
}
static fs::path apps_root_dir() {
return fs::path(fn::exe_dir()).parent_path();
}
static void scan_apps() {
g_apps.clear();
auto manifest = load_manifest();
fs::path root = apps_root_dir();
std::error_code ec;
if (!fs::exists(root, ec) || !fs::is_directory(root, ec)) {
fn_log::log_warn("hub: apps root not found: %s", root.string().c_str());
return;
}
std::string self_name = fs::path(fn::exe_dir()).filename().string();
for (auto const& entry : fs::directory_iterator(root, ec)) {
if (!entry.is_directory(ec)) continue;
std::string name = entry.path().filename().string();
if (name == self_name) continue;
fs::path exe = entry.path() / (name + ".exe");
if (!(fs::exists(exe, ec) && fs::is_regular_file(exe, ec))) continue;
AppEntry e;
e.name = name;
e.exe_path = exe;
auto it = manifest.find(name);
if (it != manifest.end()) {
e.meta = it->second;
} else {
e.meta.display_name = camel_case_from_snake(name);
e.meta.description = "";
e.meta.accent = IM_COL32(100, 116, 139, 255);
}
g_apps.push_back(std::move(e));
}
std::sort(g_apps.begin(), g_apps.end(),
[](AppEntry const& a, AppEntry const& b) {
return a.meta.display_name < b.meta.display_name;
});
fn_log::log_info("hub: scanned %zu apps at %s",
g_apps.size(), root.string().c_str());
}
static void load_icons() {
fs::path icons_dir = fs::path(fn::local_dir()) / "icons";
std::error_code ec;
if (!fs::is_directory(icons_dir, ec)) {
fn_log::log_warn("hub: icons dir missing: %s", icons_dir.string().c_str());
return;
}
int loaded = 0;
for (auto const& e : g_apps) {
auto it = g_icons.find(e.name);
if (it != g_icons.end() && it->second.ok()) continue;
fs::path png = icons_dir / (e.name + ".png");
if (!fs::exists(png, ec)) continue;
fn::GlTexture tex = fn::gl_texture_load(png.string().c_str(),
/*flip_y=*/false, /*srgb=*/false);
if (tex.ok()) {
g_icons[e.name] = tex;
++loaded;
} else {
fn_log::log_warn("hub: failed to load %s: %s",
png.string().c_str(), fn::gl_texture_last_error());
}
}
if (loaded > 0) {
fn_log::log_info("hub: loaded %d icon textures (cache=%zu)",
loaded, g_icons.size());
}
}
static bool launch_app(AppEntry const& app) {
#ifdef _WIN32
std::wstring wexe(app.exe_path.wstring());
std::wstring wdir(app.exe_path.parent_path().wstring());
HINSTANCE r = ShellExecuteW(NULL, L"open", wexe.c_str(), NULL,
wdir.c_str(), SW_SHOWNORMAL);
bool ok = ((INT_PTR)r > 32);
if (ok) {
fn_log::log_info("hub: launched %s", app.name.c_str());
g_last_status = std::string("Launched ") + app.meta.display_name;
} else {
fn_log::log_warn("hub: ShellExecuteW failed for %s (code=%lld)",
app.name.c_str(), (long long)(INT_PTR)r);
g_last_status = std::string("FAILED to launch ") + app.meta.display_name;
}
return ok;
#else
(void)app;
g_last_status = "Launch only supported on Windows";
fn_log::log_warn("hub: launch requested on non-Windows build");
return false;
#endif
}
static bool matches_filter(AppEntry const& app) {
if (g_filter[0] == '\0') return true;
auto lower = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return s;
};
std::string f = lower(g_filter);
return lower(app.name).find(f) != std::string::npos ||
lower(app.meta.display_name).find(f) != std::string::npos ||
lower(app.meta.description).find(f) != std::string::npos;
}
static void draw_card(AppEntry const& app, float card_w, float card_h) {
fn_ui::AppCardData data;
data.id = app.name.c_str();
data.title = app.meta.display_name.c_str();
data.description = app.meta.description.c_str();
data.accent = app.meta.accent;
auto it = g_icons.find(app.name);
data.icon = (it != g_icons.end() && it->second.ok())
? (ImTextureID)(intptr_t)it->second.id
: (ImTextureID)0;
bool clicked = fn_ui::app_card(data, ImVec2(card_w, card_h));
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\n%s", app.name.c_str(),
app.exe_path.string().c_str());
}
if (clicked) launch_app(app);
}
static void draw_main() {
if (!ImGui::Begin(TI_APPS " Apps", &g_show_main)) {
ImGui::End();
return;
}
ImGui::TextUnformatted("Hub Launcher");
ImGui::SameLine();
ImGui::TextDisabled("(%zu apps)", g_apps.size());
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 90.0f);
if (ImGui::Button(TI_REFRESH " Refresh")) {
scan_apps();
load_icons();
}
ImGui::Separator();
ImGui::SetNextItemWidth(-1.0f);
ImGui::InputTextWithHint("##filter", "Filter apps...",
g_filter, sizeof(g_filter));
ImGui::Spacing();
float card_w = 320.0f;
float card_h = 110.0f;
float spacing = 12.0f;
float avail = ImGui::GetContentRegionAvail().x;
int cols = std::max(1, (int)((avail + spacing) / (card_w + spacing)));
int shown = 0;
for (auto const& app : g_apps) {
if (!matches_filter(app)) continue;
if (shown % cols != 0) ImGui::SameLine(0.0f, spacing);
draw_card(app, card_w, card_h);
++shown;
if (shown % cols == 0) ImGui::Dummy(ImVec2(0.0f, spacing - 4.0f));
}
if (shown == 0) {
ImGui::TextDisabled("No apps match filter.");
}
if (!g_last_status.empty()) {
ImGui::Separator();
ImGui::TextUnformatted(g_last_status.c_str());
}
ImGui::End();
}
static void render() {
static bool icons_loaded_once = false;
if (!icons_loaded_once) {
load_icons();
icons_loaded_once = true;
}
if (g_show_main) draw_main();
}
int main(int /*argc*/, char** /*argv*/) {
static fn_ui::PanelToggle panels[] = {
{ "Apps", nullptr, &g_show_main },
};
scan_apps();
fn::AppConfig cfg;
cfg.title = "App Hub Launcher";
cfg.about = { "app_hub_launcher", "0.3.0",
"Lista y arranca apps C++ desplegadas en Windows Desktop" };
cfg.log = { "app_hub_launcher.log", 1 };
cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
cfg.init_gl_loader = true; // needed for gl_texture_load
return fn::run_app(cfg, render);
}