App Hub: update-all, commandline app, better progress indication

This commit is contained in:
Ken Van Hoeylandt 2026-10-15 19:28:36 +02:00
parent 87215baf55
commit 582797aa4c
15 changed files with 517 additions and 28 deletions

View File

@ -42,6 +42,7 @@ forum e8af
gamepad e30f gamepad e30f
hard_disk f3da hard_disk f3da
help e8fd help e8fd
hourglass ebff
hub e9f4 hub e9f4
image e3f4 image e3f4
info e88e info e88e

View File

@ -7,9 +7,6 @@
## Higher Priority ## Higher Priority
- Adding GPS: Crash when clicking "+"
- Update FreeRTOS-Kernel to 11.3.1 to fix hanging tests, then re-release apps.
- App Hub needs a commandline app
- BlueTooth as radio backend for MeshCore/Meshtastic? - BlueTooth as radio backend for MeshCore/Meshtastic?
- ESP-NOW as radio backend. Use it for MeshCore/Meshtastic? - ESP-NOW as radio backend. Use it for MeshCore/Meshtastic?
- Find out why the first build of an app with a clean build cache folder always fails - Find out why the first build of an app with a clean build cache folder always fails

View File

@ -4,10 +4,12 @@ App Hub service (`tactility.apphub`): finds, installs and removes apps from the
The C API is in `<app_hub/app_hub.h>`. The C API is in `<app_hub/app_hub.h>`.
- `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps. - `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps.
- `app_hub_refresh()`, `app_hub_install()` and `app_hub_remove()` queue an operation. Operations run one at a time - `app_hub_refresh()`, `app_hub_install()`, `app_hub_update_all()` and `app_hub_remove()` queue an operation. Operations run one at a time
on a background task, which stops after 5 seconds without operations and starts again for the next one. on a background task, which stops after 5 seconds without operations and starts again for the next one.
- Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`. - Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`.
The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED` and `REMOVING`/`REMOVED`, with the app id and the result. The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED`, `REMOVING`/`REMOVED` and `UPDATING`/`UPDATED`, with the app id and the result.
The `apphub` shell command uses the same API: `refresh`, `search [query]`, `updates`, `info <id>`, `install <id>`, `update` and `remove <id>`.
The catalog is stored in the service's user data directory, so it's available offline after the first refresh. The catalog is stored in the service's user data directory, so it's available offline after the first refresh.

View File

