From 5923a77665024ea9ce8e2d8acd94d52a6602471e Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Tue, 30 Jun 2026 22:58:46 +0800 Subject: [PATCH] feat(launcher): discover apps via per-app app.json manifests Adds an "app package" discovery model so apps are plug-and-play: drop a self-contained /../apps// directory (executable + app.json + icon) and CFDesktop picks it up at startup, no recompile. - AppDiscoverer scans /../apps//app.json, parses each manifest, resolves icon/exec to absolute paths; skips malformed manifests with a warning. - loadAppsConfig fallback chain is now: discover -> apps.json -> defaultApps. - Calculator becomes the first manifest app: executable + app.json deployed together under /../apps/calculator/ (self-contained package). - defaultApps() drops the calculator entry (now discovered, not hardcoded). - Tests: app_discoverer_test covers parse, absolute paths, skip-bad, skip-missing, empty-icon. --- apps/calculator/CMakeLists.txt | 12 +- apps/calculator/app.json | 5 + desktop/ui/CFDesktopEntity.cpp | 106 +++++++++--------- desktop/ui/components/app_entry.h | 2 - desktop/ui/components/launcher/CMakeLists.txt | 1 + .../ui/components/launcher/app_discoverer.cpp | 75 +++++++++++++ .../ui/components/launcher/app_discoverer.h | 72 ++++++++++++ document/status/current.md | 1 + document/todo/desktop/13_widget_apps.md | 1 + test/desktop/CMakeLists.txt | 3 + test/desktop/launcher/CMakeLists.txt | 21 ++++ test/desktop/launcher/app_discoverer_test.cpp | 85 ++++++++++++++ 12 files changed, 328 insertions(+), 56 deletions(-) create mode 100644 apps/calculator/app.json create mode 100644 desktop/ui/components/launcher/app_discoverer.cpp create mode 100644 desktop/ui/components/launcher/app_discoverer.h create mode 100644 test/desktop/launcher/CMakeLists.txt create mode 100644 test/desktop/launcher/app_discoverer_test.cpp diff --git a/apps/calculator/CMakeLists.txt b/apps/calculator/CMakeLists.txt index 3cd7845e0..fd45f36e9 100644 --- a/apps/calculator/CMakeLists.txt +++ b/apps/calculator/CMakeLists.txt @@ -33,8 +33,14 @@ target_link_libraries(calculator PRIVATE Qt6::Widgets ) -# Place next to the CFDesktop binary so AppLaunchService's applicationDirPath() -# resolution finds it (exec_command "calculator" -> /calculator). +# App package is self-contained under /../apps/calculator/ (executable + +# manifest in the same dir). AppDiscoverer scans /../apps//app.json, +# resolves exec to the co-located absolute path, and AppLaunchService launches +# it directly — no working-directory dependency, no PATH search needed. +set(CALC_APP_DIR "${CMAKE_BINARY_DIR}/apps/calculator") set_target_properties(calculator PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY "${CALC_APP_DIR}" ) + +# Deploy the manifest next to the executable (at configure time). +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/app.json" "${CALC_APP_DIR}/app.json" COPYONLY) diff --git a/apps/calculator/app.json b/apps/calculator/app.json new file mode 100644 index 000000000..bf5bab80c --- /dev/null +++ b/apps/calculator/app.json @@ -0,0 +1,5 @@ +{ + "app_id": "calculator", + "display_name": "Calculator", + "exec": "calculator" +} diff --git a/desktop/ui/CFDesktopEntity.cpp b/desktop/ui/CFDesktopEntity.cpp index b558c0ce3..1d44e723a 100644 --- a/desktop/ui/CFDesktopEntity.cpp +++ b/desktop/ui/CFDesktopEntity.cpp @@ -11,6 +11,7 @@ #include "components/PanelManager.h" #include "components/WindowManager.h" #include "components/builtin_apps/about_panel.h" +#include "components/launcher/app_discoverer.h" #include "components/launcher/app_launch_service.h" #include "components/launcher/app_launcher.h" #include "components/statusbar/status_bar.h" @@ -32,15 +33,19 @@ namespace cf::desktop { namespace { -/// Loads the per-board app list from /settings/apps.json. Falls back to -/// defaultApps() when the file is missing, unreadable, empty, or has no valid -/// entries -- so a board only needs to drop an apps.json to customize the -/// launcher/taskbar without a recompile. +/// Loads the app list with a fallback chain: auto-discovered apps +/// (/../apps//app.json) first, then /../apps.json manual list, +/// then defaultApps(). A board can drop app manifests or apps.json to +/// customize the launcher/taskbar without a recompile. QList loadAppsConfig() { - // Read from the desktop root (/../apps.json). Kept out of the - // board-written settings/ dir so it stays owner-writable on the NFS root. - const QString path = - QCoreApplication::applicationDirPath() + QStringLiteral("/../apps.json"); + // Prefer auto-discovered apps (/../apps//app.json manifests). + auto discovered = desktop_component::AppDiscoverer::discover(); + if (!discovered.isEmpty()) { + return discovered; + } + // Fallback: /../apps.json manual list. Kept out of the board-written + // settings/ dir so it stays owner-writable on the NFS root. + const QString path = QCoreApplication::applicationDirPath() + QStringLiteral("/../apps.json"); QFile file(path); if (!file.open(QIODevice::ReadOnly)) { return desktop_component::defaultApps(); @@ -272,38 +277,38 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { // so no framebuffer fight with the desktop on linuxfb). auto* about_panel = new cf::desktop::desktop_component::AboutPanel(desktop_entity_); - std::function launch_app = - [apps, app_pid, about_panel, panel_mgr](const QString& app_id) { - QString exec; - for (const auto& app : apps) { - if (app.app_id == app_id) { - exec = app.exec_command; - break; - } - } - if (exec.isEmpty()) { - cf::log::warningftag("CFDesktopEntity", "No exec for app_id '{}'", - app_id.toStdString()); - return; + std::function launch_app = [apps, app_pid, about_panel, + panel_mgr](const QString& app_id) { + QString exec; + for (const auto& app : apps) { + if (app.app_id == app_id) { + exec = app.exec_command; + break; } - // Builtin apps render in-process. External apps are launched - // detached (Stage 2 will add hide-desktop + managed-QProcess for - // GUI apps that need the full framebuffer). - if (exec.startsWith(QStringLiteral("builtin:"))) { - const auto id = exec.mid(QStringLiteral("builtin:").size()); - if (id == QStringLiteral("about") && about_panel != nullptr) { - about_panel->popup(panel_mgr->availableGeometry()); - } else { - cf::log::warningftag("CFDesktopEntity", "Unknown builtin app '{}'", - id.toStdString()); - } - return; - } - const auto launched = cf::desktop::desktop_component::AppLaunchService::launch(exec); - if (launched.has_value()) { - (*app_pid)[app_id] = *launched; + } + if (exec.isEmpty()) { + cf::log::warningftag("CFDesktopEntity", "No exec for app_id '{}'", + app_id.toStdString()); + return; + } + // Builtin apps render in-process. External apps are launched + // detached (Stage 2 will add hide-desktop + managed-QProcess for + // GUI apps that need the full framebuffer). + if (exec.startsWith(QStringLiteral("builtin:"))) { + const auto id = exec.mid(QStringLiteral("builtin:").size()); + if (id == QStringLiteral("about") && about_panel != nullptr) { + about_panel->popup(panel_mgr->availableGeometry()); + } else { + cf::log::warningftag("CFDesktopEntity", "Unknown builtin app '{}'", + id.toStdString()); } - }; + return; + } + const auto launched = cf::desktop::desktop_component::AppLaunchService::launch(exec); + if (launched.has_value()) { + (*app_pid)[app_id] = *launched; + } + }; QObject::connect(taskbar, &cf::desktop::desktop_component::CenteredTaskbar::appClicked, this, launch_app); @@ -312,19 +317,18 @@ CFDesktopEntity::RunsSetupResult CFDesktopEntity::run_init(RunsSetupMethod m) { app_launcher->setApps(apps); QObject::connect(app_launcher, &cf::desktop::desktop_component::AppLauncher::appLaunched, this, launch_app); - QObject::connect( - taskbar, &cf::desktop::desktop_component::CenteredTaskbar::launcherRequested, this, - [app_launcher, panel_mgr]() { - // Toggle: clicking Start while the launcher is open dismisses it, - // otherwise pop it up. The start button used to only ever call - // popup(), so a second click was a no-op while the menu was already - // visible. - if (app_launcher->isShowing()) { - app_launcher->hideLauncher(); - } else { - app_launcher->popup(panel_mgr->availableGeometry()); - } - }); + QObject::connect(taskbar, &cf::desktop::desktop_component::CenteredTaskbar::launcherRequested, + this, [app_launcher, panel_mgr]() { + // Toggle: clicking Start while the launcher is open dismisses it, + // otherwise pop it up. The start button used to only ever call + // popup(), so a second click was a no-op while the menu was already + // visible. + if (app_launcher->isShowing()) { + app_launcher->hideLauncher(); + } else { + app_launcher->popup(panel_mgr->availableGeometry()); + } + }); taskbar->show(); panel_mgr->relayout(); diff --git a/desktop/ui/components/app_entry.h b/desktop/ui/components/app_entry.h index 6a05684e4..2d03c7554 100644 --- a/desktop/ui/components/app_entry.h +++ b/desktop/ui/components/app_entry.h @@ -59,8 +59,6 @@ inline QList defaultApps() { {QStringLiteral("browser"), QStringLiteral("Browser"), QStringLiteral(":/cfdesktop/taskbar/browser.png"), QStringLiteral("xdg-open https://example.com"), false}, - {QStringLiteral("calculator"), QStringLiteral("Calculator"), QStringLiteral(""), - QStringLiteral("calculator"), false}, }; } diff --git a/desktop/ui/components/launcher/CMakeLists.txt b/desktop/ui/components/launcher/CMakeLists.txt index 9841d9b9b..23ba3d4aa 100644 --- a/desktop/ui/components/launcher/CMakeLists.txt +++ b/desktop/ui/components/launcher/CMakeLists.txt @@ -1,5 +1,6 @@ # App launcher service (QProcess-based external application launching). add_library(cfdesktop_launcher STATIC + app_discoverer.cpp app_launch_service.cpp app_launcher.cpp launcher_tile.cpp diff --git a/desktop/ui/components/launcher/app_discoverer.cpp b/desktop/ui/components/launcher/app_discoverer.cpp new file mode 100644 index 000000000..2a9e1578d --- /dev/null +++ b/desktop/ui/components/launcher/app_discoverer.cpp @@ -0,0 +1,75 @@ +/** + * @file desktop/ui/components/launcher/app_discoverer.cpp + * @brief Implementation of AppDiscoverer. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "app_discoverer.h" + +#include "cflog.h" + +#include +#include +#include +#include +#include +#include + +namespace cf::desktop::desktop_component { + +namespace { +/// Tag for AppDiscoverer log lines. +constexpr const char* kLogTag = "AppDiscoverer"; +/// Manifest filename inside each / app directory. +constexpr const char* kManifestName = "app.json"; +} // namespace + +QList AppDiscoverer::discover() { + const QString apps_dir = QCoreApplication::applicationDirPath() + QStringLiteral("/../apps"); + return discoverFrom(apps_dir); +} + +QList AppDiscoverer::discoverFrom(const QString& apps_dir) { + QList result; + QDir dir(apps_dir); + if (!dir.exists()) { + return result; + } + + const QFileInfoList subdirs = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto& sub : subdirs) { + const QString app_dir = sub.absoluteFilePath(); + const QString manifest_path = + app_dir + QLatin1Char('/') + QString::fromLatin1(kManifestName); + + QFile file(manifest_path); + if (!file.open(QIODevice::ReadOnly)) { + continue; // No app.json — not an app directory, skip silently. + } + + const QJsonObject obj = QJsonDocument::fromJson(file.readAll()).object(); + AppEntry entry; + entry.app_id = obj.value(QStringLiteral("app_id")).toString(); + entry.display_name = obj.value(QStringLiteral("display_name")).toString(); + const QString icon = obj.value(QStringLiteral("icon")).toString(); + const QString exec = obj.value(QStringLiteral("exec")).toString(); + + if (entry.app_id.isEmpty() || exec.isEmpty()) { + log::warningftag(kLogTag, "Skipping '{}': missing app_id/exec", + manifest_path.toStdString()); + continue; + } + + entry.icon_path = icon.isEmpty() ? QString() : app_dir + QLatin1Char('/') + icon; + entry.exec_command = app_dir + QLatin1Char('/') + exec; + result.append(entry); + } + return result; +} + +} // namespace cf::desktop::desktop_component diff --git a/desktop/ui/components/launcher/app_discoverer.h b/desktop/ui/components/launcher/app_discoverer.h new file mode 100644 index 000000000..219b46363 --- /dev/null +++ b/desktop/ui/components/launcher/app_discoverer.h @@ -0,0 +1,72 @@ +/** + * @file desktop/ui/components/launcher/app_discoverer.h + * @brief Discovers standalone apps via per-app manifest files. + * + * Each app ships an @c app.json manifest in @c //. + * AppDiscoverer scans the apps directory, parses each manifest, and returns + * AppEntry values with absolute @c icon_path / @c exec_command (so launch and + * icon rendering do not depend on the working directory). + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#pragma once + +#include "../app_entry.h" + +#include +#include + +namespace cf::desktop::desktop_component { + +/** + * @brief Discovers standalone apps from per-app @c app.json manifests. + * + * The discovery contract: + * - @c //app.json is the manifest (JSON object). + * - Fields: @c app_id, @c display_name, @c icon (relative to manifest), + * @c exec (relative to manifest). + * - @c icon_path / @c exec_command are resolved to absolute paths + * (@c /). + * - Subdirs without @c app.json are skipped silently; manifests missing + * @c app_id or @c exec are skipped with a warning (no silent fallback). + * + * @ingroup components + */ +class AppDiscoverer { + public: + AppDiscoverer() = delete; + + /** + * @brief Discovers apps under @c /../apps//app.json. + * + * @return Parsed AppEntry list (icon/exec resolved to absolute paths); + * empty if the apps directory is absent or has no manifests. + * + * @throws None. + * + * @since 0.20 + * @ingroup components + */ + static QList discover(); + + /** + * @brief Discovers apps under @p apps_dir. + * + * @param[in] apps_dir Directory containing / subdirs with app.json. + * + * @return Parsed AppEntry list. + * + * @throws None. + * @note Used by discover(); exposed for unit tests. + * @since 0.20 + * @ingroup components + */ + static QList discoverFrom(const QString& apps_dir); +}; + +} // namespace cf::desktop::desktop_component diff --git a/document/status/current.md b/document/status/current.md index 0a197afdc..e899ab0e5 100644 --- a/document/status/current.md +++ b/document/status/current.md @@ -61,6 +61,7 @@ description: CFDesktop 项目进度的唯一事实来源与全局导航。 - **2026-06**:`refactor: refactor the ui subsystem`;`hwtier system enabled`;文档清理 - **2026-06(壁纸资源包发现)**:壁纸层支持运行时动态发现第三方资源包——新增 `Wallpapers` PathType(`/Wallpapers/`)+ `filter_target_recursive` 递归扫描;`make_layer()` 改为「首个有图的源胜出」(config → Pictures 平铺,空则回退 Wallpapers 递归)。CF-Gallery 等包安装到 `Wallpapers//` 即被发现,**CFDesktop 零编译期耦合**(无 submodule/无 `#ifdef`/无 CMake option)。详见 [aels_cross_repo_deps.md](../todo/desktop/aels_cross_repo_deps.md)。动画轮播引擎(Gradient 交叉淡入 / Movement 平移 + QTimer 定时 + Sequential/Random 选择器)**已落地**(2026-06-30,`feat/wallpaper-animation-engine`)——新增 `WallPaperEngine` + `TransitionComposer`,strategy 过渡状态机 + 逐帧 QImage 合成,后端无关;6 个 `switch_*` 配置 key + 顺手接 `scaling`/`background_color`;16 例单测全过。详见 [wallpaper_animation_engine.md](../todo/desktop/wallpaper_animation_engine.md) 末尾「实施记录」。 - **2026-06(首个独立 App:Calculator)**:移植 CCIMXDesktop `Caculator` 为**独立可执行**(`apps/calculator/`)—— parser(递归下降 AST)保留 QString 建 `cfdesktop_calculator_parser` lib(53 例单测);UI cfui MD3(`Button` 网格 + `Label`)重写;CFDesktop 经 `AppLaunchService::launch`(QProcess)启动(隔离进程),`AppLaunchService` 加 `applicationDirPath()` 解析(自家 app 同 `bin/` 优先)。确立「工具型 App → 独立可执行」范式(展示型 about 仍 builtin)。 +- **2026-06(App 发现机制)**:`AppDiscoverer` 扫描 `/../apps//app.json` 自动发现注册 app(manifest:app_id/display_name/icon/exec);`loadAppsConfig` fallback 链 **discover → apps.json → defaultApps**;calculator 改首个 manifest app(包自包含 `apps/calculator/{calculator, app.json}`)。App 即插即用,无需 recompile。 - **已达成**:Milestone 1「桌面骨架可见」;Phase 0 / 1 / 2 / A(CI) / 6 / G(Widget) / H(显示后端)(详见 [SUMMARY.md](../todo/done/SUMMARY.md)) ## 新人入门 diff --git a/document/todo/desktop/13_widget_apps.md b/document/todo/desktop/13_widget_apps.md index 535750839..f5f099595 100644 --- a/document/todo/desktop/13_widget_apps.md +++ b/document/todo/desktop/13_widget_apps.md @@ -15,6 +15,7 @@ description: "预计周期: 4~5 周,依赖阶段: Phase 9, Phase 12" > - **资源包运行时发现已合入**(PR #13,2026-06):第三方壁纸包安装到 `/Wallpapers//` 即被递归发现;CF-Gallery 提供 `install-to-cfdesktop.sh`。 > - **轮播/动画引擎已落地**(2026-06-30,`feat/wallpaper-animation-engine`):新增 `WallPaperEngine` + `TransitionComposer`,strategy 过渡状态机逐帧合成;Gradient/Movement + Sequential/Random,配置驱动(`switch_mode`/`switch_selector`/`switch_interval_ms`/`animation_duration_ms`/`switch_easing`/`disable_animation`),顺手接 `scaling`/`background_color`;16 例单测。详见 [wallpaper_animation_engine.md](wallpaper_animation_engine.md) 末尾「实施记录」。 > - **首个独立 App(Calculator)已落地**(2026-06-30,`feat/calculator-app`):移植 CCIMXDesktop `Caculator` —— parser(递归下降 AST)保留 QString、独立 `cfdesktop_calculator_parser` lib(53 例单测);UI cfui MD3(`Button` 网格 + `Label`)重写;**独立可执行** `apps/calculator/`,CFDesktop 经 `AppLaunchService::launch`(QProcess)启动(隔离进程)。**确立「工具型 App → 独立可执行 + QProcess」范式**:展示型(about)走 builtin child panel,工具型(calculator/Noter/FileRamber)走独立可执行;`AppLaunchService` 加了 `applicationDirPath()` 解析(自家 app 同 `bin/` 优先,extern 回退 PATH)。 +> - **App 发现机制(manifest + AppDiscoverer)已落地**(2026-06-30,`feat/calculator-app`):每 app 一个 `app.json` manifest 放 `/../apps//`(自包含:可执行 + manifest + icon 同目录);`AppDiscoverer` 启动扫描 `apps//app.json` 自动发现注册,icon/exec 解析为绝对路径;`loadAppsConfig` 改 fallback 链 **discover → apps.json → defaultApps**。calculator 改作首个 manifest app。**App 即插即用**(丢 app 包目录即可,无需 recompile)。 > - **Widget 框架尺度调和**:本 Phase(Phase J)画的是**全量愿景**(沙箱/进程隔离/API);近期落地以 [milestone_06](milestone_06_widget_control_center.md) **最小版**(ClockWidget + ControlCenter)为准。沙箱依赖 IPC(0%)+CrashHandler;低 RAM 退化同进程崩溃隔离。 > - **FileManagerApp 现实**:① 依赖 P2 控件(ToolBar/ContextMenu/TreeView 等,当前 0%);② 本仓 `desktop/base/file_operations/file_op.h` **只是路径拼接微工具**(copy/move/rename/delete/trash 全无),**不能当文件管理器底座**,需新建;③ 建议参照 CCIMX `FileRamber`(QtConcurrent 异步刷新)移植。 > - **数据源前置**:WeatherWidget 依赖 `aels-network`;ResourceMonitorWidget 依赖 `base/system` 探针(已 90%);均见 [aels_cross_repo_deps.md](aels_cross_repo_deps.md)。 diff --git a/test/desktop/CMakeLists.txt b/test/desktop/CMakeLists.txt index e72d1e857..0ed473bee 100644 --- a/test/desktop/CMakeLists.txt +++ b/test/desktop/CMakeLists.txt @@ -15,3 +15,6 @@ add_subdirectory(wallpaper_animation) # Add calculator parser tests subdirectory add_subdirectory(calculator) + +# Add launcher (app discovery) tests subdirectory +add_subdirectory(launcher) diff --git a/test/desktop/launcher/CMakeLists.txt b/test/desktop/launcher/CMakeLists.txt new file mode 100644 index 000000000..6c85e517b --- /dev/null +++ b/test/desktop/launcher/CMakeLists.txt @@ -0,0 +1,21 @@ +# AppDiscoverer unit tests. +# cfdesktop_launcher is STATIC with PRIVATE deps, so re-link every transitive +# dependency pulled in through app_discoverer.cpp (cflogger for log macros, +# cfbase/aex/QuarkWidgets via the launcher target). +add_gtest_executable( + TEST_NAME app_discoverer_test + SOURCE_FILE app_discoverer_test.cpp + LINK_LIBRARIES + cfdesktop_launcher + cflogger + cfbase + QuarkWidgets::quarkwidgets + aex::aex + Qt6::Core + Qt6::Widgets + GTest::gtest + GTest::gtest_main + INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components + LABELS "desktop;unit;launcher" + LOG_MODULE launcher_tests +) diff --git a/test/desktop/launcher/app_discoverer_test.cpp b/test/desktop/launcher/app_discoverer_test.cpp new file mode 100644 index 000000000..0c865661c --- /dev/null +++ b/test/desktop/launcher/app_discoverer_test.cpp @@ -0,0 +1,85 @@ +/** + * @file test/desktop/launcher/app_discoverer_test.cpp + * @brief GoogleTest unit tests for AppDiscoverer. + * + * Covers: empty/missing apps dir, valid manifest parse (icon/exec resolved to + * absolute), skipping subdirs without app.json, skipping manifests missing + * required fields, empty icon handling. + * + * @author Charliechen114514 (chengh1922@mails.jlu.edu.cn) + * @date 2026-06-30 + * @version 0.1 + * @since 0.20 + * @ingroup components + */ + +#include "launcher/app_discoverer.h" + +#include +#include +#include + +#include + +using cf::desktop::desktop_component::AppDiscoverer; + +namespace { +/// Writes a manifest body to //app.json. +void writeManifest(const QString& apps_dir, const QString& id, const QString& body) { + QDir(apps_dir).mkpath(id); + QFile f(apps_dir + QLatin1Char('/') + id + QStringLiteral("/app.json")); + f.open(QIODevice::WriteOnly); + f.write(body.toUtf8()); +} +} // namespace + +TEST(AppDiscoverer, EmptyDirReturnsEmpty) { + QTemporaryDir tmp; + EXPECT_TRUE(tmp.isValid()); + EXPECT_TRUE(AppDiscoverer::discoverFrom(tmp.path()).isEmpty()); +} + +TEST(AppDiscoverer, NonexistentDirReturnsEmpty) { + EXPECT_TRUE( + AppDiscoverer::discoverFrom(QStringLiteral("/nonexistent/cfdesktop/apps")).isEmpty()); +} + +TEST(AppDiscoverer, ParsesValidManifest) { + QTemporaryDir tmp; + writeManifest(tmp.path(), QStringLiteral("calc"), + R"({"app_id":"calc","display_name":"Calc","exec":"calc","icon":"icon.png"})"); + const auto apps = AppDiscoverer::discoverFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].app_id.toStdString(), "calc"); + EXPECT_EQ(apps[0].display_name.toStdString(), "Calc"); + EXPECT_TRUE(apps[0].exec_command.endsWith(QStringLiteral("/calc/calc"))); + EXPECT_TRUE(apps[0].icon_path.endsWith(QStringLiteral("/calc/icon.png"))); +} + +TEST(AppDiscoverer, SkipsSubdirWithoutManifest) { + QTemporaryDir tmp; + writeManifest(tmp.path(), QStringLiteral("calc"), R"({"app_id":"calc","exec":"calc"})"); + QDir(tmp.path()).mkpath(QStringLiteral("no_manifest")); // subdir without app.json + const auto apps = AppDiscoverer::discoverFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].app_id.toStdString(), "calc"); +} + +TEST(AppDiscoverer, SkipsManifestMissingExec) { + QTemporaryDir tmp; + writeManifest(tmp.path(), QStringLiteral("bad"), + R"({"app_id":"bad","display_name":"Bad"})"); // no exec + writeManifest(tmp.path(), QStringLiteral("good"), R"({"app_id":"good","exec":"good"})"); + const auto apps = AppDiscoverer::discoverFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_EQ(apps[0].app_id.toStdString(), "good"); +} + +TEST(AppDiscoverer, EmptyIconYieldsEmptyIconPath) { + QTemporaryDir tmp; + writeManifest(tmp.path(), QStringLiteral("calc"), + R"({"app_id":"calc","exec":"calc"})"); // no icon field + const auto apps = AppDiscoverer::discoverFrom(tmp.path()); + ASSERT_EQ(apps.size(), 1); + EXPECT_TRUE(apps[0].icon_path.isEmpty()); +}