diff --git a/.github/actions/build-app/action.yml b/.github/actions/build-app/action.yml index 82784b0..6296e5a 100644 --- a/.github/actions/build-app/action.yml +++ b/.github/actions/build-app/action.yml @@ -4,6 +4,14 @@ inputs: app_name: description: The name of the app directory required: true + save_cache: + description: Save the ESP-IDF/SDK compiler cache after building (one job per run should do this) + required: false + default: 'false' + upload_artifact: + description: Upload the built app as a workflow artifact (artifact names must be unique per run) + required: false + default: 'true' runs: using: "composite" @@ -11,6 +19,14 @@ runs: - uses: actions/checkout@v4 with: submodules: recursive + # After checkout, which cleans the workspace. The SDK compiles its ESP-IDF/SDK parts through + # ccache, in .ccache/. The newest cache of any earlier run is restored. + - name: 'Restore compiler cache' + uses: actions/cache/restore@v4 + with: + path: .ccache + key: ccache-idf-v6.1-${{ github.run_id }} + restore-keys: ccache-idf-v6.1- - name: 'Build' uses: espressif/esp-idf-ci-action@v1 with: @@ -19,8 +35,16 @@ runs: # The export reset prevents an error when building other targets # The default environment variable is set to "esp32" by the GitHub Action # tt runs in the app's folder, so it uses the SDK version of this app's manifest - command: 'export IDF_TARGET= && cd Apps/${{ inputs.app_name }} && python ../../tt.py app build' + # TACTILITY_CCACHE_DIR points the SDK's compiler cache into the workspace, so it can be cached + command: 'export IDF_TARGET= && export TACTILITY_CCACHE_DIR="$(pwd)/.ccache" && cd Apps/${{ inputs.app_name }} && python ../../tt.py app build && (for d in "$TACTILITY_CCACHE_DIR"/*/; do [ -d "$d" ] && echo "ccache $d" && ccache -d "$d" -s; done; true)' + - name: 'Save compiler cache' + if: inputs.save_cache == 'true' + uses: actions/cache/save@v4 + with: + path: .ccache + key: ccache-idf-v6.1-${{ github.run_id }} - name: 'Upload Artifact' + if: inputs.upload_artifact == 'true' uses: actions/upload-artifact@v4 with: name: ${{ inputs.app_name }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0bcbbbf..10b95a1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,9 +31,23 @@ jobs: persist-credentials: false - id: set-matrix run: python3 Scripts/gh-generate-app-matrix.py + # Fills the compiler cache that the Build jobs restore: HelloWorld targets every ESP32 platform + WarmBuildCache: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: "Build" + uses: ./.github/actions/build-app + with: + app_name: HelloWorld + save_cache: 'true' + # The Build matrix also builds HelloWorld and uploads its artifact + upload_artifact: 'false' Build: runs-on: ubuntu-latest - needs: GenerateAppMatrix + needs: [GenerateAppMatrix, WarmBuildCache] strategy: matrix: ${{ fromJSON(needs.GenerateAppMatrix.outputs.matrix) }} steps: diff --git a/.gitignore b/.gitignore index 6926f26..7c19d05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store build/ +.ccache/ cmake-build-*/ CMakeCache.txt CMakeFiles diff --git a/Apps/MediaKeys/main/Source/MediaKeys.cpp b/Apps/MediaKeys/main/Source/MediaKeys.cpp index 66277b9..b210d68 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.cpp +++ b/Apps/MediaKeys/main/Source/MediaKeys.cpp @@ -39,6 +39,21 @@ const uint16_t CONSUMER_USAGE[6] = { 0x00E9, // 5 – VOL_UP: Volume Increment }; +constexpr uint32_t WORKER_STACK_SIZE = 6144; +constexpr UBaseType_t WORKER_QUEUE_LENGTH = 8; + +enum class WorkerCommandType : uint8_t { + EnableRadio, + StartHid, + StartHidAfterRadioOn, + Disable, + Shutdown, +}; + +struct WorkerCommand { + WorkerCommandType type; +}; + // Data passed to the one-shot key-send task struct SendKeyData { struct Device* hidDevice; @@ -47,7 +62,8 @@ struct SendKeyData { void enterKeyMode(Context* ctx); void exitKeyMode(Context* ctx); -void startHid(Context* ctx); +void startHid(Context* ctx, bool renameAfterStart); +void postWorkerCommand(Context* ctx, WorkerCommandType type); void handleButtonPress(Context* ctx, uint32_t buttonId); // ---- Static task / callback implementations ---- @@ -171,16 +187,9 @@ void handleBtEvent(Context* ctx, const BtEvent& event) { LOG_I(TAG, "BT radio state: %d", (int)event.radio_state); if (event.radio_state == BT_RADIO_STATE_ON) { - // Radio is now up - start HID (needs LVGL lock for UI update) - if (lvgl_try_lock(1000)) { - // Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false). - // Also skip touching widgets if this window has been buried in the meantime - // (window_manager may have already deleted them) - same reasoning as - // onKeyHighlightTimer above. - if (ctx->radioEnabling && window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) { - startHid(ctx); - } - lvgl_unlock(); + // Radio is now up. The worker starts HID only if the user didn't switch off in the meantime. + if (ctx->radioEnabling) { + postWorkerCommand(ctx, WorkerCommandType::StartHidAfterRadioOn); } } else if (event.radio_state == BT_RADIO_STATE_OFF && ctx->isEnabled) { // Radio dropped while we were active - revert UI @@ -205,31 +214,69 @@ void handleBtEvent(Context* ctx, const BtEvent& event) { // ---- Instance-equivalent functions ---- -void startHid(Context* ctx) { - // Called once the BT radio is confirmed ON (either already was, or just came up). - // May be called from handleBtEvent() on the app's own task - LVGL must already be locked by caller. +void postWorkerCommand(Context* ctx, WorkerCommandType type) { + if (ctx->workerQueue == nullptr) return; + WorkerCommand command { type }; + if (xQueueSend(ctx->workerQueue, &command, 0) != pdTRUE) { + LOG_E(TAG, "Worker queue full"); + } +} + +// Takes the LVGL lock from the worker. @return false when the lock couldn't be taken +bool lockLvgl() { + for (int i = 0; i < 5; i++) { + if (lvgl_try_lock(1000)) return true; + } + LOG_W(TAG, "Failed to acquire LVGL lock"); + return false; +} + +// Runs on the worker. Reverts the switch when HID can't start. +void showHidFailed(Context* ctx) { + ctx->isEnabled = false; + if (!lockLvgl()) return; + if (window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED && ctx->switchWidget) { + lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); + } + lvgl_unlock(); +} + +// Runs on the worker, once the BT radio is confirmed ON (either already was, or just came up). +void startHid(Context* ctx, bool renameAfterStart) { ctx->radioEnabling = false; - ctx->hidDevice = bluetooth_hid_device_get(); - if (!ctx->hidDevice) { + Device* hidDevice = bluetooth_hid_device_get(); + if (!hidDevice) { LOG_E(TAG, "BLE HID device unavailable after radio on"); - ctx->isEnabled = false; - if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); + showHidFailed(ctx); return; } - error_t err = bluetooth_hid_device_start(ctx->hidDevice, BT_HID_DEVICE_MODE_KEYBOARD); + error_t err = bluetooth_hid_device_start(hidDevice, BT_HID_DEVICE_MODE_KEYBOARD); if (err != ERROR_NONE) { LOG_E(TAG, "Failed to start HID device: %d", (int)err); - device_put(ctx->hidDevice); - ctx->hidDevice = nullptr; - ctx->isEnabled = false; - if (ctx->switchWidget) lv_obj_remove_state(ctx->switchWidget, LV_STATE_CHECKED); + device_put(hidDevice); + showHidFailed(ctx); return; } - - if (ctx->mainWrapper) lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); - if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx); + ctx->hidDevice = hidDevice; + + // Rename only after HID is up: with the radio on, a rename restarts advertising + // right away, and the GATT profile switch in bluetooth_hid_device_start() asserts in + // NimBLE (ble_svc_gap_init) if it runs while that advertising is still active. + // Once HID is active, the rename just restarts HID advertising with the new name. + if (renameAfterStart) bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME); + + // Skip touching widgets if the window was buried in the meantime (window_manager may have + // already deleted them), same reasoning as onKeyHighlightTimer above. + // Also skip if the switch was turned off while HID was starting: handleSwitchToggle already + // hid the controls and queued Disable, which stops HID. + if (!lockLvgl()) return; + if (ctx->isEnabled && window_manager_get_state(ctx->window) == WINDOW_STATE_GRANTED) { + if (ctx->mainWrapper) lv_obj_remove_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); + if (device_has_active_by_type(&KEYBOARD_TYPE)) enterKeyMode(ctx); + } + lvgl_unlock(); } // Restores the radio to the state we found it in when we turned it on ourselves. Does not touch @@ -240,6 +287,43 @@ void restoreRadioIfNeeded(Context* ctx) { ctx->radioWasOff = false; } +void workerTask(void* param) { + auto* ctx = static_cast(param); + WorkerCommand command; + bool running = true; + while (running) { + xQueueReceive(ctx->workerQueue, &command, portMAX_DELAY); + switch (command.type) { + case WorkerCommandType::EnableRadio: + bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME); + bluetooth_set_radio_enabled(ctx->btDevice, true); + break; + case WorkerCommandType::StartHid: + // Re-check here to avoid a race with a switch-off that happened after the command was queued + if (ctx->isEnabled) startHid(ctx, true); + break; + case WorkerCommandType::StartHidAfterRadioOn: + // Re-check here to avoid a race with a switch-off that happened after the event was queued + if (ctx->isEnabled && ctx->radioEnabling) startHid(ctx, false); + break; + case WorkerCommandType::Disable: + if (ctx->hidDevice) { + bluetooth_hid_device_stop(ctx->hidDevice); + device_put(ctx->hidDevice); + ctx->hidDevice = nullptr; + } + restoreRadioIfNeeded(ctx); + break; + case WorkerCommandType::Shutdown: + restoreRadioIfNeeded(ctx); + running = false; + break; + } + } + xSemaphoreGive(ctx->workerDone); + vTaskDelete(nullptr); +} + void teardownBt(Context* ctx) { // Unsubscribe FIRST - stops any in-flight BT events from firing against our (possibly // already freed) UI widget pointers after this returns. @@ -250,7 +334,17 @@ void teardownBt(Context* ctx) { // up stale context on next use. Explicit stop is handled by handleSwitchToggle. // The device itself is never stopped, it's started for the process lifetime (see // Documentation/bluetooth-app-migration.md) - just restore the radio and drop our ref. - restoreRadioIfNeeded(ctx); + if (ctx->workerQueue != nullptr) { + // Blocking send: dropping Shutdown on a full queue would hang the workerDone wait below. + // The worker keeps draining the queue, so this completes. + WorkerCommand shutdown { WorkerCommandType::Shutdown }; + xQueueSend(ctx->workerQueue, &shutdown, portMAX_DELAY); + xSemaphoreTake(ctx->workerDone, portMAX_DELAY); + vSemaphoreDelete(ctx->workerDone); + vQueueDelete(ctx->workerQueue); + ctx->workerDone = nullptr; + ctx->workerQueue = nullptr; + } if (ctx->btDevice) device_put(ctx->btDevice); if (ctx->hidDevice) device_put(ctx->hidDevice); ctx->btDevice = nullptr; @@ -272,38 +366,27 @@ void handleSwitchToggle(Context* ctx, bool enabled) { enum BtRadioState radioState; bluetooth_get_radio_state(ctx->btDevice, &radioState); + // The Bluetooth calls run on the worker: this can be the LVGL task, which must not touch flash if (radioState == BT_RADIO_STATE_ON) { // Radio already up - start HID immediately. ctx->radioWasOff = false; - startHid(ctx); - // Rename only after HID is up: with the radio on, a rename restarts advertising - // right away, and the GATT profile switch in bluetooth_hid_device_start() asserts in - // NimBLE (ble_svc_gap_init) if it runs while that advertising is still active. - // Once HID is active, the rename just restarts HID advertising with the new name. - if (ctx->hidDevice) bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME); + postWorkerCommand(ctx, WorkerCommandType::StartHid); } else { - // Radio off: renaming stores the name without touching advertising - bluetooth_set_device_name(ctx->btDevice, DEVICE_NAME); - // Turn the radio on; startHid() will be called from handleBtEvent() + // Turn the radio on; the worker starts HID from handleBtEvent() // once BT_RADIO_STATE_ON fires. Our BT event subscription (mediaKeysInitBt()) // was already claimed before the app's main loop started waiting, so that // wakeup won't be missed. LOG_I(TAG, "BT radio not on (state=%d), enabling...", (int)radioState); ctx->radioWasOff = true; ctx->radioEnabling = true; - bluetooth_set_radio_enabled(ctx->btDevice, true); + postWorkerCommand(ctx, WorkerCommandType::EnableRadio); } } else { ctx->radioEnabling = false; if (device_has_active_by_type(&KEYBOARD_TYPE)) exitKeyMode(ctx); - // Explicit user toggle-off: stop HID cleanly (safe here since we're on the - // LVGL task and the user intentionally disabled, so no race with app teardown). - if (ctx->hidDevice) { - bluetooth_hid_device_stop(ctx->hidDevice); - device_put(ctx->hidDevice); - } - ctx->hidDevice = nullptr; - restoreRadioIfNeeded(ctx); + // Explicit user toggle-off: the worker stops HID cleanly and restores the radio + // (safe here since the user intentionally disabled, so no race with app teardown). + postWorkerCommand(ctx, WorkerCommandType::Disable); if (ctx->mainWrapper) lv_obj_add_flag(ctx->mainWrapper, LV_OBJ_FLAG_HIDDEN); } } @@ -352,6 +435,20 @@ bool mediaKeysInitBt(Context* ctx) { ctx->btDevice = nullptr; return false; } + ctx->workerQueue = xQueueCreate(WORKER_QUEUE_LENGTH, sizeof(WorkerCommand)); + ctx->workerDone = xSemaphoreCreateBinary(); + if (ctx->workerQueue == nullptr || ctx->workerDone == nullptr || + xTaskCreate(workerTask, "mediakeys_bt", WORKER_STACK_SIZE, ctx, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) { + LOG_E(TAG, "Failed to start Bluetooth worker"); + if (ctx->workerQueue) vQueueDelete(ctx->workerQueue); + if (ctx->workerDone) vSemaphoreDelete(ctx->workerDone); + ctx->workerQueue = nullptr; + ctx->workerDone = nullptr; + bluetooth_event_unsubscribe(ctx->btDevice, &ctx->btEventSub); + device_put(ctx->btDevice); + ctx->btDevice = nullptr; + return false; + } return true; } diff --git a/Apps/MediaKeys/main/Source/MediaKeys.h b/Apps/MediaKeys/main/Source/MediaKeys.h index f5e86b5..9c2c58a 100644 --- a/Apps/MediaKeys/main/Source/MediaKeys.h +++ b/Apps/MediaKeys/main/Source/MediaKeys.h @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include struct Context { @@ -27,6 +30,10 @@ struct Context { struct Device* hidDevice = nullptr; struct BtEventSubscription btEventSub {}; + // Runs the Bluetooth calls. They can touch flash, which the LVGL task (stack in external RAM) must not do. + QueueHandle_t workerQueue = nullptr; + SemaphoreHandle_t workerDone = nullptr; + // State - accessed from both LVGL thread and the app's own task (BT event poll loop) std::atomic isEnabled {false}; std::atomic radioEnabling {false}; // true while waiting for radio to come ON diff --git a/Apps/MediaKeys/manifest.properties b/Apps/MediaKeys/manifest.properties index fd77a7b..5797ec8 100644 --- a/Apps/MediaKeys/manifest.properties +++ b/Apps/MediaKeys/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32s3,esp32p4 id=tactility.mediakeys -version.name=0.18.0 -version.code=18 +version.name=0.19.0 +version.code=19 name=Media Keys description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus. app.0.id=tactility.mediakeys diff --git a/Apps/WebServer/CMakeLists.txt b/Apps/WebServer/CMakeLists.txt new file mode 100644 index 0000000..ecba364 --- /dev/null +++ b/Apps/WebServer/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(webserver) +project(webserver) +tactility_project_post(webserver) diff --git a/Apps/WebServer/README.md b/Apps/WebServer/README.md new file mode 100644 index 0000000..5b977f2 --- /dev/null +++ b/Apps/WebServer/README.md @@ -0,0 +1,46 @@ +# Web Server + +A web dashboard for the device: a file browser, an app manager, WiFi and system information. +The package holds two binaries. + +- `webserver-module` is a kernel module with the HTTP server. It starts at boot and serves the dashboard when the server is enabled in the settings. +- `webserver` is the settings app. It turns the server on and off, shows the address and sets the authentication. + +The server runs in one of two modes, chosen in the settings app: +- **Station:** the server uses the WiFi connection of the device. Connect to a network with the WiFi settings first. +- **Access point:** the device becomes a WiFi network and the server is at `http://192.168.4.1`. The network name (SSID) is `Tactility-XXXX` by default, with a generated password. The password has 8 to 63 characters, unless the network is open. + +The settings are saved in the user data folder of the package, `webserver.properties`. They start disabled. A new mode, port or access point setting applies when the server starts the next time. The settings app restarts a running server when it closes. + +## Endpoints + +| Endpoint | | +|---|---| +| `GET /`, `GET /filebrowser` | Redirects to the dashboard | +| `GET /api/sysinfo` | System information, without authentication | +| `GET /api/apps`, `/api/wifi` | Installed apps and WiFi status | +| `POST /api/apps/run?id=`, `/api/apps/uninstall?id=` | Starts or uninstalls an app | +| `PUT /api/apps/install` | Installs an app from a multipart upload | +| `GET /fs/list`, `/fs/download`, `/fs/tree` | Reads files in `/data` and `/sdcard` | +| `POST /fs/upload`, `/fs/mkdir`, `/fs/delete`, `/fs/rename` | Changes files in `/data` and `/sdcard` | +| `POST /admin/reboot` | Restarts the device | +| `GET /dashboard.html`, `/version.json`, `/favicon.ico` | The files in `assets/` | +| `GET /*` | Files in `/sdcard/tactility/webserver` | + +With authentication enabled, every endpoint except `/api/sysinfo` and the redirects needs HTTP basic authentication. + +## Differences with the web server of the firmware + +This package is a port of the web server service and settings of the firmware, which can be removed once this is in use. + +- No screenshot endpoint. `/api/sysinfo` reports it as disabled. +- `/api/sysinfo` has no chip details, flash size, storage sizes or minimum free heap, and the firmware version is "unknown". Modules can't read these. +- The settings are separate from the firmware's, so the server starts disabled until it is enabled in this app. +- Both servers use port 80 by default. Only enable one of them at a time. They also can't both run an access point. +- The access point has no setting for the SSID or the channel in the app. They are in `webserver.properties` (`apSsid`, `apChannel`). + +## Using the API from another app + +The module exports the functions of [common/webserver.h](common/webserver.h) for apps in the same package: `webserver_settings_load()`, `webserver_settings_save()`, `webserver_is_enabled()` and `webserver_set_enabled()`. +The access point is started through the Wi-Fi API of the kernel (`wifi_access_point_start()` in `tactility/drivers/wifi.h`). +The settings app can only run while the module is running. diff --git a/Apps/WebServer/assets/dashboard.html b/Apps/WebServer/assets/dashboard.html new file mode 100644 index 0000000..4e86ff1 --- /dev/null +++ b/Apps/WebServer/assets/dashboard.html @@ -0,0 +1,1322 @@ + + + + + + Tactility Dashboard + + + +
+
+
+