@ -28,6 +28,8 @@ enum AppHubOperationType {
APP_HUB_OPERATION_INSTALL, APP_HUB_OPERATION_INSTALL,
/** Uninstall an app */ /** Uninstall an app */
APP_HUB_OPERATION_REMOVE, APP_HUB_OPERATION_REMOVE,
/** Install the newer catalog version of every installed app that has one */
APP_HUB_OPERATION_UPDATE_ALL,
}; };
/** An app from the catalog, an installed app, or both */ /** An app from the catalog, an installed app, or both */
@ -89,6 +91,9 @@ error_t app_hub_get_description(const char* id, char* description, size_t descri
// region Operations // region Operations
/** @return true while an operation is queued or running */
bool app_hub_is_busy(void);
/** /**
* Queue a download of the app catalog. * Queue a download of the app catalog.
* Only one refresh is queued or running at a time: while one is pending, its id is returned instead. * Only one refresh is queued or running at a time: while one is pending, its id is returned instead.
@ -105,6 +110,17 @@ error_t app_hub_refresh(uint32_t* operation_id);
*/ */
error_t app_hub_install(const char* id, uint32_t* operation_id); error_t app_hub_install(const char* id, uint32_t* operation_id);
/**
* Queue the update of every installed app that has a newer version in the catalog. The apps are installed one at a time,
* with an INSTALLING and INSTALLED event each. A failed update doesn't stop the updates of the other apps.
* While the operation is pending, the apps with an update report it as their pending operation.
* The operation takes the place of an install or remove: only one of them is queued or running at a time.
* @param[out] operation_id the id of the queued operation (optional)
* @retval ERROR_NONE the operation was queued, or it was already pending and its id is returned
* @retval ERROR_RESOURCE_BUSY an install or remove is pending
*/
error_t app_hub_update_all(uint32_t* operation_id);
/** /**
* Queue the removal of an installed app. * Queue the removal of an installed app.
* Only one install or remove is queued or running at a time. A refresh can be pending next to it. * Only one install or remove is queued or running at a time. A refresh can be pending next to it.
@ -131,12 +147,16 @@ enum AppHubEventType {
APP_HUB_EVENT_REMOVING, APP_HUB_EVENT_REMOVING,
/** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */ /** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */
APP_HUB_EVENT_REMOVED, APP_HUB_EVENT_REMOVED,
/** Updating all apps started. INSTALLING and INSTALLED events with the same operation id follow for each app. */
APP_HUB_EVENT_UPDATING,
/** Updating all apps finished. AppHubEvent.result is the error of the last failed app, ERROR_NONE when all succeeded. */
APP_HUB_EVENT_UPDATED,
}; };
struct AppHubEvent { struct AppHubEvent {
enum AppHubEventType type; enum AppHubEventType type;
uint32_t operation_id; uint32_t operation_id;
/** Empty for the refresh events */ /** Empty for the refresh, UPDATING and UPDATED events */
PackageId app_id; PackageId app_id;
/** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */ /** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */
error_t result; error_t result;

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#pragma once #pragma once
#include <app/manifest.h>
#include <service/manifest.h> #include <service/manifest.h>
/** The service id, which also determines where the catalog is stored */ /** The service id, which also determines where the catalog is stored */
@ -8,5 +9,8 @@
extern const ServiceManifest app_hub_service_manifest; extern const ServiceManifest app_hub_service_manifest;
/** The "apphub" command-line app */
extern "C" const AppManifest app_hub_command_manifest;
/** @return true while the background task that runs the operations exists */ /** @return true while the background task that runs the operations exists */
bool app_hub_is_worker_running(); bool app_hub_is_worker_running();

View File

@ -0,0 +1,294 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_hub/app_hub.h>
#include <app_hub/app_hub_service.h>
#include <app/manifest.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/time.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <vector>
constexpr TickType_t OPERATION_TIMEOUT = pdMS_TO_TICKS(180000);
constexpr size_t DESCRIPTION_MAX_SIZE = 2048;
static void print_usage() {
printf(
"usage: apphub <command>\n"
" refresh\n"
" search [query]\n"
" updates\n"
" info <id>\n"
" install <id>\n"
" update\n"
" remove <id>\n"
);
}
static int report_error(const char* action, error_t error) {
if (error == ERROR_RESOURCE_BUSY) {
printf("apphub: %s failed: another app is being installed or removed\n", action);
} else {
printf("apphub: %s failed: %s\n", action, error_to_string(error));
}
return 1;
}
/**
* Queues an operation and waits for its result.
* The subscription is made before queueing, so the operation can't finish unnoticed.
* @param[in] finished_type the event that ends the operation and carries its result
* @param[in] on_event called for the other events of the operation (optional)
*/
static error_t run_and_wait(
const char* id,
error_t (*operation)(const char* id, uint32_t* operation_id),
AppHubEventType finished_type,
void (*on_event)(const AppHubEvent& event) = nullptr
) {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppHubEventSubscription subscription {};
error_t error = app_hub_event_subscribe(&subscription, &event_group);
if (error != ERROR_NONE) {
task_event_group_destruct(&event_group);
return error;
}
uint32_t operation_id = 0;
error = operation(id, &operation_id);
if (error == ERROR_NONE) {
error = ERROR_TIMEOUT;
const TickType_t start = get_ticks();
TickType_t elapsed = 0;
bool finished = false;
while (!finished && elapsed < OPERATION_TIMEOUT) {
if (app_hub_event_await(&subscription, OPERATION_TIMEOUT - elapsed) == ERROR_NONE) {
AppHubEvent event {};
while (!finished && app_hub_event_poll(&subscription, &event) == ERROR_NONE) {
if (event.operation_id != operation_id) {
continue;
}
if (event.type == finished_type) {
error = event.result;
finished = true;
} else if (on_event != nullptr) {
on_event(event);
}
}
}
elapsed = get_ticks() - start;
}
}
app_hub_event_unsubscribe(&subscription);
task_event_group_destruct(&event_group);
return error;
}
static error_t refresh(const char* /*id*/, uint32_t* operation_id) {
return app_hub_refresh(operation_id);
}
/** Collects the apps first, as printing can block while app_hub_search() holds the service's lock */
static std::vector<AppHubApp> search(const char* query) {
std::vector<AppHubApp> apps;
app_hub_search(query, [](const AppHubApp* app, void* context) {
static_cast<std::vector<AppHubApp>*>(context)->push_back(*app);
return true;
}, &apps);
return apps;
}
static void print_app(const AppHubApp& app) {
const char marker = app.update_available ? '^' : (app.installed ? '*' : ' ');
const char* version = app.in_catalog ? app.version_name : app.installed_version_name;
printf("%c %-32s %-24s %s\n", marker, app.id, app.name, version);
}
static int command_refresh() {
printf("Downloading the catalog...\n");
const error_t error = run_and_wait(nullptr, refresh, APP_HUB_EVENT_REFRESHED);
if (error != ERROR_NONE) {
return report_error("refresh", error);
}
printf("Catalog updated\n");
return 0;
}
static int command_search(int argc, char* argv[], bool updates_only) {
if (argc > 3 || (updates_only && argc > 2)) {
print_usage();
return 1;
}
const auto apps = search(argc == 3 ? argv[2] : nullptr);
size_t count = 0;
for (const auto& app : apps) {
if (!updates_only || app.update_available) {
print_app(app);
count++;
}
}
if (count == 0) {
printf(updates_only ? "No updates available\n" : "No apps found. Run 'apphub refresh' to download the catalog.\n");
} else if (!updates_only) {
printf("(* = installed, ^ = update available)\n");
}
return 0;
}
static int command_info(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub info <id>\n");
return 1;
}
AppHubApp app {};
if (app_hub_get_app(argv[2], &app) != ERROR_NONE) {
printf("apphub: unknown app: %s\n", argv[2]);
return 1;
}
printf("id: %s\n", app.id);
printf("name: %s\n", app.name);
if (app.in_catalog) {
printf("version: %s\n", app.version_name);
}
if (app.installed) {
printf("installed: %s%s\n", app.installed_version_name, app.update_available ? " (update available)" : "");
} else {
printf("installed: no\n");
}
auto description = std::make_unique<char[]>(DESCRIPTION_MAX_SIZE);
if (app_hub_get_description(app.id, description.get(), DESCRIPTION_MAX_SIZE) == ERROR_NONE && description[0] != '\0') {
printf("\n%s\n", description.get());
}
return 0;
}
static int command_install(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub install <id>\n");
return 1;
}
printf("Installing %s...\n", argv[2]);
const error_t error = run_and_wait(argv[2], app_hub_install, APP_HUB_EVENT_INSTALLED);
if (error == ERROR_NOT_FOUND) {
printf("apphub: %s is not in the catalog\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("install", error);
}
printf("Installed %s\n", argv[2]);
return 0;
}
static error_t update_all(const char* /*id*/, uint32_t* operation_id) {
return app_hub_update_all(operation_id);
}
static void print_update_progress(const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_INSTALLING) {
printf("Updating %s...\n", event.app_id);
} else if (event.type == APP_HUB_EVENT_INSTALLED) {
if (event.result == ERROR_NONE) {
printf("Updated %s\n", event.app_id);
} else {
printf("apphub: update of %s failed: %s\n", event.app_id, error_to_string(event.result));
}
}
}
static int command_update(int argc) {
if (argc != 2) {
printf("usage: apphub update\n");
return 1;
}
// Counted first, as the update reports nothing when there are no updates
size_t count = 0;
for (const auto& app : search(nullptr)) {
if (app.update_available) {
count++;
}
}
if (count == 0) {
printf("No updates available\n");
return 0;
}
const error_t error = run_and_wait(nullptr, update_all, APP_HUB_EVENT_UPDATED, print_update_progress);
if (error == ERROR_RESOURCE_BUSY || error == ERROR_TIMEOUT) {
return report_error("update", error);
} else if (error != ERROR_NONE) {
printf("apphub: some apps failed to update\n");
return 1;
}
printf("All apps are up to date\n");
return 0;
}
static int command_remove(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub remove <id>\n");
return 1;
}
printf("Removing %s...\n", argv[2]);
const error_t error = run_and_wait(argv[2], app_hub_remove, APP_HUB_EVENT_REMOVED);
if (error == ERROR_NOT_FOUND) {
printf("apphub: %s is not installed\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("remove", error);
}
printf("Removed %s\n", argv[2]);
return 0;
}
static int32_t app_hub_command_main(int argc, char* argv[]) {
if (argc < 2 || strcmp(argv[1], "help") == 0) {
print_usage();
return 0;
}
const char* command = argv[1];
if (strcmp(command, "refresh") == 0) {
return command_refresh();
} else if (strcmp(command, "search") == 0) {
return command_search(argc, argv, false);
} else if (strcmp(command, "updates") == 0) {
return command_search(argc, argv, true);
} else if (strcmp(command, "info") == 0) {
return command_info(argc, argv);
} else if (strcmp(command, "install") == 0) {
return command_install(argc, argv);
} else if (strcmp(command, "update") == 0) {
return command_update(argc);
} else if (strcmp(command, "remove") == 0) {
return command_remove(argc, argv);
}
printf("apphub: unknown command: %s\n", command);
print_usage();
return 1;
}
extern "C" {
const AppManifest app_hub_command_manifest = {
.id = "apphub",
.name = "apphub",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(app_hub_command_main) },
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};
}

