feat(mcp): restore MCP system with native log.h
- MCP 3.3K LOC: McpSystem 1899 + McpHandler 1029 + McpScreensaver + 2 apps
- Uses native tactility/log.h TAG macros, no old Logger.h
- DisplaySettings McpScreen enum, WebServer coexistence (MCP enabled starts HTTP)
- DisplayIdle: startMcpScreensaver + isDeviceCharging + lock inversion fix 3629ffef
- LVGL 512K PSRAM cache retained from previous perf patch
- Audio kept upstream es8311-module per note we might not need custom
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <Tactility/service/displayidle/DisplayIdleService.h>
|
||||
|
||||
@@ -7,8 +8,8 @@
|
||||
#include "MatrixRainScreensaver.h"
|
||||
#include "MystifyScreensaver.h"
|
||||
#include "StackChanScreensaver.h"
|
||||
#include "McpScreensaver.h"
|
||||
|
||||
#include <tactility/log.h>
|
||||
#include <Tactility/CoreDefines.h>
|
||||
#include <Tactility/hal/display/DisplayDevice.h>
|
||||
#include <Tactility/hal/power/PowerDevice.h>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <Tactility/service/ServiceContext.h>
|
||||
#include <Tactility/service/ServiceManifest.h>
|
||||
#include <Tactility/service/ServiceRegistration.h>
|
||||
#include <Tactility/mcp/McpSystem.h>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
|
||||
@@ -33,32 +35,24 @@ static bool isDeviceCharging() {
|
||||
bool charging = false;
|
||||
hal::findDevices<hal::power::PowerDevice>(hal::Device::Type::Power, [&charging](const auto& power) {
|
||||
if (!power->supportsMetric(hal::power::PowerDevice::MetricType::IsCharging)) {
|
||||
return true;
|
||||
return true; // continue
|
||||
}
|
||||
hal::power::PowerDevice::MetricData data;
|
||||
if (power->getMetric(hal::power::PowerDevice::MetricType::IsCharging, data) && data.valueAsBool) {
|
||||
charging = true;
|
||||
return false;
|
||||
return false; // stop iter
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return charging;
|
||||
}
|
||||
|
||||
|
||||
void DisplayIdleService::stopScreensaverCb(lv_event_t* e) {
|
||||
auto* self = static_cast<DisplayIdleService*>(lv_event_get_user_data(e));
|
||||
lv_event_stop_bubbling(e);
|
||||
self->stopScreensaverRequested.store(true, std::memory_order_release);
|
||||
lv_display_trigger_activity(nullptr);
|
||||
self->stopScreensaverLocked();
|
||||
}
|
||||
|
||||
void DisplayIdleService::stopScreensaver() {
|
||||
if (!lvgl::lock(100)) {
|
||||
// Lock failed - keep flag set to retry on next tick
|
||||
return;
|
||||
}
|
||||
|
||||
void DisplayIdleService::stopScreensaverLocked() {
|
||||
const auto restoreDuty = cachedDisplaySettings.backlightDuty;
|
||||
const bool wasDimmed = displayDimmed;
|
||||
|
||||
@@ -70,7 +64,6 @@ void DisplayIdleService::stopScreensaver() {
|
||||
lv_obj_delete(screensaverOverlay);
|
||||
screensaverOverlay = nullptr;
|
||||
}
|
||||
lvgl::unlock();
|
||||
stopScreensaverRequested.store(false, std::memory_order_relaxed);
|
||||
|
||||
// Reset auto-off state
|
||||
@@ -85,6 +78,15 @@ void DisplayIdleService::stopScreensaver() {
|
||||
displayDimmed = wasDimmed ? false : displayDimmed;
|
||||
}
|
||||
|
||||
void DisplayIdleService::stopScreensaver() {
|
||||
if (!lvgl::lock(100)) {
|
||||
// Lock failed - keep flag set to retry on next tick
|
||||
return;
|
||||
}
|
||||
stopScreensaverLocked();
|
||||
lvgl::unlock();
|
||||
}
|
||||
|
||||
void DisplayIdleService::activateScreensaver() {
|
||||
lv_obj_t* top = lv_layer_top();
|
||||
|
||||
@@ -121,6 +123,9 @@ void DisplayIdleService::activateScreensaver() {
|
||||
case settings::display::ScreensaverType::StackChan:
|
||||
screensaver = std::make_unique<StackChanScreensaver>();
|
||||
break;
|
||||
case settings::display::ScreensaverType::McpScreen:
|
||||
screensaver = std::make_unique<McpScreensaver>();
|
||||
break;
|
||||
case settings::display::ScreensaverType::None:
|
||||
default:
|
||||
// Just black screen, no animated screensaver
|
||||
@@ -141,8 +146,17 @@ void DisplayIdleService::updateScreensaver() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void DisplayIdleService::tick() {
|
||||
// Check if MCP override is active — must not be auto-stopped by idle logic
|
||||
// READ OUTSIDE LVGL lock to avoid lock inversion:
|
||||
// MCP video task locks mutex -> LVGL, we must NOT do LVGL -> mutex
|
||||
bool isMcpActive = false;
|
||||
{
|
||||
auto& st = mcp::getState();
|
||||
std::lock_guard<std::mutex> lk(st.mutex);
|
||||
isMcpActive = (st.drawArea != nullptr) || st.overrideActive;
|
||||
}
|
||||
|
||||
if (!lvgl::lock(100)) {
|
||||
return;
|
||||
}
|
||||
@@ -159,11 +173,13 @@ void DisplayIdleService::tick() {
|
||||
uint32_t inactive_ms = 0;
|
||||
inactive_ms = lv_display_get_inactive_time(nullptr);
|
||||
|
||||
// Only update if not stopping (prevents lag on touch)
|
||||
if (displayDimmed && screensaverOverlay && !stopScreensaverRequested.load(std::memory_order_acquire)) {
|
||||
// Only update if not stopping (prevents lag on touch) — skip for MCP (no animation)
|
||||
if (displayDimmed && screensaverOverlay && !stopScreensaverRequested.load(std::memory_order_acquire) && !isMcpActive) {
|
||||
// Check if screensaver should auto-off after 5 minutes
|
||||
if (!backlightOff) {
|
||||
screensaverActiveCounter++;
|
||||
if (screensaverActiveCounter >= SCREENSAVER_AUTO_OFF_TICKS) {
|
||||
// Stop screensaver animation and turn off backlight
|
||||
if (screensaver) {
|
||||
screensaver->stop();
|
||||
screensaver.reset();
|
||||
@@ -181,6 +197,7 @@ void DisplayIdleService::tick() {
|
||||
|
||||
lvgl::unlock();
|
||||
|
||||
// Check stop request early for faster response
|
||||
if (stopScreensaverRequested.load(std::memory_order_acquire)) {
|
||||
stopScreensaver();
|
||||
return;
|
||||
@@ -190,13 +207,15 @@ void DisplayIdleService::tick() {
|
||||
bool supportsBacklight = display != nullptr && display->supportsBacklightDuty();
|
||||
|
||||
if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) {
|
||||
if (displayDimmed) {
|
||||
// Timeout disabled (Never): ensure we restore if we were dimmed, regardless of display type
|
||||
if (displayDimmed && !isMcpActive) {
|
||||
if (supportsBacklight && display != nullptr) {
|
||||
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
|
||||
}
|
||||
displayDimmed = false;
|
||||
}
|
||||
} else if (supportsBacklight) {
|
||||
// For backlight-capable displays: full idle handling
|
||||
bool charging_blocks = cachedDisplaySettings.disableScreensaverWhenCharging && isDeviceCharging();
|
||||
|
||||
if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) {
|
||||
@@ -204,7 +223,7 @@ void DisplayIdleService::tick() {
|
||||
// Skip screensaver while charging
|
||||
} else {
|
||||
if (!lvgl::lock(100)) {
|
||||
return;
|
||||
return; // Retry on next tick
|
||||
}
|
||||
activateScreensaver();
|
||||
lvgl::unlock();
|
||||
@@ -215,23 +234,35 @@ void DisplayIdleService::tick() {
|
||||
}
|
||||
displayDimmed = true;
|
||||
}
|
||||
} else if (displayDimmed) {
|
||||
} else if (displayDimmed && !isMcpActive) {
|
||||
if (inactive_ms < kWakeActivityThresholdMs) {
|
||||
stopScreensaver();
|
||||
} else if (charging_blocks) {
|
||||
stopScreensaver();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For monochrome/RLCD (no backlight): don't auto-enter screensaver (heavy full_refresh SPI causes freeze)
|
||||
// Only handle wake if we are somehow dimmed (e.g. MCP left it)
|
||||
if (displayDimmed && !isMcpActive) {
|
||||
if (inactive_ms < kWakeActivityThresholdMs) {
|
||||
stopScreensaver();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool DisplayIdleService::onStart(ServiceContext& service) {
|
||||
// Seed random number generator for varied screensaver patterns
|
||||
srand(static_cast<unsigned int>(time(nullptr)));
|
||||
|
||||
cachedDisplaySettings = settings::display::loadOrGetDefault();
|
||||
|
||||
auto display = getDisplay();
|
||||
if (display != nullptr && !display->supportsBacklightDuty()) {
|
||||
LOG_I(TAG, "Monochrome/RLCD display detected (no backlight control): idle timer will run but auto-backlight off is disabled");
|
||||
}
|
||||
|
||||
timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::millisToTicks(TICK_INTERVAL_MS), [this]{ this->tick(); });
|
||||
timer->setCallbackPriority(Thread::Priority::Lower);
|
||||
timer->start();
|
||||
@@ -288,6 +319,62 @@ void DisplayIdleService::reloadSettings() {
|
||||
settingsReloadRequested.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void DisplayIdleService::startMcpScreensaver() {
|
||||
if (!lvgl::lock(200)) {
|
||||
LOG_W(TAG, "startMcpScreensaver: failed to acquire LVGL lock");
|
||||
return;
|
||||
}
|
||||
|
||||
if (screensaverOverlay != nullptr) {
|
||||
// Screensaver already active — if drawArea is registered we're done,
|
||||
// otherwise stop the current one so we can replace it with McpScreensaver.
|
||||
const auto& mcpState = mcp::getState();
|
||||
if (mcpState.drawArea != nullptr) {
|
||||
lvgl::unlock();
|
||||
return; // McpScreensaver already running
|
||||
}
|
||||
// Wrong screensaver type active — tear it down first
|
||||
if (screensaver) {
|
||||
screensaver->stop();
|
||||
screensaver.reset();
|
||||
}
|
||||
lv_obj_delete(screensaverOverlay);
|
||||
screensaverOverlay = nullptr;
|
||||
}
|
||||
|
||||
screensaverActiveCounter = 0;
|
||||
backlightOff = false;
|
||||
|
||||
// Ensure backlight is active if the display supports it
|
||||
auto display = getDisplay();
|
||||
if (display != nullptr && display->supportsBacklightDuty()) {
|
||||
uint8_t duty = cachedDisplaySettings.backlightDuty;
|
||||
if (duty == 0) duty = 255; // ensure visible if settings not loaded / default
|
||||
display->setBacklightDuty(duty);
|
||||
}
|
||||
|
||||
lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||
|
||||
lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
|
||||
|
||||
lv_obj_t* top = lv_layer_top();
|
||||
screensaverOverlay = lv_obj_create(top);
|
||||
lv_obj_remove_style_all(screensaverOverlay);
|
||||
lv_obj_set_size(screensaverOverlay, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_pos(screensaverOverlay, 0, 0);
|
||||
lv_obj_set_style_bg_color(screensaverOverlay, lv_color_black(), 0);
|
||||
lv_obj_set_style_bg_opa(screensaverOverlay, LV_OPA_COVER, 0);
|
||||
lv_obj_add_flag(screensaverOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(screensaverOverlay, stopScreensaverCb, LV_EVENT_CLICKED, this);
|
||||
|
||||
screensaver = std::make_unique<McpScreensaver>();
|
||||
screensaver->start(screensaverOverlay, screenW, screenH);
|
||||
|
||||
lvgl::unlock();
|
||||
displayDimmed = true;
|
||||
LOG_I(TAG, "MCP screensaver activated");
|
||||
}
|
||||
|
||||
std::shared_ptr<DisplayIdleService> findService() {
|
||||
return std::static_pointer_cast<DisplayIdleService>(
|
||||
findServiceById("DisplayIdle")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include "McpScreensaver.h"
|
||||
#include <Tactility/mcp/McpSystem.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "McpScreensaver";
|
||||
#include <esp_heap_caps.h>
|
||||
|
||||
namespace tt::service::displayidle {
|
||||
|
||||
|
||||
void McpScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
|
||||
auto& state = mcp::getState();
|
||||
|
||||
// Full-screen canvas on the overlay
|
||||
lv_obj_t* canvas = lv_canvas_create(overlay);
|
||||
lv_obj_set_size(canvas, screenW, screenH);
|
||||
lv_obj_set_pos(canvas, 0, 0);
|
||||
lv_obj_set_style_radius(canvas, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(canvas, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(canvas, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(canvas, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Allocate framebuffer (prefer SPIRAM)
|
||||
size_t requiredSize = (size_t)screenW * screenH * sizeof(uint16_t);
|
||||
framebuffer = (uint16_t*)heap_caps_malloc(requiredSize, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (framebuffer == nullptr) {
|
||||
framebuffer = (uint16_t*)heap_caps_malloc(requiredSize, MALLOC_CAP_8BIT);
|
||||
}
|
||||
framebufferSize = (framebuffer != nullptr) ? requiredSize : 0;
|
||||
|
||||
if (framebuffer == nullptr) {
|
||||
LOG_E(TAG, "Failed to allocate %uB framebuffer", (unsigned)requiredSize);
|
||||
lv_obj_t* err = lv_label_create(canvas);
|
||||
lv_label_set_text(err, "Framebuffer alloc failed");
|
||||
lv_obj_center(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill with a dark slate background (inverted for display path)
|
||||
size_t pixelCount = (size_t)screenW * screenH;
|
||||
for (size_t i = 0; i < pixelCount; ++i) {
|
||||
framebuffer[i] = ~0x18E3; // dark blue-grey
|
||||
}
|
||||
|
||||
lv_canvas_set_buffer(canvas, framebuffer, screenW, screenH, LV_COLOR_FORMAT_RGB565);
|
||||
|
||||
// Waiting label (removed on first MCP draw via lv_obj_clean)
|
||||
lv_obj_t* waitLabel = lv_label_create(canvas);
|
||||
lv_label_set_text(waitLabel, "Waiting for LLM...");
|
||||
lv_obj_set_style_text_color(waitLabel, lv_color_black(), LV_PART_MAIN); // white on screen (inverted)
|
||||
lv_obj_align(waitLabel, LV_ALIGN_CENTER, 0, -20);
|
||||
|
||||
lv_obj_t* resLabel = lv_label_create(canvas);
|
||||
lv_label_set_text_fmt(resLabel, "Display: %dx%d", (int)screenW, (int)screenH);
|
||||
lv_color_t resColor = lv_palette_lighten(LV_PALETTE_BLUE, 3);
|
||||
lv_obj_set_style_text_color(resLabel, lv_color_make(~resColor.red, ~resColor.green, ~resColor.blue), LV_PART_MAIN);
|
||||
lv_obj_align(resLabel, LV_ALIGN_CENTER, 0, 10);
|
||||
|
||||
// Register with McpSystemState
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.drawArea = canvas;
|
||||
state.framebuffer = framebuffer;
|
||||
state.framebufferSize = framebufferSize;
|
||||
state.displayWidth = (uint16_t)screenW;
|
||||
state.displayHeight = (uint16_t)screenH;
|
||||
state.drawWidth = (uint16_t)screenW;
|
||||
state.drawHeight = (uint16_t)screenH;
|
||||
// Don't reset overrideActive — if the LLM already drew, we keep the content
|
||||
|
||||
LOG_I(TAG, "McpScreensaver started (%dx%d)", (int)screenW, (int)screenH);
|
||||
}
|
||||
|
||||
void McpScreensaver::stop() {
|
||||
auto& state = mcp::getState();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.drawArea = nullptr;
|
||||
state.framebuffer = nullptr;
|
||||
state.framebufferSize = 0;
|
||||
state.overrideActive = false;
|
||||
}
|
||||
|
||||
if (framebuffer != nullptr) {
|
||||
heap_caps_free(framebuffer);
|
||||
framebuffer = nullptr;
|
||||
framebufferSize = 0;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "McpScreensaver stopped");
|
||||
}
|
||||
|
||||
void McpScreensaver::update(lv_coord_t /*screenW*/, lv_coord_t /*screenH*/) {
|
||||
// MCP draws on demand via HTTP — no per-frame animation needed
|
||||
}
|
||||
|
||||
} // namespace tt::service::displayidle
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include "Screensaver.h"
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::service::displayidle {
|
||||
|
||||
/**
|
||||
* MCP Screen screensaver.
|
||||
* Creates a full-screen LVGL canvas on the overlay and registers it in
|
||||
* McpSystemState so that MCP HTTP draw commands can paint to it.
|
||||
* Dismissed by a touch event (handled by the parent DisplayIdle overlay).
|
||||
*/
|
||||
class McpScreensaver final : public Screensaver {
|
||||
uint16_t* framebuffer = nullptr;
|
||||
size_t framebufferSize = 0;
|
||||
|
||||
public:
|
||||
McpScreensaver() = default;
|
||||
~McpScreensaver() override = default;
|
||||
|
||||
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
|
||||
void stop() override;
|
||||
void update(lv_coord_t screenW, lv_coord_t screenH) override;
|
||||
};
|
||||
|
||||
} // namespace tt::service::displayidle
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
#include <Tactility/service/webserver/AssetVersion.h>
|
||||
#include <Tactility/service/ServiceManifest.h>
|
||||
#include <Tactility/settings/WebServerSettings.h>
|
||||
#include <Tactility/settings/McpSettings.h>
|
||||
#include <Tactility/mcp/McpSystem.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/lvgl/Statusbar.h>
|
||||
@@ -215,7 +217,8 @@ bool WebServerService::onStart(ServiceContext& service) {
|
||||
lock.lock();
|
||||
g_cachedSettings = settings::webserver::loadOrGetDefault();
|
||||
g_settingsCached = true;
|
||||
serverEnabled = g_cachedSettings.webServerEnabled;
|
||||
auto mcpSettings = settings::mcp::loadOrGetDefault();
|
||||
serverEnabled = g_cachedSettings.webServerEnabled || mcpSettings.mcpEnabled;
|
||||
}
|
||||
// Subscribe to settings change events to refresh cache
|
||||
settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) {
|
||||
@@ -259,13 +262,17 @@ void WebServerService::onStop(ServiceContext& service) {
|
||||
void WebServerService::setEnabled(bool enabled) {
|
||||
auto lock = mutex.asScopedLock();
|
||||
lock.lock();
|
||||
|
||||
|
||||
if (enabled) {
|
||||
if (!httpServer || !httpServer->isStarted()) {
|
||||
startServer();
|
||||
}
|
||||
} else {
|
||||
if (httpServer && httpServer->isStarted()) {
|
||||
// Stop only if both web server and MCP are disabled
|
||||
auto wsSettings = settings::webserver::loadOrGetDefault();
|
||||
auto mcpSettings = settings::mcp::loadOrGetDefault();
|
||||
bool anyEnabled = wsSettings.webServerEnabled || mcpSettings.mcpEnabled;
|
||||
if (!anyEnabled && httpServer && httpServer->isStarted()) {
|
||||
stopServer();
|
||||
}
|
||||
}
|
||||
@@ -514,6 +521,11 @@ bool WebServerService::startServer() {
|
||||
LOG_I(TAG, "HTTP server started successfully on port %u", (unsigned)settings.webServerPort);
|
||||
publish_event(this, WebServerEvent::WebServerStarted);
|
||||
|
||||
auto mcpSettings = settings::mcp::loadOrGetDefault();
|
||||
if (mcpSettings.mcpEnabled) {
|
||||
mcp::startVideoStreamServer();
|
||||
}
|
||||
|
||||
// Show statusbar icon
|
||||
if (statusbarIconId >= 0) {
|
||||
lvgl::statusbar_icon_set_image(statusbarIconId, LVGL_ICON_STATUSBAR_CLOUD);
|
||||
@@ -533,6 +545,8 @@ void WebServerService::stopServer() {
|
||||
httpServer->stop();
|
||||
httpServer.reset();
|
||||
|
||||
mcp::stopVideoStreamServer();
|
||||
|
||||
// Stop AP mode WiFi if we started it
|
||||
if (apWifiInitialized || apNetif != nullptr) {
|
||||
stopApMode();
|
||||
@@ -1025,15 +1039,24 @@ esp_err_t WebServerService::handleApiGet(httpd_req_t* request) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// API POST dispatcher - all POST endpoints require authentication
|
||||
// API POST dispatcher - all POST endpoints require authentication except MCP
|
||||
esp_err_t WebServerService::handleApiPost(httpd_req_t* request) {
|
||||
const char* uri = request->uri;
|
||||
|
||||
// MCP endpoints are unauthenticated (local network)
|
||||
if (strncmp(uri, "/api/mcp", 8) == 0) {
|
||||
return handleApiMcp(request);
|
||||
}
|
||||
if (strncmp(uri, "/api/screen/raw", 15) == 0) {
|
||||
return handleApiScreenRaw(request);
|
||||
}
|
||||
|
||||
bool authPassed = false;
|
||||
esp_err_t authResult = validateRequestAuth(request, authPassed);
|
||||
if (!authPassed) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const char* uri = request->uri;
|
||||
if (strncmp(uri, "/api/apps/run", 13) == 0) {
|
||||
return handleApiAppsRun(request);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user