Tactility Dashboard

+

Loading...

+
+
+
+ +
+ + +
+ 30s +
+ +
+
+ +
+ + + +
+ + +
+
+
Loading system information...
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+
+ + + +
NameTypeSizeAction
Select the Files tab to browse
+
+ + +
+
+
+

Drag & drop an .app file here, or click to select

+ + +
+
+

Installed Apps

+
+
Loading apps...
+
+
+
+ +
+ Tactility WebServer +
+
+ + + + diff --git a/Apps/WebServer/assets/favicon.png b/Apps/WebServer/assets/favicon.png new file mode 100644 index 0000000..92582c1 Binary files /dev/null and b/Apps/WebServer/assets/favicon.png differ diff --git a/Apps/WebServer/assets/version.json b/Apps/WebServer/assets/version.json new file mode 100644 index 0000000..61a2092 --- /dev/null +++ b/Apps/WebServer/assets/version.json @@ -0,0 +1,3 @@ +{ + "version": 1 +} diff --git a/Apps/WebServer/common/webserver.h b/Apps/WebServer/common/webserver.h new file mode 100644 index 0000000..cb7da4a --- /dev/null +++ b/Apps/WebServer/common/webserver.h @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define WEBSERVER_USERNAME_MAX_LENGTH 32 +#define WEBSERVER_PASSWORD_MAX_LENGTH 64 +#define WEBSERVER_DEFAULT_PORT 80 +#define WEBSERVER_SSID_MAX_LENGTH 32 +#define WEBSERVER_AP_PASSWORD_MIN_LENGTH 8 +#define WEBSERVER_AP_PASSWORD_MAX_LENGTH 63 +#define WEBSERVER_AP_PASSWORD_BUFFER_LENGTH 64 +#define WEBSERVER_AP_MAX_CHANNEL 13 + +/** How the device is reachable */ +enum WebServerWifiMode { + /** Over the Wi-Fi network that the device is connected to */ + WEBSERVER_WIFI_MODE_STATION = 0, + /** The device is a Wi-Fi network itself, at 192.168.4.1 */ + WEBSERVER_WIFI_MODE_ACCESS_POINT = 1, +}; + +/** The settings of the web server. All strings are NUL-terminated. */ +struct WebServerSettings { + /** Start the server when the module starts */ + bool enabled; + uint16_t port; + /** Require HTTP basic authentication */ + bool auth_enabled; + char username[WEBSERVER_USERNAME_MAX_LENGTH + 1]; + char password[WEBSERVER_PASSWORD_MAX_LENGTH + 1]; + /** A WebServerWifiMode */ + uint8_t wifi_mode; + /** The name of the Wi-Fi network in access point mode */ + char ap_ssid[WEBSERVER_SSID_MAX_LENGTH + 1]; + /** The password of the Wi-Fi network in access point mode. It needs 8 to 63 characters unless the network is open. */ + char ap_password[WEBSERVER_AP_PASSWORD_BUFFER_LENGTH]; + /** Access point mode without a password */ + bool ap_open_network; + /** The Wi-Fi channel from 1 to 13 */ + uint8_t ap_channel; +}; + +/** + * Reads the settings, or the defaults when there are none yet. + * Credentials that are empty while authentication is enabled are generated, but not saved. + */ +error_t webserver_settings_load(struct WebServerSettings* settings); + +/** + * Saves the settings and makes the running server use them for new requests. + * The port and the access point settings apply when the server starts the next time. + */ +error_t webserver_settings_save(const struct WebServerSettings* settings); + +/** @return true while the HTTP server is running */ +bool webserver_is_enabled(void); + +/** + * Starts or stops the HTTP server. The saved settings are not changed. + * @retval ERROR_RESOURCE the server could not be started + */ +error_t webserver_set_enabled(bool enabled); + +#ifdef __cplusplus +} +#endif diff --git a/Apps/WebServer/manifest.properties b/Apps/WebServer/manifest.properties new file mode 100644 index 0000000..6845ad3 --- /dev/null +++ b/Apps/WebServer/manifest.properties @@ -0,0 +1,12 @@ +manifest.version=0.3 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4 +id=tactility.webserver +version.name=0.1.0 +version.code=1 +name=Web Server +description=Web dashboard with a file browser, app manager and system information +app.0.id=tactility.webserver +app.0.name=Web Server +app.0.binary=webserver +module.0.binary=webserver-module diff --git a/Apps/WebServer/webserver-module/CMakeLists.txt b/Apps/WebServer/webserver-module/CMakeLists.txt new file mode 100644 index 0000000..79e2e6b --- /dev/null +++ b/Apps/WebServer/webserver-module/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(tactility.webserver.webserver-module) +project(tactility.webserver.webserver-module) +tactility_project_post(tactility.webserver.webserver-module) diff --git a/Apps/WebServer/webserver-module/main/CMakeLists.txt b/Apps/WebServer/webserver-module/main/CMakeLists.txt new file mode 100644 index 0000000..37e3cde --- /dev/null +++ b/Apps/WebServer/webserver-module/main/CMakeLists.txt @@ -0,0 +1,8 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS ../../common + REQUIRES http-module wifi-module +) diff --git a/Apps/WebServer/webserver-module/main/Source/Handlers.cpp b/Apps/WebServer/webserver-module/main/Source/Handlers.cpp new file mode 100644 index 0000000..c84dfde --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Handlers.cpp @@ -0,0 +1,848 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "Handlers.h" + +#include "Util.h" +#include "WebServer.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "WebServerHandlers"; + +constexpr auto* PACKAGE_ID = "tactility.webserver"; +constexpr size_t MAX_UPLOAD_SIZE = 10 * 1024 * 1024; +constexpr size_t MAX_APP_UPLOAD_SIZE = 20 * 1024 * 1024; + +namespace { + +// region Authentication + +error_t sendUnauthorized(HttpServerRequest* request, const char* message) { + http_server_request_set_header(request, "WWW-Authenticate", "Basic realm=\"Tactility\""); + http_server_request_send_error(request, 401, message); + // The response was sent + return ERROR_NONE; +} + +/** + * Validates HTTP basic authentication. + * @param[out] authPassed true when the request may continue. When false, a 401 response was sent already. + */ +error_t validateRequestAuth(HttpServerRequest* request, bool& authPassed) { + authPassed = false; + + const WebServerSettings settings = serverGetSettings(); + if (!settings.auth_enabled) { + authPassed = true; + return ERROR_NONE; + } + + const size_t headerLength = http_server_request_get_header(request, "Authorization", nullptr, 0); + if (headerLength == 0) { + return sendUnauthorized(request, "Authorization required"); + } + std::string header(headerLength, '\0'); + http_server_request_get_header(request, "Authorization", header.data(), headerLength + 1); + + if (header.rfind("Basic ", 0) != 0) { + LOG_W(TAG, "The authorization header is not Basic"); + return sendUnauthorized(request, "Basic authorization required"); + } + + std::string credentials; + if (!util::base64Decode(header.substr(6), credentials)) { + LOG_W(TAG, "Failed to decode the credentials"); + return sendUnauthorized(request, "Invalid credentials format"); + } + + const size_t colon = credentials.find(':'); + if (colon == std::string::npos) { + LOG_W(TAG, "The credentials have no colon separator"); + return sendUnauthorized(request, "Invalid credentials format"); + } + + const std::string username = credentials.substr(0, colon); + const std::string password = credentials.substr(colon + 1); + const bool usernameMatches = util::secureCompare(username, settings.username); + const bool passwordMatches = util::secureCompare(password, settings.password); + if (!usernameMatches || !passwordMatches) { + LOG_W(TAG, "Invalid credentials for user '%s'", username.c_str()); + return sendUnauthorized(request, "Invalid credentials"); + } + + authPassed = true; + return ERROR_NONE; +} + +// @return false when the request was answered already, with @a result as the result of the handler +bool authenticate(HttpServerRequest* request, error_t& result) { + bool authPassed = false; + result = validateRequestAuth(request, authPassed); + return authPassed; +} + +// endregion + +void sendJson(HttpServerRequest* request, const std::string& json) { + http_server_request_set_content_type(request, "application/json"); + http_server_request_send_string(request, json.c_str()); +} + +// region Files + +error_t handleFsList(HttpServerRequest* request) { + std::string path; + if (!util::getQueryParam(request, "path", path) || path.empty()) path = "/"; + const std::string normalized = util::normalizePath(path); + + // The root lists the mount points + if (!util::isAllowedBasePath(normalized, true)) { + LOG_W(TAG, "GET /fs/list: invalid path '%s'", path.c_str()); + sendJson(request, "{\"error\":\"invalid path\"}"); + return ERROR_NONE; + } + + std::string json = "{\"path\":\"" + util::escapeJson(normalized) + "\",\"entries\":["; + if (normalized == "/") { + struct Context { + std::string* json; + uint16_t count; + } context { &json, 0 }; + file_system_for_each(&context, [](auto* fs, void* data) { + auto* context = static_cast(data); + char path[128]; + if (file_system_is_mounted(fs) && file_system_get_path(fs, path, sizeof(path)) == ERROR_NONE) { + if (context->count++ > 0) *context->json += ","; + *context->json += util::format("{\"name\":\"%s\",\"type\":\"dir\",\"size\":0}", util::escapeJson(path).c_str()); + } + return true; + }); + } else { + std::vector entries; + if (!util::listDirectory(normalized, entries)) { + sendJson(request, "{\"error\":\"scan failed\"}"); + return ERROR_NONE; + } + bool first = true; + for (const auto& entry : entries) { + if (!first) json += ","; + first = false; + long size = 0; + if (!entry.isDirectory) { + struct stat info {}; + if (stat((normalized + "/" + entry.name).c_str(), &info) == 0) { + size = static_cast(info.st_size); + } + } + json += util::format("{\"name\":\"%s\",\"type\":\"%s\",\"size\":%ld}", util::escapeJson(entry.name).c_str(), entry.isDirectory ? "dir" : "file", size); + } + } + json += "]}"; + + sendJson(request, json); + return ERROR_NONE; +} + +std::string percentEncode(const std::string& text) { + std::string result; + for (unsigned char c : text) { + if (isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + result += static_cast(c); + } else { + result += util::format("%%%02X", static_cast(c)); + } + } + return result; +} + +error_t handleFsDownload(HttpServerRequest* request) { + std::string path; + if (!util::getQueryParam(request, "path", path) || path.empty()) { + http_server_request_send_error(request, 400, "path required"); + return ERROR_UNDEFINED; + } + const std::string normalized = util::normalizePath(path); + if (!util::isAllowedBasePath(normalized) || !util::isFile(normalized)) { + LOG_W(TAG, "GET /fs/download: not found or invalid path '%s'", path.c_str()); + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; + } + + http_server_request_set_content_type(request, util::getContentType(normalized)); + const std::string name = util::getLastPathSegment(normalized); + std::string disposition = "attachment; filename=\"" + name + "\""; + // The RFC 5987 form is for browsers with UTF-8 names + const std::string encoded = percentEncode(name); + if (!encoded.empty()) { + disposition += "; filename*=UTF-8''" + encoded; + } + http_server_request_set_header(request, "Content-Disposition", disposition.c_str()); + + FILE* file = fopen(normalized.c_str(), "rb"); + if (file == nullptr) { + http_server_request_send_error(request, 500, "open failed"); + return ERROR_UNDEFINED; + } + const error_t result = util::sendFileChunked(request, file); + fclose(file); + return result; +} + +error_t handleFsUpload(HttpServerRequest* request) { + std::string path; + if (!util::getQueryParam(request, "path", path) || path.empty()) { + http_server_request_send_error(request, 400, "path required"); + return ERROR_UNDEFINED; + } + + const std::string normalized = util::normalizePath(path); + const uint64_t contentLength = http_server_request_get_content_length(request); + LOG_I(TAG, "POST /fs/upload '%s' (%u bytes)", normalized.c_str(), static_cast(contentLength)); + + if (!util::isAllowedBasePath(normalized)) { + LOG_W(TAG, "POST /fs/upload: invalid path '%s'", path.c_str()); + http_server_request_send_error(request, 403, "invalid path"); + return ERROR_UNDEFINED; + } + + if (contentLength > MAX_UPLOAD_SIZE) { + http_server_request_send_error(request, 400, "file too large"); + return ERROR_UNDEFINED; + } + + // Only after the size check, so rejected uploads don't create directories + if (!util::findOrCreateParentDirectory(normalized, 0755)) { + http_server_request_send_error(request, 500, "failed to create parent directory"); + return ERROR_UNDEFINED; + } + + // Receive into a sibling file, so a failed upload doesn't destroy an existing file at the target + const std::string partPath = normalized + ".part"; + const size_t received = util::receiveFile(request, static_cast(contentLength), partPath); + if (received != contentLength) { + util::deleteFile(partPath); + http_server_request_send_error(request, 500, "upload failed"); + return ERROR_UNDEFINED; + } + + // FAT's rename() fails when the target exists, so remove the old file first + if (util::isFile(normalized) && !util::deleteFile(normalized)) { + util::deleteFile(partPath); + http_server_request_send_error(request, 500, "failed to replace existing file"); + return ERROR_UNDEFINED; + } + if (rename(partPath.c_str(), normalized.c_str()) != 0) { + LOG_W(TAG, "POST /fs/upload: rename failed: %s", strerror(errno)); + util::deleteFile(partPath); + http_server_request_send_error(request, 500, "upload failed"); + return ERROR_UNDEFINED; + } + + http_server_request_set_content_type(request, "text/plain"); + const std::string message = util::format("Uploaded %u bytes", static_cast(received)); + http_server_request_send_string(request, message.c_str()); + return ERROR_NONE; +} + +error_t handleFsTree(HttpServerRequest* request) { + std::string json = "{\"mounts\": ["; + + struct Context { + std::string* json; + uint16_t count; + } context { &json, 0 }; + file_system_for_each(&context, [](auto* fs, void* data) { + auto* context = static_cast(data); + char path[128]; + if (!file_system_is_mounted(fs) || file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) { + return true; + } + + if (context->count++ > 0) *context->json += ","; + const std::string mountPath = path; + const std::string name = mountPath.starts_with("/") ? mountPath.substr(1) : mountPath; + *context->json += util::format("{\"name\":\"%s\",\"path\":\"%s\",\"entries\": [", util::escapeJson(name).c_str(), util::escapeJson(mountPath).c_str()); + + std::vector entries; + if (util::listDirectory(mountPath, entries)) { + bool first = true; + for (const auto& entry : entries) { + if (!first) *context->json += ","; + first = false; + *context->json += util::format("{\"name\":\"%s\",\"type\":\"%s\"}", util::escapeJson(entry.name).c_str(), entry.isDirectory ? "dir" : "file"); + } + } + *context->json += "]}"; + return true; + }); + json += "]}"; + + sendJson(request, json); + return ERROR_NONE; +} + +error_t handleFsMkdir(HttpServerRequest* request) { + std::string path; + if (!util::getQueryParam(request, "path", path) || path.empty()) { + http_server_request_send_error(request, 400, "path required"); + return ERROR_UNDEFINED; + } + const std::string normalized = util::normalizePath(path); + if (!util::isAllowedBasePath(normalized)) { + http_server_request_send_error(request, 403, "invalid path"); + return ERROR_UNDEFINED; + } + if (!util::findOrCreateDirectory(normalized, 0755)) { + http_server_request_send_error(request, 500, "mkdir failed"); + return ERROR_UNDEFINED; + } + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +bool isMountPoint(const std::string& path) { + return path == "/data" || path == "/sdcard"; +} + +error_t handleFsDelete(HttpServerRequest* request) { + std::string path; + if (!util::getQueryParam(request, "path", path) || path.empty()) { + http_server_request_send_error(request, 400, "path required"); + return ERROR_UNDEFINED; + } + const std::string normalized = util::normalizePath(path); + if (!util::isAllowedBasePath(normalized)) { + http_server_request_send_error(request, 403, "invalid path"); + return ERROR_UNDEFINED; + } + if (isMountPoint(normalized)) { + http_server_request_send_error(request, 403, "cannot delete mount point"); + return ERROR_UNDEFINED; + } + const bool deleted = util::isDirectory(normalized) ? util::deleteRecursively(normalized) + : util::isFile(normalized) && util::deleteFile(normalized); + if (!deleted) { + http_server_request_send_error(request, 500, "delete failed"); + return ERROR_UNDEFINED; + } + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +std::string trimSpaces(const std::string& text) { + size_t start = 0; + while (start < text.size() && isspace(static_cast(text[start]))) ++start; + size_t end = text.size(); + while (end > start && isspace(static_cast(text[end - 1]))) --end; + return text.substr(start, end - start); +} + +error_t handleFsRename(HttpServerRequest* request) { + std::string path; + std::string newName; + if (!util::getQueryParam(request, "path", path) || path.empty()) { + http_server_request_send_error(request, 400, "path required"); + return ERROR_UNDEFINED; + } + if (!util::getQueryParam(request, "newName", newName) || newName.empty()) { + http_server_request_send_error(request, 400, "newName required"); + return ERROR_UNDEFINED; + } + const std::string normalized = util::normalizePath(path); + if (!util::isAllowedBasePath(normalized)) { + http_server_request_send_error(request, 403, "invalid path"); + return ERROR_UNDEFINED; + } + + // The new name is a plain name, not a path + // Decode before validating, so an encoded %2F can't slip past the checks below + newName = trimSpaces(util::percentDecode(newName)); + if (newName.empty() || newName.find('/') != std::string::npos || newName.find('\\') != std::string::npos || newName.find("..") != std::string::npos) { + http_server_request_send_error(request, 400, "invalid newName"); + return ERROR_UNDEFINED; + } + + std::string parent = "/"; + const size_t position = normalized.find_last_of('/'); + if (position != std::string::npos) { + parent = (position == 0) ? "/" : normalized.substr(0, position); + } + if (!util::isAllowedBasePath(parent)) { + http_server_request_send_error(request, 403, "invalid target parent"); + return ERROR_UNDEFINED; + } + + const std::string target = util::getChildPath(parent, newName); + if (util::isFile(target) || util::isDirectory(target)) { + http_server_request_send_error(request, 400, "target exists"); + return ERROR_UNDEFINED; + } + + if (rename(normalized.c_str(), target.c_str()) != 0) { + const int error = errno; + LOG_W(TAG, "rename failed: %s", strerror(error)); + const std::string message = std::string("rename failed: ") + strerror(error); + http_server_request_send_error(request, 500, message.c_str()); + return ERROR_UNDEFINED; + } + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +// endregion + +// region API + +std::string getUri(HttpServerRequest* request) { + char uri[256]; + http_server_request_get_uri(request, uri, sizeof(uri)); + return uri; +} + +error_t handleApiSysinfo(HttpServerRequest* request) { + std::string json = "{"; + + json += util::format("\"firmware\":{\"version\":\"unknown\",\"idf_version\":\"%d.%d.%d\"},", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH); + +#ifdef CONFIG_FREERTOS_NUMBER_OF_CORES + constexpr int cores = CONFIG_FREERTOS_NUMBER_OF_CORES; +#else + constexpr int cores = 1; +#endif + json += util::format("\"chip\":{\"model\":\"%s\",\"cores\":%d,\"revision\":0,\"features\":[],\"flash_size\":0},", CONFIG_IDF_TARGET, cores); + + auto appendHeap = [&json](const char* name, uint32_t capabilities, bool last) { + json += util::format("\"%s\":{\"free\":%u,\"total\":%u,\"min_free\":0,\"largest_block\":%u}%s", + name, + static_cast(heap_caps_get_free_size(capabilities)), + static_cast(heap_caps_get_total_size(capabilities)), + static_cast(heap_caps_get_largest_free_block(capabilities)), + last ? "" : ","); + }; + appendHeap("heap", MALLOC_CAP_INTERNAL, false); + appendHeap("psram", MALLOC_CAP_SPIRAM, false); + + json += "\"storage\":{"; + struct Context { + std::string* json; + uint16_t count; + } context { &json, 0 }; + file_system_for_each(&context, [](auto* fs, void* data) { + auto* context = static_cast(data); + char mountPath[128] = ""; + if (file_system_get_path(fs, mountPath, sizeof(mountPath)) != ERROR_NONE) return true; + + if (context->count++ > 0) *context->json += ","; + // The free and total sizes aren't available to modules + *context->json += util::format("\"%s\":{\"free\":0,\"total\":0,\"mounted\":%s}", mountPath[0] == '/' ? mountPath + 1 : mountPath, file_system_is_mounted(fs) ? "true" : "false"); + return true; + }); + json += "},"; + + json += util::format("\"uptime\":%d,\"task_count\":%u,\"features_enabled\":{\"screenshot\":false}}", + static_cast(esp_timer_get_time() / 1000000), + static_cast(uxTaskGetNumberOfTasks())); + + sendJson(request, json); + return ERROR_NONE; +} + +error_t handleApiApps(HttpServerRequest* request) { + std::vector manifests; + app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { + static_cast*>(context)->push_back(*manifest); + }, &manifests); + + std::string json = "{\"apps\":["; + bool first = true; + for (const auto& manifest : manifests) { + if (!first) json += ","; + first = false; + + const char* category = "user"; + if (manifest.category == APP_CATEGORY_SYSTEM) category = "system"; + else if (manifest.category == APP_CATEGORY_SETTINGS) category = "settings"; + + json += util::format("{\"id\":\"%s\",\"name\":\"%s\",\"category\":\"%s\",\"isExternal\":%s,\"hidden\":%s}", + util::escapeJson(manifest.id).c_str(), + util::escapeJson(manifest.name).c_str(), + category, + manifest.location.type == APP_LOCATION_PATH ? "true" : "false", + (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) ? "true" : "false"); + } + json += "]}"; + + sendJson(request, json); + return ERROR_NONE; +} + +error_t handleApiAppsRun(HttpServerRequest* request) { + std::string appId; + if (!util::getQueryParam(request, "id", appId) || appId.empty()) { + http_server_request_send_error(request, 400, "id parameter required"); + return ERROR_UNDEFINED; + } + + AppStartContext context; + if (app_start_context_from_id(appId.c_str(), &context) != ERROR_NONE) { + http_server_request_send_error(request, 404, "app not found"); + return ERROR_UNDEFINED; + } + + // Starts a new instance next to the ones that are running already + AppInstanceId instanceId = 0; + app_start_with_context(&context, &instanceId); + + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +error_t handleApiAppsUninstall(HttpServerRequest* request) { + std::string appId; + if (!util::getQueryParam(request, "id", appId) || appId.empty()) { + http_server_request_send_error(request, 400, "id parameter required"); + return ERROR_UNDEFINED; + } + + AppManifest manifest; + if (app_manager_find_manifest(appId.c_str(), &manifest) != ERROR_NONE) { + // Nothing to uninstall + http_server_request_send_string(request, "ok"); + return ERROR_NONE; + } + + if (manifest.location.type != APP_LOCATION_PATH) { + http_server_request_send_error(request, 403, "cannot uninstall system apps"); + return ERROR_UNDEFINED; + } + + if (app_uninstall(appId.c_str()) != ERROR_NONE) { + http_server_request_send_error(request, 500, "uninstall failed"); + return ERROR_UNDEFINED; + } + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +error_t handleApiAppsInstall(HttpServerRequest* request) { + std::string boundary; + if (!util::getMultiPartBoundaryOrSendError(request, boundary)) { + return ERROR_UNDEFINED; + } + + size_t contentLeft = static_cast(http_server_request_get_content_length(request)); + + // The part headers end with an empty line + const std::string headersText = util::receiveTextUntil(request, "\r\n\r\n"); + contentLeft -= std::min(contentLeft, headersText.length()); + + std::vector headers; + for (auto& line : util::split(headersText, "\r\n")) { + if (!line.empty()) headers.push_back(line); + } + + std::string filename; + for (const auto& [key, value] : util::parseContentDisposition(headers)) { + if (key == "filename") filename = value; + } + if (filename.empty()) { + http_server_request_send_error(request, 400, "filename parameter missing"); + return ERROR_UNDEFINED; + } + + const std::string trailer = util::format("\r\n--%s--\r\n", boundary.c_str()); + if (contentLeft <= trailer.length()) { + http_server_request_send_error(request, 400, "invalid multipart payload"); + return ERROR_UNDEFINED; + } + + const size_t fileSize = contentLeft - trailer.length(); + if (fileSize == 0 || fileSize > MAX_APP_UPLOAD_SIZE) { + http_server_request_send_error(request, 400, "file too large"); + return ERROR_UNDEFINED; + } + + char dataPath[256]; + if (paths_get_data_path(dataPath, sizeof(dataPath)) != ERROR_NONE) { + http_server_request_send_error(request, 500, "no data path"); + return ERROR_UNDEFINED; + } + const std::string temporaryPath = std::string(dataPath) + "/tactility/tmp"; + if (!util::findOrCreateDirectory(temporaryPath, 0777)) { + http_server_request_send_error(request, 500, "failed to create temp directory"); + return ERROR_UNDEFINED; + } + + const std::string safeName = util::getLastPathSegment(filename); + if (safeName.empty() || safeName.find("..") != std::string::npos || safeName.find('/') != std::string::npos || safeName.find('\\') != std::string::npos) { + http_server_request_send_error(request, 400, "invalid filename"); + return ERROR_UNDEFINED; + } + const std::string filePath = temporaryPath + "/" + safeName; + + if (util::receiveFile(request, fileSize, filePath) != fileSize) { + util::deleteFile(filePath); + http_server_request_send_error(request, 500, "failed to save file"); + return ERROR_UNDEFINED; + } + + if (!util::readAndDiscardOrSendError(request, trailer)) { + util::deleteFile(filePath); + return ERROR_UNDEFINED; + } + + const error_t installResult = app_install(filePath.c_str()); + if (!util::deleteFile(filePath)) { + LOG_W(TAG, "Failed to delete %s", filePath.c_str()); + } + if (installResult != ERROR_NONE) { + http_server_request_send_error(request, 500, "installation failed"); + return ERROR_UNDEFINED; + } + + http_server_request_send_string(request, "ok"); + return ERROR_NONE; +} + +const char* radioStateToString(WifiRadioState radioState, WifiStationState stationState) { + switch (radioState) { + case WIFI_RADIO_STATE_ON: + switch (stationState) { + case WIFI_STATION_STATE_CONNECTION_PENDING: return "connecting"; + case WIFI_STATION_STATE_CONNECTED: return "connected"; + default: return "on"; + } + case WIFI_RADIO_STATE_ON_PENDING: return "turning_on"; + case WIFI_RADIO_STATE_OFF: return "off"; + case WIFI_RADIO_STATE_OFF_PENDING: return "turning_off"; + default: return "unknown"; + } +} + +error_t handleApiWifi(HttpServerRequest* request) { + WifiRadioState radioState = WIFI_RADIO_STATE_OFF; + WifiStationState stationState = WIFI_STATION_STATE_DISCONNECTED; + char ip[16] = {}; + char ssid[33] = {}; + int32_t rssi = 1; + bool secure = false; + + Device* wifiDevice = nullptr; + if (device_get_first_by_type(&WIFI_TYPE, &wifiDevice) == ERROR_NONE) { + wifi_get_radio_state(wifiDevice, &radioState); + wifi_get_station_state(wifiDevice, &stationState); + if (stationState != WIFI_STATION_STATE_DISCONNECTED) { + wifi_station_get_target_ssid(wifiDevice, ssid); + } + if (stationState == WIFI_STATION_STATE_CONNECTED) { + wifi_station_get_ipv4_address(wifiDevice, ip); + wifi_station_get_rssi(wifiDevice, &rssi); + // The driver doesn't report the authentication of the active connection, so it comes from the last scan + WifiApRecord records[16]; + size_t recordCount = std::size(records); + if (wifi_get_scan_results(wifiDevice, records, &recordCount) == ERROR_NONE) { + for (size_t i = 0; i < recordCount; ++i) { + if (strcmp(records[i].ssid, ssid) == 0) { + secure = records[i].authentication_type != WIFI_AUTHENTICATION_TYPE_OPEN; + break; + } + } + } + } + device_put(wifiDevice); + } + + sendJson(request, util::format("{\"state\":\"%s\",\"ip\":\"%s\",\"ssid\":\"%s\",\"rssi\":%d,\"secure\":%s}", + radioStateToString(radioState, stationState), + util::escapeJson(ip).c_str(), + util::escapeJson(ssid).c_str(), + static_cast(rssi), + secure ? "true" : "false")); + return ERROR_NONE; +} + +// endregion + +error_t sendAssetFile(HttpServerRequest* request, const std::string& path, const char* contentType) { + FILE* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + return ERROR_NOT_FOUND; + } + http_server_request_set_content_type(request, contentType); + const error_t result = util::sendFileChunked(request, file); + fclose(file); + return result; +} + +} + +error_t handleRoot(HttpServerRequest* request, void*) { + http_server_request_set_status(request, 302); + http_server_request_set_header(request, "Location", "/dashboard.html"); + return http_server_request_send(request, nullptr, 0); +} + +error_t handleFileBrowser(HttpServerRequest* request, void*) { + http_server_request_set_status(request, 302); + http_server_request_set_header(request, "Location", "/dashboard.html#files"); + return http_server_request_send(request, nullptr, 0); +} + +error_t handleFsGet(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + if (util::uriMatches(uri.c_str(), "/fs/list")) return handleFsList(request); + if (util::uriMatches(uri.c_str(), "/fs/download")) return handleFsDownload(request); + if (util::uriMatches(uri.c_str(), "/fs/tree")) return handleFsTree(request); + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleFsPost(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + if (util::uriMatches(uri.c_str(), "/fs/mkdir")) return handleFsMkdir(request); + if (util::uriMatches(uri.c_str(), "/fs/delete")) return handleFsDelete(request); + if (util::uriMatches(uri.c_str(), "/fs/rename")) return handleFsRename(request); + if (util::uriMatches(uri.c_str(), "/fs/upload")) return handleFsUpload(request); + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleAdminPost(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + if (util::uriMatches(uri.c_str(), "/admin/reboot")) { + http_server_request_send_string(request, "Rebooting..."); + // Gives the response time to arrive + vTaskDelay(pdMS_TO_TICKS(2000)); + esp_restart(); + return ERROR_NONE; + } + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleApiGet(HttpServerRequest* request, void*) { + const std::string uri = getUri(request); + + // Public, for monitoring + if (util::uriMatches(uri.c_str(), "/api/sysinfo")) return handleApiSysinfo(request); + + error_t result; + if (!authenticate(request, result)) return result; + + if (util::uriMatches(uri.c_str(), "/api/apps")) return handleApiApps(request); + if (util::uriMatches(uri.c_str(), "/api/wifi")) return handleApiWifi(request); + if (util::uriMatches(uri.c_str(), "/api/screenshot")) { + http_server_request_send_error(request, 501, "screenshot feature not enabled"); + return ERROR_UNDEFINED; + } + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleApiPost(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + if (util::uriMatches(uri.c_str(), "/api/apps/run")) return handleApiAppsRun(request); + if (util::uriMatches(uri.c_str(), "/api/apps/uninstall")) return handleApiAppsUninstall(request); + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleApiPut(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + if (util::uriMatches(uri.c_str(), "/api/apps/install")) return handleApiAppsInstall(request); + http_server_request_send_error(request, 404, "not found"); + return ERROR_UNDEFINED; +} + +error_t handleAssets(HttpServerRequest* request, void*) { + error_t result; + if (!authenticate(request, result)) return result; + + const std::string uri = getUri(request); + const std::string requestedPath = util::normalizePath(uri); + if (requestedPath == "/.." || requestedPath.ends_with("/..") || requestedPath.find("/../") != std::string::npos) { + http_server_request_send_error(request, 400, "invalid path"); + return ERROR_UNDEFINED; + } + + // The dashboard files come with the package + const char* assetName = nullptr; + const char* contentType = nullptr; + if (requestedPath == "/favicon.ico") { + assetName = "favicon.png"; + contentType = "image/png"; + http_server_request_set_header(request, "Cache-Control", "public, max-age=86400"); + } else if (requestedPath == "/dashboard.html") { + assetName = "dashboard.html"; + contentType = "text/html"; + } else if (requestedPath == "/version.json") { + assetName = "version.json"; + contentType = "application/json"; + } + + if (assetName != nullptr) { + char installPath[256]; + if (app_get_install_path(PACKAGE_ID, installPath, sizeof(installPath)) == ERROR_NONE) { + const std::string assetPath = std::string(installPath) + "/assets/" + assetName; + const error_t assetResult = sendAssetFile(request, assetPath, contentType); + if (assetResult != ERROR_NOT_FOUND) { + return assetResult; + } + } + } + + // Anything else comes from the SD card + const std::string sdPath = "/sdcard/tactility/webserver" + requestedPath; + if (util::isFile(sdPath)) { + const error_t sdResult = sendAssetFile(request, sdPath, util::getContentType(sdPath)); + if (sdResult != ERROR_NOT_FOUND) { + return sdResult; + } + } + + LOG_W(TAG, "[404] %s", uri.c_str()); + http_server_request_send_error(request, 404, "File not found"); + return ERROR_UNDEFINED; +} diff --git a/Apps/WebServer/webserver-module/main/Source/Handlers.h b/Apps/WebServer/webserver-module/main/Source/Handlers.h new file mode 100644 index 0000000..137cd9b --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Handlers.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include + +error_t handleRoot(HttpServerRequest* request, void* context); +error_t handleFileBrowser(HttpServerRequest* request, void* context); +error_t handleFsGet(HttpServerRequest* request, void* context); +error_t handleFsPost(HttpServerRequest* request, void* context); +error_t handleAdminPost(HttpServerRequest* request, void* context); +error_t handleApiGet(HttpServerRequest* request, void* context); +error_t handleApiPost(HttpServerRequest* request, void* context); +error_t handleApiPut(HttpServerRequest* request, void* context); +error_t handleAssets(HttpServerRequest* request, void* context); diff --git a/Apps/WebServer/webserver-module/main/Source/Settings.cpp b/Apps/WebServer/webserver-module/main/Source/Settings.cpp new file mode 100644 index 0000000..5d614e3 --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Settings.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "Settings.h" + +#include "Util.h" + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +constexpr auto* TAG = "WebServerSettings"; + +constexpr auto* APP_ID = "tactility.webserver"; +constexpr auto* FILE_NAME = "webserver.properties"; + +constexpr auto* KEY_ENABLED = "webServerEnabled"; +constexpr auto* KEY_PORT = "webServerPort"; +constexpr auto* KEY_AUTH_ENABLED = "webServerAuthEnabled"; +constexpr auto* KEY_USERNAME = "webServerUsername"; +constexpr auto* KEY_PASSWORD = "webServerPassword"; +constexpr auto* KEY_WIFI_MODE = "wifiMode"; +constexpr auto* KEY_AP_SSID = "apSsid"; +constexpr auto* KEY_AP_PASSWORD = "apPassword"; +constexpr auto* KEY_AP_OPEN_NETWORK = "apOpenNetwork"; +constexpr auto* KEY_AP_CHANNEL = "apChannel"; + +namespace { + +std::string getSettingsPath() { + char path[256]; + if (app_paths_get_user_data_path(APP_ID, FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return ""; + } + return path; +} + +std::string trim(const std::string& text) { + constexpr auto* whitespace = " \t\r\n"; + const size_t start = text.find_first_not_of(whitespace); + if (start == std::string::npos) { + return ""; + } + return text.substr(start, text.find_last_not_of(whitespace) - start + 1); +} + +bool loadProperties(const std::string& path, std::map& properties) { + FILE* file = fopen(path.c_str(), "r"); + if (file == nullptr) { + return false; + } + char buffer[256]; + while (fgets(buffer, sizeof(buffer), file) != nullptr) { + const std::string trimmed = trim(buffer); + if (trimmed.empty() || trimmed.starts_with("#")) { + continue; + } + const size_t separator = trimmed.find('='); + if (separator == std::string::npos) { + continue; + } + properties[trim(trimmed.substr(0, separator))] = trim(trimmed.substr(separator + 1)); + } + fclose(file); + return true; +} + +void copyString(char* destination, size_t destinationSize, const std::string& source) { + strncpy(destination, source.c_str(), destinationSize - 1); + destination[destinationSize - 1] = '\0'; +} + +bool parseBool(const std::map& properties, const char* key) { + const auto iterator = properties.find(key); + return iterator != properties.end() && (iterator->second == "1" || iterator->second == "true"); +} + +std::string generateCredential(size_t length) { + static constexpr char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + std::string result; + result.reserve(length); + for (size_t i = 0; i < length; ++i) { + result += charset[esp_random() % (sizeof(charset) - 1)]; + } + return result; +} + +// Unique per device. The base MAC exists on every chip, also on those without Wi-Fi of their own. +std::string generateDefaultSsid() { + uint8_t mac[6]; + if (esp_read_mac(mac, ESP_MAC_BASE) != ESP_OK) { + return "Tactility-0000"; + } + return util::format("Tactility-%02X%02X", mac[2] ^ mac[3], mac[4] ^ mac[5]); +} + +int parseInteger(const std::map& properties, const char* key, int minimum, int maximum, int fallback) { + const auto iterator = properties.find(key); + if (iterator == properties.end()) { + return fallback; + } + char* end = nullptr; + const long value = strtol(iterator->second.c_str(), &end, 10); + if (end == iterator->second.c_str() || *end != '\0' || value < minimum || value > maximum) { + return fallback; + } + return static_cast(value); +} + + +void getDefault(WebServerSettings& settings) { + memset(&settings, 0, sizeof(settings)); + settings.port = WEBSERVER_DEFAULT_PORT; + settings.wifi_mode = WEBSERVER_WIFI_MODE_STATION; + copyString(settings.ap_ssid, sizeof(settings.ap_ssid), generateDefaultSsid()); + settings.ap_channel = 1; +} + +} + +// Only the empty credentials are generated, the ones the user typed are kept +bool settingsGenerateMissingCredentials(WebServerSettings& settings) { + bool generated = false; + if (!settings.ap_open_network && settings.ap_password[0] == '\0') { + LOG_I(TAG, "The access point has no password: generating one"); + copyString(settings.ap_password, sizeof(settings.ap_password), generateCredential(12)); + generated = true; + } + if (settings.auth_enabled && (settings.username[0] == '\0' || settings.password[0] == '\0')) { + LOG_I(TAG, "Authentication is enabled without credentials: generating them"); + copyString(settings.username, sizeof(settings.username), generateCredential(12)); + copyString(settings.password, sizeof(settings.password), generateCredential(12)); + generated = true; + } + return generated; +} + +void settingsLoadOrDefault(WebServerSettings& settings) { + getDefault(settings); + + const std::string path = getSettingsPath(); + std::map properties; + if (path.empty() || !util::isFile(path) || !loadProperties(path, properties)) { + // Saved, so every later load returns the same credentials + if (settingsGenerateMissingCredentials(settings)) settingsSave(settings); + return; + } + + settings.enabled = parseBool(properties, KEY_ENABLED); + settings.auth_enabled = parseBool(properties, KEY_AUTH_ENABLED); + + settings.port = static_cast(parseInteger(properties, KEY_PORT, 1, 65535, WEBSERVER_DEFAULT_PORT)); + settings.wifi_mode = parseInteger(properties, KEY_WIFI_MODE, 0, 1, WEBSERVER_WIFI_MODE_STATION) == 1 + ? WEBSERVER_WIFI_MODE_ACCESS_POINT + : WEBSERVER_WIFI_MODE_STATION; + settings.ap_channel = static_cast(parseInteger(properties, KEY_AP_CHANNEL, 1, WEBSERVER_AP_MAX_CHANNEL, 1)); + settings.ap_open_network = parseBool(properties, KEY_AP_OPEN_NETWORK); + + const auto apSsid = properties.find(KEY_AP_SSID); + if (apSsid != properties.end() && !apSsid->second.empty()) { + copyString(settings.ap_ssid, sizeof(settings.ap_ssid), apSsid->second); + } + const auto apPassword = properties.find(KEY_AP_PASSWORD); + if (apPassword != properties.end()) { + copyString(settings.ap_password, sizeof(settings.ap_password), apPassword->second); + } + + const auto username = properties.find(KEY_USERNAME); + if (username != properties.end()) { + copyString(settings.username, sizeof(settings.username), username->second); + } + const auto password = properties.find(KEY_PASSWORD); + if (password != properties.end()) { + copyString(settings.password, sizeof(settings.password), password->second); + } + + // Saved, so every later load returns the same credentials + if (settingsGenerateMissingCredentials(settings)) settingsSave(settings); +} + +bool settingsSave(const WebServerSettings& settings) { + const std::string path = getSettingsPath(); + if (path.empty() || !util::findOrCreateParentDirectory(path, 0755)) { + LOG_E(TAG, "Failed to create the settings directory"); + return false; + } + + FILE* file = fopen(path.c_str(), "w"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path.c_str()); + return false; + } + int written = fprintf(file, "%s=%s\n", KEY_ENABLED, settings.enabled ? "1" : "0"); + written += fprintf(file, "%s=%u\n", KEY_PORT, static_cast(settings.port)); + written += fprintf(file, "%s=%s\n", KEY_AUTH_ENABLED, settings.auth_enabled ? "1" : "0"); + written += fprintf(file, "%s=%s\n", KEY_USERNAME, settings.username); + written += fprintf(file, "%s=%s\n", KEY_PASSWORD, settings.password); + written += fprintf(file, "%s=%u\n", KEY_WIFI_MODE, static_cast(settings.wifi_mode)); + written += fprintf(file, "%s=%s\n", KEY_AP_SSID, settings.ap_ssid); + written += fprintf(file, "%s=%s\n", KEY_AP_PASSWORD, settings.ap_password); + written += fprintf(file, "%s=%s\n", KEY_AP_OPEN_NETWORK, settings.ap_open_network ? "1" : "0"); + written += fprintf(file, "%s=%u\n", KEY_AP_CHANNEL, static_cast(settings.ap_channel)); + const bool success = fclose(file) == 0 && written > 0; + if (!success) { + LOG_E(TAG, "Failed to write %s", path.c_str()); + } + return success; +} diff --git a/Apps/WebServer/webserver-module/main/Source/Settings.h b/Apps/WebServer/webserver-module/main/Source/Settings.h new file mode 100644 index 0000000..7e99475 --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Settings.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include + +/** Reads the saved settings, or the defaults when there are none. Missing credentials are generated and saved. */ +void settingsLoadOrDefault(WebServerSettings& settings); + +bool settingsSave(const WebServerSettings& settings); + +/** Generates the empty AP password (closed network) and the empty username/password (authentication enabled). @return true when anything was generated */ +bool settingsGenerateMissingCredentials(WebServerSettings& settings); diff --git a/Apps/WebServer/webserver-module/main/Source/Util.cpp b/Apps/WebServer/webserver-module/main/Source/Util.cpp new file mode 100644 index 0000000..362c36e --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Util.cpp @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "Util.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "WebServerUtil"; + +namespace util { + +std::string format(const char* format, ...) { + va_list arguments; + va_start(arguments, format); + va_list copy; + va_copy(copy, arguments); + const int length = vsnprintf(nullptr, 0, format, arguments); + va_end(arguments); + + std::string result; + if (length > 0) { + result.resize(static_cast(length)); + vsnprintf(result.data(), result.size() + 1, format, copy); + } + va_end(copy); + return result; +} + +std::string escapeJson(const std::string& text) { + std::string result; + result.reserve(text.size()); + for (char c : text) { + switch (c) { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + result += format("\\u%04x", static_cast(c)); + } else { + result += c; + } + } + } + return result; +} + +static int hexValue(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; +} + +std::string percentDecode(const std::string& text) { + std::string decoded; + decoded.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) { + char c = text[i]; + if (c == '%') { + if (i + 2 < text.size()) { + int high = hexValue(text[i + 1]); + int low = hexValue(text[i + 2]); + if (high >= 0 && low >= 0) { + decoded.push_back(static_cast((high << 4) | low)); + i += 2; + continue; + } + } + // A malformed escape is kept as it is + decoded.push_back(c); + } else if (c == '+') { + decoded.push_back(' '); + } else if (static_cast(c) > 31) { + decoded.push_back(c); + } + } + return decoded; +} + +std::string normalizePath(const std::string& raw) { + std::string text = raw; + if (text.size() >= 2 && ((text.front() == '\'' && text.back() == '\'') || (text.front() == '"' && text.back() == '"'))) { + text = text.substr(1, text.size() - 2); + } + + const std::string decoded = percentDecode(text); + + size_t start = 0; + while (start < decoded.size() && isspace(static_cast(decoded[start]))) ++start; + size_t end = decoded.size(); + while (end > start && isspace(static_cast(decoded[end - 1]))) --end; + std::string trimmed = decoded.substr(start, end - start); + + if (trimmed.empty()) trimmed = "/"; + if (trimmed.front() != '/') trimmed = '/' + trimmed; + + std::string result; + result.reserve(trimmed.size()); + bool lastWasSlash = false; + for (char c : trimmed) { + if (c == '/') { + if (!lastWasSlash) result.push_back(c); + lastWasSlash = true; + } else { + result.push_back(c); + lastWasSlash = false; + } + } + return result; +} + +bool isAllowedBasePath(const std::string& path, bool allowRoot) { + if (path.empty()) return false; + if (path == ".." || path.starts_with("../") || path.find("/../") != std::string::npos || path.ends_with("/..")) { + return false; + } + if (allowRoot && path == "/") return true; + // The root itself or a path inside it: "/database" or "/sdcard2" must not match + auto isUnder = [&path](const std::string& root) { + return path == root || path.starts_with(root + "/"); + }; + return isUnder("/data") || isUnder("/sdcard"); +} + +const char* getContentType(const std::string& path) { + auto endsWith = [&path](const char* extension) { + const size_t length = strlen(extension); + return path.length() >= length && path.compare(path.length() - length, length, extension) == 0; + }; + + if (endsWith(".html") || endsWith(".htm")) return "text/html"; + if (endsWith(".css")) return "text/css"; + if (endsWith(".js")) return "application/javascript"; + if (endsWith(".json")) return "application/json"; + if (endsWith(".xml")) return "application/xml"; + if (endsWith(".txt")) return "text/plain"; + + if (endsWith(".png")) return "image/png"; + if (endsWith(".jpg") || endsWith(".jpeg")) return "image/jpeg"; + if (endsWith(".gif")) return "image/gif"; + if (endsWith(".svg")) return "image/svg+xml"; + if (endsWith(".ico")) return "image/x-icon"; + if (endsWith(".webp")) return "image/webp"; + + if (endsWith(".woff")) return "font/woff"; + if (endsWith(".woff2")) return "font/woff2"; + if (endsWith(".ttf")) return "font/ttf"; + if (endsWith(".otf")) return "font/otf"; + if (endsWith(".eot")) return "application/vnd.ms-fontobject"; + + if (endsWith(".mp3")) return "audio/mpeg"; + if (endsWith(".wav")) return "audio/wav"; + if (endsWith(".ogg")) return "audio/ogg"; + if (endsWith(".mp4")) return "video/mp4"; + if (endsWith(".webm")) return "video/webm"; + + if (endsWith(".pdf")) return "application/pdf"; + if (endsWith(".zip")) return "application/zip"; + if (endsWith(".gz")) return "application/gzip"; + + return "application/octet-stream"; +} + +bool secureCompare(const std::string& a, const std::string& b) { + const size_t maxLength = std::max(a.size(), b.size()); + volatile unsigned char result = 0; + result |= (a.size() != b.size()); + for (size_t i = 0; i < maxLength; ++i) { + unsigned char ca = (i < a.size()) ? static_cast(a[i]) : 0; + unsigned char cb = (i < b.size()) ? static_cast(b[i]) : 0; + result |= ca ^ cb; + } + return result == 0; +} + +bool base64Decode(const std::string& encoded, std::string& decoded) { + decoded.clear(); + uint32_t buffer = 0; + int bits = 0; + size_t padding = 0; + for (char c : encoded) { + int value; + if (c >= 'A' && c <= 'Z') value = c - 'A'; + else if (c >= 'a' && c <= 'z') value = c - 'a' + 26; + else if (c >= '0' && c <= '9') value = c - '0' + 52; + else if (c == '+') value = 62; + else if (c == '/') value = 63; + else if (c == '=') { padding++; continue; } + else return false; + + // Data after padding is invalid + if (padding > 0) return false; + + buffer = (buffer << 6) | static_cast(value); + bits += 6; + if (bits >= 8) { + bits -= 8; + decoded.push_back(static_cast((buffer >> bits) & 0xFF)); + } + } + return padding <= 2; +} + +std::vector split(const std::string& input, const std::string& delimiter) { + std::vector result; + if (delimiter.empty()) { + result.push_back(input); + return result; + } + size_t start = 0; + while (true) { + size_t index = input.find(delimiter, start); + if (index == std::string::npos) { + result.push_back(input.substr(start)); + return result; + } + result.push_back(input.substr(start, index - start)); + start = index + delimiter.size(); + } +} + +// region Files + +bool isFile(const std::string& path) { + struct stat info {}; + return stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode); +} + +bool isDirectory(const std::string& path) { + struct stat info {}; + return stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode); +} + +bool findOrCreateDirectory(const std::string& path, int mode) { + if (path.empty() || isDirectory(path)) { + return true; + } + for (size_t index = path.find('/', 1); index != std::string::npos; index = path.find('/', index + 1)) { + std::string parent = path.substr(0, index); + if (!isDirectory(parent) && mkdir(parent.c_str(), mode) != 0 && !isDirectory(parent)) { + return false; + } + } + return (mkdir(path.c_str(), mode) == 0) || isDirectory(path); +} + +bool findOrCreateParentDirectory(const std::string& path, int mode) { + const size_t index = path.find_last_of('/'); + if (index == std::string::npos || index == 0) { + return true; + } + return findOrCreateDirectory(path.substr(0, index), mode); +} + +bool deleteFile(const std::string& path) { + return unlink(path.c_str()) == 0; +} + +bool deleteRecursively(const std::string& path) { + if (isDirectory(path)) { + std::vector entries; + if (!listDirectory(path, entries)) { + return false; + } + for (const auto& entry : entries) { + if (!deleteRecursively(getChildPath(path, entry.name))) { + return false; + } + } + return rmdir(path.c_str()) == 0; + } + return deleteFile(path); +} + +std::string getLastPathSegment(const std::string& path) { + const size_t index = path.find_last_of('/'); + return index == std::string::npos ? path : path.substr(index + 1); +} + +std::string getChildPath(const std::string& parent, const std::string& child) { + if (parent.empty() || parent == "/") { + return "/" + child; + } + return parent + "/" + child; +} + +bool listDirectory(const std::string& path, std::vector& entries) { + DIR* directory = opendir(path.c_str()); + if (directory == nullptr) { + return false; + } + while (dirent* entry = readdir(directory)) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + entries.push_back({ entry->d_name, entry->d_type == DT_DIR || entry->d_type == DT_CHR }); + } + closedir(directory); + return true; +} + +// endregion + +// region HTTP requests + +bool getQueryParam(HttpServerRequest* request, const char* key, std::string& value) { + const size_t length = http_server_request_get_query(request, nullptr, 0); + if (length == 0) { + return false; + } + std::string query(length, '\0'); + http_server_request_get_query(request, query.data(), length + 1); + + size_t position = 0; + while (position < query.size()) { + const size_t ampersand = query.find('&', position); + const size_t pairEnd = ampersand == std::string::npos ? query.size() : ampersand; + const size_t equals = query.find('=', position); + if (equals != std::string::npos && equals < pairEnd && query.compare(position, equals - position, key) == 0) { + value = query.substr(equals + 1, pairEnd - equals - 1); + return true; + } + if (ampersand == std::string::npos) { + break; + } + position = ampersand + 1; + } + return false; +} + +bool uriMatches(const char* uri, const char* route) { + const size_t length = strlen(route); + return strncmp(uri, route, length) == 0 && (uri[length] == '\0' || uri[length] == '?' || uri[length] == '/'); +} + +error_t sendFileChunked(HttpServerRequest* request, FILE* file) { + if (http_server_request_send_chunk_start(request) != ERROR_NONE) { + return ERROR_UNDEFINED; + } + char buffer[512]; + size_t read; + while ((read = fread(buffer, 1, sizeof(buffer), file)) > 0) { + if (http_server_request_send_chunk(request, buffer, read) != ERROR_NONE) { + return ERROR_UNDEFINED; + } + } + http_server_request_send_chunk_end(request); + return ERROR_NONE; +} + +static bool getHeaderOrSendError(HttpServerRequest* request, const char* name, std::string& value) { + const size_t length = http_server_request_get_header(request, name, nullptr, 0); + if (length == 0) { + http_server_request_send_error(request, 400, "header missing"); + return false; + } + value.resize(length); + http_server_request_get_header(request, name, value.data(), length + 1); + return true; +} + +bool getMultiPartBoundaryOrSendError(HttpServerRequest* request, std::string& boundary) { + std::string contentType; + if (!getHeaderOrSendError(request, "Content-Type", contentType)) { + return false; + } + + const size_t index = contentType.find("boundary="); + if (index == std::string::npos) { + http_server_request_send_error(request, 400, "boundary not found in Content-Type"); + return false; + } + + boundary = contentType.substr(index + 9); + boundary = boundary.substr(0, boundary.find(';')); + while (!boundary.empty() && boundary.back() == ' ') { + boundary.pop_back(); + } + if (boundary.size() >= 2 && boundary.front() == '"' && boundary.back() == '"') { + boundary = boundary.substr(1, boundary.size() - 2); + } + return true; +} + +// Reads exactly the requested amount of bytes, because http_server_request_receive() may return fewer per call +static bool receiveExact(HttpServerRequest* request, void* buffer, size_t length) { + size_t total = 0; + while (total < length) { + const int read = http_server_request_receive(request, static_cast(buffer) + total, length - total); + if (read <= 0) { + return false; + } + total += static_cast(read); + } + return true; +} + +// Bounds the preamble of a multipart part, so a client can't make this buffer grow forever +constexpr size_t MAX_PREAMBLE_LENGTH = 8192; + +std::string receiveTextUntil(HttpServerRequest* request, const std::string& terminator) { + std::string result; + while (!result.ends_with(terminator)) { + if (result.length() >= MAX_PREAMBLE_LENGTH) { + return ""; + } + char byte; + if (!receiveExact(request, &byte, 1)) { + return ""; + } + result += byte; + } + return result; +} + +bool readAndDiscardOrSendError(HttpServerRequest* request, const std::string& expected) { + std::string buffer(expected.length(), '\0'); + if (!receiveExact(request, buffer.data(), expected.length())) { + http_server_request_send_error(request, 400, "failed to read discardable data"); + return false; + } + if (buffer != expected) { + http_server_request_send_error(request, 400, "discardable data mismatch"); + return false; + } + return true; +} + +size_t receiveFile(HttpServerRequest* request, size_t length, const std::string& filePath) { + constexpr size_t BUFFER_SIZE = 512; + char buffer[BUFFER_SIZE]; + size_t received = 0; + + FILE* file = fopen(filePath.c_str(), "wb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s for writing", filePath.c_str()); + return 0; + } + + while (received < length) { + const size_t chunk = std::min(BUFFER_SIZE, length - received); + const int read = http_server_request_receive(request, buffer, chunk); + if (read <= 0) { + LOG_E(TAG, "Receive failed with %zu bytes to go", length - received); + break; + } + if (fwrite(buffer, 1, static_cast(read), file) != static_cast(read)) { + LOG_E(TAG, "Failed to write all bytes"); + break; + } + received += static_cast(read); + } + + fclose(file); + return received; +} + +std::vector> parseContentDisposition(const std::vector& headerLines) { + std::vector> result; + // Not static: the destructor of a static string needs __dso_handle, which an ELF does not have + const std::string prefix = "Content-Disposition: "; + + auto header = std::find_if(headerLines.begin(), headerLines.end(), [&prefix](const std::string& line) { + return line.starts_with(prefix); + }); + if (header == headerLines.end()) { + return result; + } + + for (const auto& part : split(header->substr(prefix.size()), "; ")) { + const size_t equals = part.find('='); + if (equals == std::string::npos) { + continue; + } + std::string value = part.substr(equals + 1); + while (!value.empty() && (value.back() == '\r' || value.back() == '\n')) { + value.pop_back(); + } + // The value is quoted + result.emplace_back(part.substr(0, equals), value.size() > 2 ? value.substr(1, value.size() - 2) : ""); + } + return result; +} + +// endregion + +} diff --git a/Apps/WebServer/webserver-module/main/Source/Util.h b/Apps/WebServer/webserver-module/main/Source/Util.h new file mode 100644 index 0000000..641139a --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/Util.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace util { + +/** printf into a std::string */ +std::string format(const char* format, ...) __attribute__((format(printf, 1, 2))); + +std::string escapeJson(const std::string& text); + +/** Decodes the %xx and + sequences and drops control characters */ +std::string percentDecode(const std::string& text); + +/** Decodes the %xx and + sequences, drops control characters and quotes, makes the path start with one "/" without double slashes */ +std::string normalizePath(const std::string& raw); + +/** Only paths in /data and /sdcard without ".." components. The root "/" is allowed when @a allowRoot is set. */ +bool isAllowedBasePath(const std::string& path, bool allowRoot = false); + +const char* getContentType(const std::string& path); + +/** Compares in constant time for equal lengths */ +bool secureCompare(const std::string& a, const std::string& b); + +/** @return false when the text isn't valid base64 */ +bool base64Decode(const std::string& encoded, std::string& decoded); + +std::vector split(const std::string& input, const std::string& delimiter); + +// region Files + +struct DirectoryEntry { + std::string name; + bool isDirectory; +}; + +bool isFile(const std::string& path); +bool isDirectory(const std::string& path); +/** Creates the directory and its missing parents */ +bool findOrCreateDirectory(const std::string& path, int mode); +bool findOrCreateParentDirectory(const std::string& path, int mode); +bool deleteFile(const std::string& path); +bool deleteRecursively(const std::string& path); +std::string getLastPathSegment(const std::string& path); +std::string getChildPath(const std::string& parent, const std::string& child); +/** @return false when the directory can't be read. The entries "." and ".." are left out. */ +bool listDirectory(const std::string& path, std::vector& entries); + +// endregion + +// region HTTP requests + +/** The raw (not decoded) value of a key in the query string */ +bool getQueryParam(HttpServerRequest* request, const char* key, std::string& value); + +/** @return true when @a uri is @a route, optionally followed by a query or a sub path */ +bool uriMatches(const char* uri, const char* route); + +/** Sends a streamed file in chunks */ +error_t sendFileChunked(HttpServerRequest* request, FILE* file); + +bool getMultiPartBoundaryOrSendError(HttpServerRequest* request, std::string& boundary); + +/** @return the received text up to and including @a terminator, or "" when the connection failed or the preamble got too long */ +std::string receiveTextUntil(HttpServerRequest* request, const std::string& terminator); + +bool readAndDiscardOrSendError(HttpServerRequest* request, const std::string& expected); + +/** @return the amount of bytes written to the file */ +size_t receiveFile(HttpServerRequest* request, size_t length, const std::string& filePath); + +/** Reads the name=value pairs of the Content-Disposition header of a multipart part */ +std::vector> parseContentDisposition(const std::vector& headerLines); + +// endregion + +} diff --git a/Apps/WebServer/webserver-module/main/Source/WebServer.cpp b/Apps/WebServer/webserver-module/main/Source/WebServer.cpp new file mode 100644 index 0000000..cb14852 --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/WebServer.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "WebServer.h" + +#include "Handlers.h" +#include "Settings.h" + +#include +#include +#include +#include +#include + +#include +#include + +constexpr auto* TAG = "WebServer"; + +namespace { + +struct State { + Mutex settingsMutex {}; + WebServerSettings settings {}; + Mutex serverMutex {}; + HttpServer* server = nullptr; + bool accessPointStarted = false; + + State() { + mutex_construct(&settingsMutex); + mutex_construct(&serverMutex); + } + + ~State() { + // Not mutex_destruct(), which calls a function that is not available to modules + vSemaphoreDelete(settingsMutex.handle); + vSemaphoreDelete(serverMutex.handle); + } +}; + +State* state = nullptr; + +// Runs the callback with the Wi-Fi device +template +error_t withWifiDevice(Function function) { + Device* wifi = nullptr; + if (device_get_first_by_type(&WIFI_TYPE, &wifi) != ERROR_NONE) { + return ERROR_NOT_FOUND; + } + const error_t result = function(wifi); + device_put(wifi); + return result; +} + +error_t startAccessPoint(const WebServerSettings& settings) { + WifiAccessPointConfig config {}; + strncpy(config.ssid, settings.ap_ssid, sizeof(config.ssid) - 1); + if (!settings.ap_open_network) { + strncpy(config.password, settings.ap_password, sizeof(config.password) - 1); + } + config.channel = settings.ap_channel; + + const error_t result = withWifiDevice([&config](Device* wifi) { + return wifi_access_point_start(wifi, &config); + }); + if (result != ERROR_NONE) { + LOG_E(TAG, "Failed to start the access point: %s", error_to_string(result)); + } + return result; +} + +void stopAccessPoint() { + withWifiDevice([](Device* wifi) { + return wifi_access_point_stop(wifi); + }); +} + +// Must be called with the server mutex held +error_t startServerLocked() { + if (state->server != nullptr) { + return ERROR_NONE; + } + + const WebServerSettings settings = serverGetSettings(); + + // In access point mode the server needs the network first + const bool accessPoint = settings.wifi_mode == WEBSERVER_WIFI_MODE_ACCESS_POINT; + if (accessPoint && !state->accessPointStarted) { + if (startAccessPoint(settings) != ERROR_NONE) { + return ERROR_RESOURCE; + } + state->accessPointStarted = true; + } + + // Dispatchers handle several paths each, because every handler takes a URI slot of the HTTP server + static const HttpServerRequestHandler handlers[] = { + { "/", HTTP_METHOD_GET, handleRoot, nullptr }, + { "/filebrowser", HTTP_METHOD_GET, handleFileBrowser, nullptr }, + { "/fs/*", HTTP_METHOD_GET, handleFsGet, nullptr }, + { "/fs/*", HTTP_METHOD_POST, handleFsPost, nullptr }, + { "/admin/*", HTTP_METHOD_POST, handleAdminPost, nullptr }, + { "/api/*", HTTP_METHOD_GET, handleApiGet, nullptr }, + { "/api/*", HTTP_METHOD_POST, handleApiPost, nullptr }, + { "/api/*", HTTP_METHOD_PUT, handleApiPut, nullptr }, + { "/*", HTTP_METHOD_GET, handleAssets, nullptr }, + }; + + HttpServerConfig config { + .port = settings.port, + .address = "0.0.0.0", + .stack_size = 8192, + .handlers = handlers, + .handler_count = std::size(handlers), + }; + + state->server = http_server_alloc(&config); + if (state->server == nullptr || http_server_start(state->server) != ERROR_NONE) { + LOG_E(TAG, "Failed to start the HTTP server on port %u", static_cast(settings.port)); + http_server_free(state->server); + state->server = nullptr; + if (state->accessPointStarted) { + stopAccessPoint(); + state->accessPointStarted = false; + } + return ERROR_RESOURCE; + } + + LOG_I(TAG, "HTTP server started on port %u", static_cast(settings.port)); + return ERROR_NONE; +} + +// Must be called with the server mutex held +void stopServerLocked() { + if (state->server == nullptr) { + return; + } + http_server_free(state->server); + state->server = nullptr; + if (state->accessPointStarted) { + stopAccessPoint(); + state->accessPointStarted = false; + } + LOG_I(TAG, "HTTP server stopped"); +} + +} + +void serverStart() { + state = new State(); + + settingsLoadOrDefault(state->settings); + + if (state->settings.enabled) { + serverSetRunning(true); + } else { + LOG_I(TAG, "The web server is disabled in the settings"); + } +} + +void serverStop() { + serverSetRunning(false); + + delete state; + state = nullptr; +} + +bool serverIsRunning() { + mutex_lock(&state->serverMutex); + const bool running = state->server != nullptr; + mutex_unlock(&state->serverMutex); + return running; +} + +error_t serverSetRunning(bool running) { + mutex_lock(&state->serverMutex); + error_t error = ERROR_NONE; + if (running) { + error = startServerLocked(); + } else { + stopServerLocked(); + } + mutex_unlock(&state->serverMutex); + return error; +} + +WebServerSettings serverGetSettings() { + mutex_lock(&state->settingsMutex); + const WebServerSettings copy = state->settings; + mutex_unlock(&state->settingsMutex); + return copy; +} + +error_t serverSaveSettings(const WebServerSettings& settings) { + // E.g. authentication was enabled with empty credentials + WebServerSettings completed = settings; + settingsGenerateMissingCredentials(completed); + // A closed access point can't start with a password outside the WPA2 limits. + // Rejected instead of saved, or the server would fail to start on every boot. + if (!completed.ap_open_network) { + const size_t length = strlen(completed.ap_password); + if (length < WEBSERVER_AP_PASSWORD_MIN_LENGTH || length > WEBSERVER_AP_PASSWORD_MAX_LENGTH) { + LOG_E(TAG, "Invalid access point password length: %u", static_cast(length)); + return ERROR_INVALID_ARGUMENT; + } + } + if (!settingsSave(completed)) { + return ERROR_RESOURCE; + } + mutex_lock(&state->settingsMutex); + state->settings = completed; + mutex_unlock(&state->settingsMutex); + return ERROR_NONE; +} diff --git a/Apps/WebServer/webserver-module/main/Source/WebServer.h b/Apps/WebServer/webserver-module/main/Source/WebServer.h new file mode 100644 index 0000000..b0c2bfc --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/WebServer.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once + +#include + +/** Loads the settings and starts the server when it is enabled in the settings */ +void serverStart(); + +/** Stops the server */ +void serverStop(); + +bool serverIsRunning(); + +error_t serverSetRunning(bool running); + +/** A copy of the settings that the server uses for new requests */ +WebServerSettings serverGetSettings(); + +/** Saves the settings and makes the server use them for new requests */ +error_t serverSaveSettings(const WebServerSettings& settings); diff --git a/Apps/WebServer/webserver-module/main/Source/module.cpp b/Apps/WebServer/webserver-module/main/Source/module.cpp new file mode 100644 index 0000000..9f3d5ad --- /dev/null +++ b/Apps/WebServer/webserver-module/main/Source/module.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "WebServer.h" + +#include + +#include +#include + +#include "Settings.h" + +extern "C" { + +static error_t start() { + serverStart(); + return ERROR_NONE; +} + +static error_t stop() { + serverStop(); + return ERROR_NONE; +} + +error_t webserver_settings_load(WebServerSettings* settings) { + // The active settings, not a fresh load: that would generate new (unsaved) random credentials + *settings = serverGetSettings(); + return ERROR_NONE; +} + +error_t webserver_settings_save(const WebServerSettings* settings) { + return serverSaveSettings(*settings); +} + +bool webserver_is_enabled() { + return serverIsRunning(); +} + +error_t webserver_set_enabled(bool enabled) { + return serverSetRunning(enabled); +} + +static const ModuleSymbol symbols[] = { + DEFINE_MODULE_SYMBOL(webserver_settings_load), + DEFINE_MODULE_SYMBOL(webserver_settings_save), + DEFINE_MODULE_SYMBOL(webserver_is_enabled), + DEFINE_MODULE_SYMBOL(webserver_set_enabled), + MODULE_SYMBOL_TERMINATOR +}; + +static Module module = { + .name = "webserver", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = symbols, + .internal = nullptr, +}; + +// The entry point of a module returns its struct Module. The asm label keeps the symbol name of main for another return type. +// The build renames main to app_main for the ELF loader, which is why the label goes through a macro. +#define ENTRY_NAME_INNER(name) #name +#define ENTRY_NAME(name) ENTRY_NAME_INNER(name) +Module* module_main(int argc, char* argv[]) __asm__(ENTRY_NAME(main)); + +Module* module_main(int argc, char* argv[]) { + return &module; +} + +} diff --git a/Apps/WebServer/webserver/CMakeLists.txt b/Apps/WebServer/webserver/CMakeLists.txt new file mode 100644 index 0000000..2239af6 --- /dev/null +++ b/Apps/WebServer/webserver/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(tactility.webserver.webserver) +project(tactility.webserver.webserver) +tactility_project_post(tactility.webserver.webserver) diff --git a/Apps/WebServer/webserver/main/CMakeLists.txt b/Apps/WebServer/webserver/main/CMakeLists.txt new file mode 100644 index 0000000..f5eb620 --- /dev/null +++ b/Apps/WebServer/webserver/main/CMakeLists.txt @@ -0,0 +1,8 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS ../../common + REQUIRES lvgl-module wifi-module +) diff --git a/Apps/WebServer/webserver/main/Source/main.cpp b/Apps/WebServer/webserver/main/Source/main.cpp new file mode 100644 index 0000000..342e228 --- /dev/null +++ b/Apps/WebServer/webserver/main/Source/main.cpp @@ -0,0 +1,532 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr auto* TAG = "WebServerApp"; + +enum Request : int32_t { + REQUEST_NONE = -1, + REQUEST_DISABLE = 0, + REQUEST_ENABLE = 1, +}; + +/** + * The functions of the web server module. They are looked up while the app runs instead of linked, + * so the app can start without the module and tell the user about it. + */ +struct ModuleApi { + decltype(&webserver_settings_load) loadSettings = nullptr; + decltype(&webserver_settings_save) saveSettings = nullptr; + decltype(&webserver_is_enabled) isEnabled = nullptr; + decltype(&webserver_set_enabled) setEnabled = nullptr; + // Holds a reference, so the module can't stop while the app uses its functions + Module* module = nullptr; +}; + +struct Context { + uint32_t appInstanceId = 0; + ModuleApi api {}; + // The dialog that tells the module isn't running, 0 when there is none + AppInstanceId dialogId = 0; + TaskEventGroup* eventGroup = nullptr; + uint32_t requestBit = 0; + // Set by the enable switch and applied by the app task, because starting the server takes a while + std::atomic enableRequest { REQUEST_NONE }; + + // Written by the app task and by LVGL callbacks, with the LVGL lock held + WebServerSettings settings {}; + bool changed = false; + // The access point mode or its settings changed, which the running server only uses after a restart + bool accessPointChanged = false; + // Set by the mode chips and applied by the app task, because it asks the Wi-Fi device + std::atomic refreshAddress { false }; + // What the address card shows + std::string address; + + // nullptr while the widgets don't exist + lv_obj_t* enableSwitch = nullptr; + lv_obj_t* urlCard = nullptr; + lv_obj_t* urlLabel = nullptr; + lv_obj_t* wifiModeChips = nullptr; + lv_obj_t* apGroup = nullptr; + lv_obj_t* apPasswordRow = nullptr; + lv_obj_t* apOpenSwitch = nullptr; + lv_obj_t* apPasswordArea = nullptr; + lv_obj_t* authSwitch = nullptr; + lv_obj_t* authCard = nullptr; + lv_obj_t* usernameArea = nullptr; + lv_obj_t* passwordArea = nullptr; +}; + +/** @return false when the module isn't running. Otherwise release it with releaseModule(). */ +bool acquireModule(ModuleApi& api) { + Module* module = nullptr; + auto resolve = [&module](const char* name, auto& function) { + uintptr_t address = 0; + Module* owner = nullptr; + if (!module_resolve_symbol_global_get(name, &address, &owner)) { + return false; + } + function = reinterpret_cast>(address); + // One reference per module is enough + if (module == nullptr) { + module = owner; + } else { + module_put(owner); + } + return true; + }; + + const bool found = resolve("webserver_settings_load", api.loadSettings) + && resolve("webserver_settings_save", api.saveSettings) + && resolve("webserver_is_enabled", api.isEnabled) + && resolve("webserver_set_enabled", api.setEnabled); + if (!found) { + if (module != nullptr) module_put(module); + return false; + } + api.module = module; + return true; +} + +void releaseModule(ModuleApi& api) { + if (api.module != nullptr) { + module_put(api.module); + api.module = nullptr; + } +} + +void setHidden(lv_obj_t* object, bool hidden) { + if (hidden) { + lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_remove_flag(object, LV_OBJ_FLAG_HIDDEN); + } +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void onEnableSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + const bool enabled = lv_obj_has_state(ctx->enableSwitch, LV_STATE_CHECKED); + setHidden(ctx->urlCard, !enabled); + ctx->enableRequest = enabled ? REQUEST_ENABLE : REQUEST_DISABLE; + task_event_group_signal(ctx->eventGroup, ctx->requestBit); +} + +void onAuthSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settings.auth_enabled = lv_obj_has_state(ctx->authSwitch, LV_STATE_CHECKED); + ctx->changed = true; + setHidden(ctx->authCard, !ctx->settings.auth_enabled); +} + +void onCredentialChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Copied here, because the widgets are gone when another window covers this one + strncpy(ctx->settings.username, lv_textarea_get_text(ctx->usernameArea), sizeof(ctx->settings.username) - 1); + strncpy(ctx->settings.password, lv_textarea_get_text(ctx->passwordArea), sizeof(ctx->settings.password) - 1); + ctx->changed = true; +} + +/** Shows the widgets of the selected mode. Requires the LVGL lock. */ +void updateVisibility(Context* ctx) { + const bool accessPoint = ctx->settings.wifi_mode == WEBSERVER_WIFI_MODE_ACCESS_POINT; + const uint32_t chipCount = lv_obj_get_child_count(ctx->wifiModeChips); + for (uint32_t i = 0; i < chipCount; i++) { + lv_obj_set_state(lv_obj_get_child(ctx->wifiModeChips, static_cast(i)), LV_STATE_CHECKED, (i == 1) == accessPoint); + } + setHidden(ctx->apGroup, !accessPoint); + setHidden(ctx->apPasswordRow, ctx->settings.ap_open_network); +} + +void onWifiModeChip(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Chip 0 is station mode, chip 1 is access point mode + const uint32_t index = lv_obj_get_index(lv_event_get_target_obj(event)); + ctx->settings.wifi_mode = index == 1 ? WEBSERVER_WIFI_MODE_ACCESS_POINT : WEBSERVER_WIFI_MODE_STATION; + ctx->changed = true; + ctx->accessPointChanged = true; + updateVisibility(ctx); + ctx->refreshAddress = true; + task_event_group_signal(ctx->eventGroup, ctx->requestBit); +} + +void onApOpenSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settings.ap_open_network = lv_obj_has_state(ctx->apOpenSwitch, LV_STATE_CHECKED); + ctx->changed = true; + ctx->accessPointChanged = true; + updateVisibility(ctx); +} + +void onApPasswordChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + strncpy(ctx->settings.ap_password, lv_textarea_get_text(ctx->apPasswordArea), sizeof(ctx->settings.ap_password) - 1); + ctx->changed = true; + ctx->accessPointChanged = true; +} + +lv_obj_t* createCard(lv_obj_t* parent) { + auto* card = lvgl_card_create(parent); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + return card; +} + +/** A transparent column of rows, to show or hide them together */ +lv_obj_t* createGroup(lv_obj_t* parent) { + auto* group = lv_obj_create(parent); + lv_obj_set_size(group, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(group, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(group, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(group, false); + return group; +} + +/** A transparent row: "Title [content]" */ +lv_obj_t* createRow(lv_obj_t* parent, const char* title) { + auto* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(row, false); + auto* label = lv_label_create(row); + lv_label_set_text(label, title); + lv_obj_set_flex_grow(label, 1); + return row; +} + +lv_obj_t* createTextArea(lv_obj_t* row, const char* text, uint32_t maxLength, bool password, lv_event_cb_t onChanged, Context* ctx) { + auto* textArea = lv_textarea_create(row); + lv_obj_set_width(textArea, LV_PCT(50)); + lv_textarea_set_one_line(textArea, true); + lv_textarea_set_max_length(textArea, maxLength); + lv_textarea_set_password_mode(textArea, password); + lv_textarea_set_text(textArea, text); + lv_obj_add_event_cb(textArea, onChanged, LV_EVENT_VALUE_CHANGED, ctx); + return textArea; +} + +// Called with the LVGL lock held +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Web Server"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar); + if (ctx->settings.enabled) { + lv_obj_add_state(ctx->enableSwitch, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + + // The address, while the web server is enabled + ctx->urlCard = createCard(wrapper); + lv_label_set_text(lv_label_create(ctx->urlCard), "Web server address"); + ctx->urlLabel = lv_label_create(ctx->urlCard); + lv_label_set_text(ctx->urlLabel, ctx->address.c_str()); + setHidden(ctx->urlCard, !ctx->settings.enabled); + + // The Wi-Fi mode, with the access point settings in access point mode + lv_label_set_text(lv_label_create(wrapper), "WiFi mode"); + auto* wifiCard = createCard(wrapper); + lv_obj_set_flex_align(wifiCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // The chips are centered + ctx->wifiModeChips = lv_obj_create(wifiCard); + lv_obj_set_size(ctx->wifiModeChips, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->wifiModeChips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(ctx->wifiModeChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scroll_dir(ctx->wifiModeChips, LV_DIR_HOR); + lv_obj_set_style_bg_opa(ctx->wifiModeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); + // In WebServerWifiMode order + for (const char* name : { "Station", "Access point" }) { + auto* chip = lvgl_chip_create(ctx->wifiModeChips); + lv_label_set_text(lv_label_create(chip), name); + lv_obj_add_event_cb(chip, onWifiModeChip, LV_EVENT_SHORT_CLICKED, ctx); + } + lvgl_grid_navigation_add(ctx->wifiModeChips); + + ctx->apGroup = createGroup(wifiCard); + auto* apOpenRow = createRow(ctx->apGroup, "Open network"); + ctx->apOpenSwitch = lv_switch_create(apOpenRow); + if (ctx->settings.ap_open_network) { + lv_obj_add_state(ctx->apOpenSwitch, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(ctx->apOpenSwitch, onApOpenSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->apPasswordRow = createRow(ctx->apGroup, "Password"); + ctx->apPasswordArea = createTextArea(ctx->apPasswordRow, ctx->settings.ap_password, WEBSERVER_AP_PASSWORD_MAX_LENGTH, true, onApPasswordChanged, ctx); + updateVisibility(ctx); + + // Authentication, with the credentials while it's required + auto* authRow = createRow(wrapper, "Require authentication"); + ctx->authSwitch = lv_switch_create(authRow); + if (ctx->settings.auth_enabled) { + lv_obj_add_state(ctx->authSwitch, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(ctx->authSwitch, onAuthSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->authCard = createCard(wrapper); + auto* usernameRow = createRow(ctx->authCard, "Username"); + ctx->usernameArea = createTextArea(usernameRow, ctx->settings.username, WEBSERVER_USERNAME_MAX_LENGTH, false, onCredentialChanged, ctx); + auto* passwordRow = createRow(ctx->authCard, "Password"); + ctx->passwordArea = createTextArea(passwordRow, ctx->settings.password, WEBSERVER_PASSWORD_MAX_LENGTH, true, onCredentialChanged, ctx); + setHidden(ctx->authCard, !ctx->settings.auth_enabled); + + auto* info = lv_label_create(wrapper); + lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP); + lv_obj_set_width(info, LV_PCT(100)); + lv_label_set_text(info, + "Station mode uses the WiFi connection of the device. Use the WiFi menu to connect to a network.\n\n" + "Access point mode makes the device a WiFi network. Its password needs 8 to 63 characters, unless the network is open. " + "A change of these settings applies when the app closes."); +} + +// Called with the LVGL lock held. Only the toolbar, behind the dialog that tells the module isn't running. +void createEmptyWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Web Server"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); +} + +// Called with the LVGL lock held +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->enableSwitch = nullptr; + ctx->urlCard = nullptr; + ctx->urlLabel = nullptr; + ctx->wifiModeChips = nullptr; + ctx->apGroup = nullptr; + ctx->apPasswordRow = nullptr; + ctx->apOpenSwitch = nullptr; + ctx->apPasswordArea = nullptr; + ctx->authSwitch = nullptr; + ctx->authCard = nullptr; + ctx->usernameArea = nullptr; + ctx->passwordArea = nullptr; +} + +std::string getAddress(const WebServerSettings& settings) { + char ip[16] = {}; + Device* wifiDevice = nullptr; + if (device_get_first_by_type(&WIFI_TYPE, &wifiDevice) == ERROR_NONE) { + if (settings.wifi_mode == WEBSERVER_WIFI_MODE_ACCESS_POINT) { + // The address is fixed, also while the access point is not started + if (wifi_access_point_get_ipv4_address(wifiDevice, ip) != ERROR_NONE) { + strcpy(ip, "192.168.4.1"); + } + } else { + wifi_station_get_ipv4_address(wifiDevice, ip); + } + device_put(wifiDevice); + } + if (ip[0] == '\0') { + return "Not connected"; + } + + std::string address = std::string("http://") + ip; + if (settings.port != WEBSERVER_DEFAULT_PORT) { + address += ":" + std::to_string(settings.port); + } + return address; +} + +/** @return false when the dialog could not be started */ +bool showModuleMissingDialog(Context* ctx) { + AppStartContext start; + if (app_start_context_from_id("tactility.alertdialog", &start) != ERROR_NONE) { + return false; + } + const char* arguments[] = { + "Web Server", + "The web server module is not running.\nInstall the Web Server package again or restart the device.", + "OK", + }; + app_start_context_set_arguments_ext(&start, 3, arguments); + app_start_context_set_parent(&start, ctx->appInstanceId); + return app_start_with_context(&start, &ctx->dialogId) == ERROR_NONE; +} + +void refreshAddress(Context* ctx) { + lvgl_lock(); + const WebServerSettings settings = ctx->settings; + lvgl_unlock(); + + const std::string address = settings.enabled ? getAddress(settings) : "Disabled"; + + lvgl_lock(); + ctx->address = address; + if (ctx->urlLabel != nullptr) { + lv_label_set_text(ctx->urlLabel, ctx->address.c_str()); + } + lvgl_unlock(); +} + +void applyEnableRequest(Context* ctx, bool enabled) { + // Takes a while, so the LVGL lock is not held + WebServerSettings settings; + lvgl_lock(); + ctx->settings.enabled = enabled; + // Saved and applied below, so the server doesn't need a restart for them + ctx->accessPointChanged = false; + settings = ctx->settings; + lvgl_unlock(); + + if (ctx->api.saveSettings(&settings) != ERROR_NONE) { + // The server still starts, only the choice isn't kept for the next boot + } + ctx->api.setEnabled(enabled); + const bool running = ctx->api.isEnabled(); + const std::string address = running ? getAddress(settings) : "Disabled"; + + lvgl_lock(); + ctx->address = address; + if (ctx->urlLabel != nullptr) { + lv_label_set_text(ctx->urlLabel, ctx->address.c_str()); + } + // A start that failed, shows as switched off + if (ctx->enableSwitch != nullptr && enabled != running) { + lv_obj_set_state(ctx->enableSwitch, LV_STATE_CHECKED, running); + if (ctx->urlCard != nullptr) setHidden(ctx->urlCard, !running); + } + ctx->settings.enabled = running; + lvgl_unlock(); +} + +} + +extern "C" int main(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.requestBit) == ERROR_NONE); + + AppEventSubscription subscription {}; + check(app_event_subscribe(&subscription, &eventGroup) == ERROR_NONE); + + const bool moduleAvailable = acquireModule(ctx.api); + if (moduleAvailable) { + ctx.api.loadSettings(&ctx.settings); + // Shows what the server does, in case that differs from the saved choice + ctx.settings.enabled = ctx.api.isEnabled(); + ctx.address = ctx.settings.enabled ? getAddress(ctx.settings) : "Disabled"; + } + + WindowId window = window_manager_create_ext(ctx.appInstanceId, moduleAvailable ? createWidgets : createEmptyWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + if (!moduleAvailable && !showModuleMissingDialog(&ctx)) { + shouldClose = true; + } + + while (!shouldClose) { + task_event_group_wait_any(&eventGroup, nullptr, portMAX_DELAY); + + const int32_t request = ctx.enableRequest.exchange(REQUEST_NONE); + if (request != REQUEST_NONE && moduleAvailable) { + applyEnableRequest(&ctx, request == REQUEST_ENABLE); + } + if (ctx.refreshAddress.exchange(false) && moduleAvailable) { + refreshAddress(&ctx); + } + + AppEvent event {}; + while (app_event_poll(&subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + // Dismissing the dialog ends the app, because there is nothing to show without the module + if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.dialogId) { + app_manager_stop(ctx.dialogId); + ctx.dialogId = 0; + shouldClose = true; + break; + } + } + } + + // The credentials and authentication choice are saved when the app closes + lvgl_lock(); + const bool changed = ctx.changed; + const bool accessPointChanged = ctx.accessPointChanged; + const WebServerSettings settings = ctx.settings; + lvgl_unlock(); + if (changed && moduleAvailable) { + // Not saved when invalid (e.g. a too short access point password): the running server keeps its settings + const error_t saveResult = ctx.api.saveSettings(&settings); + if (saveResult != ERROR_NONE) { + LOG_E(TAG, "Failed to save the settings: %s", error_to_string(saveResult)); + } else if (accessPointChanged && ctx.api.isEnabled()) { + // The running server only uses a new mode or access point setting after a restart + ctx.api.setEnabled(false); + const error_t startResult = ctx.api.setEnabled(true); + if (startResult != ERROR_NONE) { + LOG_E(TAG, "Failed to restart the web server: %s", error_to_string(startResult)); + } + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&subscription) == ERROR_NONE); + task_event_group_release_bit(&eventGroup, ctx.requestBit); + task_event_group_destruct(&eventGroup); + releaseModule(ctx.api); + + return 0; +} diff --git a/tt.cmd b/tt.cmd deleted file mode 100755 index fc685aa..0000000 --- a/tt.cmd +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -rem Runs tt.py from the directory of this script, on Windows -where py >nul 2>nul -if %ERRORLEVEL% equ 0 ( - py -3 "%~dp0tt.py" %* -) else ( - python "%~dp0tt.py" %* -) -exit /b %ERRORLEVEL% diff --git a/tt.ps1 b/tt.ps1 new file mode 100755 index 0000000..4cc5537 --- /dev/null +++ b/tt.ps1 @@ -0,0 +1,9 @@ +# Runs tt.py from the folder of this script, on Windows. +# PowerShell passes the arguments to Python as they are, without parsing them again. +$script = Join-Path $PSScriptRoot "tt.py" +if (Get-Command py -ErrorAction SilentlyContinue) { + & py -3 $script @args +} else { + & python $script @args +} +exit $LASTEXITCODE diff --git a/tt.py b/tt.py index 667c46c..c98d529 100755 --- a/tt.py +++ b/tt.py @@ -5,11 +5,12 @@ "tt [arguments]" runs .py from the tools of that SDK version. When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them. Both ~/.tactility and TACTILITY_SDK_PATH hold sdk//tools/ and sdk//platform-/TactilitySDK/. -The tt (Linux, macOS) and tt.cmd (Windows) wrappers next to this script run it. +The tt (Linux, macOS) and tt.ps1 (Windows PowerShell) wrappers next to this script run it. The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties. """ import hashlib +import io import json import os import re @@ -20,11 +21,16 @@ import urllib.request import zipfile -TT_VERSION = "1.0.0" +TT_VERSION = "1.3.0" CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") INDEX_VALIDITY_SECONDS = 3600 COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$") +VERSION_PATTERN = re.compile(r"^[0-9A-Za-z.+-]+$") +ID_PATTERN = re.compile(r"^[0-9A-Za-z.]{5,32}$") +NAME_PATTERN = re.compile(r"^[0-9A-Za-z -]{2,32}$") +NEW_PROJECT_ARCHIVE = "new-project.zip" +PROJECT_VARIANTS = ("gui", "terminal", "module") def fail(message): @@ -78,16 +84,125 @@ def find_manifest(): return None +def is_valid_version(version): + return VERSION_PATTERN.match(version) is not None and ".." not in version + + +def ask(question, default=None, is_valid=None, description="invalid value"): + """Asks until the answer is valid. An empty answer or the end of the input gives the default. Returns None when there is none.""" + suffix = f" [{default}]" if default is not None else "" + while True: + try: + # On stderr, so the prompts don't end up in the output of the command + print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True) + answer = input().strip() + except EOFError: + answer = "" + if not answer: + return default + if is_valid is None or is_valid(answer): + return answer + print(f"tt: {description}", file=sys.stderr) + + +def ask_yes_no(question, default): + answer = ask(f"{question} [{'Y/n' if default else 'y/N'}]") + if answer is None: + return default + return answer.lower() in ("y", "yes") + + +def read_new_project_archive(version): + """Returns the content of the new project archive of the SDK version, from the local SDK or else the CDN""" + local_path = os.environ.get("TACTILITY_SDK_PATH") or None + if local_path is not None: + archive_path = os.path.join(version_path(os.path.abspath(local_path), version), NEW_PROJECT_ARCHIVE) + if not os.path.isfile(archive_path): + fail(f"new project not found: {archive_path}") + with open(archive_path, "rb") as file: + return file.read() + version_directory = version_path(HOME_DIRECTORY, version) + os.makedirs(version_directory, exist_ok=True) + entry = load_index(version, version_directory).get("newProject") + if entry is None: + fail(f"SDK {version} has no new project") + archive_path = os.path.join(version_directory, NEW_PROJECT_ARCHIVE) + if not download(sdk_url(version, entry["file"]), archive_path): + fail(f"failed to download the new project of SDK {version}") + try: + with open(archive_path, "rb") as file: + content = file.read() + finally: + os.remove(archive_path) + if hashlib.sha256(content).hexdigest() != entry["sha256"]: + fail(f"the new project of SDK {version} doesn't match its checksum") + return content + + +def generate_project(version, package_id, app_id, app_name, variant): + """Extracts the new project of the SDK version into . and fills in the answers""" + root = os.path.realpath(".") + created = [] + with zipfile.ZipFile(io.BytesIO(read_new_project_archive(version))) as archive: + for member in archive.infolist(): + if member.is_dir() or not member.filename.startswith(f"{variant}/"): + continue + relative_path = member.filename[len(variant) + 1:] + destination = os.path.realpath(os.path.join(root, relative_path)) + if not destination.startswith(root + os.sep): + fail(f"invalid entry in the new project: {member.filename}") + content = archive.read(member) + if relative_path == "manifest.properties": + text = content.decode("utf-8") + for placeholder, value in (("@SDK_VERSION@", version), ("@PACKAGE_ID@", package_id), ("@APP_ID@", app_id), ("@APP_NAME@", app_name)): + text = text.replace(placeholder, value) + content = text.encode("utf-8") + os.makedirs(os.path.dirname(destination), exist_ok=True) + with open(destination, "wb") as file: + file.write(content) + created.append(relative_path) + if "manifest.properties" not in created: + fail(f"the new project of SDK {version} has no {variant} variant") + return created + + +def offer_new_project(): + """Asks to generate a new app or module project. Returns the manifest path when it did, else None""" + if not ask_yes_no("Generate a new project in this folder?", False): + return None + if os.path.exists("manifest.properties") or os.path.exists("main"): + fail("this folder already has a manifest.properties or main/") + version = ask("SDK version to use", None, is_valid_version, "invalid SDK version") + if version is None: + fail("no SDK version given") + variant = ask("Project type (gui, terminal or module)", "gui", lambda answer: answer in PROJECT_VARIANTS, "use gui, terminal or module") + package_id = ask("Package id", "example.helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") + app_id = app_name = "" + if variant != "module": + app_id = ask("App id", "helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") + app_name = ask("App name", "Hello World", NAME_PATTERN.match, "use 2 to 32 letters, digits, spaces and dashes") + for path in generate_project(version, package_id, app_id, app_name, variant): + print(f"Created {path}", file=sys.stderr) + return "manifest.properties" + + def resolve_target(): """Returns the SDK version and platforms of the app manifest""" manifest_path = find_manifest() if manifest_path is None: - fail("no manifest.properties found in . or Apps/*/ or apps/*/") + print("tt: no manifest.properties found in . or Apps/*/ or apps/*/", file=sys.stderr) + manifest_path = offer_new_project() + if manifest_path is None: + # Commands like "tt shell" don't need an app, only the tools of an SDK version + version = ask("SDK version to use without a project (leave empty to abort)", None, is_valid_version, "invalid SDK version") + if version is None: + fail("no manifest.properties found in . or Apps/*/ or apps/*/") + return version, [] manifest = read_properties(manifest_path) version = manifest.get("target.sdk") if not version: fail(f"target.sdk not found in {manifest_path}") - if not re.match(r"^[0-9A-Za-z.+-]+$", version) or ".." in version: + if not is_valid_version(version): fail(f"invalid target.sdk in {manifest_path}: {version}") platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()] return version, platforms @@ -143,16 +258,9 @@ def install_tools(version, version_directory, index): fail(f"SDK {version} has no tools") tools_directory = os.path.join(version_directory, "tools") hash_path = os.path.join(version_directory, "tools.sha256") - if os.path.isdir(tools_directory) and os.path.isfile(hash_path): - with open(hash_path) as file: - if file.read().strip() == tools["sha256"]: - return print(f"Downloading the tools of SDK {version}", file=sys.stderr) zip_path = os.path.join(version_directory, "tools.zip") if not download(sdk_url(version, tools["file"]), zip_path): - if os.path.isdir(tools_directory): - warn(f"using the cached tools of SDK {version}") - return fail(f"failed to download the tools of SDK {version}") if file_sha256(zip_path) != tools["sha256"]: os.remove(zip_path) @@ -167,8 +275,6 @@ def install_platforms(version, version_directory, index, platforms): available = index.get("platforms", {}) for platform in platforms: platform_directory = os.path.join(version_directory, f"platform-{platform}") - if os.path.isdir(os.path.join(platform_directory, "TactilitySDK")): - continue if platform not in available: fail(f"platform {platform} is not available in SDK {version}") print(f"Downloading SDK {version} for {platform}", file=sys.stderr) @@ -182,12 +288,50 @@ def install_platforms(version, version_directory, index, platforms): file.write(index.get("gitCommit", "")) +def read_text(path): + try: + with open(path) as file: + return file.read().strip() + except OSError: + return None + + +def is_installed_build(version_directory, index): + """True when the installed tools and platforms are of the build that the index describes""" + installed_tools_hash = read_text(os.path.join(version_directory, "tools.sha256")) + if installed_tools_hash is not None and installed_tools_hash != index.get("tools", {}).get("sha256"): + return False + for name in os.listdir(version_directory): + commit = read_text(os.path.join(version_directory, name, "commit")) + if name.startswith("platform-") and commit is not None and commit != index.get("gitCommit", ""): + return False + return True + + def prepare_sdk(version, platforms): + """Installs what's missing of the SDK version. Installed tools and platforms are only replaced by "tt update-sdk", so they stay from the same build.""" version_directory = version_path(HOME_DIRECTORY, version) + index = None + # "target.platforms=all" means every platform of the SDK + if platforms == ["all"]: + os.makedirs(version_directory, exist_ok=True) + index = load_index(version, version_directory) + platforms = sorted(index.get("platforms", {})) + has_tools = os.path.isdir(os.path.join(version_directory, "tools")) + missing_platforms = [platform for platform in platforms if not os.path.isdir(os.path.join(version_directory, f"platform-{platform}", "TactilitySDK"))] + if has_tools and not missing_platforms: + return version_directory os.makedirs(version_directory, exist_ok=True) - index = load_index(version, version_directory) - install_tools(version, version_directory, index) - install_platforms(version, version_directory, index, platforms) + if index is None: + index = load_index(version, version_directory) + # The CDN only has the latest build of a version, so a newer build can't be mixed with the installed one + if not is_installed_build(version_directory, index): + if has_tools: + fail(f"SDK {version} was published again since it was installed: run 'tt update-sdk' first") + fail(f"SDK {version} was published again since it was installed: remove {version_directory} and try again") + if not has_tools: + install_tools(version, version_directory, index) + install_platforms(version, version_directory, index, missing_platforms) return version_directory @@ -258,7 +402,9 @@ def main(arguments): fail(f"tools not found: {tools_directory}") else: sdk_path = HOME_DIRECTORY - tools_directory = os.path.join(prepare_sdk(version, platforms), "tools") + # update-sdk replaces the installed build, so it only needs the tools + required_platforms = [] if command == "update-sdk" else platforms + tools_directory = os.path.join(prepare_sdk(version, required_platforms), "tools") environment = tool_environment(version, sdk_path, is_local) if is_help: print_help(version, tools_directory)