View File

@ -86,6 +86,7 @@ AppHubEventType getStartedEventType(AppHubOperationType operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING; case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING;
case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATING;
} }
return APP_HUB_EVENT_REFRESHING; return APP_HUB_EVENT_REFRESHING;
} }
@ -95,16 +96,20 @@ AppHubEventType getFinishedEventType(AppHubOperationType operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED; case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED;
case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATED;
} }
return APP_HUB_EVENT_REFRESHED; return APP_HUB_EVENT_REFRESHED;
} }
/** Requires the global lock */ /**
void emitEvent(AppHubEventType type, const Operation& operation, error_t result) { * Requires the global lock
* @param[in] appId the app the event is about, which an update of all apps reports per app
*/
void emitEvent(AppHubEventType type, const Operation& operation, const char* appId, error_t result) {
AppHubEvent event {}; AppHubEvent event {};
event.type = type; event.type = type;
event.operation_id = operation.id; event.operation_id = operation.id;
strcpy(event.app_id, operation.app_id); strcpy(event.app_id, appId);
event.result = result; event.result = result;
for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) { if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) {
@ -235,8 +240,13 @@ AppList collectApps(const std::vector<InstalledPackage>& installedPackages) {
app->update_available = app->in_catalog && app->installed_version_code < app->version_code; app->update_available = app->in_catalog && app->installed_version_code < app->version_code;
} }
const Operation* pendingAppOperation = findPendingOperationOfKind(false);
const bool updatingAll = pendingAppOperation != nullptr && pendingAppOperation->type == APP_HUB_OPERATION_UPDATE_ALL;
for (auto& app : apps) { for (auto& app : apps) {
const Operation* pending = findPendingOperation(app.id); const Operation* pending = findPendingOperation(app.id);
if (pending == nullptr && updatingAll && app.update_available) {
pending = pendingAppOperation;
}
app.operation_pending = pending != nullptr; app.operation_pending = pending != nullptr;
if (pending != nullptr) { if (pending != nullptr) {
app.pending_operation = pending->type; app.pending_operation = pending->type;
@ -374,6 +384,41 @@ error_t executeInstall(const char* id) {
return error; return error;
} }
error_t executeUpdateAll(const Operation& operation) {
const auto installed = getInstalledPackages();
std::vector<std::string> ids;
lock();
for (const auto& app : collectApps(installed)) {
if (app.update_available) {
ids.emplace_back(app.id);
}
}
unlock();
error_t lastError = ERROR_NONE;
for (const auto& id : ids) {
lock();
const bool stop = stopping;
if (!stop) {
emitEvent(APP_HUB_EVENT_INSTALLING, operation, id.c_str(), ERROR_NONE);
}
unlock();
if (stop) {
return ERROR_INVALID_STATE;
}
const error_t error = executeInstall(id.c_str());
if (error != ERROR_NONE) {
lastError = error;
}
lock();
emitEvent(APP_HUB_EVENT_INSTALLED, operation, id.c_str(), error);
unlock();
}
return lastError;
}
error_t execute(const Operation& operation) { error_t execute(const Operation& operation) {
switch (operation.type) { switch (operation.type) {
case APP_HUB_OPERATION_REFRESH: case APP_HUB_OPERATION_REFRESH:
@ -382,6 +427,8 @@ error_t execute(const Operation& operation) {
return executeInstall(operation.app_id); return executeInstall(operation.app_id);
case APP_HUB_OPERATION_REMOVE: case APP_HUB_OPERATION_REMOVE:
return app_uninstall(operation.app_id); return app_uninstall(operation.app_id);
case APP_HUB_OPERATION_UPDATE_ALL:
return executeUpdateAll(operation);
} }
return ERROR_INVALID_ARGUMENT; return ERROR_INVALID_ARGUMENT;
} }
@ -417,14 +464,14 @@ int32_t workerMain(void* /*context*/) {
queue.pop_front(); queue.pop_front();
operationRunning = true; operationRunning = true;
const Operation operation = runningOperation; const Operation operation = runningOperation;
emitEvent(getStartedEventType(operation.type), operation, ERROR_NONE); emitEvent(getStartedEventType(operation.type), operation, operation.app_id, ERROR_NONE);
unlock(); unlock();
const error_t result = execute(operation); const error_t result = execute(operation);
lock(); lock();
operationRunning = false; operationRunning = false;
emitEvent(getFinishedEventType(operation.type), operation, result); emitEvent(getFinishedEventType(operation.type), operation, operation.app_id, result);
unlock(); unlock();
} }
} }
@ -611,6 +658,13 @@ error_t app_hub_get_description(const char* id, char* description, size_t descri
// region Operations // region Operations
bool app_hub_is_busy() {
lock();
const bool busy = operationRunning || !queue.empty();
unlock();
return busy;
}
error_t app_hub_refresh(uint32_t* operation_id) { error_t app_hub_refresh(uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id); return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id);
} }
@ -619,6 +673,10 @@ error_t app_hub_install(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id); return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id);
} }
error_t app_hub_update_all(uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_UPDATE_ALL, nullptr, operation_id);
}
error_t app_hub_remove(const char* id, uint32_t* operation_id) { error_t app_hub_remove(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id); return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id);
} }

View File

@ -3,6 +3,7 @@
#include <app_hub/app_hub_service.h> #include <app_hub/app_hub_service.h>
#include <app_hub/module.h> #include <app_hub/module.h>
#include <app/manager.h>
#include <service/manager.h> #include <service/manager.h>
#include <tactility/error.h> #include <tactility/error.h>
@ -11,23 +12,37 @@
extern "C" { extern "C" {
static error_t start() { static error_t start() {
return service_manager_add(&app_hub_service_manifest, /*auto_start=*/true); error_t error = app_manager_add(&app_hub_command_manifest);
if (error != ERROR_NONE) {
return error;
}
error = service_manager_add(&app_hub_service_manifest, /*auto_start=*/true);
if (error != ERROR_NONE) {
app_manager_remove(app_hub_command_manifest.id);
}
return error;
} }
static error_t stop() { static error_t stop() {
const error_t error = service_manager_stop(app_hub_service_manifest.id); error_t error = service_manager_stop(app_hub_service_manifest.id);
if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { if (error != ERROR_NONE && error != ERROR_NOT_FOUND) {
return error; return error;
} }
return service_manager_remove(app_hub_service_manifest.id); error = service_manager_remove(app_hub_service_manifest.id);
if (error != ERROR_NONE) {
return error;
}
return app_manager_remove(app_hub_command_manifest.id);
} }
static const ModuleSymbol app_hub_module_symbols[] = { static const ModuleSymbol app_hub_module_symbols[] = {
DEFINE_MODULE_SYMBOL(app_hub_search), DEFINE_MODULE_SYMBOL(app_hub_search),
DEFINE_MODULE_SYMBOL(app_hub_get_app), DEFINE_MODULE_SYMBOL(app_hub_get_app),
DEFINE_MODULE_SYMBOL(app_hub_get_description), DEFINE_MODULE_SYMBOL(app_hub_get_description),
DEFINE_MODULE_SYMBOL(app_hub_is_busy),
DEFINE_MODULE_SYMBOL(app_hub_refresh), DEFINE_MODULE_SYMBOL(app_hub_refresh),
DEFINE_MODULE_SYMBOL(app_hub_install), DEFINE_MODULE_SYMBOL(app_hub_install),
DEFINE_MODULE_SYMBOL(app_hub_update_all),
DEFINE_MODULE_SYMBOL(app_hub_remove), DEFINE_MODULE_SYMBOL(app_hub_remove),
DEFINE_MODULE_SYMBOL(app_hub_event_subscribe), DEFINE_MODULE_SYMBOL(app_hub_event_subscribe),
DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe), DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe),

View File

@ -249,6 +249,28 @@ TEST_CASE("app_hub drops newer events while a subscriber's queue is full") {
task_event_group_destruct(&group); task_event_group_destruct(&group);
} }
TEST_CASE("app_hub_update_all finishes without installs when nothing has an update") {
ensureFixture();
TaskEventGroup group {};
task_event_group_construct(&group);
AppHubEventSubscription sub {};
REQUIRE_EQ(app_hub_event_subscribe(&sub, &group), ERROR_NONE);
uint32_t id = 0;
REQUIRE_EQ(app_hub_update_all(&id), ERROR_NONE);
AppHubEvent event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_UPDATING);
CHECK_EQ(event.operation_id, id);
CHECK_EQ(std::string(event.app_id), "");
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_UPDATED);
CHECK_EQ(event.operation_id, id);
CHECK_EQ(event.result, ERROR_NONE);
CHECK_EQ(app_hub_event_unsubscribe(&sub), ERROR_NONE);
task_event_group_destruct(&group);
}
TEST_CASE("app_hub rejects invalid ids") { TEST_CASE("app_hub rejects invalid ids") {
ensureFixture(); ensureFixture();
CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT); CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT);

View File

@ -159,6 +159,7 @@ shared_symbol_code_point_names = [
"language", # Globe "language", # Globe
"lists", # Chat app toolbar "lists", # Chat app toolbar
"list_alt", # Settings apps: opens a selection dialog "list_alt", # Settings apps: opens a selection dialog
"hourglass", # App Hub: an operation for the app is pending
"mail", "mail",
"menu", "menu",
"memory", # System Info "memory", # System Info

View File

@ -9,7 +9,7 @@ extern "C" {
#endif #endif
/** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */ /** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */
#define LVGL_ICON_FONT_VERSION 0xE8678DFBu #define LVGL_ICON_FONT_VERSION 0x3F505982u
// Icon names per icon font, resolved through the .codepoints file next to the TTF // Icon names per icon font, resolved through the .codepoints file next to the TTF
extern const char* const lvgl_icon_shared_names[]; extern const char* const lvgl_icon_shared_names[];

View File

@ -32,6 +32,7 @@
#define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87" #define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87"
#define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9" #define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9"
#define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE" #define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE"
#define LVGL_ICON_SHARED_HOURGLASS "\xEE\xAF\xBF"
#define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99" #define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99"
#define LVGL_ICON_SHARED_MENU "\xEE\x97\x92" #define LVGL_ICON_SHARED_MENU "\xEE\x97\x92"
#define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2" #define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2"

View File

@ -34,6 +34,7 @@ const char* const lvgl_icon_shared_names[] = {
"language", "language",
"lists", "lists",
"list_alt", "list_alt",
"hourglass",
"mail", "mail",
"menu", "menu",
"memory", "memory",

View File

@ -1,4 +1,5 @@
#include <Tactility/Mutex.h> #include <Tactility/Mutex.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h> #include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <app/event.h> #include <app/event.h>
@ -14,7 +15,9 @@
#include <tactility/drivers/wifi.h> #include <tactility/drivers/wifi.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h> #include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h> #include <lvgl/widgets/chip.h>
@ -50,6 +53,8 @@ struct Context {
lv_obj_t* contentWrapper = nullptr; lv_obj_t* contentWrapper = nullptr;
lv_obj_t* refreshButton = nullptr; lv_obj_t* refreshButton = nullptr;
lv_obj_t* updateAllButton = nullptr;
lv_obj_t* updateAllSpinner = nullptr;
lv_obj_t* filterChips = nullptr; lv_obj_t* filterChips = nullptr;
// Survives across a bury/resurface cycle // Survives across a bury/resurface cycle
Filter filter = Filter::All; Filter filter = Filter::All;
@ -74,6 +79,10 @@ struct Context {
AppHubEventSubscription appHubSub {}; AppHubEventSubscription appHubSub {};
/** The id of the queued catalog refresh, 0 when none is pending */ /** The id of the queued catalog refresh, 0 when none is pending */
std::atomic<uint32_t> refreshOperationId {0}; std::atomic<uint32_t> refreshOperationId {0};
/** The id of the running update of all apps, 0 when none is running */
uint32_t updateAllOperationId = 0;
/** The ids of the apps that failed to update during updateAllOperationId */
std::vector<std::string> failedUpdates;
}; };
@ -103,6 +112,30 @@ void updateFilterChips(Context* ctx) {
} }
} }
/**
* Shows the spinner while App Hub is busy, and otherwise the update all button when there are updates.
* Requires the LVGL lock.
*/
void updateToolbarActions(Context* ctx, bool hasUpdates) {
if (ctx->updateAllSpinner == nullptr) {
return;
}
const bool busy = app_hub_is_busy();
lv_obj_set_hidden(ctx->updateAllSpinner, !busy);
lv_obj_set_hidden(ctx->updateAllButton, busy || !hasUpdates);
}
void onUpdateAllPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
const error_t error = app_hub_update_all(nullptr);
if (error != ERROR_NONE) {
LOG_E(TAG, "Failed to queue the update of all apps: %s", error_to_string(error));
return;
}
// Shows the spinner
showApps(ctx);
}
void updateUpdatesChipText(Context* ctx, size_t updateCount) { void updateUpdatesChipText(Context* ctx, size_t updateCount) {
auto* chip = lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(Filter::Updates)); auto* chip = lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(Filter::Updates));
const auto text = std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates"); const auto text = std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates");
@ -189,6 +222,7 @@ void showRefreshFailedError(Context* ctx, const char* message) {
lv_label_set_text(label, message); lv_label_set_text(label, message);
lv_obj_set_hidden(ctx->refreshButton, false); lv_obj_set_hidden(ctx->refreshButton, false);
updateToolbarActions(ctx, false);
} }
void showNoInternet(Context* ctx) { void showNoInternet(Context* ctx) {
@ -236,6 +270,7 @@ void showApps(Context* ctx) {
}, &apps); }, &apps);
const bool hasApps = !apps.empty(); const bool hasApps = !apps.empty();
updateUpdatesChipText(ctx, std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; })); updateUpdatesChipText(ctx, std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; }));
updateToolbarActions(ctx, std::ranges::any_of(apps, [](const AppHubApp& app) { return app.update_available; }));
std::erase_if(apps, [ctx](const AppHubApp& app) { std::erase_if(apps, [ctx](const AppHubApp& app) {
return !matchesFilter(app, ctx->filter); return !matchesFilter(app, ctx->filter);
}); });
@ -256,8 +291,11 @@ void showApps(Context* ctx) {
lv_obj_t* focus_target = nullptr; lv_obj_t* focus_target = nullptr;
for (int i = 0; i < apps.size(); i++) { for (int i = 0; i < apps.size(); i++) {
const auto& app = apps[i]; const auto& app = apps[i];
const char* icon = app.installed ? LV_SYMBOL_OK : nullptr; const char* icon = app.operation_pending ? LVGL_ICON_SHARED_HOURGLASS : (app.installed ? LV_SYMBOL_OK : nullptr);
auto* entry_button = lvgl_list_add_button(list, icon, app.name); auto* entry_button = lvgl_list_add_button(list, icon, app.name);
if (app.operation_pending) {
lv_obj_set_style_text_font(lv_obj_get_child(entry_button, 0), lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
}
auto int_as_voidptr = reinterpret_cast<void*>(i); auto int_as_voidptr = reinterpret_cast<void*>(i);
lv_obj_set_user_data(entry_button, int_as_voidptr); lv_obj_set_user_data(entry_button, int_as_voidptr);
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx); lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx);
@ -331,7 +369,17 @@ void refresh(Context* ctx) {
lvgl_unlock(); lvgl_unlock();
} }
void onOperationFinished(Context* ctx, const AppHubEvent& event) { void onOperationEvent(Context* ctx, const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_REFRESHING) {
// The list doesn't change yet, only the toolbar shows that App Hub is busy
lvgl_lock();
if (ctx->updateAllButton != nullptr) {
updateToolbarActions(ctx, !lv_obj_is_hidden(ctx->updateAllButton));
}
lvgl_unlock();
return;
}
const bool isRefresh = event.operation_id == ctx->refreshOperationId; const bool isRefresh = event.operation_id == ctx->refreshOperationId;
if (isRefresh) { if (isRefresh) {
ctx->refreshOperationId = 0; ctx->refreshOperationId = 0;
@ -341,13 +389,10 @@ void onOperationFinished(Context* ctx, const AppHubEvent& event) {
} }
} }
if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp is open): createWidgets() rebuilds the list on resurface
return;
}
lvgl_lock(); lvgl_lock();
if (isRefresh && event.result != ERROR_NONE) { if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface
} else if (isRefresh && event.result != ERROR_NONE) {
showRefreshFailedError(ctx, "Cannot reach server"); showRefreshFailedError(ctx, "Cannot reach server");
} else { } else {
showApps(ctx); showApps(ctx);
@ -366,6 +411,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx); ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx);
lv_obj_set_hidden(ctx->refreshButton, true); lv_obj_set_hidden(ctx->refreshButton, true);
ctx->updateAllButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdateAllPressed, ctx);
lv_obj_set_hidden(ctx->updateAllButton, true);
ctx->updateAllSpinner = lvgl_toolbar_add_spinner_action(toolbar);
lv_obj_set_hidden(ctx->updateAllSpinner, true);
ctx->filterChips = createFilterChips(parent, ctx); ctx->filterChips = createFilterChips(parent, ctx);
updateFilterChips(ctx); updateFilterChips(ctx);
@ -391,9 +440,36 @@ void destroyWidgets(void* userData) {
ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper); ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper);
ctx->contentWrapper = nullptr; ctx->contentWrapper = nullptr;
ctx->refreshButton = nullptr; ctx->refreshButton = nullptr;
ctx->updateAllButton = nullptr;
ctx->updateAllSpinner = nullptr;
ctx->filterChips = nullptr; ctx->filterChips = nullptr;
} }
/** Tracks the update of all apps, and shows which apps failed when it's done */
void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_UPDATING) {
ctx->updateAllOperationId = event.operation_id;
ctx->failedUpdates.clear();
} else if (event.type == APP_HUB_EVENT_INSTALLED && event.operation_id == ctx->updateAllOperationId && event.result != ERROR_NONE) {
ctx->failedUpdates.emplace_back(event.app_id);
} else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) {
ctx->updateAllOperationId = 0;
if (!ctx->failedUpdates.empty()) {
std::string names;
for (const auto& id : ctx->failedUpdates) {
AppHubApp app {};
if (!names.empty()) {
names += ", ";
}
names += (app_hub_get_app(id.c_str(), &app) == ERROR_NONE) ? app.name : id.c_str();
}
const auto count = ctx->failedUpdates.size();
alertdialog::start(ctx->appInstanceId, "Update failed", std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names));
ctx->failedUpdates.clear();
}
}
}
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx; Context ctx;
@ -429,11 +505,8 @@ int32_t appMain(int argc, char* argv[]) {
AppHubEvent app_hub_event {}; AppHubEvent app_hub_event {};
while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) { while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) {
if (app_hub_event.type == APP_HUB_EVENT_REFRESHED || onUpdateAllEvent(&ctx, app_hub_event);
app_hub_event.type == APP_HUB_EVENT_INSTALLED || onOperationEvent(&ctx, app_hub_event);
app_hub_event.type == APP_HUB_EVENT_REMOVED) {
onOperationFinished(&ctx, app_hub_event);
}
} }
if (!shouldClose && ctx.refreshRequested.exchange(false)) { if (!shouldClose && ctx.refreshRequested.exchange(false)) {