Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcfd4e9bcc | |||
| 982bba70b2 | |||
| 9630707b41 | |||
| fd82efe08f | |||
| 72089f74f3 | |||
| 89e8baf517 | |||
| 2060095028 | |||
| 620d56e19a | |||
| a0583e3a30 | |||
| 054d1286b2 | |||
| 85da419a98 | |||
| 87c82f5314 | |||
| 4b7f82c38f | |||
| 6d55f0b200 | |||
| 61fc67cf4c | |||
| c8ee763f3d | |||
| 8556103eb1 | |||
| 9e3dc3dc15 | |||
| 2496dea5c2 |
@@ -63,3 +63,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
For current-firmware merges, board customization retention, ESP-IDF build/flash,
|
||||
and external-app compatibility verification, also follow
|
||||
`.claude/skills/tactility-firmware-development/SKILL.md`.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: tactility-firmware-development
|
||||
description: Use when merging current Tactility firmware, preserving local ES3C28P/ES3C35P customizations, building/flashing ESP32 firmware, or validating external apps against it.
|
||||
---
|
||||
|
||||
# Tactility firmware development
|
||||
|
||||
## Scope and success criteria
|
||||
|
||||
Use this skill for firmware upgrades, especially when upstream changes the app
|
||||
loader, dashboard API, SDK export, or device model. Before editing, record the
|
||||
active branch, `git status --short`, selected `Devices/<id>`, serial port, and
|
||||
the expected device identity. Preserve user work and local board
|
||||
customizations; do not reset or overwrite a dirty tree.
|
||||
|
||||
Success is not merely a successful flash: the board must boot, mount storage,
|
||||
return `/api/sysinfo`, expose required internal apps, and accept an external
|
||||
app rebuilt against this exact firmware.
|
||||
|
||||
## Upgrade and customization audit
|
||||
|
||||
1. Fetch and compare the intended upstream branch before merging. Treat
|
||||
`upstream/main` as distinct from experimental release/IDF branches unless
|
||||
the task explicitly asks for one of those branches.
|
||||
2. Merge without discarding local work (for example, `git merge --autostash
|
||||
upstream/main` after inspecting status). Resolve conflicts by preserving
|
||||
required ES3C28P and ES3C35P device definitions, partition selection,
|
||||
display/font customizations, and product features.
|
||||
3. Compare the pre-upgrade customization commit against the resulting worktree.
|
||||
Check new app registrations, CMake/source inclusion, settings pages,
|
||||
web-server endpoints, and board-specific `sdkconfig` entries—not just files
|
||||
that happen to conflict.
|
||||
4. Commit the resulting firmware customization as a focused, reviewable
|
||||
commit after `git diff --check`.
|
||||
|
||||
## Multi-binary external-app compatibility
|
||||
|
||||
Upstream app packaging changed to `bin/<platform>/<binary>.elf`. The old
|
||||
`elf/<platform>.elf` archive layout produces a loader `Not executable` error.
|
||||
|
||||
- Manifest v0.2: `bin/esp32s3/app.elf`
|
||||
- Manifest v0.3: `bin/esp32s3/<app.0.binary>.elf`
|
||||
|
||||
When this change arrives, update the companion app tool and rebuild every app
|
||||
from a fresh SDK exported from this firmware. Do not attempt to repair a
|
||||
packaged ELF on the SD card: its layout and ABI must both be regenerated.
|
||||
|
||||
## Build and flash
|
||||
|
||||
Use the installed IDF 5.5 environment; host virtual environments can corrupt
|
||||
both Python dependencies and IDF tooling:
|
||||
|
||||
```zsh
|
||||
unset VIRTUAL_ENV PYTHONPATH PYTHONHOME
|
||||
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.9_env
|
||||
source /Users/adolforeyna/esp/esp-idf/export.sh
|
||||
|
||||
# Select/check the intended board before this step.
|
||||
python device.py <device-id>
|
||||
idf.py build
|
||||
idf.py -p /dev/cu.usbmodemXXXX flash
|
||||
```
|
||||
|
||||
`Tactility/CMakeLists.txt` uses a non-configure-dependent source glob. After
|
||||
adding a new `.c`/`.cpp` file, run `idf.py reconfigure build`; otherwise a
|
||||
linker error for a newly referenced symbol may only mean CMake has not
|
||||
discovered the source file yet.
|
||||
|
||||
## Boot and feature acceptance
|
||||
|
||||
Capture serial at 115200 after flash or reset. Confirm the board name, SD-card
|
||||
mount, HTTP-server start, Wi-Fi address, and any feature-specific startup logs.
|
||||
Then query the resolved device:
|
||||
|
||||
```zsh
|
||||
curl -fsS http://<ip>/api/sysinfo
|
||||
curl -fsS http://<ip>/api/apps
|
||||
```
|
||||
|
||||
For MCP settings, verify the `McpSettings` internal app appears in `/api/apps`.
|
||||
When enabled, verify MCP stream startup logs. For a feature restored from an
|
||||
older customization, validate its registration path and persisted settings,
|
||||
not only its source files.
|
||||
|
||||
## External-app acceptance gate
|
||||
|
||||
Use the companion app skill's exact-firmware wrapper:
|
||||
|
||||
```zsh
|
||||
cd /path/to/tactility_apps
|
||||
scripts/build_current_firmware_app.sh Apps/MyApp --firmware /path/to/tactility
|
||||
```
|
||||
|
||||
Inspect the resulting tar member path, install through port-80 dashboard API,
|
||||
launch it, and read serial. Required evidence is the loader's `Loading
|
||||
.../bin/...`, an ELF entry address, `Task started`, and app-specific startup
|
||||
logs. HTTP 200 or a package simply appearing in `/api/apps` is insufficient.
|
||||
|
||||
## Host simulator (buildsim) + web viewer
|
||||
|
||||
The POSIX simulator runs the real firmware (LVGL, services, web server) on
|
||||
macOS/Linux with an SDL backend. On current firmware, `Main.cpp` runs SDL's
|
||||
event loop on the process main thread while FreeRTOS runs on a separate thread.
|
||||
This is required by AppKit and supports a native macOS window as well as the
|
||||
web viewer. `SDL_VIDEODRIVER=dummy` remains useful for headless automation.
|
||||
|
||||
Code locations:
|
||||
|
||||
- `Devices/simulator/Source/module.cpp` — display resolution + `SIM_DISPLAY_W/H`
|
||||
- `Devices/simulator/Source/drivers/sdl_display.{h,cpp}` — SDL backend
|
||||
- `Devices/simulator/Source/drivers/sdl_input.{h,cpp}` — pointer/key state +
|
||||
web touch-injection override
|
||||
- `Tactility/Source/service/webserver/WebServerService.cpp` — `/sim` viewer,
|
||||
`/sim/api/*` aliases, `POST /api/sim/touch`, `GET /api/screenshot?fast=`
|
||||
- `Tactility/Private/Tactility/service/webserver/WebServerService.h` — handler decls
|
||||
|
||||
### Build and run
|
||||
|
||||
```zsh
|
||||
# one-time host deps (outside any ESP-IDF env)
|
||||
mkdir -p /tmp/simbin && ln -sf "$(which python3)" /tmp/simbin/python
|
||||
pip3 install --break-system-packages lark pyyaml # devicetree compiler
|
||||
|
||||
cd /path/to/tactility
|
||||
export PATH="/tmp/simbin:$PATH"
|
||||
env -u ESP_IDF_VERSION -u IDF_PATH cmake -S . -B buildsim -DCMAKE_BUILD_TYPE=Release
|
||||
env -u ESP_IDF_VERSION -u IDF_PATH cmake --build buildsim --target Tactility -j "$(sysctl -n hw.ncpu)"
|
||||
|
||||
# POSIX SDK for host apps (arm64)
|
||||
env -u ESP_IDF_VERSION -u IDF_PATH cmake --build buildsim --target TactilityKernel lvgl minitar minmea \
|
||||
app-module crypt-module gps-module http-module lvgl-module lvgl-window-manager-module service-module
|
||||
env -u ESP_IDF_VERSION -u IDF_PATH python3 Buildscripts/release-sdk-posix.py /tmp/sim-sdk
|
||||
|
||||
# release (MUST run from the firmware root: release-simulator.sh uses relative
|
||||
# version.txt / Data paths)
|
||||
sh Buildscripts/release-simulator.sh buildsim /tmp/simrun
|
||||
# Native macOS window + web API. Run this from the release directory because
|
||||
# data/ and system/ are relative to the process working directory.
|
||||
(cd /tmp/simrun && SIM_DISPLAY_W=480 SIM_DISPLAY_H=320 \
|
||||
nohup ./Tactility > /tmp/sim_gui.log 2>&1 &)
|
||||
# For CI/headless operation, set SDL_VIDEODRIVER=dummy instead.
|
||||
curl -s --max-time 5 http://127.0.0.1/api/sysinfo | head -c 120
|
||||
```
|
||||
|
||||
Display resolution: `SIM_DISPLAY_W/H` env (default **480x320 landscape**,
|
||||
matching on-device screenshots). ES3C35P panel is 320x480 portrait in DTS but
|
||||
presents 480x320 landscape; ES3C28P is 320x240. The chosen geometry is logged
|
||||
as `Simulator Sim display WxH`. `SDL_VIDEODRIVER=dummy` is expected to log one
|
||||
`SdlDisplay Failed to create SDL window: Couldn't find matching render
|
||||
driver` line — LVGL still renders and screenshots work. A native macOS launch
|
||||
must not emit that line.
|
||||
|
||||
### Web viewer, touch, screenshots
|
||||
|
||||
- `GET /sim` → 301 to `/sim/` (trailing slash required so the page's relative
|
||||
`api/` URLs resolve under `/sim/`). Viewer polls `api/screenshot?fast=1`
|
||||
every 500 ms, footer shows live `naturalWidth×naturalHeight`, click/tap
|
||||
POSTs `api/sim/touch?x=&y=`.
|
||||
- `POST /api/sim/touch?x=123&y=456[&down=0|1]` (also `/sim/api/sim/touch` via
|
||||
alias). Coordinates are LVGL logical pixels. `down=1` (default) presses and
|
||||
**auto-releases after 1500 ms** (`SIM_TOUCH_HOLD_MS` in `sdl_input.cpp`),
|
||||
long enough for LVGL indev polls to register a click. Simulator-only: 404 on
|
||||
ESP32 (`#ifndef ESP_PLATFORM`).
|
||||
- `GET /api/screenshot?fast=1` (default): `lv_snapshot_take` (RGB888) → in-place
|
||||
BGR→RGB swap → `lodepng_encode24` **to memory** → chunked HTTP. No filesystem
|
||||
touch, ~8 ms/shot. `?fast=0` keeps the legacy `webscreenshotN.png` file path
|
||||
(slot scan + accumulation — avoid for viewer loops).
|
||||
- lodepng include in `.cpp`: `#define LODEPNG_NO_COMPILE_CPP` before
|
||||
`#include "src/libs/lodepng/lodepng.h"`, otherwise its C++ `std::vector`
|
||||
overloads collide with the C declarations (`conflicting types for 'encode'`).
|
||||
- MCP includes and `settings::mcp` reads are `#ifdef ESP_PLATFORM`-gated; the
|
||||
sim has no `McpSystem`.
|
||||
|
||||
### Tailscale viewer
|
||||
|
||||
```zsh
|
||||
tailscale serve --bg --set-path=/simagent http://127.0.0.1:80/
|
||||
# open: https://<node>/simagent/sim/
|
||||
```
|
||||
|
||||
The serve target must be `/` (not `/sim`): the page resolves `api/` against
|
||||
its own directory, so at `/simagent/sim/` fetches go to `/simagent/sim/api/…`,
|
||||
which tailscale strips to `/sim/api/…` and the firmware's `/sim/api/*`
|
||||
aliases (GET+POST, registered in `startServer()`) handle. Absolute `/api/…`
|
||||
URLs would 404 at the edge (no `/api` mount there).
|
||||
|
||||
### Simulator pitfalls
|
||||
|
||||
- **Rebuild ≠ redeploy.** `cmake --build buildsim` updates `buildsim/` only.
|
||||
Re-run `release-simulator.sh`, restart the process, then retest. A stale
|
||||
`/tmp/simrun/Tactility` serves old handlers with new logs nowhere to be found.
|
||||
- **One simulator owns port 80.** Do not launch a second instance while another
|
||||
simulator is listening: it will initialize LVGL but fail `bind/listen`, so its
|
||||
app API and viewer target the other process. Identify the listener with
|
||||
`lsof -nP -iTCP:80 -sTCP:LISTEN`, stop only the intended simulator, then
|
||||
release/restart it before installing or running POSIX apps.
|
||||
- **Input is cross-thread on macOS.** SDL event pumping occurs on the real main
|
||||
thread; LVGL and web touch injection run elsewhere. Keep all shared pointer,
|
||||
key-queue, and touch-override state under `sdl_input.cpp`'s mutex.
|
||||
- **C array `sizeof` decay.** A helper like
|
||||
`f(HttpServerRequest*, char uri[256])` sees `sizeof(uri) == 8`, truncating
|
||||
`get_uri` output to 7 chars (`/api/sy`, `/sim/ap` 404s). Pass the size
|
||||
explicitly: `f(request, buf, sizeof(buf))`.
|
||||
- **Log truncation.** `LOG_QUEUE_MESSAGE_MAX_LENGTH` is 256 (`TactilityKernel/
|
||||
private/tactility/log_queue.h`), including color/timestamp prefix. Long URIs
|
||||
and messages truncate — don't over-interpret a short path in the log.
|
||||
- **Auth.** The viewer and API handlers enforce `validateRequestAuth` like any
|
||||
other endpoint; failures surface as 401/404, not viewer bugs.
|
||||
@@ -22,19 +22,17 @@ runs:
|
||||
run: python Buildscripts/release-sdk-posix.py release/TactilitySDK
|
||||
- name: 'Test Integration Prep'
|
||||
shell: bash
|
||||
# The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK
|
||||
# This way, it only works with our custom build. That means we have to create a copy of the SDK with the correct folder structure:
|
||||
env:
|
||||
TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
|
||||
run: |
|
||||
TACTILITY_SDK_NAME="0.0.0-posix-$TACTILITY_ARCH"
|
||||
TACTILITY_SDK_NAME="$(cat version.txt)-posix-$TACTILITY_ARCH"
|
||||
mkdir -p test_sdk/$TACTILITY_SDK_NAME
|
||||
cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME
|
||||
- name: 'Test Integration'
|
||||
shell: bash
|
||||
env:
|
||||
TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
|
||||
run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build posix-$TACTILITY_ARCH --local-sdk
|
||||
run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build -a posix-$TACTILITY_ARCH --local-sdk
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -35,7 +35,7 @@ runs:
|
||||
# The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK
|
||||
# This way, it only works with our custom build. That means we have to create a copy of the SDK with the correct folder structure:
|
||||
run: |
|
||||
TACTILITY_SDK_NAME="0.0.0-${{ inputs.arch }}"
|
||||
TACTILITY_SDK_NAME="$(cat version.txt)-${{ inputs.arch }}"
|
||||
mkdir -p test_sdk/$TACTILITY_SDK_NAME
|
||||
cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME
|
||||
- name: 'Test Integration'
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
with:
|
||||
esp_idf_version: v5.5.2
|
||||
target: ${{ inputs.arch }}
|
||||
command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python tactility.py build ${{ inputs.arch }} --local-sdk
|
||||
command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python tactility.py build -a ${{ inputs.arch }} --local-sdk
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -4,11 +4,8 @@ inputs:
|
||||
os_name:
|
||||
description: A descriptive name for the operating system (e.g. linux, windows)
|
||||
required: true
|
||||
platform_name:
|
||||
description: A descriptive name for the target platform (e.g. amd64, aarch64, etc.)
|
||||
required: true
|
||||
publish:
|
||||
description: A boolean that enables publishing of artifacts
|
||||
architecture:
|
||||
description: A descriptive name for the target architecture (e.g. x86_64, aarch64, etc.)
|
||||
required: true
|
||||
|
||||
runs:
|
||||
@@ -48,11 +45,10 @@ runs:
|
||||
run: cmake --build buildsim --target Tactility
|
||||
- name: 'Release'
|
||||
shell: bash
|
||||
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
|
||||
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ inputs.publish == 'true' }}
|
||||
with:
|
||||
name: Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
|
||||
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
|
||||
name: Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
|
||||
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
|
||||
retention-days: 30
|
||||
|
||||
@@ -17,8 +17,7 @@ jobs:
|
||||
uses: ./.github/actions/build-simulator
|
||||
with:
|
||||
os_name: linux
|
||||
platform_name: amd64
|
||||
publish: true
|
||||
architecture: x86_64
|
||||
macOS:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
@@ -29,6 +28,4 @@ jobs:
|
||||
uses: ./.github/actions/build-simulator
|
||||
with:
|
||||
os_name: macos
|
||||
platform_name: aarch64
|
||||
# macOS simulator currently fails due to main thread requirement for rendering
|
||||
publish: false
|
||||
architecture: aarch64
|
||||
|
||||
+3
-1
@@ -3,6 +3,8 @@
|
||||
|
||||
build*/
|
||||
!.github/actions/build*/
|
||||
!Buildscripts/
|
||||
!Buildscripts/release-simulator-macos-app.sh
|
||||
cmake*/
|
||||
CMakeCache.txt
|
||||
*.cbp
|
||||
@@ -27,4 +29,4 @@ sdkconfig.board.*.dev
|
||||
.caveman.json
|
||||
.ai/mcp
|
||||
|
||||
__pycache__
|
||||
__pycache__
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Convert an image to an uncompressed LVGL RGB565 launcher background."""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LV_IMAGE_HEADER_MAGIC = 0x19
|
||||
LV_COLOR_FORMAT_RGB565 = 0x12
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Create an uncompressed LVGL .bin image for "
|
||||
"/sdcard/tactility/launcher/background.bin. Use a square image sized "
|
||||
"to the display's longest edge to support both orientations without scaling "
|
||||
"(for example, 320x320 for a 320x240 display)."
|
||||
)
|
||||
)
|
||||
parser.add_argument("input", type=Path, help="Source image")
|
||||
parser.add_argument("output", type=Path, help="Destination .bin file")
|
||||
parser.add_argument("--width", type=int, required=True, help="Output width")
|
||||
parser.add_argument("--height", type=int, required=True, help="Output height")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.width <= 0 or args.width > 65535 or args.height <= 0 or args.height > 65535:
|
||||
raise SystemExit("width and height must be between 1 and 65535")
|
||||
|
||||
magick = shutil.which("magick")
|
||||
if magick is None:
|
||||
raise SystemExit("ImageMagick is required (the 'magick' command was not found)")
|
||||
|
||||
command = [
|
||||
magick,
|
||||
str(args.input),
|
||||
"-resize",
|
||||
f"{args.width}x{args.height}^",
|
||||
"-gravity",
|
||||
"center",
|
||||
"-extent",
|
||||
f"{args.width}x{args.height}",
|
||||
"-depth",
|
||||
"8",
|
||||
"rgb:-",
|
||||
]
|
||||
rgb888 = subprocess.run(command, check=True, stdout=subprocess.PIPE).stdout
|
||||
expected_size = args.width * args.height * 3
|
||||
if len(rgb888) != expected_size:
|
||||
raise SystemExit(f"unexpected ImageMagick output: {len(rgb888)} bytes, expected {expected_size}")
|
||||
|
||||
rgb565 = bytearray(args.width * args.height * 2)
|
||||
for source_offset in range(0, len(rgb888), 3):
|
||||
r, g, b = rgb888[source_offset : source_offset + 3]
|
||||
pixel = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
|
||||
destination_offset = (source_offset // 3) * 2
|
||||
struct.pack_into("<H", rgb565, destination_offset, pixel)
|
||||
|
||||
stride = args.width * 2
|
||||
header = struct.pack(
|
||||
"<BBHHHHH",
|
||||
LV_IMAGE_HEADER_MAGIC,
|
||||
LV_COLOR_FORMAT_RGB565,
|
||||
0,
|
||||
args.width,
|
||||
args.height,
|
||||
stride,
|
||||
0,
|
||||
)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_bytes(header + rgb565)
|
||||
print(
|
||||
f"Wrote {args.output} ({args.width}x{args.height}, "
|
||||
f"{len(header) + len(rgb565)} bytes, uncompressed RGB565)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Usage: release-simulator-macos-app.sh [builddir] [Tactility.app]
|
||||
# Example: release-simulator-macos-app.sh buildsim release/Tactility.app
|
||||
|
||||
set -eu
|
||||
|
||||
build_path=$1
|
||||
bundle_path=$2
|
||||
|
||||
if [ -e "$bundle_path" ]; then
|
||||
echo "Refusing to overwrite existing bundle: $bundle_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
contents_path="$bundle_path/Contents"
|
||||
resources_path="$contents_path/Resources"
|
||||
macos_path="$contents_path/MacOS"
|
||||
|
||||
mkdir -p "$resources_path" "$macos_path"
|
||||
|
||||
cp "$build_path/Tactility/Tactility" "$resources_path/Tactility-bin"
|
||||
cp version.txt "$resources_path/"
|
||||
cp -R Data/data "$resources_path/"
|
||||
cp -R Data/system "$resources_path/"
|
||||
|
||||
cat > "$contents_path/Info.plist" <<'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Tactility</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.tactilityproject.simulator</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Tactility</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.8.0-dev</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Tactility uses the microphone when a simulator app records audio.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
cat > "$macos_path/Tactility" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
resources_path="$(CDPATH= cd -- "$(dirname -- "$0")/../Resources" && pwd)"
|
||||
cd "$resources_path"
|
||||
exec "$resources_path/Tactility-bin" "$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$macos_path/Tactility" "$resources_path/Tactility-bin"
|
||||
+11
-3
@@ -62,9 +62,13 @@ if (DEFINED ENV{ESP_IDF_VERSION})
|
||||
# to every target; PanicHandler.cpp branches internally per architecture.
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_panic_handler" APPEND)
|
||||
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=read" APPEND)
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=write" APPEND)
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=close" APPEND)
|
||||
# Wraps newlib's reentrant syscall stubs, not the plain read()/write()/close() newlib itself
|
||||
# implements as thin wrappers around them. newlib's own stdio (fflush()'s buffer-flush path in particular)
|
||||
# calls these _r stubs directly, bypassing the plain names entirely.
|
||||
# See Modules/app-module/source/stdio_wrap.cpp's own comment for the exact call chain.
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_read_r" APPEND)
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_write_r" APPEND)
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_close_r" APPEND)
|
||||
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_button_create" APPEND)
|
||||
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_dropdown_create" APPEND)
|
||||
@@ -106,6 +110,9 @@ endif ()
|
||||
|
||||
# Defined as regular project for PC and component for ESP
|
||||
if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
||||
if (APPLE)
|
||||
enable_language(OBJC OBJCXX)
|
||||
endif ()
|
||||
add_subdirectory(Tactility)
|
||||
add_subdirectory(TactilityFreeRtos)
|
||||
add_subdirectory(TactilityKernel)
|
||||
@@ -133,6 +140,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
||||
add_subdirectory(Modules/lvgl-window-manager-module)
|
||||
add_subdirectory(Drivers/gps-generic-module)
|
||||
add_subdirectory(Drivers/gps-meshtastic-module)
|
||||
add_subdirectory(Drivers/audio-stream-module)
|
||||
|
||||
# FreeRTOS
|
||||
set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "")
|
||||
|
||||
@@ -17,7 +17,7 @@ apOpenNetwork=0
|
||||
apChannel=1
|
||||
|
||||
# Web Server Settings
|
||||
webServerEnabled=0
|
||||
webServerEnabled=1
|
||||
webServerPort=80
|
||||
|
||||
# HTTP Basic Authentication (optional)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
@@ -19,3 +19,8 @@ display.dpi=143
|
||||
lvgl.colorDepth=16
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
# Launcher clock and full-screen wallpaper
|
||||
sdkconfig.CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||
sdkconfig.CONFIG_LV_CACHE_DEF_SIZE=1048576
|
||||
sdkconfig.CONFIG_LV_IMAGE_HEADER_CACHE_DEF_CNT=16
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
compatible = "everest,es8311";
|
||||
reg = <0x18>;
|
||||
i2s = <&i2s0>;
|
||||
input-gain-percent = <100>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,13 @@ display.shape=rectangle
|
||||
display.dpi=165
|
||||
|
||||
lvgl.colorDepth=16
|
||||
lvgl.fontSize=16
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
dependencies.useDeprecatedHal=false
|
||||
|
||||
# Launcher clock and full-screen wallpaper
|
||||
sdkconfig.CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||
sdkconfig.CONFIG_LV_CACHE_DEF_SIZE=1048576
|
||||
sdkconfig.CONFIG_LV_IMAGE_HEADER_CACHE_DEF_CNT=16
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
compatible = "everest,es8311";
|
||||
reg = <0x18>;
|
||||
i2s = <&i2s0>;
|
||||
input-gain-percent = <100>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ properties:
|
||||
type: int
|
||||
default: 20
|
||||
description: Ambient temperature in °C, used for waveform timing compensation
|
||||
draw-mode:
|
||||
quality-draw-mode:
|
||||
type: int
|
||||
default: MODE_DU
|
||||
description: Default EpdDrawMode waveform used for screen updates (e.g. MODE_DU, MODE_GC16)
|
||||
default: MODE_GC16
|
||||
description: >
|
||||
EpdDrawMode waveform used for full-quality refreshes (e.g. MODE_GC16, MODE_GL16).
|
||||
Fast partial updates always use MODE_DU internally and are not configurable - see
|
||||
driver comments.
|
||||
rotation:
|
||||
type: int
|
||||
default: EPD_ROT_PORTRAIT
|
||||
|
||||
@@ -7,7 +7,7 @@ apps.launcherAppId=tactility.launcher
|
||||
hardware.target=esp32s3
|
||||
hardware.flashSize=16MB
|
||||
hardware.spiRam=true
|
||||
hardware.spiRamMode=OPI
|
||||
hardware.spiRamMode=OCT
|
||||
hardware.spiRamSpeed=80M
|
||||
hardware.esptoolFlashFreq=80M
|
||||
hardware.tinyUsbMsc=true
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
/**
|
||||
* Board definition for M5Stack PaperS3.
|
||||
*
|
||||
* Kept out-of-tree (see epd_board_m5papers3.h) instead of forking epdiy: this file only
|
||||
* uses epdiy's public API, so it builds as an ordinary consumer of the upstream component.
|
||||
*
|
||||
* Pin mapping from M5GFX source code (authoritative reference):
|
||||
* https://github.com/m5stack/M5GFX/blob/master/src/M5GFX.cpp
|
||||
*
|
||||
* Data bus: DB0-DB7 on GPIO 6,14,7,12,9,11,8,10
|
||||
* Control: STH=13, LEH=15, STV=17, CKV=18, CKH=16
|
||||
* Power: PWR=46, OE=45
|
||||
*/
|
||||
|
||||
#include "epd_board_m5papers3.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include "epdiy.h"
|
||||
|
||||
#include <output_lcd/lcd_driver.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32S3
|
||||
#error "M5Paper S3 board only supports ESP32-S3"
|
||||
#endif
|
||||
|
||||
static const char* TAG = "m5paper_s3";
|
||||
|
||||
/* Data Lines - from M5GFX source */
|
||||
#define D0 GPIO_NUM_6
|
||||
#define D1 GPIO_NUM_14
|
||||
#define D2 GPIO_NUM_7
|
||||
#define D3 GPIO_NUM_12
|
||||
#define D4 GPIO_NUM_9
|
||||
#define D5 GPIO_NUM_11
|
||||
#define D6 GPIO_NUM_8
|
||||
#define D7 GPIO_NUM_10
|
||||
|
||||
/* Control Lines - from M5GFX source */
|
||||
#define STH GPIO_NUM_13 /* Start pulse horizontal (active low) */
|
||||
#define LEH GPIO_NUM_15 /* Latch enable horizontal */
|
||||
#define STV GPIO_NUM_17 /* Start vertical */
|
||||
#define CKV GPIO_NUM_18 /* Clock vertical */
|
||||
#define CKH GPIO_NUM_16 /* Clock horizontal - LCD peripheral clock output */
|
||||
|
||||
/* Power control - from M5GFX source */
|
||||
#define PWR_PIN GPIO_NUM_46 /* Main power enable */
|
||||
#define OE_PIN GPIO_NUM_45 /* Output enable */
|
||||
|
||||
|
||||
static lcd_bus_config_t lcd_config = {
|
||||
.clock = CKH,
|
||||
.ckv = CKV,
|
||||
.leh = LEH,
|
||||
.start_pulse = STH,
|
||||
.stv = STV,
|
||||
.data[0] = D0,
|
||||
.data[1] = D1,
|
||||
.data[2] = D2,
|
||||
.data[3] = D3,
|
||||
.data[4] = D4,
|
||||
.data[5] = D5,
|
||||
.data[6] = D6,
|
||||
.data[7] = D7,
|
||||
};
|
||||
|
||||
static void epd_board_init(uint32_t epd_row_width, const EpdInitConfig* init_config) {
|
||||
(void)init_config;
|
||||
ESP_LOGI(TAG, "Initializing M5Paper S3 board");
|
||||
|
||||
/* Configure power pin - start with power off */
|
||||
gpio_reset_pin(PWR_PIN);
|
||||
gpio_set_direction(PWR_PIN, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(PWR_PIN, 0);
|
||||
|
||||
/* Configure output enable pin - active high */
|
||||
gpio_reset_pin(OE_PIN);
|
||||
gpio_set_direction(OE_PIN, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(OE_PIN, 0);
|
||||
|
||||
const EpdDisplay_t* display = epd_get_display();
|
||||
|
||||
LcdEpdConfig_t config = {
|
||||
.pixel_clock = display->bus_speed * 1000 * 1000,
|
||||
.ckv_high_time = 60,
|
||||
.line_front_porch = 4,
|
||||
.le_high_time = 4,
|
||||
.bus_width = display->bus_width,
|
||||
.bus = lcd_config,
|
||||
};
|
||||
|
||||
epd_lcd_init(&config, display->width, display->height);
|
||||
|
||||
ESP_LOGI(TAG, "Board initialized: %dx%d @ %dMHz",
|
||||
display->width, display->height, display->bus_speed);
|
||||
}
|
||||
|
||||
static void epd_board_deinit() {
|
||||
ESP_LOGI(TAG, "Deinitializing M5Paper S3 board");
|
||||
|
||||
/* Disable output first */
|
||||
gpio_set_level(OE_PIN, 0);
|
||||
|
||||
/* Power off display */
|
||||
gpio_set_level(PWR_PIN, 0);
|
||||
|
||||
epd_lcd_deinit();
|
||||
}
|
||||
|
||||
static void epd_board_set_ctrl(epd_ctrl_state_t* state, const epd_ctrl_state_t* const mask) {
|
||||
/* Handle output enable changes */
|
||||
if (mask->ep_output_enable) {
|
||||
gpio_set_level(OE_PIN, state->ep_output_enable ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void epd_board_poweron(epd_ctrl_state_t* state) {
|
||||
ESP_LOGI(TAG, "Powering on display");
|
||||
|
||||
/* Enable main power first */
|
||||
gpio_set_level(PWR_PIN, 1);
|
||||
|
||||
/* Wait for power to stabilize */
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
/* Enable output */
|
||||
gpio_set_level(OE_PIN, 1);
|
||||
|
||||
/* Update state */
|
||||
state->ep_stv = true;
|
||||
state->ep_mode = false;
|
||||
state->ep_output_enable = true;
|
||||
state->ep_sth = true;
|
||||
}
|
||||
|
||||
static void epd_board_poweroff(epd_ctrl_state_t* state) {
|
||||
ESP_LOGI(TAG, "Powering off display");
|
||||
|
||||
/* Disable output first */
|
||||
gpio_set_level(OE_PIN, 0);
|
||||
|
||||
state->ep_stv = false;
|
||||
state->ep_output_enable = false;
|
||||
state->ep_mode = false;
|
||||
state->ep_sth = false;
|
||||
|
||||
/* Small delay before cutting power */
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
/* Cut main power */
|
||||
gpio_set_level(PWR_PIN, 0);
|
||||
}
|
||||
|
||||
static float epd_board_ambient_temperature() {
|
||||
/* TODO: Could read from BMI270 temperature sensor */
|
||||
return 20.0f;
|
||||
}
|
||||
|
||||
const EpdBoardDefinition epd_board_m5papers3 = {
|
||||
.init = epd_board_init,
|
||||
.deinit = epd_board_deinit,
|
||||
.set_ctrl = epd_board_set_ctrl,
|
||||
.poweron = epd_board_poweron,
|
||||
.poweroff = epd_board_poweroff,
|
||||
.get_temperature = epd_board_ambient_temperature,
|
||||
// No hardware VCOM control path on this board yet - a non-null callback that doesn't touch
|
||||
// hardware would let epd_set_vcom() complete without changing anything on the panel.
|
||||
.set_vcom = NULL,
|
||||
.gpio_set_direction = NULL,
|
||||
.gpio_read = NULL,
|
||||
.gpio_write = NULL,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
/**
|
||||
* @file "epd_board_m5papers3.h"
|
||||
* @brief Board definition for M5Stack PaperS3, kept out-of-tree because upstream epdiy
|
||||
* (https://github.com/vroland/epdiy) does not support this board.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <epd_board.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern const EpdBoardDefinition epd_board_m5papers3;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -7,8 +7,10 @@
|
||||
#include <tactility/error.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/module.h>
|
||||
#include <tactility/time.h>
|
||||
|
||||
#include "epd_board_m5papers3.h"
|
||||
|
||||
#include <epd_board.h>
|
||||
#include <epdiy.h>
|
||||
|
||||
#include <esp_heap_caps.h>
|
||||
@@ -19,6 +21,70 @@
|
||||
#define TAG "Papers3Display"
|
||||
#define GET_CONFIG(device) (static_cast<const Papers3DisplayConfig*>((device)->config))
|
||||
|
||||
// Fast partial updates are always MODE_DU (strict black/white); config->quality_draw_mode is
|
||||
// only used for the periodic full-quality pass.
|
||||
static constexpr EpdDrawMode FAST_DRAW_MODE = MODE_DU;
|
||||
|
||||
// A partial update covering at least this fraction of the panel is a full-screen content change
|
||||
// (e.g. an app switch rebuilding the whole window, see lvgl.md) rather than a small widget
|
||||
// redraw, and is promoted to a quality refresh immediately.
|
||||
static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f;
|
||||
|
||||
// Bounds worst-case ghost accumulation during sustained fast-mode interaction (e.g. scrolling),
|
||||
// regardless of idle time. LVGL's PARTIAL-mode draw buffer covers vres/10 rows (see
|
||||
// lvgl-module/source/devices/devices.cpp's buffer_height), so a single full-screen redraw is
|
||||
// already ~10 tiles - this must clear a full sweep comfortably, or a normal full-screen redraw
|
||||
// gets promoted to slow GC16 partway through.
|
||||
static constexpr uint32_t QUALITY_REFRESH_PARTIAL_COUNT = 20;
|
||||
|
||||
// Cleans up ghosting left behind after interaction stops, since nothing else triggers a refresh
|
||||
// once draw_bitmap() calls stop arriving. Matches the M5Stack official demo's timer.
|
||||
static constexpr uint32_t QUALITY_REFRESH_IDLE_SECONDS = 10;
|
||||
|
||||
// LVGL's PARTIAL render mode flushes one draw_bitmap() call per still-unjoined dirty rect, so
|
||||
// one visual refresh is usually several back-to-back calls, not one; this holds quality mode
|
||||
// across a sibling rect's near-zero gap so they don't end up on inconsistent modes. Must stay
|
||||
// well under a GC16 draw's own duration (400ms+), or it also bridges the much larger gap between
|
||||
// separate real frames and pins a whole multi-frame interaction to GC16.
|
||||
static constexpr uint32_t QUALITY_HOLD_MS = 50;
|
||||
|
||||
// epd_fullclear() (white fill + GC16 draw + 3-cycle black/white flash, see epdiy's
|
||||
// highlevel.c/render.c) only runs once, at boot (papers3_display_init()), never periodically:
|
||||
// it wipes the whole panel, and this driver has no way to force LVGL to redraw everything
|
||||
// afterward - only whatever rect is drawn next gets restored, leaving the rest blank.
|
||||
|
||||
// 4x4 ordered (Bayer) dither thresholds, spread evenly across a 0-15 nibble range.
|
||||
static constexpr uint8_t BAYER_4X4[4][4] = {
|
||||
{ 0, 8, 2, 10 },
|
||||
{ 12, 4, 14, 6 },
|
||||
{ 3, 11, 1, 9 },
|
||||
{ 15, 7, 13, 5 },
|
||||
};
|
||||
|
||||
// Dithers an 8-bit luminance sample (0x00=black..0xFF=white) down to a 4-bit nibble
|
||||
// (0x0=black..0xF=white, matching EPDiy's MODE_PACKING_2PPB), spreading the rounding error
|
||||
// spatially instead of truncating every pixel the same way - this is what turns flat/banded
|
||||
// output into something that reads as smooth grayscale.
|
||||
static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) {
|
||||
// BAYER_4X4 is 0-15; scaled by 17 it spans a full 0-255 quantization step (one increment of
|
||||
// luminance*15), so the dither bias is actually comparable to the rounding it perturbs
|
||||
// instead of a few percent of one step.
|
||||
const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 17U;
|
||||
const uint32_t level = (static_cast<uint32_t>(luminance) * 15U + threshold) / 255U;
|
||||
return static_cast<uint8_t>(level > 15U ? 15U : level);
|
||||
}
|
||||
|
||||
// Binary variant for MODE_DU, which only supports pure black/white (see epdiy.h) - dithering
|
||||
// still applies so a partial-update area doesn't look coarser than the quality pass that
|
||||
// preceded it.
|
||||
static inline uint8_t dither_to_bw_nibble(uint8_t luminance, int32_t x, int32_t y) {
|
||||
// *16 (not 17) keeps the max threshold at 240, strictly below 255 - otherwise luminance 0xFF
|
||||
// (pure white) would tie the top Bayer cell's threshold and the strict ">" would misclassify
|
||||
// it as black.
|
||||
const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 16U;
|
||||
return luminance > threshold ? 0xF : 0x0;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
extern Module m5stack_papers3_module;
|
||||
@@ -34,6 +100,15 @@ struct Papers3DisplayInternal {
|
||||
// Scratch buffer for the grayscale8->EPDiy(4bpp packed, 2px/byte) conversion in draw_bitmap().
|
||||
uint8_t* packed_buffer;
|
||||
bool powered;
|
||||
uint32_t panel_pixel_count;
|
||||
// Fast (MODE_DU) partial updates since the last quality refresh; see
|
||||
// QUALITY_REFRESH_PARTIAL_COUNT.
|
||||
uint32_t partial_count_since_quality;
|
||||
// get_ticks() at the last quality refresh; see QUALITY_REFRESH_IDLE_SECONDS.
|
||||
TickType_t last_quality_refresh_tick;
|
||||
// While get_ticks() < this, every draw_bitmap() call uses quality mode regardless of the
|
||||
// other triggers; see QUALITY_HOLD_MS.
|
||||
TickType_t quality_hold_until_tick;
|
||||
};
|
||||
|
||||
static void power_on(Papers3DisplayInternal* internal) {
|
||||
@@ -64,9 +139,48 @@ static error_t papers3_display_init(Device* device) {
|
||||
// pass, leaving a faint ghost. Run a full clear now, before LVGL's first flush ever reaches
|
||||
// draw_bitmap(), so it never has to undo content LVGL already put on screen.
|
||||
epd_fullclear(&internal->hl_state, config->temperature_celsius);
|
||||
internal->partial_count_since_quality = 0;
|
||||
internal->last_quality_refresh_tick = get_ticks();
|
||||
internal->quality_hold_until_tick = 0;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
// Decides whether this update should be a full-quality (config->quality_draw_mode) refresh or
|
||||
// a fast MODE_DU one. Read-only - see commit_quality_mode_decision() for the state this decision
|
||||
// leads to.
|
||||
static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t width, int32_t height) {
|
||||
const TickType_t now = get_ticks();
|
||||
|
||||
const uint32_t area = static_cast<uint32_t>(width) * static_cast<uint32_t>(height);
|
||||
const bool is_full_screen_change = area >= static_cast<uint32_t>(
|
||||
static_cast<float>(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD
|
||||
);
|
||||
const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT;
|
||||
// Idle refresh is too problematic to worth the possible gains. So it isn't done on purpose.
|
||||
// Ghosting is very minimal now anyway (it's still around but way less bad)
|
||||
const bool idle_exceeded = now - internal->last_quality_refresh_tick >= seconds_to_ticks(QUALITY_REFRESH_IDLE_SECONDS);
|
||||
const bool within_hold = now < internal->quality_hold_until_tick;
|
||||
|
||||
return is_full_screen_change || partial_count_exceeded || idle_exceeded || within_hold;
|
||||
}
|
||||
|
||||
// Applies should_use_quality_mode()'s decision, but only commits the quality-mode reset once the
|
||||
// draw actually succeeded - a failed quality refresh must not make a still-ghosting panel look
|
||||
// freshly cleaned to every trigger above. A failed fast update still counts toward the partial
|
||||
// count, since it was still MODE_DU content, not a clean slate.
|
||||
static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool used_quality, bool draw_succeeded) {
|
||||
if (used_quality) {
|
||||
if (draw_succeeded) {
|
||||
const TickType_t now = get_ticks();
|
||||
internal->partial_count_since_quality = 0;
|
||||
internal->last_quality_refresh_tick = now;
|
||||
internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS);
|
||||
}
|
||||
} else {
|
||||
internal->partial_count_since_quality++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reports GRAYSCALE8 (not MONOCHROME) so LVGL uses partial/tile updates instead of forcing
|
||||
// full-frame - the bridge hardcodes full-frame for MONOCHROME/I1 regardless of capability flags.
|
||||
// So draw_bitmap is called once per changed tile, not necessarily the whole panel.
|
||||
@@ -76,11 +190,12 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3
|
||||
|
||||
const int32_t width = x_end - x_start;
|
||||
const int32_t height = y_end - y_start;
|
||||
const bool use_quality = should_use_quality_mode(internal, width, height);
|
||||
|
||||
// color_data is DISPLAY_COLOR_FORMAT_GRAYSCALE8: row-major, 1 byte/pixel luminance
|
||||
// (0x00=black..0xFF=white, matching LVGL's L8). EPDiy wants 4bpp packed (2px/byte, 0x0=black,
|
||||
// 0xF=white) - a plain >>4 truncation preserves all 16 real gray levels the panel supports
|
||||
// (this panel is not B/W-only; see MODE_GC16/GL16 in papers3-display.yaml's draw-mode doc).
|
||||
// 0xF=white); Bayer dithering (full 16-level for the quality pass, binary for MODE_DU)
|
||||
// spreads the rounding error instead of a flat truncation.
|
||||
const auto* src = static_cast<const uint8_t*>(color_data);
|
||||
const size_t src_stride = static_cast<size_t>(width);
|
||||
const size_t packed_stride = static_cast<size_t>(width + 1) / 2;
|
||||
@@ -90,12 +205,18 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3
|
||||
uint8_t* dst_row = internal->packed_buffer + static_cast<size_t>(row) * packed_stride;
|
||||
int32_t col = 0;
|
||||
for (; col + 2 <= width; col += 2) {
|
||||
const uint8_t p0 = src_row[col] >> 4U;
|
||||
const uint8_t p1 = src_row[col + 1] >> 4U;
|
||||
const uint8_t p0 = use_quality
|
||||
? dither_to_nibble(src_row[col], x_start + col, y_start + row)
|
||||
: dither_to_bw_nibble(src_row[col], x_start + col, y_start + row);
|
||||
const uint8_t p1 = use_quality
|
||||
? dither_to_nibble(src_row[col + 1], x_start + col + 1, y_start + row)
|
||||
: dither_to_bw_nibble(src_row[col + 1], x_start + col + 1, y_start + row);
|
||||
dst_row[col / 2] = static_cast<uint8_t>((p1 << 4U) | p0);
|
||||
}
|
||||
if (col < width) { // odd width: last column has no pair, low nibble unused
|
||||
dst_row[col / 2] = static_cast<uint8_t>(src_row[col] >> 4U);
|
||||
dst_row[col / 2] = use_quality
|
||||
? dither_to_nibble(src_row[col], x_start + col, y_start + row)
|
||||
: dither_to_bw_nibble(src_row[col], x_start + col, y_start + row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +229,15 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3
|
||||
|
||||
power_on(internal);
|
||||
epd_draw_rotated_image(update_area, internal->packed_buffer, internal->framebuffer);
|
||||
const auto draw_mode = use_quality ? config->quality_draw_mode : FAST_DRAW_MODE;
|
||||
auto draw_result = epd_hl_update_area(
|
||||
&internal->hl_state,
|
||||
static_cast<EpdDrawMode>(config->draw_mode | MODE_PACKING_2PPB),
|
||||
static_cast<EpdDrawMode>(draw_mode | MODE_PACKING_2PPB),
|
||||
config->temperature_celsius,
|
||||
update_area
|
||||
);
|
||||
|
||||
commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS);
|
||||
return draw_result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE;
|
||||
}
|
||||
|
||||
@@ -133,13 +256,11 @@ static DisplayColorFormat papers3_display_get_color_format(Device*) {
|
||||
return DISPLAY_COLOR_FORMAT_GRAYSCALE8;
|
||||
}
|
||||
|
||||
// epd_width()/epd_height() are the panel's native, unrotated dimensions (display->width/height in
|
||||
// epdiy.c) - epd_rotated_display_width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT.
|
||||
// epd_draw_rotated_image() clamps its input rect against the *rotated* dims and epd_draw_pixel()
|
||||
// applies the rotation transform on top of that (see _rotate() in epdiy.c), so both LVGL's canvas
|
||||
// size and draw_bitmap()'s rect must be in rotated-space, not native-space - using the native
|
||||
// epd_width()/epd_height() here fed rotated-space code a landscape-sized canvas, which produced
|
||||
// exactly the "rotated + landscape" symptom this was fixed for.
|
||||
// epd_width()/epd_height() are the panel's native, unrotated dimensions; epd_rotated_display_
|
||||
// width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT. epd_draw_rotated_image()
|
||||
// clamps its input rect against the rotated dims and epd_draw_pixel() applies the rotation
|
||||
// transform on top of that (see _rotate() in epdiy.c), so both LVGL's canvas size and
|
||||
// draw_bitmap()'s rect must be in rotated-space, not native-space.
|
||||
static uint16_t papers3_display_get_resolution_x(Device*) {
|
||||
return static_cast<uint16_t>(epd_rotated_display_width());
|
||||
}
|
||||
@@ -232,6 +353,11 @@ static error_t start(Device* device) {
|
||||
return ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
internal->panel_pixel_count = static_cast<uint32_t>(epd_rotated_display_width()) * static_cast<uint32_t>(epd_rotated_display_height());
|
||||
internal->partial_count_since_quality = 0;
|
||||
internal->last_quality_refresh_tick = get_ticks();
|
||||
internal->quality_hold_until_tick = 0;
|
||||
|
||||
device_set_driver_data(device, internal);
|
||||
|
||||
LOG_I(TAG, "EPDiy initialized (%dx%d native, %dx%d rotated)", epd_width(), epd_height(), epd_rotated_display_width(), epd_rotated_display_height());
|
||||
|
||||
@@ -10,7 +10,7 @@ extern "C" {
|
||||
|
||||
struct Papers3DisplayConfig {
|
||||
int temperature_celsius;
|
||||
enum EpdDrawMode draw_mode;
|
||||
enum EpdDrawMode quality_draw_mode;
|
||||
enum EpdRotation rotation;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,4 +21,13 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
||||
|
||||
target_link_libraries(simulator PRIVATE ${SDL2_LIBRARIES})
|
||||
|
||||
if (APPLE)
|
||||
target_sources(simulator PRIVATE Source/drivers/sdl_audio_permission.mm)
|
||||
target_link_libraries(simulator PRIVATE "-framework AVFoundation" "-framework Foundation")
|
||||
# The developer executable can also request recording outside an .app bundle.
|
||||
target_link_options(simulator INTERFACE
|
||||
"LINKER:-sectcreate,__TEXT,__info_plist,${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
|
||||
set_property(TARGET Tactility APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
|
||||
endif ()
|
||||
|
||||
endif()
|
||||
|
||||
@@ -10,13 +10,13 @@ constexpr auto* TAG = "FreeRTOS";
|
||||
|
||||
namespace simulator {
|
||||
|
||||
MainFunction mainFunction = nullptr;
|
||||
static MainFunction mainFunction = nullptr;
|
||||
|
||||
void setMain(MainFunction newMainFunction) {
|
||||
mainFunction = newMainFunction;
|
||||
}
|
||||
|
||||
static void freertosMainTask(void* parameter) {
|
||||
static void freertosMainTask(void*) {
|
||||
LOG_I(TAG, "starting app_main()");
|
||||
assert(simulator::mainFunction);
|
||||
mainFunction();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "Main.h"
|
||||
#include "drivers/sdl_bridge.h"
|
||||
|
||||
#include <csignal>
|
||||
#include <pthread.h>
|
||||
#include <thread>
|
||||
|
||||
namespace simulator {
|
||||
/** Set the function pointer of the real app_main() */
|
||||
@@ -14,8 +19,23 @@ void app_main(); // ESP-IDF's main function, implemented in the application
|
||||
}
|
||||
|
||||
int main() {
|
||||
// Actual main function that passes on app_main() (to be executed in a FreeRTOS task) and bootstraps FreeRTOS
|
||||
// The FreeRTOS POSIX port arms a process-wide SIGALRM timer for its tick and expects every one
|
||||
// of its task pthreads to have all signals but SIGINT blocked.
|
||||
// (see prvSetupSignalsAndSchedulerPolicy() in FreeRTOS-Kernel's Posix port.c)
|
||||
// A signal-generated SIGALRM can land on any thread in the process that doesn't block it.
|
||||
// This thread stays a plain OS thread (running the SDL loop below, never a FreeRTOS task),
|
||||
// so without this it's eligible to catch a tick SIGALRM and freeze inside the scheduler's handler.
|
||||
// Block the same set here, before anything else, so it never can.
|
||||
sigset_t all_signals_except_sigint;
|
||||
sigfillset(&all_signals_except_sigint);
|
||||
sigdelset(&all_signals_except_sigint, SIGINT);
|
||||
pthread_sigmask(SIG_SETMASK, &all_signals_except_sigint, nullptr);
|
||||
|
||||
// FreeRTOS and app_main() run on a separate thread: macOS requires SDL/Cocoa window creation,
|
||||
// event pumping and rendering to happen on the real OS main thread, which sdl_bridge_run_main_loop()
|
||||
// below takes over. freertosMain() never returns, so this thread is detached rather than joined.
|
||||
simulator::setMain(app_main);
|
||||
simulator::freertosMain();
|
||||
std::thread(simulator::freertosMain).detach();
|
||||
sdl_bridge_run_main_loop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "sdl_audio.h"
|
||||
#include "sdl_audio_buffer.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/freertos/task.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/module.h>
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto* TAG = "SdlAudio";
|
||||
constexpr uint32_t SAMPLE_RATE = 48000;
|
||||
|
||||
struct AudioData {
|
||||
SdlAudioBuffer buffer;
|
||||
SemaphoreHandle_t mutex = nullptr; // task-side only; never used by the SDL callback
|
||||
SDL_AudioDeviceID id = 0;
|
||||
AudioCodecDirection direction;
|
||||
std::atomic<float> volume { 100.0f };
|
||||
std::atomic<bool> muted { false };
|
||||
std::atomic<uint32_t> callbacks { 0 };
|
||||
};
|
||||
|
||||
AudioData* get_data(Device* device) {
|
||||
return static_cast<AudioData*>(device_get_driver_data(device));
|
||||
}
|
||||
|
||||
class Lock {
|
||||
SemaphoreHandle_t mutex;
|
||||
public:
|
||||
explicit Lock(AudioData* data) : mutex(data->mutex) { xSemaphoreTake(mutex, portMAX_DELAY); }
|
||||
~Lock() { xSemaphoreGive(mutex); }
|
||||
};
|
||||
|
||||
const char* device_name(const AudioData* data) {
|
||||
const char* name = std::getenv(data->direction == AUDIO_CODEC_DIR_INPUT ? "SIM_AUDIO_INPUT" : "SIM_AUDIO_OUTPUT");
|
||||
return name != nullptr && name[0] != '\0' ? name : nullptr;
|
||||
}
|
||||
|
||||
bool available(const AudioData* data) {
|
||||
const char* name = device_name(data);
|
||||
if (name != nullptr && std::strcmp(name, "none") == 0) return false;
|
||||
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
|
||||
const int count = SDL_GetNumAudioDevices(capture);
|
||||
if (name == nullptr) return count > 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char* candidate = SDL_GetAudioDeviceName(i, capture);
|
||||
if (candidate != nullptr && std::strcmp(candidate, name) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void apply_volume(AudioData* data, void* bytes, size_t size) {
|
||||
const float gain = data->muted.load() ? 0.0f : data->volume.load() / 100.0f;
|
||||
auto* output = static_cast<uint8_t*>(bytes);
|
||||
for (size_t i = 0; i < size; i += sizeof(int16_t)) {
|
||||
int16_t sample;
|
||||
std::memcpy(&sample, output + i, sizeof(sample));
|
||||
sample = static_cast<int16_t>(sample * gain);
|
||||
std::memcpy(output + i, &sample, sizeof(sample));
|
||||
}
|
||||
}
|
||||
|
||||
void audio_callback(void* context, Uint8* stream, int length) {
|
||||
auto* data = static_cast<AudioData*>(context);
|
||||
const size_t count = static_cast<size_t>(length) / sizeof(int16_t);
|
||||
if (data->direction == AUDIO_CODEC_DIR_INPUT) {
|
||||
// Drop incoming frames when the bounded capture buffer is full. Muted capture
|
||||
// must not leave real microphone samples queued for a later unmute.
|
||||
if (data->muted.load()) std::memset(stream, 0, length);
|
||||
data->buffer.write(stream, count);
|
||||
} else {
|
||||
const size_t copied = data->buffer.read(stream, count) * sizeof(int16_t);
|
||||
std::memset(stream + copied, 0, length - copied); // silence on underrun
|
||||
apply_volume(data, stream, copied);
|
||||
}
|
||||
data->callbacks.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
error_t start(Device* device) {
|
||||
const auto* config = static_cast<const SdlAudioConfig*>(device->config);
|
||||
if (config == nullptr || (config->direction != AUDIO_CODEC_DIR_INPUT && config->direction != AUDIO_CODEC_DIR_OUTPUT)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
|
||||
LOG_E(TAG, "Cannot initialize audio: %s", SDL_GetError());
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
auto* data = new (std::nothrow) AudioData;
|
||||
if (data == nullptr) {
|
||||
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
return ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
data->direction = config->direction;
|
||||
data->mutex = xSemaphoreCreateMutex();
|
||||
if (data->mutex == nullptr) {
|
||||
delete data;
|
||||
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
return ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
device_set_driver_data(device, data);
|
||||
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
|
||||
for (int i = 0; i < SDL_GetNumAudioDevices(capture); ++i) {
|
||||
LOG_I(TAG, "%s device: %s", capture ? "Input" : "Output", SDL_GetAudioDeviceName(i, capture));
|
||||
}
|
||||
LOG_I(TAG, "%s: %s (%s)", device->name, device_name(data) != nullptr ? device_name(data) : "system default",
|
||||
available(data) ? "available" : "unavailable");
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t open(Device* device, const AudioCodecStreamConfig* config) {
|
||||
auto* data = get_data(device);
|
||||
if (config == nullptr) return ERROR_INVALID_ARGUMENT;
|
||||
if (config->direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||
// The shared stream module performs rate/channel conversion on S16 PCM.
|
||||
if (config->bits_per_sample != 16) return ERROR_NOT_SUPPORTED;
|
||||
const uint8_t channels = data->direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
|
||||
if (config->sample_rate != SAMPLE_RATE || config->channels != channels) return ERROR_INVALID_ARGUMENT;
|
||||
Lock lock(data);
|
||||
if (data->id != 0) return ERROR_INVALID_STATE;
|
||||
if (!available(data)) {
|
||||
LOG_W(TAG, "No selected %s device available", data->direction == AUDIO_CODEC_DIR_INPUT ? "input" : "output");
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
#ifdef __APPLE__
|
||||
// Only ask when recording is requested, and only with real macOS audio (dummy
|
||||
// and disk backends are also useful for automated tests).
|
||||
if (data->direction == AUDIO_CODEC_DIR_INPUT && std::strcmp(SDL_GetCurrentAudioDriver(), "coreaudio") == 0) {
|
||||
error_t permission;
|
||||
while ((permission = sdl_audio_microphone_permission()) == ERROR_RESOURCE_BUSY) vTaskDelay(1);
|
||||
if (permission != ERROR_NONE) {
|
||||
LOG_W(TAG, "Microphone permission denied; enable access in macOS Privacy & Security > Microphone");
|
||||
return permission;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
SDL_AudioSpec wanted {};
|
||||
wanted.freq = SAMPLE_RATE;
|
||||
wanted.format = AUDIO_S16SYS;
|
||||
wanted.channels = channels;
|
||||
wanted.samples = 512;
|
||||
wanted.callback = audio_callback;
|
||||
wanted.userdata = data;
|
||||
data->buffer.reset();
|
||||
data->callbacks.store(0);
|
||||
// No allowed changes: SDL converts between our fixed PCM format and the host
|
||||
// device's format when necessary. No hardware-specific format leaks to apps.
|
||||
data->id = SDL_OpenAudioDevice(device_name(data), data->direction == AUDIO_CODEC_DIR_INPUT, &wanted, nullptr, 0);
|
||||
if (data->id == 0) {
|
||||
LOG_E(TAG, "Cannot open %s: %s", device->name, SDL_GetError());
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
SDL_PauseAudioDevice(data->id, 0);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t close(Device* device) {
|
||||
auto* data = get_data(device);
|
||||
Lock lock(data);
|
||||
if (data->id != 0) {
|
||||
// Preserve the end of short sounds. Drain is bounded even if the device has
|
||||
// disappeared; microphone close never waits for the capture buffer to empty.
|
||||
const TickType_t start = xTaskGetTickCount();
|
||||
while (data->direction == AUDIO_CODEC_DIR_OUTPUT && !data->buffer.empty()
|
||||
&& xTaskGetTickCount() - start < pdMS_TO_TICKS(250)
|
||||
&& SDL_GetAudioDeviceStatus(data->id) == SDL_AUDIO_PLAYING) {
|
||||
vTaskDelay(1);
|
||||
}
|
||||
SDL_CloseAudioDevice(data->id);
|
||||
data->id = 0;
|
||||
data->buffer.reset();
|
||||
}
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t stop(Device* device) {
|
||||
auto* data = get_data(device);
|
||||
close(device);
|
||||
vSemaphoreDelete(data->mutex);
|
||||
delete data;
|
||||
device_set_driver_data(device, nullptr);
|
||||
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t transfer(Device* device, void* destination, const void* source, size_t size, size_t* transferred, TickType_t timeout, bool capture) {
|
||||
if (transferred != nullptr) *transferred = 0;
|
||||
auto* data = get_data(device);
|
||||
if ((data->direction == AUDIO_CODEC_DIR_INPUT) != capture) return ERROR_NOT_SUPPORTED;
|
||||
const size_t frame_size = sizeof(int16_t) * (capture ? 1 : 2);
|
||||
if (size % frame_size != 0 || (size != 0 && (capture ? destination == nullptr : source == nullptr))) return ERROR_INVALID_ARGUMENT;
|
||||
Lock lock(data);
|
||||
if (data->id == 0) return ERROR_INVALID_STATE;
|
||||
const TickType_t start = xTaskGetTickCount();
|
||||
TickType_t last_callback = start;
|
||||
uint32_t callbacks = data->callbacks.load();
|
||||
size_t done = 0;
|
||||
error_t result = ERROR_NONE;
|
||||
while (done < size) {
|
||||
if (SDL_GetAudioDeviceStatus(data->id) != SDL_AUDIO_PLAYING) {
|
||||
result = ERROR_RESOURCE;
|
||||
break;
|
||||
}
|
||||
const size_t count = (size - done) / sizeof(int16_t);
|
||||
const size_t copied = capture
|
||||
? data->buffer.read(static_cast<uint8_t*>(destination) + done, count)
|
||||
: data->buffer.write(static_cast<const uint8_t*>(source) + done, count);
|
||||
done += copied * sizeof(int16_t);
|
||||
if (done == size) break;
|
||||
const TickType_t now = xTaskGetTickCount();
|
||||
if (timeout != portMAX_DELAY && now - start >= timeout) {
|
||||
result = ERROR_TIMEOUT;
|
||||
break;
|
||||
}
|
||||
const uint32_t current_callbacks = data->callbacks.load();
|
||||
if (current_callbacks != callbacks) {
|
||||
callbacks = current_callbacks;
|
||||
last_callback = now;
|
||||
} else if (now - last_callback >= pdMS_TO_TICKS(2000)) {
|
||||
// A stopped backend must not strand an infinite-timeout caller or a
|
||||
// concurrent Settings disable waiting for that caller to finish.
|
||||
result = ERROR_RESOURCE;
|
||||
break;
|
||||
}
|
||||
vTaskDelay(1);
|
||||
}
|
||||
if (capture) apply_volume(data, destination, done);
|
||||
if (transferred != nullptr) *transferred = done;
|
||||
return result;
|
||||
}
|
||||
|
||||
error_t read(Device* device, void* destination, size_t size, size_t* count, TickType_t timeout) {
|
||||
return transfer(device, destination, nullptr, size, count, timeout, true);
|
||||
}
|
||||
|
||||
error_t write(Device* device, const void* source, size_t size, size_t* count, TickType_t timeout) {
|
||||
return transfer(device, nullptr, source, size, count, timeout, false);
|
||||
}
|
||||
|
||||
error_t set_volume(Device* device, AudioCodecDirection direction, float volume) {
|
||||
auto* data = get_data(device);
|
||||
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||
if (!std::isfinite(volume) || volume < 0.0f || volume > 100.0f) return ERROR_INVALID_ARGUMENT;
|
||||
data->volume.store(volume);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_volume(Device* device, AudioCodecDirection direction, float* volume) {
|
||||
auto* data = get_data(device);
|
||||
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||
*volume = data->volume.load();
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t set_mute(Device* device, AudioCodecDirection direction, bool muted) {
|
||||
auto* data = get_data(device);
|
||||
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||
data->muted.store(muted);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_mute(Device* device, AudioCodecDirection direction, bool* muted) {
|
||||
auto* data = get_data(device);
|
||||
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||
*muted = data->muted.load();
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_rate(Device* device, AudioCodecDirection direction, uint32_t* rate) {
|
||||
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
|
||||
*rate = SAMPLE_RATE;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_channels(Device* device, AudioCodecDirection direction, uint8_t* channels) {
|
||||
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
|
||||
*channels = direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_capabilities(Device* device, AudioCodecDirection* direction) {
|
||||
auto* data = get_data(device);
|
||||
if (!available(data)) return ERROR_NOT_SUPPORTED;
|
||||
*direction = data->direction;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
const AudioCodecApi api = {
|
||||
.open = open,
|
||||
.close = close,
|
||||
.read = read,
|
||||
.write = write,
|
||||
.set_volume = set_volume,
|
||||
.get_volume = get_volume,
|
||||
.set_mute = set_mute,
|
||||
.get_mute = get_mute,
|
||||
.get_native_sample_rate = get_rate,
|
||||
.get_native_channels = get_channels,
|
||||
.get_capabilities = get_capabilities,
|
||||
.get_input_gain_multiplier = nullptr,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" Module simulator_module;
|
||||
|
||||
Driver sdl_audio_driver = {
|
||||
.name = "sdl-audio",
|
||||
.compatible = (const char*[]) { "tactility,sdl-audio", nullptr },
|
||||
.start_device = start,
|
||||
.stop_device = stop,
|
||||
.api = &api,
|
||||
.device_type = &AUDIO_CODEC_TYPE,
|
||||
.owner = &simulator_module,
|
||||
.internal = nullptr,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/drivers/audio_codec.h>
|
||||
|
||||
struct SdlAudioConfig {
|
||||
AudioCodecDirection direction;
|
||||
};
|
||||
|
||||
#ifdef __APPLE__
|
||||
// Nonblocking: ERROR_RESOURCE_BUSY means that the user has not answered the prompt yet.
|
||||
error_t sdl_audio_microphone_permission();
|
||||
#endif
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
// Single producer / single consumer. The SDL callback is a native thread: it must never
|
||||
// allocate, block, log, or call FreeRTOS. Unsigned counters also work across wraparound.
|
||||
class SdlAudioBuffer {
|
||||
static constexpr uint32_t CAPACITY = 16384; // samples; ~171 ms of 48 kHz stereo
|
||||
std::array<int16_t, CAPACITY> samples {};
|
||||
std::atomic<uint32_t> read_position { 0 };
|
||||
std::atomic<uint32_t> write_position { 0 };
|
||||
|
||||
public:
|
||||
size_t write(const void* source, size_t count) {
|
||||
const uint32_t write = write_position.load(std::memory_order_relaxed);
|
||||
const uint32_t read = read_position.load(std::memory_order_acquire);
|
||||
count = std::min(count, static_cast<size_t>(CAPACITY - (write - read)));
|
||||
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - write % CAPACITY));
|
||||
const auto* bytes = static_cast<const uint8_t*>(source);
|
||||
std::memcpy(samples.data() + write % CAPACITY, bytes, first * sizeof(int16_t));
|
||||
std::memcpy(samples.data(), bytes + first * sizeof(int16_t), (count - first) * sizeof(int16_t));
|
||||
write_position.store(write + count, std::memory_order_release);
|
||||
return count;
|
||||
}
|
||||
|
||||
size_t read(void* destination, size_t count) {
|
||||
const uint32_t read = read_position.load(std::memory_order_relaxed);
|
||||
const uint32_t write = write_position.load(std::memory_order_acquire);
|
||||
count = std::min(count, static_cast<size_t>(write - read));
|
||||
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - read % CAPACITY));
|
||||
auto* bytes = static_cast<uint8_t*>(destination);
|
||||
std::memcpy(bytes, samples.data() + read % CAPACITY, first * sizeof(int16_t));
|
||||
std::memcpy(bytes + first * sizeof(int16_t), samples.data(), (count - first) * sizeof(int16_t));
|
||||
read_position.store(read + count, std::memory_order_release);
|
||||
return count;
|
||||
}
|
||||
|
||||
bool empty() const {
|
||||
return read_position.load(std::memory_order_acquire) == write_position.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
// Only while the SDL device is closed and no caller is doing I/O.
|
||||
void reset() {
|
||||
read_position.store(0);
|
||||
write_position.store(0);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "sdl_audio.h"
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#include <atomic>
|
||||
|
||||
error_t sdl_audio_microphone_permission() {
|
||||
@autoreleasepool {
|
||||
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]) {
|
||||
case AVAuthorizationStatusAuthorized:
|
||||
return ERROR_NONE;
|
||||
case AVAuthorizationStatusDenied:
|
||||
case AVAuthorizationStatusRestricted:
|
||||
return ERROR_NOT_ALLOWED;
|
||||
case AVAuthorizationStatusNotDetermined: {
|
||||
static std::atomic<bool> requested { false };
|
||||
if (!requested.exchange(true)) {
|
||||
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
|
||||
requested.store(false);
|
||||
}];
|
||||
}
|
||||
return ERROR_RESOURCE_BUSY;
|
||||
}
|
||||
}
|
||||
return ERROR_NOT_ALLOWED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "sdl_bridge.h"
|
||||
#include "sdl_input.h"
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
|
||||
namespace {
|
||||
|
||||
struct PresentJob {
|
||||
Device* device;
|
||||
void* internal;
|
||||
int32_t x_start;
|
||||
int32_t y_start;
|
||||
int32_t x_end;
|
||||
int32_t y_end;
|
||||
const void* color_data;
|
||||
error_t result;
|
||||
};
|
||||
|
||||
std::mutex job_mutex;
|
||||
std::condition_variable job_ready_cv;
|
||||
std::condition_variable job_done_cv;
|
||||
bool job_pending = false;
|
||||
bool job_done = false;
|
||||
PresentJob pending_job;
|
||||
|
||||
}
|
||||
|
||||
error_t sdl_bridge_present(Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
|
||||
std::unique_lock<std::mutex> lock(job_mutex);
|
||||
|
||||
pending_job = { device, internal, x_start, y_start, x_end, y_end, color_data, ERROR_NONE };
|
||||
job_pending = true;
|
||||
job_done = false;
|
||||
job_ready_cv.notify_one();
|
||||
|
||||
job_done_cv.wait(lock, [] { return job_done; });
|
||||
return pending_job.result;
|
||||
}
|
||||
|
||||
void sdl_bridge_run_main_loop() {
|
||||
while (true) {
|
||||
sdl_input_pump();
|
||||
|
||||
std::unique_lock<std::mutex> lock(job_mutex);
|
||||
if (job_ready_cv.wait_for(lock, std::chrono::milliseconds(1), [] { return job_pending; })) {
|
||||
PresentJob job = pending_job;
|
||||
lock.unlock();
|
||||
|
||||
job.result = sdl_display_execute_draw_bitmap(job.device, job.internal, job.x_start, job.y_start, job.x_end, job.y_end, job.color_data);
|
||||
|
||||
lock.lock();
|
||||
pending_job.result = job.result;
|
||||
job_pending = false;
|
||||
job_done = true;
|
||||
lock.unlock();
|
||||
job_done_cv.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct Device;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Runs forever, pumping SDL input and executing display present jobs submitted via
|
||||
* sdl_bridge_present(). Must be called exactly once, from the real OS main thread: macOS requires
|
||||
* SDL/Cocoa window creation, event pumping and rendering to happen there, but FreeRTOS tasks
|
||||
* (including the lvgl task that owns display flush and indev polling) run on separate pthreads
|
||||
* spawned by the FreeRTOS POSIX port, not on that thread.
|
||||
*/
|
||||
void sdl_bridge_run_main_loop(void);
|
||||
|
||||
/**
|
||||
* @brief Hands a display flush off to the main thread and blocks until it has finished copying
|
||||
* the pixel data out (see sdl_display_execute_draw_bitmap() in sdl_display.cpp). Called from the
|
||||
* lvgl task. Must block: the caller's pixel buffer is single-buffered and gets reused as soon as
|
||||
* this returns.
|
||||
*/
|
||||
error_t sdl_bridge_present(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
|
||||
|
||||
/**
|
||||
* @brief Implemented in sdl_display.cpp: the actual SDL work behind a display flush (lazy window
|
||||
* init on first call, SDL_UpdateTexture, present). Only ever called from sdl_bridge_run_main_loop()
|
||||
* on the main thread.
|
||||
*/
|
||||
error_t sdl_display_execute_draw_bitmap(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "sdl_display.h"
|
||||
#include "sdl_bridge.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
@@ -141,8 +142,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
|
||||
return true;
|
||||
}
|
||||
|
||||
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
|
||||
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
|
||||
// Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for
|
||||
// SDL/Cocoa window creation and rendering on macOS.
|
||||
error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
|
||||
auto* internal = static_cast<SdlDisplayInternal*>(internal_ptr);
|
||||
|
||||
if (internal->init_failed) {
|
||||
return ERROR_RESOURCE;
|
||||
@@ -166,6 +169,16 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
|
||||
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
|
||||
|
||||
if (internal->init_failed) {
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
|
||||
return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data);
|
||||
}
|
||||
|
||||
static enum DisplayColorFormat sdl_display_get_color_format(Device*) {
|
||||
return DISPLAY_COLOR_FORMAT_RGB565;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,48 @@
|
||||
#include <SDL2/SDL.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t KEY_QUEUE_CAPACITY = 32;
|
||||
|
||||
// Written by sdl_input_pump() on the real main thread, read by sdl_input_get_pointer_state()/
|
||||
// sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task.
|
||||
std::mutex state_mutex;
|
||||
|
||||
SdlPointerState pointer_state = { 0, 0, false };
|
||||
|
||||
} // namespace
|
||||
|
||||
// Web touch-injection override (headless sim viewer). Guarded by tick count so
|
||||
// a press auto-releases even if the viewer never sends the release event.
|
||||
// File-scope (not anonymous namespace): the extern "C" setters below must be
|
||||
// visible to the linker for WebServerService's touch endpoint.
|
||||
bool touch_override_active = false;
|
||||
SdlPointerState touch_override = { 0, 0, false };
|
||||
uint32_t touch_override_until_tick = 0;
|
||||
#define SIM_TOUCH_HOLD_MS 1500
|
||||
|
||||
extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed) {
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
touch_override.x = x;
|
||||
touch_override.y = y;
|
||||
touch_override.pressed = pressed;
|
||||
touch_override_active = pressed;
|
||||
if (pressed) {
|
||||
touch_override_until_tick = SDL_GetTicks() + SIM_TOUCH_HOLD_MS;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" void sdl_input_clear_touch_override(void) {
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
touch_override_active = false;
|
||||
touch_override.pressed = false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
uint32_t key_queue[KEY_QUEUE_CAPACITY];
|
||||
size_t key_queue_head = 0;
|
||||
size_t key_queue_count = 0;
|
||||
@@ -70,65 +105,92 @@ uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void sdl_input_pump() {
|
||||
if (!text_input_started) {
|
||||
SDL_StartTextInput();
|
||||
text_input_started = true;
|
||||
// exit() must run with state_mutex unlocked: it never returns, so a lock_guard held across it
|
||||
// would never release the mutex, hanging any other thread that later calls into this file's
|
||||
// other functions (all of which lock state_mutex) while exit() tears the process down.
|
||||
bool quit_requested = false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
|
||||
if (!text_input_started) {
|
||||
SDL_StartTextInput();
|
||||
text_input_started = true;
|
||||
}
|
||||
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_MOUSEMOTION:
|
||||
set_pointer_position(event.motion.x, event.motion.y);
|
||||
break;
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
if (event.button.button == SDL_BUTTON_LEFT) {
|
||||
// event.button.x/y can be stale immediately after a window resize (an
|
||||
// SDL/X11 event-queue quirk - confirmed by comparing against a live
|
||||
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
|
||||
// OS for the current pointer position directly, sidestepping that entirely.
|
||||
int live_x, live_y;
|
||||
SDL_GetMouseState(&live_x, &live_y);
|
||||
set_pointer_position(live_x, live_y);
|
||||
pointer_state.pressed = true;
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (event.button.button == SDL_BUTTON_LEFT) {
|
||||
pointer_state.pressed = false;
|
||||
}
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
|
||||
break;
|
||||
case SDL_TEXTINPUT:
|
||||
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
|
||||
push_key(static_cast<uint8_t>(event.text.text[0]));
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
// Resizing doesn't change what LVGL last rendered, only how large it should
|
||||
// appear - re-present the existing frame at the new scale immediately, rather
|
||||
// than leaving stale-looking content on screen until the next LVGL-driven flush.
|
||||
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
sdl_display_present_now();
|
||||
}
|
||||
break;
|
||||
case SDL_QUIT:
|
||||
quit_requested = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_MOUSEMOTION:
|
||||
set_pointer_position(event.motion.x, event.motion.y);
|
||||
break;
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
if (event.button.button == SDL_BUTTON_LEFT) {
|
||||
// event.button.x/y can be stale immediately after a window resize (an
|
||||
// SDL/X11 event-queue quirk - confirmed by comparing against a live
|
||||
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
|
||||
// OS for the current pointer position directly, sidestepping that entirely.
|
||||
int live_x, live_y;
|
||||
SDL_GetMouseState(&live_x, &live_y);
|
||||
set_pointer_position(live_x, live_y);
|
||||
pointer_state.pressed = true;
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (event.button.button == SDL_BUTTON_LEFT) {
|
||||
pointer_state.pressed = false;
|
||||
}
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
|
||||
break;
|
||||
case SDL_TEXTINPUT:
|
||||
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
|
||||
push_key(static_cast<uint8_t>(event.text.text[0]));
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
// Resizing doesn't change what LVGL last rendered, only how large it should
|
||||
// appear - re-present the existing frame at the new scale immediately, rather
|
||||
// than leaving stale-looking content on screen until the next LVGL-driven flush.
|
||||
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
sdl_display_present_now();
|
||||
}
|
||||
break;
|
||||
case SDL_QUIT:
|
||||
exit(0);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (quit_requested) {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
void sdl_input_get_pointer_state(SdlPointerState* out_state) {
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (touch_override_active) {
|
||||
// Auto-release: viewer sends press only; LVGL needs press then release
|
||||
// to register a click. Hold long enough for several indev polls.
|
||||
if ((int32_t)(SDL_GetTicks() - touch_override_until_tick) >= 0) {
|
||||
touch_override_active = false;
|
||||
touch_override.pressed = false;
|
||||
} else {
|
||||
*out_state = touch_override;
|
||||
return;
|
||||
}
|
||||
}
|
||||
*out_state = pointer_state;
|
||||
}
|
||||
|
||||
bool sdl_input_pop_key(uint32_t* out_key) {
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (key_queue_count == 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -139,5 +201,6 @@ bool sdl_input_pop_key(uint32_t* out_key) {
|
||||
}
|
||||
|
||||
bool sdl_input_has_queued_key() {
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
return key_queue_count > 0;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ struct SdlPointerState {
|
||||
|
||||
/**
|
||||
* @brief Drains all pending SDL events exactly once, updating the pointer state and key queue
|
||||
* below. Safe to call from both the sdl-pointer and sdl-keyboard drivers' polling functions:
|
||||
* SDL_PollEvent() drains a single global queue, so whichever driver is polled first on a given
|
||||
* LVGL indev tick pumps events for both.
|
||||
* below. Must be called only from the real OS main thread (sdl_bridge_run_main_loop()): SDL
|
||||
* requires event pumping to happen there on macOS. The getters below are safe to call from a
|
||||
* different thread (the lvgl task, via sdl-pointer/sdl-keyboard's polling functions).
|
||||
*/
|
||||
void sdl_input_pump(void);
|
||||
|
||||
@@ -42,6 +42,16 @@ bool sdl_input_pop_key(uint32_t* out_key);
|
||||
*/
|
||||
bool sdl_input_has_queued_key(void);
|
||||
|
||||
/**
|
||||
* @brief Web-injected touch override (for headless sim viewer without SDL window).
|
||||
* When active, sdl_pointer_get_touched_points() reports this state instead of
|
||||
* the SDL mouse state. Coordinates are in LVGL logical pixels (e.g. 640x480).
|
||||
* Pass pressed=false to release. Auto-releases after SIM_TOUCH_HOLD_MS unless
|
||||
* refreshed (tap = press, wait, release handled by the web viewer).
|
||||
*/
|
||||
void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed);
|
||||
void sdl_input_clear_touch_override(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -16,8 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
|
||||
// region KeyboardApi
|
||||
|
||||
static error_t sdl_keyboard_read_key(Device*, KeyboardKeyData* data) {
|
||||
sdl_input_pump();
|
||||
|
||||
uint32_t key = 0;
|
||||
if (sdl_input_pop_key(&key)) {
|
||||
data->key = key;
|
||||
|
||||
@@ -16,7 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
|
||||
// region PointerApi
|
||||
|
||||
static error_t sdl_pointer_read_data(Device*, TickType_t) {
|
||||
sdl_input_pump();
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "drivers/sdl_display.h"
|
||||
#include "drivers/sdl_audio.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/device_listener.h>
|
||||
@@ -16,11 +17,13 @@ extern "C" {
|
||||
extern Driver sdl_display_driver;
|
||||
extern Driver sdl_pointer_driver;
|
||||
extern Driver sdl_keyboard_driver;
|
||||
extern Driver sdl_audio_driver;
|
||||
|
||||
static Driver* const simulator_drivers[] = {
|
||||
&sdl_display_driver,
|
||||
&sdl_pointer_driver,
|
||||
&sdl_keyboard_driver,
|
||||
&sdl_audio_driver,
|
||||
nullptr
|
||||
};
|
||||
|
||||
@@ -29,10 +32,35 @@ static Driver* const simulator_drivers[] = {
|
||||
// These devices have no real bus to attach to (SDL has no notion of one), but every non-root
|
||||
// device is still expected to have a parent (see Device::parent) - they're parented to root once
|
||||
// it's available below.
|
||||
static const SdlDisplayConfig sdl_display_config = { 640, 480 };
|
||||
// Display resolution is overridable at runtime via SIM_DISPLAY_W/H env vars
|
||||
// so the sim can match real hardware (e.g. ES3C35P 3.5" = 320x480 portrait,
|
||||
// ES3C28P 2.8" = 320x240 landscape). Defaults to 640x480.
|
||||
static uint16_t sim_display_width(void) {
|
||||
const char* w = getenv("SIM_DISPLAY_W");
|
||||
if (w != nullptr) {
|
||||
long v = atol(w);
|
||||
if (v >= 120 && v <= 2048) return (uint16_t)v;
|
||||
}
|
||||
return 480;
|
||||
}
|
||||
|
||||
static uint16_t sim_display_height(void) {
|
||||
const char* h = getenv("SIM_DISPLAY_H");
|
||||
if (h != nullptr) {
|
||||
long v = atol(h);
|
||||
if (v >= 120 && v <= 2048) return (uint16_t)v;
|
||||
}
|
||||
return 320;
|
||||
}
|
||||
|
||||
static SdlDisplayConfig sdl_display_config = { 480, 320 };
|
||||
static Device sdl_display_device {};
|
||||
static Device sdl_pointer_device {};
|
||||
static Device sdl_keyboard_device {};
|
||||
static Device sdl_speaker_device {};
|
||||
static Device sdl_microphone_device {};
|
||||
static const SdlAudioConfig sdl_speaker_config { AUDIO_CODEC_DIR_OUTPUT };
|
||||
static const SdlAudioConfig sdl_microphone_config { AUDIO_CODEC_DIR_INPUT };
|
||||
|
||||
static bool construct_add_start(Device* device, Device* parent, const char* name, const void* config, const char* compatible) {
|
||||
device->address = 0;
|
||||
@@ -81,9 +109,15 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) {
|
||||
return;
|
||||
}
|
||||
|
||||
sdl_display_config.horizontal_resolution = sim_display_width();
|
||||
sdl_display_config.vertical_resolution = sim_display_height();
|
||||
LOG_I(TAG, "Sim display %dx%d (SIM_DISPLAY_W/H override, default 480x320 landscape)",
|
||||
sdl_display_config.horizontal_resolution, sdl_display_config.vertical_resolution);
|
||||
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
|
||||
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
|
||||
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
|
||||
construct_add_start(&sdl_speaker_device, device, "speaker0", &sdl_speaker_config, "tactility,sdl-audio");
|
||||
construct_add_start(&sdl_microphone_device, device, "microphone0", &sdl_microphone_config, "tactility,sdl-audio");
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
dependencies:
|
||||
- Platforms/platform-posix
|
||||
- Drivers/audio-stream-module
|
||||
dts: simulator.dts
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.tactilityproject.simulator</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Tactility</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Tactility uses the microphone when a simulator app records audio.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
add_executable(SimulatorAudioTests EXCLUDE_FROM_ALL
|
||||
audio.cpp
|
||||
../Source/drivers/sdl_audio.cpp
|
||||
)
|
||||
target_include_directories(SimulatorAudioTests PRIVATE ${DOCTESTINC} ../Source/drivers)
|
||||
target_link_libraries(SimulatorAudioTests PRIVATE TactilityKernel platform-posix audio-stream-module SDL2-static)
|
||||
if (APPLE)
|
||||
target_sources(SimulatorAudioTests PRIVATE ../Source/drivers/sdl_audio_permission.mm)
|
||||
target_link_libraries(SimulatorAudioTests PRIVATE "-framework AVFoundation" "-framework Foundation")
|
||||
endif ()
|
||||
add_test(NAME SimulatorAudioTests COMMAND SimulatorAudioTests)
|
||||
set_tests_properties(SimulatorAudioTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=dummy;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
|
||||
add_test(NAME SimulatorAudioPcmTests COMMAND SimulatorAudioTests --no-skip "--test-case=disk PCM*")
|
||||
set_tests_properties(SimulatorAudioPcmTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=disk;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
|
||||
@@ -0,0 +1,301 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#define DOCTEST_CONFIG_IMPLEMENT
|
||||
#include "doctest.h"
|
||||
#include "sdl_audio.h"
|
||||
#include "sdl_audio_buffer.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/freertos/task.h>
|
||||
#include <tactility/kernel_init.h>
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
extern Driver sdl_audio_driver;
|
||||
extern "C" {
|
||||
extern Module platform_posix_module;
|
||||
extern Module audio_stream_module;
|
||||
extern Device audio_stream_device;
|
||||
static Driver* const drivers[] = { &sdl_audio_driver, nullptr };
|
||||
Module simulator_module = { .name = "simulator-audio-test", .drivers = drivers };
|
||||
}
|
||||
|
||||
static const SdlAudioConfig output_config { AUDIO_CODEC_DIR_OUTPUT };
|
||||
static const SdlAudioConfig input_config { AUDIO_CODEC_DIR_INPUT };
|
||||
static Device speaker { .name = "speaker-test", .config = &output_config };
|
||||
static Device microphone { .name = "microphone-test", .config = &input_config };
|
||||
|
||||
struct Stream {
|
||||
AudioStreamHandle handle = nullptr;
|
||||
~Stream() { if (handle != nullptr) audio_stream_close(handle); }
|
||||
};
|
||||
|
||||
TEST_CASE("bounded PCM buffer preserves data across wrap and overflow") {
|
||||
SdlAudioBuffer buffer;
|
||||
std::vector<int16_t> input(20000);
|
||||
for (size_t i = 0; i < input.size(); ++i) input[i] = static_cast<int16_t>(i);
|
||||
std::vector<int16_t> output(20000, -1);
|
||||
CHECK(buffer.read(output.data(), output.size()) == 0);
|
||||
REQUIRE(buffer.write(input.data(), input.size()) == 16384);
|
||||
CHECK(buffer.write(input.data(), 2) == 0);
|
||||
REQUIRE(buffer.read(output.data(), 10000) == 10000);
|
||||
CHECK(std::equal(output.begin(), output.begin() + 10000, input.begin()));
|
||||
REQUIRE(buffer.write(input.data(), 10000) == 10000);
|
||||
REQUIRE(buffer.read(output.data(), output.size()) == 16384);
|
||||
CHECK(std::equal(output.begin(), output.begin() + 6384, input.begin() + 10000));
|
||||
CHECK(std::equal(output.begin() + 6384, output.begin() + 16384, input.begin()));
|
||||
CHECK(buffer.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("simulator streams support independent full duplex and common PCM rates") {
|
||||
for (uint32_t rate : { 16000u, 44100u, 48000u }) {
|
||||
CAPTURE(rate);
|
||||
const AudioStreamConfig config { rate, 16, 1 };
|
||||
Stream input;
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
AudioStreamHandle duplicate = nullptr;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &duplicate) == ERROR_INVALID_STATE);
|
||||
std::vector<int16_t> samples(rate / 20, 1234);
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
// Closing the speaker must not stop the microphone.
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("simulator audio controls persist across opens and disabling closes output") {
|
||||
auto* device = &audio_stream_device;
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25.0f) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
float volume = 0;
|
||||
bool muted = false;
|
||||
REQUIRE(audio_codec_get_volume(&speaker, AUDIO_CODEC_DIR_OUTPUT, &volume) == ERROR_NONE);
|
||||
REQUIRE(audio_codec_get_mute(&speaker, AUDIO_CODEC_DIR_OUTPUT, &muted) == ERROR_NONE);
|
||||
CHECK(volume == 25.0f);
|
||||
CHECK(muted);
|
||||
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
|
||||
output.handle = nullptr; // set_enabled closes and owns destruction of the handle
|
||||
CHECK(audio_stream_open_output(device, &config, &output.handle) == ERROR_NOT_ALLOWED);
|
||||
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("bounded playback reports partial progress on a nonblocking timeout") {
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(48000 * 2, 0);
|
||||
size_t count = 999;
|
||||
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 4 == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("converted streams report partial progress on timeout") {
|
||||
const AudioStreamConfig config { 16000, 16, 1 };
|
||||
Stream output;
|
||||
Stream input;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(16000, 0);
|
||||
size_t count = 999;
|
||||
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 2 == 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(30)); // allow dummy capture to produce some samples
|
||||
count = 999;
|
||||
CHECK(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 2 == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("missing selected microphone does not prevent speaker playback") {
|
||||
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "tactility-nonexistent-microphone", 1) == 0);
|
||||
AudioCodecDirection capability;
|
||||
CHECK(audio_codec_get_capabilities(µphone, &capability) == ERROR_NOT_SUPPORTED);
|
||||
const AudioCodecStreamConfig config { 48000, 16, 1, AUDIO_CODEC_DIR_INPUT };
|
||||
CHECK(audio_codec_open(µphone, &config) == ERROR_NOT_SUPPORTED);
|
||||
CHECK(audio_codec_get_capabilities(&speaker, &capability) == ERROR_NONE);
|
||||
CHECK(capability == AUDIO_CODEC_DIR_OUTPUT);
|
||||
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "", 1) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("unsupported sample widths fail without breaking a later open") {
|
||||
AudioStreamConfig config { 16000, 24, 1 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) != ERROR_NONE);
|
||||
CHECK(output.handle == nullptr);
|
||||
config.bits_per_sample = 16;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("zero sample rate is rejected before conversion") {
|
||||
const AudioStreamConfig config { 0, 16, 1 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
TEST_CASE("disabling while a codec is opening cancels the pending stream") {
|
||||
const auto* original_api = static_cast<const AudioCodecApi*>(sdl_audio_driver.api);
|
||||
static const AudioCodecApi* wrapped_api;
|
||||
wrapped_api = original_api;
|
||||
AudioCodecApi delayed_api = *original_api;
|
||||
delayed_api.open = [](Device* device, const AudioCodecStreamConfig* config) {
|
||||
vTaskDelay(pdMS_TO_TICKS(50)); // models waiting for microphone permission
|
||||
return wrapped_api->open(device, config);
|
||||
};
|
||||
sdl_audio_driver.api = &delayed_api;
|
||||
struct RestoreApi {
|
||||
const AudioCodecApi* api;
|
||||
~RestoreApi() { sdl_audio_driver.api = api; }
|
||||
} restore { original_api };
|
||||
REQUIRE(xTaskCreate([](void*) {
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, false);
|
||||
// Re-enabling does not resurrect the cancelled attempt.
|
||||
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, true);
|
||||
vTaskDelete(nullptr);
|
||||
}, "disable-audio", 8192, nullptr, 1, nullptr) == pdPASS);
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NOT_ALLOWED);
|
||||
CHECK(output.handle == nullptr);
|
||||
sdl_audio_driver.api = original_api;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("hardware output smoke test" * doctest::skip()) {
|
||||
// Explicit opt-in only: SDL_AUDIODRIVER=coreaudio ... --no-skip --test-case='hardware output smoke test'
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_set_volume(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, 20) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(48000); // half a second, stereo, quiet 440 Hz tone
|
||||
for (size_t frame = 0; frame < samples.size() / 2; ++frame) {
|
||||
samples[frame * 2] = samples[frame * 2 + 1] = static_cast<int16_t>(3000 * std::sin(frame * 440.0 * 6.283185307 / 48000));
|
||||
}
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(2000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
}
|
||||
|
||||
TEST_CASE("disk PCM capture and playback apply gain and mute" * doctest::skip()) {
|
||||
REQUIRE(std::strcmp(SDL_GetCurrentAudioDriver(), "disk") == 0);
|
||||
auto* device = &audio_stream_device;
|
||||
const AudioStreamConfig config { 48000, 16, 1 };
|
||||
Stream input;
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_INPUT, 50) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_input(device, &config, &input.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(960, -1);
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
// Fixture contains 10000; microphone gain is 50%.
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 5000; }));
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
{
|
||||
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
|
||||
REQUIRE(file.good());
|
||||
size_t nonzero = 0;
|
||||
int16_t sample;
|
||||
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
|
||||
CHECK((sample == 0 || sample == 1250)); // 25% output volume
|
||||
if (sample != 0) nonzero++;
|
||||
}
|
||||
CHECK(nonzero == samples.size() * 2); // mono was duplicated to stereo
|
||||
}
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_INPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
std::fill(samples.begin(), samples.end(), 5000);
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
|
||||
REQUIRE(file.good());
|
||||
size_t total = 0;
|
||||
int16_t sample;
|
||||
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
|
||||
CHECK(sample == 0);
|
||||
total++;
|
||||
}
|
||||
CHECK(total >= samples.size() * 2);
|
||||
}
|
||||
|
||||
struct TestContext { int argc; char** argv; int result = 1; };
|
||||
|
||||
static void run_tests(void* argument) {
|
||||
auto* data = static_cast<TestContext*>(argument);
|
||||
Module* modules[] = { &platform_posix_module, &simulator_module, &audio_stream_module, nullptr };
|
||||
DtsDevice devices[] = { DTS_DEVICE_TERMINATOR };
|
||||
if (kernel_init(modules, devices) == ERROR_NONE
|
||||
&& device_construct_add_start(&speaker, "tactility,sdl-audio") == ERROR_NONE
|
||||
&& device_construct_add_start(µphone, "tactility,sdl-audio") == ERROR_NONE) {
|
||||
doctest::Context context(data->argc, data->argv);
|
||||
context.setOption("no-breaks", true);
|
||||
data->result = context.run();
|
||||
device_stop(µphone);
|
||||
device_stop(&speaker);
|
||||
}
|
||||
vTaskEndScheduler();
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (std::getenv("SDL_AUDIODRIVER") == nullptr) SDL_setenv("SDL_AUDIODRIVER", "dummy", 1);
|
||||
char input_path[] = "sim-audio-input-XXXXXX";
|
||||
char output_path[] = "sim-audio-output-XXXXXX";
|
||||
const bool disk = std::strcmp(std::getenv("SDL_AUDIODRIVER"), "disk") == 0;
|
||||
if (disk) {
|
||||
const int input_fd = mkstemp(input_path);
|
||||
const int output_fd = mkstemp(output_path);
|
||||
if (input_fd < 0 || output_fd < 0) return 1;
|
||||
FILE* file = fdopen(input_fd, "wb");
|
||||
if (file == nullptr) return 1;
|
||||
const std::vector<int16_t> fixture(48000, 10000);
|
||||
const size_t written = std::fwrite(fixture.data(), sizeof(int16_t), fixture.size(), file);
|
||||
std::fclose(file);
|
||||
::close(output_fd);
|
||||
if (written != fixture.size()) return 1;
|
||||
SDL_setenv("SDL_DISKAUDIOFILEIN", input_path, 1);
|
||||
SDL_setenv("SDL_DISKAUDIOFILE", output_path, 1);
|
||||
}
|
||||
TestContext data { argc, argv };
|
||||
if (xTaskCreate(run_tests, "audio-test", 32768, &data, 1, nullptr) != pdPASS) return 1;
|
||||
vTaskStartScheduler();
|
||||
if (disk) {
|
||||
std::remove(input_path);
|
||||
std::remove(output_path);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Simulator audio
|
||||
|
||||
The desktop simulator exposes an SDL speaker and microphone through Tactility's
|
||||
standard `audio_stream_*` API. On macOS, SDL uses CoreAudio. Audio Settings controls
|
||||
the simulator's input/output volume, mute, and enabled state; these controls do not
|
||||
change macOS's system volume.
|
||||
|
||||
## Running on macOS
|
||||
|
||||
Build the simulator in the usual host build environment (with `python`, `lark`,
|
||||
and `pyyaml` available, and without `ESP_IDF_VERSION`):
|
||||
|
||||
```sh
|
||||
cmake -S . -B buildsim
|
||||
cmake --build buildsim --target Tactility -j 8
|
||||
```
|
||||
|
||||
Create a fresh application bundle (the script deliberately refuses to overwrite
|
||||
an existing bundle):
|
||||
|
||||
```sh
|
||||
sh Buildscripts/release-simulator-macos-app.sh buildsim release/Tactility-audio.app
|
||||
open release/Tactility-audio.app
|
||||
```
|
||||
|
||||
Alternatively, run `../buildsim/Tactility/Tactility` with `Data/` as the working
|
||||
directory. Both the executable and the application bundle include a microphone
|
||||
usage description. macOS asks for microphone access on the first actual recording
|
||||
request, not at simulator startup. If denied, enable access in **System Settings
|
||||
→ Privacy & Security → Microphone** and relaunch. For command-line launches, macOS
|
||||
may attribute the permission to the terminal or launching application.
|
||||
|
||||
A Mac mini needs an external input device, such as a USB mic or headset. Without
|
||||
an input device, speaker output still works and input is reported unavailable.
|
||||
Connect the input before launch for predictable discovery/UI behavior.
|
||||
|
||||
## Selecting devices
|
||||
|
||||
By default, each stream opens the system's default device. Startup logs list SDL's
|
||||
device names. Optional environment variables select an exact name:
|
||||
|
||||
```sh
|
||||
SIM_AUDIO_OUTPUT="Mac mini Speakers" SIM_AUDIO_INPUT="USB Microphone" \
|
||||
release/Tactility-audio.app/Contents/MacOS/Tactility
|
||||
```
|
||||
|
||||
- `SIM_AUDIO_OUTPUT`: exact output name, or `none` to disable output.
|
||||
- `SIM_AUDIO_INPUT`: exact input name, or `none` to disable input.
|
||||
- Unset or empty values use the system default.
|
||||
|
||||
Selection is applied when opening a stream. An already-open stream does not
|
||||
automatically switch when the system default changes; close/reopen it or relaunch.
|
||||
A missing selected device causes an open failure rather than silently selecting a
|
||||
different device. Permission/device errors are logged under `SdlAudio`.
|
||||
|
||||
## Formats and behavior
|
||||
|
||||
- Signed 16-bit PCM; the shared stream module converts app sample rates and channel
|
||||
counts to 48 kHz mono capture / stereo playback. SDL handles host format conversion.
|
||||
- One input and one output stream can be open simultaneously.
|
||||
- Read/write from a worker task, never the LVGL thread.
|
||||
- Audio callbacks use bounded lock-free buffers, with silence on playback underrun
|
||||
and dropped incoming frames on capture overflow.
|
||||
- Reads/writes report partial byte counts on timeout, including converted streams.
|
||||
- Closing output drains its bounded buffer for up to 250 ms. Closing input does not
|
||||
affect output, and vice versa.
|
||||
- No audio mixing or acoustic echo cancellation is added by this backend. Use
|
||||
headphones when testing simultaneous microphone capture and playback.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cmake --build buildsim --target SimulatorAudioTests -j 8
|
||||
ctest --test-dir buildsim/Tests -R SimulatorAudio --output-on-failure
|
||||
```
|
||||
|
||||
These tests use SDL's dummy and disk backends without requiring microphone access.
|
||||
They cover full duplex, 16/44.1/48 kHz app formats, bounded buffering, partial
|
||||
timeouts, enable/disable, unavailable inputs, and sample-level input/output gain
|
||||
and mute. They also cover disabling audio while a slow codec open is pending,
|
||||
as can happen during a microphone permission prompt. Disk fixtures and output
|
||||
files are temporary and removed after the run.
|
||||
|
||||
To explicitly play a quiet half-second 440 Hz tone through real Mac audio:
|
||||
|
||||
```sh
|
||||
SDL_AUDIODRIVER=coreaudio SIM_AUDIO_OUTPUT= \
|
||||
buildsim/Tests/simulator/SimulatorAudioTests \
|
||||
--no-skip --test-case="hardware output smoke test"
|
||||
```
|
||||
|
||||
Physical microphone capture, the first-use permission prompt, and live device
|
||||
unplugging require a separate manual check with an input device attached.
|
||||
@@ -113,6 +113,10 @@ struct AudioStreamHandleImpl : AudioStreamHandleData {
|
||||
SemaphoreHandle_t drain_semaphore = nullptr;
|
||||
};
|
||||
|
||||
// A slow codec open (notably the macOS microphone permission prompt) reserves a
|
||||
// direction before a real handle exists. It must never be passed to close_stream().
|
||||
AudioStreamHandleImpl* const OPENING_STREAM = reinterpret_cast<AudioStreamHandleImpl*>(1);
|
||||
|
||||
struct AudioStreamData {
|
||||
Device* input_codec = nullptr;
|
||||
Device* output_codec = nullptr;
|
||||
@@ -130,6 +134,8 @@ struct AudioStreamData {
|
||||
bool output_muted = false;
|
||||
AudioStreamHandleImpl* open_input = nullptr;
|
||||
AudioStreamHandleImpl* open_output = nullptr;
|
||||
bool input_open_cancelled = false;
|
||||
bool output_open_cancelled = false;
|
||||
// Guards open_input/open_output and the closing/busy_count fields of any handle reachable
|
||||
// through them, so close (possibly forced by set_enabled) can't race with read/write.
|
||||
SemaphoreHandle_t mutex = nullptr;
|
||||
@@ -261,7 +267,7 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (config->channels == 0) {
|
||||
if (config->channels == 0 || config->sample_rate == 0) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
@@ -291,7 +297,9 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
||||
|
||||
// Reserve the slot with a placeholder so concurrent opens can't race past the check
|
||||
// above while we do the (potentially slow) codec open below outside the lock.
|
||||
auto* reservation = reinterpret_cast<AudioStreamHandleImpl*>(1);
|
||||
auto* reservation = OPENING_STREAM;
|
||||
bool* open_cancelled = is_input ? &data->input_open_cancelled : &data->output_open_cancelled;
|
||||
*open_cancelled = false;
|
||||
*slot = reservation;
|
||||
xSemaphoreGive(data->mutex);
|
||||
|
||||
@@ -361,6 +369,18 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
||||
}
|
||||
|
||||
xSemaphoreTake(data->mutex, portMAX_DELAY);
|
||||
if (*open_cancelled) {
|
||||
// Keep the reservation until the codec is closed, so re-enabling cannot
|
||||
// open a second stream while this cancelled open is still cleaning up.
|
||||
xSemaphoreGive(data->mutex);
|
||||
vSemaphoreDelete(handle->drain_semaphore);
|
||||
delete handle;
|
||||
audio_codec_close(codec);
|
||||
xSemaphoreTake(data->mutex, portMAX_DELAY);
|
||||
*slot = nullptr;
|
||||
xSemaphoreGive(data->mutex);
|
||||
return ERROR_NOT_ALLOWED;
|
||||
}
|
||||
*slot = handle;
|
||||
xSemaphoreGive(data->mutex);
|
||||
|
||||
@@ -377,6 +397,7 @@ error_t open_output(Device* device, const struct AudioStreamConfig* config, Audi
|
||||
}
|
||||
|
||||
error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_size, size_t* bytes_read, TickType_t timeout) {
|
||||
if (bytes_read != nullptr) *bytes_read = 0;
|
||||
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
||||
if (handle->direction != AUDIO_CODEC_DIR_INPUT || handle->bytes_per_frame == 0) {
|
||||
return ERROR_INVALID_STATE;
|
||||
@@ -419,7 +440,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
||||
|
||||
size_t codec_bytes_read = 0;
|
||||
result = audio_codec_read(data->input_codec, handle->codec_buffer.data(), codec_bytes_needed, &codec_bytes_read, timeout);
|
||||
if (result == ERROR_NONE) {
|
||||
if (codec_bytes_read > 0) {
|
||||
size_t codec_frames_read = codec_bytes_read / handle->codec_bytes_per_frame;
|
||||
const int16_t* rate_input = reinterpret_cast<const int16_t*>(handle->codec_buffer.data());
|
||||
uint8_t rate_input_channels = handle->codec_channels;
|
||||
@@ -448,7 +469,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
||||
}
|
||||
}
|
||||
|
||||
if (result == ERROR_NONE && handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
|
||||
if (handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
|
||||
auto* samples = reinterpret_cast<int16_t*>(out_data);
|
||||
size_t sample_count = *bytes_read / sizeof(int16_t);
|
||||
for (size_t i = 0; i < sample_count; i++) {
|
||||
@@ -462,6 +483,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
||||
}
|
||||
|
||||
error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t data_size, size_t* bytes_written, TickType_t timeout) {
|
||||
if (bytes_written != nullptr) *bytes_written = 0;
|
||||
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
||||
if (handle->direction != AUDIO_CODEC_DIR_OUTPUT || handle->bytes_per_frame == 0) {
|
||||
return ERROR_INVALID_STATE;
|
||||
@@ -532,9 +554,13 @@ error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t
|
||||
size_t codec_bytes_to_write = codec_frames * handle->codec_bytes_per_frame;
|
||||
size_t codec_bytes_written = 0;
|
||||
result = audio_codec_write(data->output_codec, handle->codec_buffer.data(), codec_bytes_to_write, &codec_bytes_written, timeout);
|
||||
if (result == ERROR_NONE && bytes_written != nullptr) {
|
||||
// The caller provided `data_size` worth of input; we consumed all of it (resampled/converted).
|
||||
*bytes_written = data_size;
|
||||
if (bytes_written != nullptr && codec_frames > 0) {
|
||||
// A bounded host/hardware queue can accept only part of a converted write,
|
||||
// including when returning ERROR_TIMEOUT. Report progress in app-side whole
|
||||
// frames rather than leaving the count untouched or claiming the entire write.
|
||||
size_t written_frames = codec_bytes_written / handle->codec_bytes_per_frame;
|
||||
if (written_frames > codec_frames) written_frames = codec_frames;
|
||||
*bytes_written = (in_frames * written_frames / codec_frames) * handle->bytes_per_frame;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,13 +712,17 @@ error_t set_enabled(Device* device, AudioCodecDirection direction, bool enabled)
|
||||
data->output_enabled = enabled;
|
||||
}
|
||||
|
||||
// Capture and clear the slot under the lock so we hand close_stream() a pointer that
|
||||
// can't simultaneously be torn down by a racing close from the owning app (close_stream
|
||||
// re-checks `*slot == handle` and no-ops if it's already been cleared/replaced).
|
||||
// A pending open has no handle to close yet. Let its owning task clean up when
|
||||
// the codec returns, even if the user re-enables the direction in the meantime.
|
||||
AudioStreamHandleImpl* to_close = nullptr;
|
||||
if (!enabled) {
|
||||
AudioStreamHandleImpl** slot = is_input ? &data->open_input : &data->open_output;
|
||||
to_close = *slot;
|
||||
if (*slot == OPENING_STREAM) {
|
||||
if (is_input) data->input_open_cancelled = true;
|
||||
else data->output_open_cancelled = true;
|
||||
} else {
|
||||
to_close = *slot;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(data->mutex);
|
||||
|
||||
|
||||
@@ -9,3 +9,7 @@ properties:
|
||||
type: phandle
|
||||
required: true
|
||||
description: "I2S controller device that carries audio data"
|
||||
input-gain-percent:
|
||||
type: int
|
||||
default: 100
|
||||
description: "Extra digital gain multiplier applied by audio_stream on top of the ES8311's own 42dB hardware ADC gain, as an integer percentage (100 = 1.0x / no extra boost). For quiet MEMS mic capsules that are still quiet even at max hardware gain."
|
||||
|
||||
@@ -25,6 +25,14 @@ struct Es8311Config {
|
||||
uint8_t address;
|
||||
/** I2S controller device that carries audio data */
|
||||
struct Device* i2s_device;
|
||||
/**
|
||||
* Extra fixed digital gain multiplier applied by audio_stream on top of the ES8311's
|
||||
* own hardware ADC gain (0..42dB), as an integer percentage (100 = 1.0x / no extra boost).
|
||||
* Small MEMS mic capsules can still sound quiet even near max hardware gain; this is for
|
||||
* boards where 42dB hardware gain alone isn't enough. devicetree has no float property type,
|
||||
* hence the x100 integer encoding.
|
||||
*/
|
||||
uint16_t input_gain_percent;
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -31,6 +31,7 @@ struct Es8311Data {
|
||||
bool is_open = false;
|
||||
AudioCodecDirection open_direction = AUDIO_CODEC_DIR_BOTH;
|
||||
esp_codec_dev_sample_info_t open_sample_info = {};
|
||||
float input_gain = 1.0f;
|
||||
};
|
||||
|
||||
#define GET_CONFIG(device) (static_cast<const Es8311Config*>((device)->config))
|
||||
@@ -144,8 +145,8 @@ error_t set_volume(Device* device, AudioCodecDirection direction, float volume_p
|
||||
}
|
||||
|
||||
if (direction == AUDIO_CODEC_DIR_INPUT) {
|
||||
// ES8311 ADC gain range is roughly 0..24 dB; map 0..100% linearly onto it.
|
||||
float db = (volume_percent / 100.0f) * 24.0f;
|
||||
// ES8311 ADC gain supports 0..42dB (0,6,12,18,24,30,36,42) in 6dB steps
|
||||
float db = (volume_percent / 100.0f) * 42.0f;
|
||||
return (esp_codec_dev_set_in_gain(data->codec_device, db) == ESP_CODEC_DEV_OK) ? ERROR_NONE : ERROR_RESOURCE;
|
||||
}
|
||||
|
||||
@@ -172,7 +173,8 @@ error_t get_volume(Device* device, AudioCodecDirection direction, float* volume_
|
||||
if (esp_codec_dev_get_in_gain(data->codec_device, &db) != ESP_CODEC_DEV_OK) {
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
*volume_percent = (db / 24.0f) * 100.0f;
|
||||
*volume_percent = (db / 42.0f) * 100.0f;
|
||||
if (*volume_percent > 100.0f) *volume_percent = 100.0f;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
@@ -235,6 +237,12 @@ error_t get_capabilities(Device* device, AudioCodecDirection* supported_directio
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t get_input_gain_multiplier(Device* device, float* gain) {
|
||||
auto* data = GET_DATA(device);
|
||||
*gain = data->input_gain;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static const struct AudioCodecApi API = {
|
||||
.open = open,
|
||||
.close = close,
|
||||
@@ -247,7 +255,7 @@ static const struct AudioCodecApi API = {
|
||||
.get_native_sample_rate = get_native_sample_rate,
|
||||
.get_native_channels = get_native_channels,
|
||||
.get_capabilities = get_capabilities,
|
||||
.get_input_gain_multiplier = nullptr,
|
||||
.get_input_gain_multiplier = get_input_gain_multiplier,
|
||||
};
|
||||
|
||||
// endregion
|
||||
@@ -257,6 +265,11 @@ static const struct AudioCodecApi API = {
|
||||
error_t start_device(Device* device) {
|
||||
const auto* config = GET_CONFIG(device);
|
||||
|
||||
if (config->input_gain_percent > 2000) {
|
||||
LOG_E(TAG, "Invalid input_gain_percent %u (must be 0..2000)", config->input_gain_percent);
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
|
||||
auto* i2c_controller = device_get_parent(device);
|
||||
if (i2c_controller == nullptr || device_get_type(i2c_controller) != &I2C_CONTROLLER_TYPE) {
|
||||
LOG_E(TAG, "Parent is not an I2C controller");
|
||||
@@ -270,6 +283,8 @@ error_t start_device(Device* device) {
|
||||
}
|
||||
|
||||
auto* data = new Es8311Data();
|
||||
data->input_gain = (float) config->input_gain_percent / 100.0f;
|
||||
if (config->input_gain_percent == 0) data->input_gain = 1.0f; // 0 means unset
|
||||
|
||||
data->ctrl_if = audio_codec_adapter_new_i2c_ctrl(i2c_controller, config->address);
|
||||
data->data_if = audio_codec_adapter_new_i2s_data(i2s_controller);
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <esp_elf.h>
|
||||
#include <esp_err.h>
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <new>
|
||||
#include <string>
|
||||
@@ -64,13 +66,22 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) {
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
// location.location can be either an app's install directory or the .elf file directly; the
|
||||
// former resolves to the per-target binary at {dir}/elf/{CONFIG_IDF_TARGET}.elf.
|
||||
bool is_regular_file(const std::string& path) {
|
||||
struct stat path_stat {};
|
||||
return ::stat(path.c_str(), &path_stat) == 0 && S_ISREG(path_stat.st_mode);
|
||||
}
|
||||
|
||||
// location.location can be either an app's install directory or the .elf file directly. A
|
||||
// "packaged" app's install directory always holds its single binary at the fixed path
|
||||
// {dir}/bin/{CONFIG_IDF_TARGET}/app.elf - a "terminal" app has no such file (its several
|
||||
// binaries keep their own names), so this correctly leaves it unresolvable - terminal apps
|
||||
// aren't run through AppLoaderApi (see app/install.h).
|
||||
std::string resolve_elf_path(const std::string& path) {
|
||||
if (path.ends_with(".elf")) {
|
||||
return path;
|
||||
}
|
||||
return path + "/elf/" + CONFIG_IDF_TARGET + ".elf";
|
||||
std::string candidate = path + "/bin/" CONFIG_IDF_TARGET "/app.elf";
|
||||
return is_regular_file(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
constexpr ElfRequirements EXECUTABLE_REQUIREMENTS = {
|
||||
|
||||
@@ -9,13 +9,36 @@ tactility_add_module(app-module
|
||||
PRIV_INCLUDE_DIRS private/
|
||||
INCLUDE_DIRS include/
|
||||
REQUIRES TactilityKernel service-module minitar
|
||||
PRIV_REQUIRES TactilityKernelCpp
|
||||
WHOLE_ARCHIVE
|
||||
)
|
||||
|
||||
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close()
|
||||
# rather than calling ::read/::write/::close() directly, on every platform whose build wraps
|
||||
# those symbols (ESP-IDF always; POSIX except macOS, whose linker doesn't support --wrap) -
|
||||
# see Tactility/CMakeLists.txt and the top-level CMakeLists.txt for where that's applied.
|
||||
if (NOT APPLE)
|
||||
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() rather
|
||||
# than calling ::read/::write/::close() directly, on every platform where those are wrapped: via
|
||||
# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt) and non-Apple POSIX (below), or via
|
||||
# source/stdio_wrap.cpp's dyld interpose on Apple (whose linker lacks --wrap).
|
||||
tactility_get_module_name(app-module MODULE_NAME)
|
||||
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
|
||||
|
||||
# Routes every read()/write()/close() and printf-family call to source/stdio_wrap.cpp's __wrap_*
|
||||
# functions; see that file for what they do and why. ESP-IDF wraps the same symbols itself (see
|
||||
# the top-level CMakeLists.txt); Apple gets the same routing via dyld interpose instead, self-
|
||||
# registered in that file. PUBLIC so every consumer of this module (Tactility, and any test target
|
||||
# that links app-module directly) gets the flags too.
|
||||
if (NOT ESP_PLATFORM AND NOT APPLE)
|
||||
tactility_get_module_name(app-module MODULE_NAME)
|
||||
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
|
||||
target_link_options(${MODULE_NAME} PUBLIC
|
||||
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
|
||||
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
|
||||
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
|
||||
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
|
||||
)
|
||||
endif ()
|
||||
|
||||
# install.cpp resolves an installed binary's fixed bin/<platform>/<binary>.so path itself, so it
|
||||
# needs the same platform-arch string app-posix-module's own CMakeLists.txt defines for its
|
||||
# loader (the ESP32 equivalent, CONFIG_IDF_TARGET, comes for free from sdkconfig.h there).
|
||||
if (NOT ESP_PLATFORM)
|
||||
tactility_get_module_name(app-module MODULE_NAME)
|
||||
target_compile_definitions(${MODULE_NAME} PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"")
|
||||
endif ()
|
||||
|
||||
@@ -12,8 +12,8 @@ extern "C" {
|
||||
/**
|
||||
* Computes the install directory for @a app_id (does not check whether anything is actually
|
||||
* installed there).
|
||||
* @param[out] path always NULL-terminated on return, even on failure (empty string if
|
||||
* @a path_size == 0 - nothing is written in that case; otherwise at least "" is written)
|
||||
* @param[out] path always NULL-terminated on return, even on failure. Empty when @a path_size
|
||||
* is 0, since nothing is written in that case.
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_BUFFER_OVERFLOW @a path_size is too small to hold the path (including the
|
||||
* NULL terminator)
|
||||
@@ -22,14 +22,15 @@ extern "C" {
|
||||
error_t app_get_install_path(const char* app_id, char* path, size_t path_size);
|
||||
|
||||
/**
|
||||
* Installs an app from a tarball at @a source_path: extracts it into the app install directory,
|
||||
* parses the extracted manifest.properties (see app/metadata.h) to determine its id, then
|
||||
* registers it with app_manager_add() as an AppLocation{APP_LOCATION_PATH, <install dir>} app.
|
||||
* If an app with the same id is already installed (via a previous app_install() call), it is
|
||||
* uninstalled first - stopped if running, its old install directory removed - before the new
|
||||
* one takes its place.
|
||||
* @param[in] source_path path to a tar file containing the app (must have manifest.properties
|
||||
* at its root)
|
||||
* Installs a package from a tarball at @a source_path: extracts it into the package's install
|
||||
* directory, parses the extracted manifest.properties (see app/package_manifest.h) into a
|
||||
* PackageManifest and one or more AppManifestBindings, then registers each with app_manager_add()
|
||||
* as an AppLocation{APP_LOCATION_PATH, <binary path>} app.
|
||||
* If a package with the same id is already installed (via a previous app_install() call), it is
|
||||
* uninstalled first: every one of its apps stopped if running, then its old install directory
|
||||
* removed, before the new one takes its place.
|
||||
* @param[in] source_path path to a tar file containing the package (must have
|
||||
* manifest.properties at its root)
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_NOT_FOUND @a source_path doesn't exist / can't be read
|
||||
* @retval ERROR_INVALID_ARGUMENT the tarball has no valid manifest.properties at its root
|
||||
@@ -37,11 +38,11 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size);
|
||||
error_t app_install(const char* source_path);
|
||||
|
||||
/**
|
||||
* Uninstalls a previously app_install()-ed app: stops it if currently running, deletes its
|
||||
* install directory, and unregisters it (app_manager_remove()).
|
||||
* @param[in] app_id the id the app was installed under (AppMetadata::app_id)
|
||||
* Uninstalls a previously app_install()-ed package: stops every one of its apps if currently
|
||||
* running, deletes its install directory, and unregisters all of them (app_manager_remove()).
|
||||
* @param[in] app_id the package id it was installed under (PackageManifest::id)
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_NOT_FOUND no such app was installed via app_install()
|
||||
* @retval ERROR_NOT_FOUND no such package was installed via app_install()
|
||||
*/
|
||||
error_t app_uninstall(const char* app_id);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include "location.h"
|
||||
#include <app/manifest.h>
|
||||
#include <tactility/error.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "location.h"
|
||||
#include <tactility/error.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <app/instance.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/stream.h>
|
||||
|
||||
#include <tactility/error.h>
|
||||
@@ -44,6 +45,48 @@ error_t app_manager_find_manifest(const char* id, struct AppManifest* out_manife
|
||||
typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context);
|
||||
void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context);
|
||||
|
||||
/**
|
||||
* Registers a package for enumeration via app_manager_for_each_package(). Separate from
|
||||
* registering its apps - the caller still calls app_manager_add() for each AppManifest.
|
||||
* @param[in] app_ids the ids of the AppManifest(s) this package registered
|
||||
* @retval ERROR_INVALID_ARGUMENT a package with the same id is already registered
|
||||
* @retval ERROR_NONE on success
|
||||
*/
|
||||
error_t app_manager_add_package(const struct PackageManifest* package, const char* const* app_ids, size_t app_id_count);
|
||||
|
||||
/**
|
||||
* Unregisters a previously-added package. Does not touch its apps' own registrations.
|
||||
* @retval ERROR_NOT_FOUND no package with this id is registered
|
||||
* @retval ERROR_NONE on success
|
||||
*/
|
||||
error_t app_manager_remove_package(const char* package_id);
|
||||
|
||||
/**
|
||||
* @param[out] out_package set to a copy of the package on success
|
||||
* @retval ERROR_NOT_FOUND no package with this id is registered
|
||||
* @retval ERROR_NONE on success
|
||||
*/
|
||||
error_t app_manager_find_package(const char* package_id, struct PackageManifest* out_package);
|
||||
|
||||
/** One registered package, handed to AppPackageVisitorFn - see app_manager_for_each_package(). */
|
||||
struct AppPackage {
|
||||
struct PackageManifest package;
|
||||
/** How many entries @a app_ids points to. */
|
||||
size_t app_id_count;
|
||||
/** Valid only for the duration of the app_manager_for_each_package() call that produced
|
||||
* this - copy out what's needed before returning from the visitor. */
|
||||
const char* const* app_ids;
|
||||
};
|
||||
|
||||
typedef void (*AppPackageVisitorFn)(const struct AppPackage* pkg, void* context);
|
||||
|
||||
/**
|
||||
* Calls @a visitor once for every registered package. Iteration order is unspecified.
|
||||
* @warning Same threading contract as app_manager_for_each_manifest(): runs with an internal
|
||||
* lock held - do not call any app_manager_*() function from inside @a visitor.
|
||||
*/
|
||||
void app_manager_for_each_package(AppPackageVisitorFn visitor, void* context);
|
||||
|
||||
/** One fd-to-stream binding for app_start_with_streams() (app/start.h). Every field is passed
|
||||
* through to app_stream_subscribe() as-is; see its own doc for the ownership contracts. */
|
||||
struct AppStreamBinding {
|
||||
@@ -90,8 +133,8 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size);
|
||||
|
||||
/**
|
||||
* Registers @a path as a directory to scan for app manifests - each direct subdirectory of
|
||||
* @a path is expected to hold a manifest.properties (see app/metadata.h), matching the layout
|
||||
* app_install() creates ({install dir}/{app_id}/manifest.properties), though this is not
|
||||
* @a path is expected to hold a manifest.properties (see app/package_manifest.h), matching the
|
||||
* layout app_install() creates ({install dir}/{package id}/manifest.properties), though this is not
|
||||
* install/uninstall - it only ever adds/removes manifest registrations, never touches files on
|
||||
* disk or running instances. No-op if @a path is already registered. Does not scan immediately -
|
||||
* call app_manager_install_path_scan() to do that.
|
||||
|
||||
@@ -11,7 +11,10 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
// Character count, excluding null terminator
|
||||
#define APP_ID_LENGTH 32
|
||||
#define APP_MANIFEST_ID_LENGTH 32
|
||||
|
||||
// Character count, excluding null terminator
|
||||
#define APP_MANIFEST_NAME_LENGTH 32
|
||||
|
||||
/** Broad classification of an app, used for grouping/launcher presentation. */
|
||||
enum AppCategory {
|
||||
@@ -45,10 +48,10 @@ struct AppStackConfig {
|
||||
|
||||
/** Describes a registrable app. One manifest exists per app id. */
|
||||
struct AppManifest {
|
||||
/** Unique app identifier. Should never be NULL. */
|
||||
const char* id;
|
||||
/** Human-readable name. Should never be NULL. */
|
||||
const char* name;
|
||||
/** Unique app identifier. Must be NULL-terminated. */
|
||||
char id[APP_MANIFEST_ID_LENGTH + 1];
|
||||
/** Human-readable name. Must be NULL-terminated. */
|
||||
char name[APP_MANIFEST_NAME_LENGTH + 1];
|
||||
enum AppCategory category;
|
||||
struct AppLocation location;
|
||||
/** Bitmask of AppManifestFlags. Most apps should leave this 0. */
|
||||
@@ -57,7 +60,9 @@ struct AppManifest {
|
||||
struct AppStackConfig stack;
|
||||
};
|
||||
|
||||
bool app_id_is_valid(const char* id);
|
||||
bool app_manifest_id_is_valid(const char* id);
|
||||
bool app_manifest_name_is_valid(const char* name);
|
||||
bool app_manifest_stack_size_is_valid(const char* value);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define APP_METADATA_TARGET_SDK_LENGTH 16
|
||||
#define APP_METADATA_APP_ID_LENGTH 32
|
||||
#define APP_METADATA_APP_NAME_LENGTH 32
|
||||
#define APP_METADATA_APP_VERSION_NAME_LENGTH 16
|
||||
#define APP_METADATA_REQUIRES_DEVICE_ID_LENGTH 64
|
||||
|
||||
struct AppMetadata {
|
||||
|
||||
/**
|
||||
* The SDK version that was used to compile this app. (e.g. "0.6.0")
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char target_sdk[APP_METADATA_TARGET_SDK_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* The identifier by which the app is launched by the system and other apps.
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char app_id[APP_METADATA_APP_ID_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* The user-readable name of the app. Used in UI.
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char app_name[APP_METADATA_APP_NAME_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* The version as it is displayed to the user (e.g. "1.2.0")
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char app_version_name[APP_METADATA_APP_VERSION_NAME_LENGTH + 1];
|
||||
|
||||
/** The technical version (must be incremented with new releases of the app) */
|
||||
uint64_t app_version_code;
|
||||
|
||||
/**
|
||||
* Comma-separated list of device ids the app is restricted to (e.g. "m5stack-tab5"), matching
|
||||
* the folder names under Devices/. Empty means unrestricted.
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char requires_device_id[APP_METADATA_REQUIRES_DEVICE_ID_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* Stack depth (in words) for the app's task. Optional; 0 means scheduler default.
|
||||
* @warning Avoid default values: the default is conservative, which wastes memory.
|
||||
*/
|
||||
uint32_t stack_depth;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a manifest.properties file at @a path into @a out_metadata, auto-detecting the V1
|
||||
* (sectioned, e.g. "[app]id=...") or V2 (flat dot-notation, e.g. "app.id=...") format from its
|
||||
* first line.
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened
|
||||
* @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit
|
||||
* @a out_metadata's fixed-size buffers
|
||||
*/
|
||||
error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define PACKAGE_MANIFEST_TARGET_SDK_LENGTH 16
|
||||
#define PACKAGE_MANIFEST_ID_LENGTH 32
|
||||
#define PACKAGE_MANIFEST_VERSION_NAME_LENGTH 16
|
||||
#define PACKAGE_MANIFEST_REQUIRES_DEVICE_ID_LENGTH 64
|
||||
|
||||
/** Character count, excluding null terminator, for AppManifestBinding::binary. */
|
||||
#define APP_MANIFEST_BINARY_LENGTH 31
|
||||
|
||||
/** Largest number of AppManifest entries package_manifest_parse() can produce from a single
|
||||
* manifest.properties file. */
|
||||
#define PACKAGE_MANIFEST_MAX_APP_MANIFESTS 32
|
||||
|
||||
/** A package-level manifest.properties: SDK/version metadata that applies to the whole package,
|
||||
* not to any one of its (possibly several) apps. Not kept in memory at runtime - only used to
|
||||
* create and cache the AppManifest(s) it describes. */
|
||||
struct PackageManifest {
|
||||
/**
|
||||
* The package identifier (e.g. the install directory name). Distinct from any of its own
|
||||
* AppManifest ids.
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char id[PACKAGE_MANIFEST_ID_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* The package version as it is displayed to the user (e.g. "1.2.0")
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char version_name[PACKAGE_MANIFEST_VERSION_NAME_LENGTH + 1];
|
||||
|
||||
/** The package's technical version (must be incremented with new releases). */
|
||||
uint64_t version_code;
|
||||
|
||||
/**
|
||||
* The SDK version that was used to compile this package. (e.g. "0.6.0")
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char target_sdk[PACKAGE_MANIFEST_TARGET_SDK_LENGTH + 1];
|
||||
|
||||
/**
|
||||
* Comma-separated list of device ids the package is restricted to (e.g. "m5stack-tab5"),
|
||||
* matching the folder names under Devices/. Empty means unrestricted.
|
||||
* Must be NULL-terminated.
|
||||
*/
|
||||
char requires_device_id[PACKAGE_MANIFEST_REQUIRES_DEVICE_ID_LENGTH + 1];
|
||||
|
||||
/** How many AppManifest entries this package's manifest.properties declared. */
|
||||
uint32_t app_manifest_count;
|
||||
};
|
||||
|
||||
/** Pairs a parsed AppManifest with the filename (without extension) it installs as under
|
||||
* bin/<platform>/ - e.g. "main" resolves to bin/posix-x86_64/main.so. Not kept anywhere at
|
||||
* runtime - same lifetime as PackageManifest, only used to hand parse results to the installer/
|
||||
* scanner, which resolve `binary` into AppManifest::location::location. */
|
||||
struct AppManifestBinding {
|
||||
struct AppManifest manifest;
|
||||
char binary[APP_MANIFEST_BINARY_LENGTH + 1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a manifest.properties file at @a path (flat dot-notation, e.g. "app.id=...") into
|
||||
* @a out_package and @a out_bindings.
|
||||
* @param[out] out_bindings written with up to @a bindings_capacity entries (see
|
||||
* PackageManifest::app_manifest_count for how many)
|
||||
* @param[in] bindings_capacity the capacity of @a out_bindings
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened
|
||||
* @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit
|
||||
* its fixed-size buffer
|
||||
* @retval ERROR_BUFFER_OVERFLOW the manifest declares more apps than @a bindings_capacity
|
||||
*/
|
||||
error_t app_package_manifest_parse(const char* path, struct PackageManifest* out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
// Resolves an AppManifestBinding::binary filename (without extension) to its installed path
|
||||
// under this fixed, predictable location, mirroring what each platform's own loader
|
||||
// (app_posix_loader_service.cpp / app_esp32_loader_service.cpp) already resolves a plain
|
||||
// ".so"/".elf" AppLocation::location straight through unchanged - so setting location.location
|
||||
// to this exact path means neither loader needs to guess which of a package's several binaries
|
||||
// an AppManifest refers to.
|
||||
inline std::string app_resolve_binary_path(const std::string& install_dir, const std::string& binary) {
|
||||
#ifdef ESP_PLATFORM
|
||||
return install_dir + "/bin/" CONFIG_IDF_TARGET "/" + binary + ".elf";
|
||||
#else
|
||||
return install_dir + "/bin/posix-" TACTILITY_POSIX_ARCH "/" + binary + ".so";
|
||||
#endif
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
#include <app/instance.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/private/fd_table.h>
|
||||
|
||||
#include <TactilityCpp/Allocator.h>
|
||||
|
||||
#include <tactility/concurrent/mutex.h>
|
||||
#include <tactility/freertos/freertos.h>
|
||||
#include <tactility/freertos/semphr.h>
|
||||
@@ -13,6 +16,7 @@
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* A dedicated completion signal(1) for one app instance's task, given as the
|
||||
@@ -57,8 +61,16 @@ struct AppInstanceRecord {
|
||||
AppFdTable fd_table {};
|
||||
};
|
||||
|
||||
/** A registered installed package - see app_manager_add_package() (app/manager.h). */
|
||||
struct AppPackageRecord {
|
||||
struct PackageManifest package;
|
||||
std::vector<std::string> app_ids;
|
||||
};
|
||||
|
||||
struct AppLedger {
|
||||
std::unordered_map<std::string, const AppManifest*> manifests;
|
||||
// OptExternalAllocator: bigger entries than `manifests`, and unlike `instances` isn't on the app start/stop hot path.
|
||||
std::unordered_map<std::string, AppPackageRecord, std::hash<std::string>, std::equal_to<std::string>, tt::OptExternalAllocator<std::pair<const std::string, AppPackageRecord>>> packages;
|
||||
std::unordered_map<uint32_t, AppInstanceRecord> instances;
|
||||
uint32_t next_instance_id = 1;
|
||||
Mutex mutex {};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <app/metadata.h>
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
/** Shared helpers + per-format parsers for app_metadata_parse() (source/app_metadata_parsing.cpp)
|
||||
* - split out like the old tt::app manifest parser (AppManifestParsing/V1/V2.cpp) that this is
|
||||
* modelled on, one file per format plus a shared dispatcher. */
|
||||
|
||||
bool app_metadata_get_value(const std::map<std::string, std::string>& properties, const std::string& key, std::string& out_value);
|
||||
|
||||
bool app_metadata_is_valid_format_version(const std::string& version);
|
||||
bool app_metadata_is_valid_name(const std::string& name);
|
||||
bool app_metadata_is_valid_version_name(const std::string& version);
|
||||
bool app_metadata_is_valid_version_code(const std::string& version);
|
||||
bool app_metadata_is_valid_stack_size(const std::string& value);
|
||||
|
||||
/** Validates a comma-separated list of device ids (alphanumeric + '-' items, matching Devices/<id> folder names). */
|
||||
bool app_metadata_is_valid_device_id_list(const std::string& value);
|
||||
|
||||
/** Copies @a value into @a dest (a fixed-size buffer of @a dest_size bytes, including the NULL
|
||||
* terminator) if it fits.
|
||||
* @retval false @a value doesn't fit in @a dest_size bytes - @a dest is left untouched */
|
||||
bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value);
|
||||
|
||||
/** Parses a V1 (sectioned INI, e.g. "[app]versionName=...") manifest map into @a out_metadata. */
|
||||
bool app_metadata_parse_v1(const std::map<std::string, std::string>& properties, struct AppMetadata& out_metadata);
|
||||
|
||||
/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map into @a out_metadata. */
|
||||
bool app_metadata_parse_v2(const std::map<std::string, std::string>& properties, struct AppMetadata& out_metadata);
|
||||
|
||||
bool app_metadata_validate_string(const std::string& value, bool (*is_valid_char)(char));
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <app/package_manifest.h>
|
||||
|
||||
#include <TactilityCpp/Allocator.h>
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/** Shared helpers + parser for app_package_manifest_parse() (source/package_manifest_parsing.cpp). */
|
||||
|
||||
bool app_package_manifest_get_value(const std::map<std::string, std::string>& properties, const std::string& key, std::string& out_value);
|
||||
|
||||
bool app_package_manifest_is_valid_format_version(const std::string& version);
|
||||
bool app_package_manifest_is_valid_version_name(const std::string& version);
|
||||
bool app_package_manifest_is_valid_version_code(const std::string& version);
|
||||
bool app_package_manifest_is_valid_bool(const std::string& value);
|
||||
|
||||
/** Validates a comma-separated list of device ids (alphanumeric + '-' items, matching Devices/<id> folder names). */
|
||||
bool app_package_manifest_is_valid_device_id_list(const std::string& value);
|
||||
|
||||
/** Validates a binary filename stem (AppManifestBinding::binary): alphanumeric plus '.', '_', '-'. */
|
||||
bool app_package_manifest_is_valid_binary_name(const std::string& value);
|
||||
|
||||
/** Copies @a value into @a dest (a fixed-size buffer of @a dest_size bytes, including the NULL
|
||||
* terminator) if it fits.
|
||||
* @retval false @a value doesn't fit in @a dest_size bytes - @a dest is left untouched */
|
||||
bool app_package_manifest_copy_bounded(char* dest, size_t dest_size, const std::string& value);
|
||||
|
||||
/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map into @a out_package
|
||||
* and (unless @a bindings_capacity is 0) its single AppManifestBinding, out_bindings[0].
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_INVALID_ARGUMENT a required key is missing or a value doesn't fit/validate
|
||||
* @retval ERROR_BUFFER_OVERFLOW @a bindings_capacity is nonzero but smaller than needed */
|
||||
error_t package_manifest_parse_v2(const std::map<std::string, std::string>& properties, struct PackageManifest& out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity);
|
||||
|
||||
/** Parses a V3 (flat dot-notation with 0-indexed "app.N.*" blocks) manifest map into
|
||||
* @a out_package and up to @a bindings_capacity AppManifestBinding entries.
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_INVALID_ARGUMENT a required key is missing or a value doesn't fit/validate
|
||||
* @retval ERROR_BUFFER_OVERFLOW @a bindings_capacity is nonzero but smaller than the
|
||||
* manifest's app_manifest_count */
|
||||
error_t package_manifest_parse_v3(const std::map<std::string, std::string>& properties, struct PackageManifest& out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity);
|
||||
|
||||
bool app_package_manifest_validate_string(const std::string& value, bool (*is_valid_char)(char));
|
||||
|
||||
/** Convenience wrapper around app_package_manifest_parse(): parses @a path once to learn
|
||||
* PackageManifest::app_manifest_count, then resizes @a out_bindings to fit exactly and parses
|
||||
* again to fill it - so the caller never needs to pre-allocate a fixed maximum.
|
||||
* @retval ERROR_NONE on success
|
||||
* @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened
|
||||
* @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit
|
||||
* its fixed-size buffer */
|
||||
error_t app_package_manifest_parse_into(const char* path, struct PackageManifest& out_package, std::vector<struct AppManifestBinding, tt::OptExternalAllocator<struct AppManifestBinding>>& out_bindings);
|
||||
@@ -2,10 +2,14 @@
|
||||
#include <app/install.h>
|
||||
|
||||
#include <app/manager.h>
|
||||
#include <app/metadata.h>
|
||||
#include <app/package_manifest.h>
|
||||
|
||||
#include <app/private/binary_path.h>
|
||||
#include <app/private/fs.h>
|
||||
#include <app/private/ledger.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
|
||||
#include <TactilityCpp/Allocator.h>
|
||||
|
||||
#include <tactility/concurrent/mutex.h>
|
||||
#include <tactility/log.h>
|
||||
@@ -29,7 +33,7 @@ constexpr auto* TAG = "app_install";
|
||||
|
||||
namespace {
|
||||
|
||||
// region Filesystem helpers (app-module may not depend upward on Tactility::file - see
|
||||
// region Filesystem helpers (app-module may not depend upward on Tactility::file; see
|
||||
// app_metadata_parsing.cpp for the same constraint applied to properties-file loading)
|
||||
|
||||
std::string last_path_segment(const std::string& path) {
|
||||
@@ -164,9 +168,9 @@ bool untar(const std::string& tar_path, const std::string& destination_path) {
|
||||
// endregion
|
||||
|
||||
// region Staging-path lock: at most one caller may clean up/populate a given staging_path at a
|
||||
// time, keyed by source basename. The HTTP server and app tasks can call app_install()
|
||||
// concurrently, e.g. two uploads sharing a source basename - without this, one call's cleanup
|
||||
// can delete or overwrite the staging directory another call is still extracting into.
|
||||
// time, keyed by source basename. The HTTP server and app tasks can call app_install_package()
|
||||
// concurrently, e.g. two uploads sharing a source basename; without this lock, one call's
|
||||
// cleanup could delete or overwrite the staging directory another call is still extracting into.
|
||||
|
||||
struct StagingLock {
|
||||
Mutex mutex {};
|
||||
@@ -223,18 +227,19 @@ void release_staging_lock(const std::string& path) {
|
||||
|
||||
// endregion
|
||||
|
||||
// region Installed-app registry: owns the AppManifest (and its id/name/path strings) that
|
||||
// app_manager's ledger only keeps a non-owning pointer to (see app_manager_add()'s contract).
|
||||
// region Installed-package registry: owns the AppManifests (and their backing location-path
|
||||
// strings) that app_manager's ledger only keeps non-owning pointers to (see app_manager_add()'s
|
||||
// contract). AppManifest::id/name own their own storage directly (fixed arrays), so this doesn't
|
||||
// need to separately own those.
|
||||
|
||||
struct InstalledAppRecord {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string path;
|
||||
AppManifest manifest {};
|
||||
struct InstalledPackageRecord {
|
||||
std::string path; // install directory
|
||||
std::vector<AppManifest> manifests; // one per AppManifest the package declared
|
||||
std::vector<std::string> locations; // backs manifests[i].location.location, same indices
|
||||
};
|
||||
|
||||
struct InstallRegistry {
|
||||
std::unordered_map<std::string, std::unique_ptr<InstalledAppRecord>> apps;
|
||||
std::unordered_map<std::string, std::unique_ptr<InstalledPackageRecord>> apps;
|
||||
Mutex mutex {};
|
||||
|
||||
InstallRegistry() { mutex_construct(&mutex); }
|
||||
@@ -245,46 +250,68 @@ InstallRegistry& install_registry() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
// Registers @a app_dir_path (already confirmed to hold a valid manifest.properties, parsed into
|
||||
// @a metadata) with app_manager_add(), taking ownership of its id/name/path strings.
|
||||
// @warning Caller must hold install_registry().mutex, and must have already ensured
|
||||
// @a metadata.app_id isn't already registered (app_manager_add() rejects duplicates, but the
|
||||
// InstalledAppRecord for the earlier registration would leak since this always inserts fresh).
|
||||
error_t register_installed_app_locked(const std::string& app_dir_path, const AppMetadata& metadata) {
|
||||
// Registers every AppManifest in @a bindings (@a count of them) with app_manager_add(), taking
|
||||
// ownership of their backing location strings, then registers @a package itself.
|
||||
// @warning Caller must hold install_registry().mutex.
|
||||
error_t register_installed_package_locked(const PackageManifest& package, const std::string& install_path, const AppManifestBinding* bindings, size_t count) {
|
||||
auto& registry = install_registry();
|
||||
|
||||
auto record = std::make_unique<InstalledAppRecord>();
|
||||
record->id = metadata.app_id;
|
||||
record->name = metadata.app_name;
|
||||
record->path = app_dir_path;
|
||||
record->manifest = AppManifest {
|
||||
.id = record->id.c_str(),
|
||||
.name = record->name.c_str(),
|
||||
.category = APP_CATEGORY_USER,
|
||||
.location = { APP_LOCATION_PATH, const_cast<char*>(record->path.c_str()) },
|
||||
.flags = 0,
|
||||
.stack = { .depth = static_cast<uint16_t>(metadata.stack_depth), .desired_memory_capability = 0 },
|
||||
};
|
||||
auto record = std::make_unique<InstalledPackageRecord>();
|
||||
record->path = install_path;
|
||||
// Sized once, up front: manifests[i].location.location points into locations[i].c_str(),
|
||||
// which would dangle if either vector reallocated afterward.
|
||||
record->manifests.resize(count);
|
||||
record->locations.resize(count);
|
||||
|
||||
// Belt-and-braces: app_install()'s earlier app_manager_remove() call is meant to have
|
||||
// already cleared any stale registration for this id (e.g. left over from
|
||||
// app_manager_install_path_scan()'s separate registry), but that call happens before the
|
||||
// tarball is even extracted - remove once more, right before add, so a duplicate id can
|
||||
// never turn a filesystem-level install success into a reported failure.
|
||||
app_manager_remove(record->id.c_str());
|
||||
|
||||
error_t add_result = app_manager_add(&record->manifest);
|
||||
if (add_result != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to register app '%s': %s", record->id.c_str(), error_to_string(add_result));
|
||||
return add_result;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
record->manifests[i] = bindings[i].manifest;
|
||||
record->locations[i] = app_resolve_binary_path(install_path, bindings[i].binary);
|
||||
record->manifests[i].location = { APP_LOCATION_PATH, const_cast<char*>(record->locations[i].c_str()) };
|
||||
}
|
||||
|
||||
registry.apps[record->id] = std::move(record);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
// The caller's earlier uninstall_locked() call is meant to have already cleared any stale registration for this id
|
||||
// (e.g. left over from app_manager_install_path_scan()'s separate registry),
|
||||
// but that call happens before the package is even extracted / the binaries moved into place; remove once more
|
||||
// right before add, so a duplicate id can never turn a filesystem-level install success into a reported failure.
|
||||
// Only if installed (APP_LOCATION_PATH) - never steal a built-in's id.
|
||||
AppManifest existing {};
|
||||
if (app_manager_find_manifest(record->manifests[i].id, &existing) == ERROR_NONE && existing.location.type == APP_LOCATION_PATH) {
|
||||
app_manager_remove(record->manifests[i].id);
|
||||
}
|
||||
|
||||
error_t add_result = app_manager_add(&record->manifests[i]);
|
||||
if (add_result != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to register app '%s': %s", record->manifests[i].id, error_to_string(add_result));
|
||||
// All-or-nothing: unregister whatever this package already added before failing.
|
||||
for (size_t j = 0; j < i; j++) {
|
||||
app_manager_remove(record->manifests[j].id);
|
||||
}
|
||||
return add_result;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<const char*> app_id_ptrs;
|
||||
app_id_ptrs.reserve(count);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
app_id_ptrs.push_back(record->manifests[i].id);
|
||||
}
|
||||
app_manager_remove_package(package.id);
|
||||
error_t add_package_result = app_manager_add_package(&package, app_id_ptrs.data(), app_id_ptrs.size());
|
||||
if (add_package_result != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to register package '%s': %s", package.id, error_to_string(add_package_result));
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
app_manager_remove(record->manifests[i].id);
|
||||
}
|
||||
return add_package_result;
|
||||
}
|
||||
|
||||
registry.apps[package.id] = std::move(record);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
// Stops every currently-running instance of @a manifest. Collects matching instance ids while
|
||||
// holding the ledger lock, then calls app_manager_stop() on each after releasing it - that call
|
||||
// holding the ledger lock, then calls app_manager_stop() on each after releasing it: that call
|
||||
// bound-joins the instance's thread, which must not happen while the ledger mutex (also taken by
|
||||
// the instance's own thread_main()) is held, or the two threads would deadlock each other.
|
||||
void stop_all_instances_of(const AppManifest* manifest) {
|
||||
@@ -305,20 +332,22 @@ void stop_all_instances_of(const AppManifest* manifest) {
|
||||
}
|
||||
|
||||
// Caller must already hold install_registry().mutex
|
||||
error_t uninstall_locked(const std::string& app_id) {
|
||||
error_t uninstall_locked(const std::string& package_id) {
|
||||
auto& registry = install_registry();
|
||||
auto iterator = registry.apps.find(app_id);
|
||||
auto iterator = registry.apps.find(package_id);
|
||||
if (iterator == registry.apps.end()) {
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
// Can't uninstall in-memory apps
|
||||
if (iterator->second->manifest.location.type != APP_LOCATION_PATH) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
for (const auto& manifest : iterator->second->manifests) {
|
||||
// Can't uninstall in-memory apps
|
||||
if (manifest.location.type != APP_LOCATION_PATH) {
|
||||
continue;
|
||||
}
|
||||
stop_all_instances_of(&manifest);
|
||||
app_manager_remove(manifest.id);
|
||||
}
|
||||
|
||||
stop_all_instances_of(&iterator->second->manifest);
|
||||
app_manager_remove(app_id.c_str());
|
||||
app_manager_remove_package(package_id.c_str());
|
||||
delete_recursively(iterator->second->path);
|
||||
registry.apps.erase(iterator);
|
||||
|
||||
@@ -352,7 +381,7 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size) {
|
||||
}
|
||||
|
||||
error_t app_install(const char* source_path) {
|
||||
LOG_I(TAG, "Installing app from %s", source_path);
|
||||
LOG_I(TAG, "Installing app package from %s", source_path);
|
||||
|
||||
std::string app_parent_path;
|
||||
if (!get_app_install_directory(app_parent_path)) {
|
||||
@@ -389,8 +418,9 @@ error_t app_install(const char* source_path) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
AppMetadata metadata {};
|
||||
if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) {
|
||||
PackageManifest package {};
|
||||
std::vector<AppManifestBinding, tt::OptExternalAllocator<AppManifestBinding>> app_bindings;
|
||||
if (app_package_manifest_parse_into(manifest_path.c_str(), package, app_bindings) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Install failed: invalid manifest");
|
||||
delete_recursively(staging_path);
|
||||
release_staging_lock(staging_path);
|
||||
@@ -400,24 +430,13 @@ error_t app_install(const char* source_path) {
|
||||
auto& registry = install_registry();
|
||||
mutex_lock(®istry.mutex);
|
||||
|
||||
// Replace any previous install of this app id (mirrors the old install()'s "already
|
||||
// running/present" handling). uninstall_locked() only clears app_install.cpp's own
|
||||
// registry - the same app id may instead be registered by app_manager_install_path_scan()
|
||||
// (manager.cpp's separate registry, scanning this same directory tree), which
|
||||
// uninstall_locked() doesn't know about. Clear the app-manager registration unconditionally
|
||||
// too, or app_manager_add() below rejects the re-add as a duplicate.
|
||||
uninstall_locked(metadata.app_id);
|
||||
// Replace any previous installation of this package - this also handles the app_manager
|
||||
// registrations of its old AppManifests, so there's no separate app_manager_remove() needed
|
||||
// here (register_installed_package_locked() below still defends against a stale registration
|
||||
// per individual id, e.g. one left by app_manager_install_path_scan()'s separate registry).
|
||||
uninstall_locked(package.id);
|
||||
|
||||
error_t remove_result = app_manager_remove(metadata.app_id);
|
||||
if (remove_result != ERROR_NONE && remove_result != ERROR_NOT_FOUND) {
|
||||
LOG_E(TAG, "Install failed: failed to remove existing installation");
|
||||
mutex_unlock(®istry.mutex);
|
||||
delete_recursively(staging_path);
|
||||
release_staging_lock(staging_path);
|
||||
return ERROR_RESOURCE;
|
||||
}
|
||||
|
||||
auto final_path = app_parent_path + "/" + metadata.app_id;
|
||||
auto final_path = app_parent_path + "/" + package.id;
|
||||
delete_recursively(final_path);
|
||||
|
||||
if (rename(staging_path.c_str(), final_path.c_str()) != 0) {
|
||||
@@ -429,9 +448,8 @@ error_t app_install(const char* source_path) {
|
||||
}
|
||||
release_staging_lock(staging_path);
|
||||
|
||||
// Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above
|
||||
// already removed any previous registration for this exact id.
|
||||
error_t add_result = register_installed_app_locked(final_path, metadata);
|
||||
// app_bindings.size(), not package.app_manifest_count - the safe bound to index by.
|
||||
error_t add_result = register_installed_package_locked(package, final_path, app_bindings.data(), app_bindings.size());
|
||||
mutex_unlock(®istry.mutex);
|
||||
|
||||
return add_result;
|
||||
|
||||
@@ -8,13 +8,42 @@
|
||||
#include <cerrno>
|
||||
|
||||
#if defined(TT_APP_IO_WRAPS_STDIO)
|
||||
#ifdef ESP_PLATFORM
|
||||
// Newlib's stdio (fflush()'s buffer-flush path, in particular) calls the reentrant _read_r/
|
||||
// _write_r/_close_r syscall stubs directly. The plain read()/write()/close() newlib provides are
|
||||
// just thin wrappers around them (esp-idf's components/newlib/src/syscalls.c: `write(fd, dst,
|
||||
// size) { return _write_r(__getreent(), fd, dst, size); }`). Wrapping the _r stubs catches both;
|
||||
// wrapping the plain names would only catch direct write()-style callers.
|
||||
#include <reent.h>
|
||||
extern "C" {
|
||||
ssize_t __real__read_r(struct _reent* r, int fd, void* buffer, size_t size);
|
||||
ssize_t __real__write_r(struct _reent* r, int fd, const void* buffer, size_t size);
|
||||
int __real__close_r(struct _reent* r, int fd);
|
||||
}
|
||||
namespace {
|
||||
ssize_t real_read(int fd, void* buffer, size_t size) { return __real__read_r(__getreent(), fd, buffer, size); }
|
||||
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real__write_r(__getreent(), fd, buffer, size); }
|
||||
int real_close(int fd) { return __real__close_r(__getreent(), fd); }
|
||||
} // namespace
|
||||
#else
|
||||
extern "C" {
|
||||
ssize_t __real_read(int fd, void* buffer, size_t size);
|
||||
ssize_t __real_write(int fd, const void* buffer, size_t size);
|
||||
int __real_close(int fd);
|
||||
}
|
||||
namespace {
|
||||
ssize_t real_read(int fd, void* buffer, size_t size) { return __real_read(fd, buffer, size); }
|
||||
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real_write(fd, buffer, size); }
|
||||
int real_close(int fd) { return __real_close(fd); }
|
||||
} // namespace
|
||||
#endif
|
||||
#else
|
||||
#include <unistd.h>
|
||||
namespace {
|
||||
ssize_t real_read(int fd, void* buffer, size_t size) { return ::read(fd, buffer, size); }
|
||||
ssize_t real_write(int fd, const void* buffer, size_t size) { return ::write(fd, buffer, size); }
|
||||
int real_close(int fd) { return ::close(fd); }
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
@@ -56,11 +85,7 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) {
|
||||
errno = EBADF;
|
||||
return -1;
|
||||
}
|
||||
#if defined(TT_APP_IO_WRAPS_STDIO)
|
||||
return __real_read(fd, buffer, size);
|
||||
#else
|
||||
return ::read(fd, buffer, size);
|
||||
#endif
|
||||
return real_read(fd, buffer, size);
|
||||
}
|
||||
|
||||
ssize_t app_io_write(int fd, const void* buffer, size_t size) {
|
||||
@@ -78,22 +103,14 @@ ssize_t app_io_write(int fd, const void* buffer, size_t size) {
|
||||
// output is currently being intercepted. Without this, a log line emitted while any app
|
||||
// instance has its stdout captured would vanish from the console entirely instead of
|
||||
// just also being visible to the capturing parent.
|
||||
#if defined(TT_APP_IO_WRAPS_STDIO)
|
||||
__real_write(fd, buffer, size);
|
||||
#else
|
||||
::write(fd, buffer, size);
|
||||
#endif
|
||||
real_write(fd, buffer, size);
|
||||
return result;
|
||||
}
|
||||
if (table != nullptr && app_fd_table_is_app_owned(table, fd)) {
|
||||
errno = EBADF;
|
||||
return -1;
|
||||
}
|
||||
#if defined(TT_APP_IO_WRAPS_STDIO)
|
||||
return __real_write(fd, buffer, size);
|
||||
#else
|
||||
return ::write(fd, buffer, size);
|
||||
#endif
|
||||
return real_write(fd, buffer, size);
|
||||
}
|
||||
|
||||
int app_io_close(int fd) {
|
||||
@@ -108,11 +125,7 @@ int app_io_close(int fd) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
#if defined(TT_APP_IO_WRAPS_STDIO)
|
||||
return __real_close(fd);
|
||||
#else
|
||||
return ::close(fd);
|
||||
#endif
|
||||
return real_close(fd);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <app/manager.h>
|
||||
#include <app/metadata.h>
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/private/arguments.h>
|
||||
#include <app/private/binary_path.h>
|
||||
#include <app/private/fd_table.h>
|
||||
#include <app/private/fs.h>
|
||||
#include <app/private/ledger.h>
|
||||
#include <app/private/manager_internal.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
#include <app/private/scheduler.h>
|
||||
|
||||
#include <TactilityCpp/Allocator.h>
|
||||
|
||||
#include <tactility/concurrent/mutex.h>
|
||||
#include <tactility/error.h>
|
||||
#include <tactility/log.h>
|
||||
@@ -72,6 +76,67 @@ void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context)
|
||||
mutex_unlock(&ledger.mutex);
|
||||
}
|
||||
|
||||
error_t app_manager_add_package(const PackageManifest* package, const char* const* app_ids, size_t app_id_count) {
|
||||
auto& ledger = app_ledger();
|
||||
mutex_lock(&ledger.mutex);
|
||||
if (ledger.packages.contains(package->id)) {
|
||||
mutex_unlock(&ledger.mutex);
|
||||
LOG_E(TAG, "Package with id '%s' is already registered", package->id);
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
AppPackageRecord record { .package = *package };
|
||||
record.app_ids.reserve(app_id_count);
|
||||
for (size_t i = 0; i < app_id_count; i++) {
|
||||
record.app_ids.emplace_back(app_ids[i]);
|
||||
}
|
||||
ledger.packages[package->id] = std::move(record);
|
||||
mutex_unlock(&ledger.mutex);
|
||||
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t app_manager_remove_package(const char* package_id) {
|
||||
auto& ledger = app_ledger();
|
||||
mutex_lock(&ledger.mutex);
|
||||
auto iterator = ledger.packages.find(package_id);
|
||||
if (iterator == ledger.packages.end()) {
|
||||
mutex_unlock(&ledger.mutex);
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
ledger.packages.erase(iterator);
|
||||
mutex_unlock(&ledger.mutex);
|
||||
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
error_t app_manager_find_package(const char* package_id, PackageManifest* out_package) {
|
||||
auto& ledger = app_ledger();
|
||||
mutex_lock(&ledger.mutex);
|
||||
auto iterator = ledger.packages.find(package_id);
|
||||
if (iterator == ledger.packages.end()) {
|
||||
mutex_unlock(&ledger.mutex);
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
*out_package = iterator->second.package;
|
||||
mutex_unlock(&ledger.mutex);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
void app_manager_for_each_package(AppPackageVisitorFn visitor, void* context) {
|
||||
auto& ledger = app_ledger();
|
||||
mutex_lock(&ledger.mutex);
|
||||
for (auto& [id, record] : ledger.packages) {
|
||||
std::vector<const char*> app_id_ptrs;
|
||||
app_id_ptrs.reserve(record.app_ids.size());
|
||||
for (const auto& app_id : record.app_ids) {
|
||||
app_id_ptrs.push_back(app_id.c_str());
|
||||
}
|
||||
AppPackage pkg { .package = record.package, .app_id_count = app_id_ptrs.size(), .app_ids = app_id_ptrs.data() };
|
||||
visitor(&pkg, context);
|
||||
}
|
||||
mutex_unlock(&ledger.mutex);
|
||||
}
|
||||
|
||||
error_t app_manager_start_internal(const AppManifest* manifest, AppLocation location, AppStackConfig stack, AppInstanceId parent_instance_id, int argc, const char* const argv_in[], const AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id) {
|
||||
char** argv = app_arguments_copy(argc, argv_in);
|
||||
if (argc > 0 && argv == nullptr) {
|
||||
@@ -200,19 +265,19 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Owns the AppManifest (and its id/name/path strings) that app_manager_add() only keeps a
|
||||
// non-owning pointer to. Separate from app_install.cpp's registry: scanning only
|
||||
// adds/removes registrations, never touches disk or running instances.
|
||||
struct ScannedAppManifest {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string path;
|
||||
AppManifest manifest {};
|
||||
// Owns the AppManifests (and their backing location-path strings) that app_manager_add() only
|
||||
// keeps non-owning pointers to. Separate from app_install.cpp's registry: scanning only
|
||||
// adds/removes registrations, never touches disk or running instances. AppManifest::id/name own
|
||||
// their own storage directly (fixed arrays), so this doesn't need to separately own those.
|
||||
struct ScannedPackageManifest {
|
||||
std::string path; // scanned directory
|
||||
std::vector<AppManifest> manifests; // one per AppManifest the package declared
|
||||
std::vector<std::string> locations; // backs manifests[i].location.location, same indices
|
||||
};
|
||||
|
||||
struct InstallPathRegistry {
|
||||
std::vector<std::string> paths;
|
||||
std::unordered_map<std::string, std::unique_ptr<ScannedAppManifest>> scanned;
|
||||
std::unordered_map<std::string, std::unique_ptr<ScannedPackageManifest>> scanned;
|
||||
Mutex mutex {};
|
||||
|
||||
InstallPathRegistry() { mutex_construct(&mutex); }
|
||||
@@ -249,50 +314,69 @@ void app_manager_install_path_scan(void) {
|
||||
app_fs_list_direct_subdirectories(root, found_app_dirs);
|
||||
}
|
||||
|
||||
// Snapshot once so the rest of the scan doesn't hold registry.mutex.
|
||||
// Snapshot once so the rest of the scan doesn't hold registry.mutex. Keeps each known
|
||||
// package's own manifest ids too, so a package whose directory has disappeared can have all
|
||||
// of its (possibly several) app_manager registrations removed below, not just one.
|
||||
struct KnownPackage {
|
||||
std::string path;
|
||||
std::vector<std::string> manifest_ids;
|
||||
};
|
||||
mutex_lock(®istry.mutex);
|
||||
std::unordered_map<std::string, std::string> known_paths;
|
||||
std::unordered_map<std::string, KnownPackage> known_packages;
|
||||
for (const auto& [id, record] : registry.scanned) {
|
||||
known_paths.emplace(id, record->path);
|
||||
KnownPackage known { .path = record->path };
|
||||
for (const auto& manifest : record->manifests) {
|
||||
known.manifest_ids.emplace_back(manifest.id);
|
||||
}
|
||||
known_packages.emplace(id, std::move(known));
|
||||
}
|
||||
mutex_unlock(®istry.mutex);
|
||||
|
||||
// Parses without registry.mutex held; filesystem IO is slow.
|
||||
std::vector<std::unique_ptr<ScannedAppManifest>> new_records;
|
||||
std::vector<std::unique_ptr<ScannedPackageManifest>> new_records;
|
||||
std::vector<std::string> new_package_ids;
|
||||
std::vector<PackageManifest> new_packages;
|
||||
for (const auto& app_dir : found_app_dirs) {
|
||||
auto manifest_path = app_dir + "/manifest.properties";
|
||||
if (!app_fs_is_file(manifest_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AppMetadata metadata {};
|
||||
if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) {
|
||||
PackageManifest package {};
|
||||
// Heap-allocated (not a stack array - too large for a typical app task's stack; this
|
||||
// function runs on whichever task calls app_manager_install_path_scan(), e.g. Boot's, via
|
||||
// registerInstalledAppsFromFileSystems()) and sized to fit exactly, not pre-allocated to
|
||||
// some fixed maximum (see app_package_manifest_parse_into()). OptExternalAllocator prefers
|
||||
// PSRAM for this transient buffer, freeing up scarce internal RAM.
|
||||
std::vector<AppManifestBinding, tt::OptExternalAllocator<AppManifestBinding>> app_bindings;
|
||||
if (app_package_manifest_parse_into(manifest_path.c_str(), package, app_bindings) != ERROR_NONE) {
|
||||
LOG_W(TAG, "Invalid manifest at %s", manifest_path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (known_paths.contains(metadata.app_id)) {
|
||||
if (known_packages.contains(package.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto record = std::make_unique<ScannedAppManifest>();
|
||||
record->id = metadata.app_id;
|
||||
record->name = metadata.app_name;
|
||||
auto record = std::make_unique<ScannedPackageManifest>();
|
||||
record->path = app_dir;
|
||||
record->manifest = AppManifest {
|
||||
.id = record->id.c_str(),
|
||||
.name = record->name.c_str(),
|
||||
.category = APP_CATEGORY_USER,
|
||||
.location = { APP_LOCATION_PATH, const_cast<char*>(record->path.c_str()) },
|
||||
.flags = 0,
|
||||
.stack = { .depth = static_cast<uint16_t>(metadata.stack_depth), .desired_memory_capability = 0 },
|
||||
};
|
||||
// Sized once, up front: manifests[i].location.location points into locations[i].c_str(),
|
||||
// which would dangle if either vector reallocated afterward.
|
||||
record->manifests.resize(app_bindings.size());
|
||||
record->locations.resize(app_bindings.size());
|
||||
for (size_t i = 0; i < app_bindings.size(); i++) {
|
||||
record->manifests[i] = app_bindings[i].manifest;
|
||||
record->locations[i] = app_resolve_binary_path(app_dir, app_bindings[i].binary);
|
||||
record->manifests[i].location = { APP_LOCATION_PATH, const_cast<char*>(record->locations[i].c_str()) };
|
||||
}
|
||||
new_package_ids.emplace_back(package.id);
|
||||
new_packages.push_back(package);
|
||||
new_records.push_back(std::move(record));
|
||||
}
|
||||
|
||||
std::vector<std::string> missing_ids;
|
||||
for (const auto& [id, path] : known_paths) {
|
||||
if (!app_fs_is_directory(path)) {
|
||||
for (const auto& [id, known] : known_packages) {
|
||||
if (!app_fs_is_directory(known.path)) {
|
||||
missing_ids.push_back(id);
|
||||
}
|
||||
}
|
||||
@@ -301,23 +385,60 @@ void app_manager_install_path_scan(void) {
|
||||
// registry.mutex would fix a lock order an opposite-order caller could deadlock against.
|
||||
// registry.mutex is retaken afterward only to publish the in-memory results.
|
||||
for (const auto& id : missing_ids) {
|
||||
app_manager_remove(id.c_str());
|
||||
}
|
||||
std::vector<std::unique_ptr<ScannedAppManifest>> added_records;
|
||||
for (auto& record : new_records) {
|
||||
if (app_manager_add(&record->manifest) == ERROR_NONE) {
|
||||
added_records.push_back(std::move(record));
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->id.c_str());
|
||||
for (const auto& manifest_id : known_packages.at(id).manifest_ids) {
|
||||
app_manager_remove(manifest_id.c_str());
|
||||
}
|
||||
app_manager_remove_package(id.c_str());
|
||||
}
|
||||
std::vector<std::unique_ptr<ScannedPackageManifest>> added_records;
|
||||
std::vector<std::string> added_package_ids;
|
||||
for (size_t r = 0; r < new_records.size(); r++) {
|
||||
auto& record = new_records[r];
|
||||
|
||||
// known_packages is only a pre-scan snapshot - two new directories can still share an id.
|
||||
if (std::ranges::find(added_package_ids, new_package_ids[r]) != added_package_ids.end()) {
|
||||
LOG_W(TAG, "Skipping duplicate package id %s found in this scan", new_package_ids[r].c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t added_count = 0;
|
||||
for (; added_count < record->manifests.size(); added_count++) {
|
||||
if (app_manager_add(&record->manifests[added_count]) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->manifests[added_count].id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (added_count != record->manifests.size()) {
|
||||
// All-or-nothing: unregister whatever this package already added before failing.
|
||||
for (size_t j = 0; j < added_count; j++) {
|
||||
app_manager_remove(record->manifests[j].id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<const char*> app_id_ptrs;
|
||||
app_id_ptrs.reserve(record->manifests.size());
|
||||
for (const auto& app_manifest : record->manifests) {
|
||||
app_id_ptrs.push_back(app_manifest.id);
|
||||
}
|
||||
if (app_manager_add_package(&new_packages[r], app_id_ptrs.data(), app_id_ptrs.size()) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to register package %s (duplicate id?)", new_packages[r].id);
|
||||
for (const auto& app_manifest : record->manifests) {
|
||||
app_manager_remove(app_manifest.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
added_package_ids.push_back(new_package_ids[r]);
|
||||
added_records.push_back(std::move(record));
|
||||
}
|
||||
|
||||
mutex_lock(®istry.mutex);
|
||||
for (const auto& id : missing_ids) {
|
||||
registry.scanned.erase(id);
|
||||
}
|
||||
for (auto& record : added_records) {
|
||||
registry.scanned[record->id] = std::move(record);
|
||||
for (size_t i = 0; i < added_records.size(); i++) {
|
||||
registry.scanned[added_package_ids[i]] = std::move(added_records[i]);
|
||||
}
|
||||
mutex_unlock(®istry.mutex);
|
||||
}
|
||||
@@ -332,7 +453,10 @@ error_t app_manager_install_path_uninstall(const char* app_id) {
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
const AppManifest* manifest = &iterator->second->manifest;
|
||||
// Pointer, not a copy: the ledger's own AppInstanceRecord::manifest pointers (set by
|
||||
// app_manager_add() from this exact vector) are compared against it by address below, same
|
||||
// as the pre-existing single-manifest version of this function did.
|
||||
const std::vector<AppManifest>* manifests = &iterator->second->manifests;
|
||||
auto path = iterator->second->path;
|
||||
mutex_unlock(®istry.mutex);
|
||||
|
||||
@@ -342,8 +466,11 @@ error_t app_manager_install_path_uninstall(const char* app_id) {
|
||||
auto& ledger = app_ledger();
|
||||
mutex_lock(&ledger.mutex);
|
||||
for (const auto& [id, record] : ledger.instances) {
|
||||
if (record.manifest == manifest) {
|
||||
instance_ids.push_back(id);
|
||||
for (const auto& manifest : *manifests) {
|
||||
if (record.manifest == &manifest) {
|
||||
instance_ids.push_back(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
mutex_unlock(&ledger.mutex);
|
||||
@@ -354,7 +481,10 @@ error_t app_manager_install_path_uninstall(const char* app_id) {
|
||||
|
||||
// app_manager_remove() takes ledger.mutex; call outside registry.mutex too, matching
|
||||
// the lock order in app_manager_install_path_scan().
|
||||
app_manager_remove(app_id);
|
||||
for (const auto& manifest : *manifests) {
|
||||
app_manager_remove(manifest.id);
|
||||
}
|
||||
app_manager_remove_package(app_id);
|
||||
|
||||
// Delete before erasing the scan record, so a failed deletion still leaves the
|
||||
// entry discoverable for a retry.
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
#include <app/manifest.h>
|
||||
#include <app/private/metadata_parsing_internal.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
bool app_id_is_valid(const char* id) {
|
||||
bool app_manifest_id_is_valid(const char* id) {
|
||||
auto size = strlen(id);
|
||||
return size >= 5 && size <= APP_ID_LENGTH && app_metadata_validate_string(id, [](char c) {
|
||||
return size >= 5 && size <= APP_MANIFEST_ID_LENGTH && app_package_manifest_validate_string(id, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_manifest_name_is_valid(const char* name) {
|
||||
auto size = strlen(name);
|
||||
return size >= 2 && size <= APP_MANIFEST_NAME_LENGTH && app_package_manifest_validate_string(name, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == ' ' || c == '-';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_manifest_stack_size_is_valid(const char* value) {
|
||||
// 10 digits is the maximum decimal width of uint32_t.
|
||||
auto size = strlen(value);
|
||||
return size > 0 && size <= 10 && app_package_manifest_validate_string(value, [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <app/metadata.h>
|
||||
#include <app/private/metadata_parsing_internal.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
constexpr auto* TAG = "app_metadata";
|
||||
|
||||
bool app_metadata_validate_string(const std::string& value, bool (*is_valid_char)(char)) {
|
||||
for (char c: value) {
|
||||
if (!is_valid_char(c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
#define validate_string app_metadata_validate_string
|
||||
|
||||
std::string trim(const std::string& value) {
|
||||
constexpr auto* whitespace = " \t\r\n";
|
||||
auto start = value.find_first_not_of(whitespace);
|
||||
if (start == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
auto end = value.find_last_not_of(whitespace);
|
||||
return value.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
/** Validates a comma-separated list: non-empty, no leading/trailing/double commas (which would
|
||||
* produce an empty item), and every item passing @a is_valid_item. */
|
||||
bool validate_csv_list(const std::string& value, bool (*is_valid_item)(const std::string&)) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
auto comma = value.find(',', start);
|
||||
auto end = comma == std::string::npos ? value.size() : comma;
|
||||
if (end == start || !is_valid_item(value.substr(start, end - start))) {
|
||||
return false;
|
||||
}
|
||||
if (comma == std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
start = comma + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** manifest.properties format: "key=value" lines, "[section]" lines prefix every following key
|
||||
* until the next section, "#" lines are comments, blank lines are skipped. Deliberately a local,
|
||||
* minimal re-implementation rather than depending on Tactility's file::loadPropertiesFile() -
|
||||
* app-module (like every other kernel module) may not depend upward on the Tactility layer. */
|
||||
bool load_properties(const std::string& path, std::map<std::string, std::string>& out_properties, std::string& out_first_line) {
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
std::string section_prefix;
|
||||
bool got_first_line = false;
|
||||
while (std::getline(file, line)) {
|
||||
auto trimmed_line = trim(line);
|
||||
|
||||
if (trimmed_line.empty() || trimmed_line.starts_with("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!got_first_line) {
|
||||
out_first_line = trimmed_line;
|
||||
got_first_line = true;
|
||||
}
|
||||
|
||||
if (trimmed_line.starts_with("[")) {
|
||||
section_prefix = trimmed_line;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto separator_index = trimmed_line.find('=');
|
||||
if (separator_index == std::string::npos) {
|
||||
LOG_E(TAG, "Failed to parse manifest line (skipped): %s", trimmed_line.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
auto key = section_prefix + trim(trimmed_line.substr(0, separator_index));
|
||||
auto value = trim(trimmed_line.substr(separator_index + 1));
|
||||
out_properties[key] = value;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool app_metadata_get_value(const std::map<std::string, std::string>& properties, const std::string& key, std::string& out_value) {
|
||||
const auto iterator = properties.find(key);
|
||||
if (iterator == properties.end()) {
|
||||
LOG_E(TAG, "Failed to find %s in manifest", key.c_str());
|
||||
return false;
|
||||
}
|
||||
out_value = iterator->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_format_version(const std::string& version) {
|
||||
return !version.empty() && validate_string(version, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_name(const std::string& name) {
|
||||
return name.size() >= 2 && name.size() <= APP_METADATA_APP_NAME_LENGTH && validate_string(name, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == ' ' || c == '-';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_version_name(const std::string& version) {
|
||||
return !version.empty() && version.size() <= APP_METADATA_APP_VERSION_NAME_LENGTH && validate_string(version, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.' || c == '-' || c == '_';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_version_code(const std::string& version) {
|
||||
// 20 digits is the maximum decimal width of uint64_t.
|
||||
return !version.empty() && version.size() <= 20 && validate_string(version, [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_stack_size(const std::string& value) {
|
||||
// 10 digits is the maximum decimal width of uint32_t.
|
||||
return !value.empty() && value.size() <= 10 && validate_string(value, [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_is_valid_device_id_list(const std::string& value) {
|
||||
return validate_csv_list(value, [](const std::string& item) {
|
||||
bool has_alnum = false;
|
||||
for (char c: item) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c)) != 0) {
|
||||
has_alnum = true;
|
||||
} else if (c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return has_alnum;
|
||||
});
|
||||
}
|
||||
|
||||
bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value) {
|
||||
if (value.size() >= dest_size) {
|
||||
return false;
|
||||
}
|
||||
memcpy(dest, value.c_str(), value.size() + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata) {
|
||||
LOG_I(TAG, "Parsing manifest %s", path);
|
||||
|
||||
// requires_device_id is optional in V2 and unwritten by V1; zeroing here (rather than relying
|
||||
// on the caller) guarantees it reads back as empty ("unrestricted") either way.
|
||||
*out_metadata = {};
|
||||
|
||||
std::map<std::string, std::string> properties;
|
||||
std::string first_line;
|
||||
if (!load_properties(path, properties, first_line)) {
|
||||
LOG_E(TAG, "Failed to load manifest at %s", path);
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
// The V1 format's first line is always the literal "[manifest]" section header; V2 files are
|
||||
// flat from the first line onward.
|
||||
bool is_v1_format = first_line == "[manifest]";
|
||||
bool success = is_v1_format
|
||||
? app_metadata_parse_v1(properties, *out_metadata)
|
||||
: app_metadata_parse_v2(properties, *out_metadata);
|
||||
|
||||
return success ? ERROR_NONE : ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "app/manifest.h"
|
||||
|
||||
|
||||
#include <app/metadata.h>
|
||||
#include <app/private/metadata_parsing_internal.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "app_metadata_v1";
|
||||
|
||||
bool app_metadata_parse_v1(const std::map<std::string, std::string>& properties, AppMetadata& out_metadata) {
|
||||
// [manifest]
|
||||
LOG_W(TAG, "This manifest version is deprecated. Replace it with the newer version.");
|
||||
|
||||
std::string format_version;
|
||||
if (!app_metadata_get_value(properties, "[manifest]version", format_version)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_format_version(format_version)) {
|
||||
LOG_E(TAG, "Invalid version");
|
||||
return false;
|
||||
}
|
||||
|
||||
// [app]
|
||||
|
||||
std::string id;
|
||||
if (!app_metadata_get_value(properties, "[app]id", id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_id_is_valid(id.c_str())) {
|
||||
LOG_E(TAG, "Invalid app id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) {
|
||||
LOG_E(TAG, "App id too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string name;
|
||||
if (!app_metadata_get_value(properties, "[app]name", name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_name(name)) {
|
||||
LOG_E(TAG, "Invalid app name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) {
|
||||
LOG_E(TAG, "App name too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_name;
|
||||
if (!app_metadata_get_value(properties, "[app]versionName", version_name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_version_name(version_name)) {
|
||||
LOG_E(TAG, "Invalid app version name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) {
|
||||
LOG_E(TAG, "App version name too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!app_metadata_get_value(properties, "[app]versionCode", version_code_string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_version_code(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid app version code");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t version_code = 0;
|
||||
const auto* first = version_code_string.data();
|
||||
const auto* last = first + version_code_string.size();
|
||||
if (std::from_chars(first, last, version_code).ec != std::errc {}) {
|
||||
LOG_E(TAG, "App version code out of range");
|
||||
return false;
|
||||
}
|
||||
out_metadata.app_version_code = version_code; // [target]
|
||||
|
||||
std::string target_sdk;
|
||||
if (!app_metadata_get_value(properties, "[target]sdk", target_sdk)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) {
|
||||
LOG_E(TAG, "Target sdk too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
out_metadata.stack_depth = 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "app/manifest.h"
|
||||
|
||||
|
||||
#include <app/metadata.h>
|
||||
#include <app/private/metadata_parsing_internal.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "app_metadata_v2";
|
||||
|
||||
bool app_metadata_parse_v2(const std::map<std::string, std::string>& properties, AppMetadata& out_metadata) {
|
||||
// manifest
|
||||
|
||||
std::string format_version;
|
||||
if (!app_metadata_get_value(properties, "manifest.version", format_version)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_format_version(format_version)) {
|
||||
LOG_E(TAG, "Invalid version");
|
||||
return false;
|
||||
}
|
||||
|
||||
// app
|
||||
|
||||
std::string id;
|
||||
if (!app_metadata_get_value(properties, "app.id", id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_id_is_valid(id.c_str())) {
|
||||
LOG_E(TAG, "Invalid app id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) {
|
||||
LOG_E(TAG, "App id too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string name;
|
||||
if (!app_metadata_get_value(properties, "app.name", name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_name(name)) {
|
||||
LOG_E(TAG, "Invalid app name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) {
|
||||
LOG_E(TAG, "App name too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_name;
|
||||
if (!app_metadata_get_value(properties, "app.version.name", version_name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_version_name(version_name)) {
|
||||
LOG_E(TAG, "Invalid app version name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) {
|
||||
LOG_E(TAG, "App version name too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!app_metadata_get_value(properties, "app.version.code", version_code_string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_is_valid_version_code(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid app version code");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t version_code = 0;
|
||||
const auto* first = version_code_string.data();
|
||||
const auto* last = first + version_code_string.size();
|
||||
if (std::from_chars(first, last, version_code).ec != std::errc {}) {
|
||||
LOG_E(TAG, "App version code out of range");
|
||||
return false;
|
||||
}
|
||||
out_metadata.app_version_code = version_code; // [target]
|
||||
|
||||
// target
|
||||
|
||||
std::string target_sdk;
|
||||
if (!app_metadata_get_value(properties, "target.sdk", target_sdk)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) {
|
||||
LOG_E(TAG, "Target sdk too long");
|
||||
return false;
|
||||
}
|
||||
|
||||
// requires.device.id (optional; if present, must be a non-empty comma-separated list)
|
||||
|
||||
auto device_id_iterator = properties.find("requires.device.id");
|
||||
if (device_id_iterator != properties.end()) {
|
||||
const std::string& device_id = device_id_iterator->second;
|
||||
if (!app_metadata_is_valid_device_id_list(device_id)) {
|
||||
LOG_E(TAG, "Invalid requires.device.id");
|
||||
return false;
|
||||
}
|
||||
if (!app_metadata_copy_bounded(out_metadata.requires_device_id, sizeof(out_metadata.requires_device_id), device_id)) {
|
||||
LOG_E(TAG, "requires.device.id too long");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// app.stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t)
|
||||
|
||||
auto stack_size_iterator = properties.find("app.stack.depth");
|
||||
if (stack_size_iterator != properties.end()) {
|
||||
const std::string& stack_size_string = stack_size_iterator->second;
|
||||
if (!app_metadata_is_valid_stack_size(stack_size_string)) {
|
||||
LOG_E(TAG, "Invalid app.stack.depth");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t stack_size = 0;
|
||||
const auto* stack_size_first = stack_size_string.data();
|
||||
const auto* stack_size_last = stack_size_first + stack_size_string.size();
|
||||
if (std::from_chars(stack_size_first, stack_size_last, stack_size).ec != std::errc {}) {
|
||||
LOG_E(TAG, "App stack depth out of range");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) -
|
||||
// a value like 1073741825 would otherwise silently narrow to 1, handing the app a
|
||||
// catastrophically undersized stack instead of the huge one it declared.
|
||||
if (stack_size > APP_STACK_SIZE_MAX) {
|
||||
LOG_E(TAG, "App stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", stack_size, APP_STACK_SIZE_MAX);
|
||||
return false;
|
||||
}
|
||||
out_metadata.stack_depth = stack_size;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
#include <app/io.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/metadata.h>
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/paths.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/start.h>
|
||||
@@ -47,20 +47,21 @@ static const ModuleSymbol SYMBOLS[] = {
|
||||
DEFINE_MODULE_SYMBOL(app_manager_for_each_manifest),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_add),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_remove),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_add_package),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_remove_package),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_find_package),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_for_each_package),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_get_topmost_instance_id),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_get_topmost_app_id),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_install_path_add),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_install_path_scan),
|
||||
DEFINE_MODULE_SYMBOL(app_manager_install_path_uninstall),
|
||||
// app/start
|
||||
DEFINE_MODULE_SYMBOL(app_start),
|
||||
DEFINE_MODULE_SYMBOL(app_start_for_result),
|
||||
DEFINE_MODULE_SYMBOL(app_start_with_streams),
|
||||
DEFINE_MODULE_SYMBOL(app_start_for_result_with_streams),
|
||||
// app/manifest
|
||||
DEFINE_MODULE_SYMBOL(app_id_is_valid),
|
||||
// app/metadata
|
||||
DEFINE_MODULE_SYMBOL(app_metadata_parse),
|
||||
DEFINE_MODULE_SYMBOL(app_manifest_id_is_valid),
|
||||
DEFINE_MODULE_SYMBOL(app_manifest_name_is_valid),
|
||||
DEFINE_MODULE_SYMBOL(app_manifest_stack_size_is_valid),
|
||||
// app/package_manifest
|
||||
DEFINE_MODULE_SYMBOL(app_package_manifest_parse),
|
||||
// app/paths
|
||||
DEFINE_MODULE_SYMBOL(app_paths_get_user_data_directory),
|
||||
DEFINE_MODULE_SYMBOL(app_paths_get_user_data_path),
|
||||
@@ -68,6 +69,11 @@ static const ModuleSymbol SYMBOLS[] = {
|
||||
DEFINE_MODULE_SYMBOL(app_paths_get_assets_path),
|
||||
// app/scheduler
|
||||
DEFINE_MODULE_SYMBOL(app_scheduler_current_app_id),
|
||||
// app/start
|
||||
DEFINE_MODULE_SYMBOL(app_start),
|
||||
DEFINE_MODULE_SYMBOL(app_start_for_result),
|
||||
DEFINE_MODULE_SYMBOL(app_start_with_streams),
|
||||
DEFINE_MODULE_SYMBOL(app_start_for_result_with_streams),
|
||||
// app/stream
|
||||
DEFINE_MODULE_SYMBOL(app_stream_subscribe),
|
||||
DEFINE_MODULE_SYMBOL(app_stream_unsubscribe),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <app/manifest.h>
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
constexpr auto* TAG = "app_metadata";
|
||||
|
||||
bool app_package_manifest_validate_string(const std::string& value, bool (*is_valid_char)(char)) {
|
||||
for (char c: value) {
|
||||
if (!is_valid_char(c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
#define validate_string app_package_manifest_validate_string
|
||||
|
||||
std::string trim(const std::string& value) {
|
||||
constexpr auto* whitespace = " \t\r\n";
|
||||
auto start = value.find_first_not_of(whitespace);
|
||||
if (start == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
auto end = value.find_last_not_of(whitespace);
|
||||
return value.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
/** Validates a comma-separated list: non-empty, no leading/trailing/double commas (which would
|
||||
* produce an empty item), and every item passing @a is_valid_item. */
|
||||
bool validate_csv_list(const std::string& value, bool (*is_valid_item)(const std::string&)) {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
auto comma = value.find(',', start);
|
||||
auto end = comma == std::string::npos ? value.size() : comma;
|
||||
if (end == start || !is_valid_item(value.substr(start, end - start))) {
|
||||
return false;
|
||||
}
|
||||
if (comma == std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
start = comma + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** manifest.properties format: flat "key=value" lines, "#" lines are comments, blank lines are
|
||||
* skipped. Deliberately a local, minimal re-implementation rather than depending on Tactility's
|
||||
* file::loadPropertiesFile() - app-module (like every other kernel module) may not depend upward
|
||||
* on the Tactility layer. */
|
||||
bool load_properties(const std::string& path, std::map<std::string, std::string>& out_properties) {
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
auto trimmed_line = trim(line);
|
||||
|
||||
if (trimmed_line.empty() || trimmed_line.starts_with("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto separator_index = trimmed_line.find('=');
|
||||
if (separator_index == std::string::npos) {
|
||||
LOG_E(TAG, "Failed to parse manifest line (skipped): %s", trimmed_line.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
auto key = trim(trimmed_line.substr(0, separator_index));
|
||||
auto value = trim(trimmed_line.substr(separator_index + 1));
|
||||
out_properties[key] = value;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool app_package_manifest_get_value(const std::map<std::string, std::string>& properties, const std::string& key, std::string& out_value) {
|
||||
const auto iterator = properties.find(key);
|
||||
if (iterator == properties.end()) {
|
||||
LOG_E(TAG, "Failed to find %s in manifest", key.c_str());
|
||||
return false;
|
||||
}
|
||||
out_value = iterator->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_format_version(const std::string& version) {
|
||||
return !version.empty() && validate_string(version, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_version_name(const std::string& version) {
|
||||
return !version.empty() && version.size() <= PACKAGE_MANIFEST_VERSION_NAME_LENGTH && validate_string(version, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.' || c == '-' || c == '_';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_version_code(const std::string& version) {
|
||||
// 20 digits is the maximum decimal width of uint64_t.
|
||||
return !version.empty() && version.size() <= 20 && validate_string(version, [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_bool(const std::string& value) {
|
||||
return value == "true" || value == "false";
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_device_id_list(const std::string& value) {
|
||||
return validate_csv_list(value, [](const std::string& item) {
|
||||
bool has_alnum = false;
|
||||
for (char c: item) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c)) != 0) {
|
||||
has_alnum = true;
|
||||
} else if (c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return has_alnum;
|
||||
});
|
||||
}
|
||||
|
||||
bool app_package_manifest_is_valid_binary_name(const std::string& value) {
|
||||
return !value.empty() && value.size() <= APP_MANIFEST_BINARY_LENGTH && validate_string(value, [](char c) {
|
||||
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '.' || c == '_' || c == '-';
|
||||
});
|
||||
}
|
||||
|
||||
bool app_package_manifest_copy_bounded(char* dest, size_t dest_size, const std::string& value) {
|
||||
if (value.size() >= dest_size) {
|
||||
return false;
|
||||
}
|
||||
memcpy(dest, value.c_str(), value.size() + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
error_t app_package_manifest_parse(const char* path, struct PackageManifest* out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity) {
|
||||
LOG_I(TAG, "Parsing manifest %s", path);
|
||||
|
||||
// requires_device_id is optional; zeroing here (rather than relying on the caller) guarantees
|
||||
// it reads back as empty ("unrestricted") when the manifest omits it.
|
||||
*out_package = {};
|
||||
for (size_t i = 0; i < bindings_capacity; i++) {
|
||||
out_bindings[i] = {};
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> properties;
|
||||
if (!load_properties(path, properties)) {
|
||||
LOG_E(TAG, "Failed to load manifest at %s", path);
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
std::string format_version;
|
||||
if (!app_package_manifest_get_value(properties, "manifest.version", format_version)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (format_version == "0.2") {
|
||||
return package_manifest_parse_v2(properties, *out_package, out_bindings, bindings_capacity);
|
||||
} else if (format_version == "0.3") {
|
||||
return package_manifest_parse_v3(properties, *out_package, out_bindings, bindings_capacity);
|
||||
} else {
|
||||
LOG_E(TAG, "Unsupported manifest.version: %s", format_version.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
error_t app_package_manifest_parse_into(const char* path, PackageManifest& out_package, std::vector<AppManifestBinding, tt::OptExternalAllocator<AppManifestBinding>>& out_bindings) {
|
||||
error_t result = app_package_manifest_parse(path, &out_package, nullptr, 0);
|
||||
if (result != ERROR_NONE) {
|
||||
return result;
|
||||
}
|
||||
out_bindings.resize(out_package.app_manifest_count);
|
||||
result = app_package_manifest_parse(path, &out_package, out_bindings.data(), out_bindings.size());
|
||||
if (result != ERROR_NONE) {
|
||||
return result;
|
||||
}
|
||||
// Manifest could have changed between the two parses above.
|
||||
if (out_package.app_manifest_count != out_bindings.size()) {
|
||||
LOG_E(TAG, "Manifest at %s changed while being parsed", path);
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
return ERROR_NONE;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "app/manifest.h"
|
||||
|
||||
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
|
||||
#include <charconv>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "app_metadata_v2";
|
||||
|
||||
error_t package_manifest_parse_v2(const std::map<std::string, std::string>& properties, PackageManifest& out_package, AppManifestBinding* out_bindings, size_t bindings_capacity) {
|
||||
// manifest
|
||||
|
||||
std::string format_version;
|
||||
if (!app_package_manifest_get_value(properties, "manifest.version", format_version)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_is_valid_format_version(format_version)) {
|
||||
LOG_E(TAG, "Invalid version");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// app
|
||||
|
||||
std::string id;
|
||||
if (!app_package_manifest_get_value(properties, "app.id", id)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_manifest_id_is_valid(id.c_str())) {
|
||||
LOG_E(TAG, "Invalid app id");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_copy_bounded(out_package.id, sizeof(out_package.id), id)) {
|
||||
LOG_E(TAG, "App id too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string name;
|
||||
if (!app_package_manifest_get_value(properties, "app.name", name)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_manifest_name_is_valid(name.c_str())) {
|
||||
LOG_E(TAG, "Invalid app name");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string version_name;
|
||||
if (!app_package_manifest_get_value(properties, "app.version.name", version_name)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_is_valid_version_name(version_name)) {
|
||||
LOG_E(TAG, "Invalid app version name");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_copy_bounded(out_package.version_name, sizeof(out_package.version_name), version_name)) {
|
||||
LOG_E(TAG, "App version name too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!app_package_manifest_get_value(properties, "app.version.code", version_code_string)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_is_valid_version_code(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid app version code");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
uint64_t version_code = 0;
|
||||
const auto* first = version_code_string.data();
|
||||
const auto* last = first + version_code_string.size();
|
||||
if (std::from_chars(first, last, version_code).ec != std::errc {}) {
|
||||
LOG_E(TAG, "App version code out of range");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
out_package.version_code = version_code;
|
||||
|
||||
// target
|
||||
|
||||
std::string target_sdk;
|
||||
if (!app_package_manifest_get_value(properties, "target.sdk", target_sdk)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_copy_bounded(out_package.target_sdk, sizeof(out_package.target_sdk), target_sdk)) {
|
||||
LOG_E(TAG, "Target sdk too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// requires.device.id (optional; if present, must be a non-empty comma-separated list)
|
||||
|
||||
auto device_id_iterator = properties.find("requires.device.id");
|
||||
if (device_id_iterator != properties.end()) {
|
||||
const std::string& device_id = device_id_iterator->second;
|
||||
if (!app_package_manifest_is_valid_device_id_list(device_id)) {
|
||||
LOG_E(TAG, "Invalid requires.device.id");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_package.requires_device_id, sizeof(out_package.requires_device_id), device_id)) {
|
||||
LOG_E(TAG, "requires.device.id too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
// A v2 manifest always describes exactly one app, sharing the package's own id.
|
||||
out_package.app_manifest_count = 1;
|
||||
if (bindings_capacity == 0) {
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
AppManifestBinding& binding = out_bindings[0];
|
||||
AppManifest& manifest = binding.manifest;
|
||||
|
||||
// v2 predates the "binary" key - the single app always installs as bin/<platform>/app.{elf,so}.
|
||||
if (!app_package_manifest_copy_bounded(binding.binary, sizeof(binding.binary), "app")) {
|
||||
LOG_E(TAG, "Binary name too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!app_package_manifest_copy_bounded(manifest.id, sizeof(manifest.id), id)) {
|
||||
LOG_E(TAG, "App id too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(manifest.name, sizeof(manifest.name), name)) {
|
||||
LOG_E(TAG, "App name too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// app.stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t)
|
||||
|
||||
auto stack_size_iterator = properties.find("app.stack.depth");
|
||||
if (stack_size_iterator != properties.end()) {
|
||||
const std::string& stack_size_string = stack_size_iterator->second;
|
||||
if (!app_manifest_stack_size_is_valid(stack_size_string.c_str())) {
|
||||
LOG_E(TAG, "Invalid app.stack.depth");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
uint32_t stack_size = 0;
|
||||
const auto* stack_size_first = stack_size_string.data();
|
||||
const auto* stack_size_last = stack_size_first + stack_size_string.size();
|
||||
if (std::from_chars(stack_size_first, stack_size_last, stack_size).ec != std::errc {}) {
|
||||
LOG_E(TAG, "App stack depth out of range");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) -
|
||||
// a value like 1073741825 would otherwise silently narrow to 1, handing the app a
|
||||
// catastrophically undersized stack instead of the huge one it declared.
|
||||
if (stack_size > APP_STACK_SIZE_MAX) {
|
||||
LOG_E(TAG, "App stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", stack_size, APP_STACK_SIZE_MAX);
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
manifest.stack.depth = static_cast<uint16_t>(stack_size);
|
||||
}
|
||||
|
||||
// v2 predates per-app visibility - always visible.
|
||||
manifest.flags = 0;
|
||||
manifest.category = APP_CATEGORY_USER;
|
||||
|
||||
return ERROR_NONE;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "app/manifest.h"
|
||||
|
||||
|
||||
#include <app/package_manifest.h>
|
||||
#include <app/private/package_manifest_parsing.h>
|
||||
|
||||
#include <charconv>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "app_metadata_v3";
|
||||
|
||||
namespace {
|
||||
|
||||
// Numeric index out of an "app.<index>.<rest>" key, or nullopt if it doesn't match that shape.
|
||||
std::optional<size_t> parse_app_key_index(const std::string& key) {
|
||||
constexpr auto* prefix = "app.";
|
||||
constexpr size_t prefix_len = 4;
|
||||
if (!key.starts_with(prefix)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto dot = key.find('.', prefix_len);
|
||||
if (dot == std::string::npos || dot == prefix_len) {
|
||||
return std::nullopt;
|
||||
}
|
||||
size_t index = 0;
|
||||
const auto* first = key.data() + prefix_len;
|
||||
const auto* last = key.data() + dot;
|
||||
auto result = std::from_chars(first, last, index);
|
||||
if (result.ec != std::errc {} || result.ptr != last) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// Parses one "app.<index>.*" block into @a out_binding.
|
||||
error_t parse_app_manifest(const std::map<std::string, std::string>& properties, size_t index, AppManifestBinding& out_binding) {
|
||||
auto prefix = std::format("app.{}.", index);
|
||||
AppManifest& out_manifest = out_binding.manifest;
|
||||
|
||||
std::string id;
|
||||
if (!app_package_manifest_get_value(properties, prefix + "id", id)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_manifest_id_is_valid(id.c_str())) {
|
||||
LOG_E(TAG, "Invalid %sid", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_manifest.id, sizeof(out_manifest.id), id)) {
|
||||
LOG_E(TAG, "%sid too long", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string binary;
|
||||
if (!app_package_manifest_get_value(properties, prefix + "binary", binary)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_is_valid_binary_name(binary)) {
|
||||
LOG_E(TAG, "Invalid %sbinary", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
// Filename (without extension) this app installs as under bin/<platform>/ - see
|
||||
// app_package_manifest_parse()'s own doc.
|
||||
if (!app_package_manifest_copy_bounded(out_binding.binary, sizeof(out_binding.binary), binary)) {
|
||||
LOG_E(TAG, "%sbinary too long", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string name;
|
||||
if (!app_package_manifest_get_value(properties, prefix + "name", name)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_manifest_name_is_valid(name.c_str())) {
|
||||
LOG_E(TAG, "Invalid %sname", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_manifest.name, sizeof(out_manifest.name), name)) {
|
||||
LOG_E(TAG, "%sname too long", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// <index>.stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t)
|
||||
auto stack_size_iterator = properties.find(prefix + "stack.depth");
|
||||
if (stack_size_iterator != properties.end()) {
|
||||
const std::string& stack_size_string = stack_size_iterator->second;
|
||||
if (!app_manifest_stack_size_is_valid(stack_size_string.c_str())) {
|
||||
LOG_E(TAG, "Invalid %sstack.depth", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
uint32_t stack_size = 0;
|
||||
const auto* first = stack_size_string.data();
|
||||
const auto* last = first + stack_size_string.size();
|
||||
if (std::from_chars(first, last, stack_size).ec != std::errc {}) {
|
||||
LOG_E(TAG, "%sstack.depth out of range", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
// Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) -
|
||||
// a value like 1073741825 would otherwise silently narrow to 1, handing the app a
|
||||
// catastrophically undersized stack instead of the huge one it declared.
|
||||
if (stack_size > APP_STACK_SIZE_MAX) {
|
||||
LOG_E(TAG, "%sstack.depth %u exceeds APP_STACK_SIZE_MAX(%u)", prefix.c_str(), stack_size, APP_STACK_SIZE_MAX);
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
out_manifest.stack.depth = static_cast<uint16_t>(stack_size);
|
||||
}
|
||||
|
||||
// <index>.hidden (optional; defaults to false)
|
||||
auto hidden_iterator = properties.find(prefix + "hidden");
|
||||
if (hidden_iterator != properties.end()) {
|
||||
if (!app_package_manifest_is_valid_bool(hidden_iterator->second)) {
|
||||
LOG_E(TAG, "Invalid %shidden", prefix.c_str());
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
out_manifest.flags = hidden_iterator->second == "true" ? APP_MANIFEST_FLAG_HIDDEN : 0;
|
||||
}
|
||||
|
||||
out_manifest.category = APP_CATEGORY_USER;
|
||||
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
error_t package_manifest_parse_v3(const std::map<std::string, std::string>& properties, PackageManifest& out_package, AppManifestBinding* out_bindings, size_t bindings_capacity) {
|
||||
// package-level fields: bare keys, no "app." prefix (that's reserved for the app.<index>.*
|
||||
// blocks below).
|
||||
|
||||
std::string id;
|
||||
if (!app_package_manifest_get_value(properties, "id", id)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_manifest_id_is_valid(id.c_str())) {
|
||||
LOG_E(TAG, "Invalid id");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_package.id, sizeof(out_package.id), id)) {
|
||||
LOG_E(TAG, "id too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string version_name;
|
||||
if (!app_package_manifest_get_value(properties, "version.name", version_name)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_is_valid_version_name(version_name)) {
|
||||
LOG_E(TAG, "Invalid version.name");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_package.version_name, sizeof(out_package.version_name), version_name)) {
|
||||
LOG_E(TAG, "version.name too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!app_package_manifest_get_value(properties, "version.code", version_code_string)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_is_valid_version_code(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid version.code");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
uint64_t version_code = 0;
|
||||
const auto* first = version_code_string.data();
|
||||
const auto* last = first + version_code_string.size();
|
||||
if (std::from_chars(first, last, version_code).ec != std::errc {}) {
|
||||
LOG_E(TAG, "version.code out of range");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
out_package.version_code = version_code;
|
||||
|
||||
std::string target_sdk;
|
||||
if (!app_package_manifest_get_value(properties, "target.sdk", target_sdk)) {
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_package.target_sdk, sizeof(out_package.target_sdk), target_sdk)) {
|
||||
LOG_E(TAG, "target.sdk too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
auto device_id_iterator = properties.find("requires.device.id");
|
||||
if (device_id_iterator != properties.end()) {
|
||||
const std::string& device_id = device_id_iterator->second;
|
||||
if (!app_package_manifest_is_valid_device_id_list(device_id)) {
|
||||
LOG_E(TAG, "Invalid requires.device.id");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (!app_package_manifest_copy_bounded(out_package.requires_device_id, sizeof(out_package.requires_device_id), device_id)) {
|
||||
LOG_E(TAG, "requires.device.id too long");
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
// app.<index>.* blocks: 0-indexed, contiguous - the first missing "app.<index>.id" ends the list.
|
||||
size_t count = 0;
|
||||
while (properties.contains(std::format("app.{}.id", count))) {
|
||||
count++;
|
||||
// Bounded here, not just against the caller's bindings_capacity below: a caller that
|
||||
// sizes its own buffer off PackageManifest::app_manifest_count (see
|
||||
// app_package_manifest_parse_into()) would otherwise let a malicious/malformed manifest
|
||||
// demand an unbounded allocation.
|
||||
if (count > PACKAGE_MANIFEST_MAX_APP_MANIFESTS) {
|
||||
LOG_E(TAG, "Manifest declares more than %d apps", PACKAGE_MANIFEST_MAX_APP_MANIFESTS);
|
||||
return ERROR_BUFFER_OVERFLOW;
|
||||
}
|
||||
}
|
||||
|
||||
out_package.app_manifest_count = static_cast<uint32_t>(count);
|
||||
|
||||
// Catch a gap the count loop above would otherwise silently drop, e.g. app.0.* + app.3.*.
|
||||
for (const auto& [key, value] : properties) {
|
||||
auto index = parse_app_key_index(key);
|
||||
if (index.has_value() && *index >= count) {
|
||||
LOG_E(TAG, "Manifest declares %s but only %zu contiguous app(s) starting at app.0 were found", key.c_str(), count);
|
||||
return ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0 || bindings_capacity == 0) {
|
||||
return ERROR_NONE;
|
||||
}
|
||||
if (count > bindings_capacity) {
|
||||
LOG_E(TAG, "Manifest declares %zu apps, capacity is %zu", count, bindings_capacity);
|
||||
return ERROR_BUFFER_OVERFLOW;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
error_t result = parse_app_manifest(properties, i, out_bindings[i]);
|
||||
if (result != ERROR_NONE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return ERROR_NONE;
|
||||
}
|
||||
@@ -6,6 +6,27 @@
|
||||
#include <tactility/paths.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
// manifest.location.location is the fully-resolved binary file path
|
||||
// ({install_dir}/bin/<platform>/<binary>.{elf,so} - see app_resolve_binary_path()), not the
|
||||
// install directory itself - strip that fixed 3-segment suffix to recover it.
|
||||
bool app_get_install_dir_from_binary_path(const char* binary_path, std::string& out_install_dir) {
|
||||
std::string path = binary_path;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
auto separator = path.find_last_of('/');
|
||||
if (separator == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
path = path.substr(0, separator);
|
||||
}
|
||||
out_install_dir = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
@@ -46,7 +67,12 @@ error_t app_paths_get_assets_directory(const char* app_id, char* out_path, size_
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
int written = std::snprintf(out_path, out_path_size, "%s/assets", static_cast<const char*>(manifest.location.location));
|
||||
std::string install_dir;
|
||||
if (!app_get_install_dir_from_binary_path(static_cast<const char*>(manifest.location.location), install_dir)) {
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
int written = std::snprintf(out_path, out_path_size, "%s/assets", install_dir.c_str());
|
||||
if (written < 0 || (size_t)written >= out_path_size) {
|
||||
return ERROR_BUFFER_OVERFLOW;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ constexpr size_t APP_INSTANCE_ID_THREAD_SLOT_INDEX = 1;
|
||||
// Matches TactilityKernel's Thread wrapper's THREAD_PRIORITY_NORMAL.
|
||||
constexpr UBaseType_t APP_TASK_PRIORITY = 4;
|
||||
|
||||
// Used when an app's manifest doesn't request a specific stack depth (0). 8192 bytes' worth.
|
||||
constexpr size_t APP_DEFAULT_STACK_DEPTH = 8192 / sizeof(StackType_t);
|
||||
// Used when an app's manifest doesn't request a specific stack depth (0). 12288 bytes' worth.
|
||||
// Existing window-manager app manifests request at least 2400 words (9600 bytes); use a
|
||||
// conservative default above that baseline so a zero-depth app task cannot exhaust its stack.
|
||||
constexpr size_t APP_DEFAULT_STACK_DEPTH = 12288 / sizeof(StackType_t);
|
||||
|
||||
// Task control blocks must stay in internal RAM; only the stack itself may live in external memory.
|
||||
constexpr MemoryPolicy APP_TASK_TCB_POLICY = { MEMORY_CAPABILITY_INTERNAL, 0, 0 };
|
||||
@@ -303,7 +305,7 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location,
|
||||
return ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
// Same bound app_metadata_parse() enforces on manifest.properties-declared depths - a
|
||||
// Same bound package_manifest_parse() enforces on manifest.properties-declared depths - a
|
||||
// manifest built directly in C++ (not parsed from a file) must be held to it too.
|
||||
if (stack.depth > APP_STACK_SIZE_MAX) {
|
||||
LOG_E(TAG, "[instance %lu] stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", app_instance_id, stack.depth, APP_STACK_SIZE_MAX);
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Paired with -Wl,--wrap= on POSIX (this module's own CMakeLists.txt) and ESP32 (top-level
|
||||
// CMakeLists.txt); self-registered via dyld interpose below on Apple, whose linker doesn't
|
||||
// support --wrap.
|
||||
#include <app/io.h>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
// Newlib's own stdio (fflush()'s buffer-flush path, in particular) calls the reentrant
|
||||
// _read_r/_write_r/_close_r syscall stubs directly, not the plain read()/write()/close() newlib
|
||||
// itself provides as thin wrappers around them (see e.g. esp-idf's components/newlib/src/
|
||||
// syscalls.c: `write(fd, dst, size) { return _write_r(__getreent(), fd, dst, size); }`)
|
||||
// Wrapping the plain names only catches direct write()-style callers, not newlib's own internal
|
||||
// stdio calls, so the _r stubs are wrapped here instead.
|
||||
#include <reent.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
ssize_t __wrap__read_r(struct _reent* r, int fd, void* buffer, size_t size) {
|
||||
(void)r;
|
||||
return app_io_read(fd, buffer, size);
|
||||
}
|
||||
|
||||
ssize_t __wrap__write_r(struct _reent* r, int fd, const void* buffer, size_t size) {
|
||||
(void)r;
|
||||
return app_io_write(fd, buffer, size);
|
||||
}
|
||||
|
||||
int __wrap__close_r(struct _reent* r, int fd) {
|
||||
(void)r;
|
||||
return app_io_close(fd);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
extern "C" {
|
||||
|
||||
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
|
||||
return app_io_read(fd, buffer, size);
|
||||
}
|
||||
|
||||
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
|
||||
return app_io_write(fd, buffer, size);
|
||||
}
|
||||
|
||||
int __wrap_close(int fd) {
|
||||
return app_io_close(fd);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// --wrap also synthesizes __real_read/write/close automatically; dyld interpose doesn't, so
|
||||
// io.cpp's TT_APP_IO_WRAPS_STDIO fallback needs them defined here. dlsym(RTLD_NEXT, ...) is the
|
||||
// standard way to reach the true libSystem implementation despite the interpose below: a direct
|
||||
// call to read/write/close from this file would just recurse into __wrap_read/write/close, since
|
||||
// interpose rewrites every reference to those symbols in the process, this file included.
|
||||
#include <dlfcn.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
ssize_t __real_read(int fd, void* buffer, size_t size) {
|
||||
static auto real = reinterpret_cast<ssize_t (*)(int, void*, size_t)>(dlsym(RTLD_NEXT, "read"));
|
||||
return real(fd, buffer, size);
|
||||
}
|
||||
|
||||
ssize_t __real_write(int fd, const void* buffer, size_t size) {
|
||||
static auto real = reinterpret_cast<ssize_t (*)(int, const void*, size_t)>(dlsym(RTLD_NEXT, "write"));
|
||||
return real(fd, buffer, size);
|
||||
}
|
||||
|
||||
int __real_close(int fd) {
|
||||
static auto real = reinterpret_cast<int (*)(int)>(dlsym(RTLD_NEXT, "close"));
|
||||
return real(fd);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
|
||||
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally; reused below
|
||||
// for the printf-family interposes too.
|
||||
#define TT_DYLD_INTERPOSE(replacement, replacee) \
|
||||
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
|
||||
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
|
||||
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
|
||||
};
|
||||
|
||||
TT_DYLD_INTERPOSE(__wrap_read, read)
|
||||
TT_DYLD_INTERPOSE(__wrap_write, write)
|
||||
TT_DYLD_INTERPOSE(__wrap_close, close)
|
||||
|
||||
#endif // __APPLE__
|
||||
|
||||
// region glibc stdio wraps
|
||||
//
|
||||
// libc's printf/fprintf/etc are compiled into the C library and call an internal, non-exported
|
||||
// write() alias, which --wrap=write/the read/write/close interpose above can't reach: only calls
|
||||
// WE make to the public symbol. These wraps instead redirect calls WE make to printf/fprintf/etc,
|
||||
// the same trick as read/write/close above. Newlib (ESP-IDF) doesn't have this gap: its stdio does
|
||||
// call the wrappable syscall stubs, so this block is POSIX-only.
|
||||
//
|
||||
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
|
||||
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
|
||||
// route every such call through this file's stdin/stdout check, a correctness risk for unrelated
|
||||
// code that isn't worth taking here. putc/getc are excluded too since libc defines them as
|
||||
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
|
||||
|
||||
#if !defined(ESP_PLATFORM)
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" {
|
||||
int __real_vfprintf(FILE* stream, const char* format, va_list args);
|
||||
int __real_fputs(const char* s, FILE* stream);
|
||||
int __real_fputc(int c, FILE* stream);
|
||||
int __real_fgetc(FILE* stream);
|
||||
char* __real_fgets(char* buffer, int size, FILE* stream);
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// --wrap synthesizes these automatically elsewhere; on Apple they're defined here via
|
||||
// dlsym(RTLD_NEXT, ...) instead. See the read/write/close __real_* block above for why.
|
||||
#include <dlfcn.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
int __real_vfprintf(FILE* stream, const char* format, va_list args) {
|
||||
static auto real = reinterpret_cast<int (*)(FILE*, const char*, va_list)>(dlsym(RTLD_NEXT, "vfprintf"));
|
||||
return real(stream, format, args);
|
||||
}
|
||||
|
||||
int __real_fputs(const char* s, FILE* stream) {
|
||||
static auto real = reinterpret_cast<int (*)(const char*, FILE*)>(dlsym(RTLD_NEXT, "fputs"));
|
||||
return real(s, stream);
|
||||
}
|
||||
|
||||
int __real_fputc(int c, FILE* stream) {
|
||||
static auto real = reinterpret_cast<int (*)(int, FILE*)>(dlsym(RTLD_NEXT, "fputc"));
|
||||
return real(c, stream);
|
||||
}
|
||||
|
||||
int __real_fgetc(FILE* stream) {
|
||||
static auto real = reinterpret_cast<int (*)(FILE*)>(dlsym(RTLD_NEXT, "fgetc"));
|
||||
return real(stream);
|
||||
}
|
||||
|
||||
char* __real_fgets(char* buffer, int size, FILE* stream) {
|
||||
static auto real = reinterpret_cast<char* (*)(char*, int, FILE*)>(dlsym(RTLD_NEXT, "fgets"));
|
||||
return real(buffer, size, stream);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif // __APPLE__
|
||||
|
||||
namespace {
|
||||
|
||||
void writeAllToStdout(const void* data, size_t size) {
|
||||
const auto* bytes = static_cast<const char*>(data);
|
||||
size_t remaining = size;
|
||||
while (remaining > 0) {
|
||||
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
|
||||
if (written <= 0) {
|
||||
break;
|
||||
}
|
||||
bytes += written;
|
||||
remaining -= static_cast<size_t>(written);
|
||||
}
|
||||
}
|
||||
|
||||
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
|
||||
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
|
||||
int formatToStdout(const char* format, va_list args) {
|
||||
char stackBuffer[256];
|
||||
va_list argsForStack;
|
||||
va_copy(argsForStack, args);
|
||||
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
|
||||
va_end(argsForStack);
|
||||
if (needed < 0) {
|
||||
return needed;
|
||||
}
|
||||
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
|
||||
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
|
||||
return needed;
|
||||
}
|
||||
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
|
||||
va_list argsForHeap;
|
||||
va_copy(argsForHeap, args);
|
||||
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
|
||||
va_end(argsForHeap);
|
||||
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
|
||||
return needed;
|
||||
}
|
||||
|
||||
int readOneFromStdin(char& out) {
|
||||
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
int __wrap_vprintf(const char* format, va_list args) {
|
||||
return formatToStdout(format, args);
|
||||
}
|
||||
|
||||
int __wrap_printf(const char* format, ...) {
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int result = formatToStdout(format, args);
|
||||
va_end(args);
|
||||
return result;
|
||||
}
|
||||
|
||||
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
|
||||
if (stream == stdout) {
|
||||
return formatToStdout(format, args);
|
||||
}
|
||||
return __real_vfprintf(stream, format, args);
|
||||
}
|
||||
|
||||
int __wrap_fprintf(FILE* stream, const char* format, ...) {
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
|
||||
va_end(args);
|
||||
return result;
|
||||
}
|
||||
|
||||
int __wrap_puts(const char* s) {
|
||||
writeAllToStdout(s, strlen(s));
|
||||
writeAllToStdout("\n", 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __wrap_fputs(const char* s, FILE* stream) {
|
||||
if (stream == stdout) {
|
||||
writeAllToStdout(s, strlen(s));
|
||||
return 0;
|
||||
}
|
||||
return __real_fputs(s, stream);
|
||||
}
|
||||
|
||||
int __wrap_putchar(int c) {
|
||||
auto ch = static_cast<char>(c);
|
||||
writeAllToStdout(&ch, 1);
|
||||
return c;
|
||||
}
|
||||
|
||||
int __wrap_fputc(int c, FILE* stream) {
|
||||
if (stream == stdout) {
|
||||
return __wrap_putchar(c);
|
||||
}
|
||||
return __real_fputc(c, stream);
|
||||
}
|
||||
|
||||
int __wrap_getchar() {
|
||||
char c;
|
||||
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
|
||||
}
|
||||
|
||||
int __wrap_fgetc(FILE* stream) {
|
||||
if (stream == stdin) {
|
||||
return __wrap_getchar();
|
||||
}
|
||||
return __real_fgetc(stream);
|
||||
}
|
||||
|
||||
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
|
||||
if (stream != stdin) {
|
||||
return __real_fgets(buffer, size, stream);
|
||||
}
|
||||
if (size <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
int i = 0;
|
||||
for (; i < size - 1; ++i) {
|
||||
char c;
|
||||
if (readOneFromStdin(c) != 1) {
|
||||
break;
|
||||
}
|
||||
buffer[i] = c;
|
||||
if (c == '\n') {
|
||||
++i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
buffer[i] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf)
|
||||
TT_DYLD_INTERPOSE(__wrap_printf, printf)
|
||||
TT_DYLD_INTERPOSE(__wrap_vfprintf, vfprintf)
|
||||
TT_DYLD_INTERPOSE(__wrap_fprintf, fprintf)
|
||||
TT_DYLD_INTERPOSE(__wrap_puts, puts)
|
||||
TT_DYLD_INTERPOSE(__wrap_fputs, fputs)
|
||||
TT_DYLD_INTERPOSE(__wrap_putchar, putchar)
|
||||
TT_DYLD_INTERPOSE(__wrap_fputc, fputc)
|
||||
TT_DYLD_INTERPOSE(__wrap_getchar, getchar)
|
||||
TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc)
|
||||
TT_DYLD_INTERPOSE(__wrap_fgets, fgets)
|
||||
#endif // __APPLE__
|
||||
|
||||
#endif // !ESP_PLATFORM
|
||||
|
||||
// endregion
|
||||
@@ -6,30 +6,12 @@ enable_language(C CXX ASM)
|
||||
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
|
||||
add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
|
||||
|
||||
if (NOT APPLE)
|
||||
# Provides __wrap_read/write/close for the -Wl,--wrap= below (see Tactility/CMakeLists.txt
|
||||
# for the canonical pairing of this file with those flags).
|
||||
target_sources(AppModuleTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../../../Tactility/Source/AppStdioWrap.cpp)
|
||||
endif ()
|
||||
|
||||
target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC} ${CMAKE_CURRENT_LIST_DIR}/../private)
|
||||
|
||||
add_test(NAME AppModuleTests COMMAND AppModuleTests)
|
||||
|
||||
# Matches app-module's own TT_APP_IO_WRAPS_STDIO (see Modules/app-module/CMakeLists.txt):
|
||||
# io.cpp calls __real_read/write/close() on non-Apple POSIX, so any final link of it needs
|
||||
# these wraps too, or those symbols go unresolved. The printf-family/getc-family flags are
|
||||
# needed for the same reason: AppStdioWrap.cpp compiles those __wrap_* functions unconditionally
|
||||
# on this platform (see its own #if guard), and they reference __real_vfprintf/fputs/fputc/
|
||||
# fgetc/fgets - those only exist once the matching --wrap flag is passed.
|
||||
if (NOT APPLE)
|
||||
target_link_options(AppModuleTests PRIVATE
|
||||
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
|
||||
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
|
||||
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
|
||||
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
|
||||
)
|
||||
endif ()
|
||||
# No --wrap flags or stdio_wrap.cpp source needed here: linking app-module below already brings
|
||||
# both along (see its own CMakeLists.txt).
|
||||
|
||||
target_link_libraries(AppModuleTests PUBLIC
|
||||
TactilityKernel
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <tactility/delay.h>
|
||||
#include <tactility/freertos/task.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -110,6 +111,11 @@ int32_t stream_writer_app_main(int, char*[]) {
|
||||
return 7;
|
||||
}
|
||||
|
||||
int32_t printf_stream_writer_app_main(int, char*[]) {
|
||||
printf("loc");
|
||||
return 7;
|
||||
}
|
||||
|
||||
// Writes argv[0] to its own stdout, for the app_execute_with_streams() argv-delivery test.
|
||||
int32_t argv_echo_app_main(int argc, char* argv[]) {
|
||||
if (argc < 1) {
|
||||
@@ -321,3 +327,53 @@ TEST_CASE("app_execute_for_result_with_streams delivers both the stream data and
|
||||
app_manager_stop(parent_id);
|
||||
app_manager_remove("test.app.execute.parent_streams");
|
||||
}
|
||||
|
||||
TEST_CASE("app_execute_for_result_with_streams pipes a child's plain printf() calls too") {
|
||||
ensure_memory_loader_registered();
|
||||
|
||||
AppManifest parent_manifest { "test.app.execute.printf_parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(location_app_main) } };
|
||||
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
|
||||
|
||||
uint32_t parent_id = 0;
|
||||
REQUIRE_EQ(app_start("test.app.execute.printf_parent", 0, nullptr, &parent_id), ERROR_NONE);
|
||||
CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000));
|
||||
|
||||
TaskEventGroup parent_event_group {};
|
||||
task_event_group_construct(&parent_event_group);
|
||||
AppEventSubscription parent_sub {};
|
||||
REQUIRE_EQ(app_event_subscribe_with_app_id(&parent_sub, &parent_event_group, parent_id), ERROR_NONE);
|
||||
|
||||
uint8_t storage[64];
|
||||
AppStream child_stdout {};
|
||||
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &parent_event_group };
|
||||
|
||||
AppLocation location { APP_LOCATION_MEMORY, reinterpret_cast<void*>(printf_stream_writer_app_main) };
|
||||
uint32_t child_id = 0;
|
||||
REQUIRE_EQ(app_execute_for_result_with_streams(location, AppStackConfig {}, 0, nullptr, &binding, 1, parent_id, &child_id), ERROR_NONE);
|
||||
|
||||
std::vector<uint8_t> received;
|
||||
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
|
||||
uint8_t chunk[16];
|
||||
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
|
||||
if (n == 0) {
|
||||
break; // EOF
|
||||
}
|
||||
received.insert(received.end(), chunk, chunk + n);
|
||||
}
|
||||
REQUIRE_EQ(received.size(), 3u);
|
||||
CHECK_EQ(std::memcmp(received.data(), "loc", 3), 0);
|
||||
|
||||
REQUIRE_EQ(task_event_group_wait(&parent_event_group, parent_sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
|
||||
AppEvent event {};
|
||||
REQUIRE_EQ(app_event_poll(&parent_sub, &event), ERROR_NONE);
|
||||
CHECK_EQ(event.type, APP_EVENT_RESULT);
|
||||
CHECK_EQ(event.result.launch_id, child_id);
|
||||
CHECK_EQ(event.result.result, 7);
|
||||
|
||||
app_stream_unsubscribe(&child_stdout);
|
||||
app_event_unsubscribe(&parent_sub);
|
||||
task_event_group_destruct(&parent_event_group);
|
||||
app_manager_stop(child_id);
|
||||
app_manager_stop(parent_id);
|
||||
app_manager_remove("test.app.execute.printf_parent");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
@@ -66,6 +67,11 @@ int32_t stdout_writer_app_main(int, char*[]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32_t stdout_printf_app_main(int, char*[]) {
|
||||
printf("hello");
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::atomic<bool> g_blocked_writer_saw_error { false };
|
||||
std::atomic<bool> g_blocked_writer_done { false };
|
||||
|
||||
@@ -180,6 +186,41 @@ TEST_CASE("app_start_with_streams pipes a child's app_io_write() calls into a pa
|
||||
app_manager_remove("test.io.writer");
|
||||
}
|
||||
|
||||
TEST_CASE("app_start_with_streams pipes a child's plain printf() calls into a parent-owned AppStream") {
|
||||
ensure_memory_loader_registered();
|
||||
|
||||
AppManifest manifest { "test.io.printf_writer", "PrintfWriter", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(stdout_printf_app_main) } };
|
||||
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
|
||||
|
||||
TaskEventGroup event_group {};
|
||||
task_event_group_construct(&event_group);
|
||||
|
||||
uint8_t storage[64];
|
||||
AppStream child_stdout {};
|
||||
|
||||
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &event_group };
|
||||
AppInstanceId child_id = 0;
|
||||
REQUIRE_EQ(app_start_with_streams("test.io.printf_writer", &binding, 1, &child_id), ERROR_NONE);
|
||||
|
||||
std::vector<uint8_t> received;
|
||||
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
|
||||
uint8_t chunk[16];
|
||||
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
|
||||
if (n == 0) {
|
||||
break; // EOF
|
||||
}
|
||||
received.insert(received.end(), chunk, chunk + n);
|
||||
}
|
||||
|
||||
REQUIRE_EQ(received.size(), 5u);
|
||||
CHECK_EQ(std::memcmp(received.data(), "hello", 5), 0);
|
||||
|
||||
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
|
||||
app_stream_unsubscribe(&child_stdout);
|
||||
task_event_group_destruct(&event_group);
|
||||
app_manager_remove("test.io.printf_writer");
|
||||
}
|
||||
|
||||
TEST_CASE("a write blocked on a full stream wakes with an error once the consumer closes it") {
|
||||
ensure_memory_loader_registered();
|
||||
g_blocked_writer_saw_error.store(false, std::memory_order_relaxed);
|
||||
|
||||
@@ -73,7 +73,11 @@ bool wait_for_state(AppInstanceId id, AppInstanceState target, uint32_t timeout_
|
||||
|
||||
AppInstanceId start_idle_app(const char* id) {
|
||||
ensure_memory_loader_registered();
|
||||
AppManifest manifest { id, id, APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(idle_app_main) } };
|
||||
AppManifest manifest {};
|
||||
std::strncpy(manifest.id, id, sizeof(manifest.id) - 1);
|
||||
std::strncpy(manifest.name, id, sizeof(manifest.name) - 1);
|
||||
manifest.category = APP_CATEGORY_USER;
|
||||
manifest.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(idle_app_main) };
|
||||
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
|
||||
AppInstanceId instance_id = 0;
|
||||
REQUIRE_EQ(app_start(id, 0, nullptr, &instance_id), ERROR_NONE);
|
||||
|
||||
@@ -58,19 +58,21 @@ bool is_executable_file(const std::string& resolved_path) {
|
||||
#endif
|
||||
}
|
||||
|
||||
// location.location can be either an app's install directory or the .so file directly; the
|
||||
// former resolves to the per-architecture binary at {dir}/elf/posix-{TACTILITY_POSIX_ARCH}.so,
|
||||
// mirroring app_esp32_loader_service.cpp's resolve_elf_path().
|
||||
// location.location can be either an app's install directory or the .so file directly, mirroring
|
||||
// app_esp32_loader_service.cpp's resolve_elf_path(). A "packaged" app's install directory always
|
||||
// holds its single binary at the fixed path {dir}/bin/posix-{TACTILITY_POSIX_ARCH}/app.so - a
|
||||
// "terminal" app has no such file (its several binaries keep their own names), so this correctly
|
||||
// leaves it unresolvable - terminal apps aren't run through AppLoaderApi (see app/install.h).
|
||||
error_t resolve_app_path(const std::string& path, std::string& resolvedPath) {
|
||||
if (path.ends_with(".so")) {
|
||||
resolvedPath = path;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
std::string shared_object_path = path + "/elf/posix-" TACTILITY_POSIX_ARCH ".so";
|
||||
if (!is_regular_file(shared_object_path)) {
|
||||
std::string candidate = path + "/bin/posix-" TACTILITY_POSIX_ARCH "/app.so";
|
||||
if (!is_regular_file(candidate)) {
|
||||
return ERROR_NOT_FOUND;
|
||||
}
|
||||
resolvedPath = shared_object_path;
|
||||
resolvedPath = candidate;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
@@ -94,9 +96,6 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) {
|
||||
|
||||
LOG_I(TAG, "Loading %s", app_path.c_str());
|
||||
|
||||
// RTLD_NOW: a missing symbol fails here, not mid-run(). RTLD_LOCAL: this app's own exported
|
||||
// symbols (if any beyond its entry point) don't leak into the process's global scope and
|
||||
// clash with a different app's.
|
||||
void* handle = dlopen(app_path.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||
if (handle == nullptr) {
|
||||
LOG_E(TAG, "dlopen(%s) failed: %s", app_path.c_str(), dlerror());
|
||||
@@ -117,8 +116,8 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) {
|
||||
int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) {
|
||||
auto* runtime = static_cast<PosixAppRuntime*>(runtime_ptr);
|
||||
|
||||
dlerror(); // clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL
|
||||
// symbol address apart from a real lookup failure
|
||||
// Clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL symbol address apart from a real lookup failure
|
||||
dlerror();
|
||||
void* symbol = dlsym(runtime->handle, "main");
|
||||
const char* lookup_error = dlerror();
|
||||
if (symbol == nullptr || lookup_error != nullptr) {
|
||||
|
||||
@@ -15,14 +15,27 @@ set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPEND
|
||||
set(NON_ELF_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/not-elf.so")
|
||||
file(WRITE "${NON_ELF_FIXTURE_PATH}" "not an elf file")
|
||||
|
||||
# An install-directory-shaped fixture: {dir}/bin/posix-{arch}/app.so, matching where
|
||||
# app_posix_loader_service.cpp's resolve_app_path() looks for a "packaged" app's single binary.
|
||||
set(INSTALL_DIR_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/install-dir-fixture")
|
||||
set(INSTALL_DIR_FIXTURE_BIN_DIR "${INSTALL_DIR_FIXTURE_PATH}/bin/posix-${CMAKE_SYSTEM_PROCESSOR}")
|
||||
add_custom_command(
|
||||
OUTPUT "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTALL_DIR_FIXTURE_BIN_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:app_posix_module_test_fixture>" "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so"
|
||||
DEPENDS app_posix_module_test_fixture
|
||||
)
|
||||
add_custom_target(install_dir_fixture DEPENDS "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so")
|
||||
|
||||
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
|
||||
add_executable(AppPosixModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
|
||||
add_dependencies(AppPosixModuleTests app_posix_module_test_fixture)
|
||||
add_dependencies(AppPosixModuleTests app_posix_module_test_fixture install_dir_fixture)
|
||||
|
||||
target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC})
|
||||
target_compile_definitions(AppPosixModuleTests PRIVATE
|
||||
FIXTURE_APP_PATH="$<TARGET_FILE:app_posix_module_test_fixture>"
|
||||
FIXTURE_NON_ELF_PATH="${NON_ELF_FIXTURE_PATH}"
|
||||
FIXTURE_INSTALL_DIR_PATH="${INSTALL_DIR_FIXTURE_PATH}"
|
||||
)
|
||||
|
||||
add_test(NAME AppPosixModuleTests COMMAND AppPosixModuleTests)
|
||||
|
||||
@@ -148,6 +148,12 @@ TEST_CASE("app_is_executable() rejects a nonexistent path") {
|
||||
TEST_CASE("app_is_executable() rejects an install-directory-shaped path missing its per-arch .so") {
|
||||
ensure_path_loader_registered();
|
||||
|
||||
// FIXTURE_DIR itself has no elf/posix-<arch>.so under it, so resolution fails.
|
||||
// FIXTURE_DIR itself has no bin/posix-<arch>/app.so under it, so resolution fails.
|
||||
CHECK_FALSE(is_executable_path(FIXTURE_DIR.c_str()));
|
||||
}
|
||||
|
||||
TEST_CASE("app_is_executable() accepts an install-directory-shaped path with bin/<arch>/app.so") {
|
||||
ensure_path_loader_registered();
|
||||
|
||||
CHECK(is_executable_path(FIXTURE_INSTALL_DIR_PATH));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ enum LvglFontSize {
|
||||
FONT_SIZE_LARGE,
|
||||
};
|
||||
|
||||
enum LvglFontScale {
|
||||
FONT_SCALE_SMALL,
|
||||
FONT_SCALE_DEFAULT,
|
||||
FONT_SCALE_LARGE,
|
||||
};
|
||||
|
||||
void lvgl_set_text_font_scale(enum LvglFontScale font_scale);
|
||||
enum LvglFontScale lvgl_get_text_font_scale(void);
|
||||
|
||||
const lv_font_t* lvgl_get_shared_icon_font(void);
|
||||
uint32_t lvgl_get_shared_icon_font_height(void);
|
||||
|
||||
|
||||
@@ -13,8 +13,30 @@ extern const lv_font_t TT_LVGL_LAUNCHER_FONT_ICON_SYMBOL;
|
||||
extern const lv_font_t TT_LVGL_STATUSBAR_FONT_ICON_SYMBOL;
|
||||
extern const lv_font_t TT_LVGL_SHARED_FONT_ICON_SYMBOL;
|
||||
|
||||
static enum LvglFontScale text_font_scale = FONT_SCALE_DEFAULT;
|
||||
|
||||
void lvgl_set_text_font_scale(enum LvglFontScale font_scale) {
|
||||
check(font_scale >= FONT_SCALE_SMALL && font_scale <= FONT_SCALE_LARGE);
|
||||
text_font_scale = font_scale;
|
||||
}
|
||||
|
||||
enum LvglFontScale lvgl_get_text_font_scale(void) {
|
||||
return text_font_scale;
|
||||
}
|
||||
|
||||
static enum LvglFontSize resolve_text_font_size(enum LvglFontSize font_size) {
|
||||
int resolved = (int)font_size + (int)text_font_scale - (int)FONT_SCALE_DEFAULT;
|
||||
if (resolved < FONT_SIZE_SMALL) {
|
||||
return FONT_SIZE_SMALL;
|
||||
}
|
||||
if (resolved > FONT_SIZE_LARGE) {
|
||||
return FONT_SIZE_LARGE;
|
||||
}
|
||||
return (enum LvglFontSize)resolved;
|
||||
}
|
||||
|
||||
uint32_t lvgl_get_text_font_height(enum LvglFontSize font_size) {
|
||||
switch (font_size) {
|
||||
switch (resolve_text_font_size(font_size)) {
|
||||
case FONT_SIZE_SMALL: return TT_LVGL_TEXT_FONT_SMALL_SIZE;
|
||||
case FONT_SIZE_DEFAULT: return TT_LVGL_TEXT_FONT_DEFAULT_SIZE;
|
||||
case FONT_SIZE_LARGE: return TT_LVGL_TEXT_FONT_LARGE_SIZE;
|
||||
@@ -22,7 +44,7 @@ uint32_t lvgl_get_text_font_height(enum LvglFontSize font_size) {
|
||||
}
|
||||
}
|
||||
const lv_font_t* lvgl_get_text_font(enum LvglFontSize font_size) {
|
||||
switch (font_size) {
|
||||
switch (resolve_text_font_size(font_size)) {
|
||||
case FONT_SIZE_SMALL: return &TT_LVGL_TEXT_FONT_SMALL_SYMBOL;
|
||||
case FONT_SIZE_DEFAULT: return &TT_LVGL_TEXT_FONT_DEFAULT_SYMBOL;
|
||||
case FONT_SIZE_LARGE: return &TT_LVGL_TEXT_FONT_LARGE_SYMBOL;
|
||||
|
||||
@@ -26,6 +26,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
|
||||
DEFINE_MODULE_SYMBOL(lvgl_module),
|
||||
DEFINE_MODULE_SYMBOL(lvgl_module_configure),
|
||||
// lvgl_fonts
|
||||
DEFINE_MODULE_SYMBOL(lvgl_set_text_font_scale),
|
||||
DEFINE_MODULE_SYMBOL(lvgl_get_text_font_scale),
|
||||
DEFINE_MODULE_SYMBOL(lvgl_get_shared_icon_font),
|
||||
DEFINE_MODULE_SYMBOL(lvgl_get_shared_icon_font_height),
|
||||
DEFINE_MODULE_SYMBOL(lvgl_get_text_font),
|
||||
@@ -494,6 +496,12 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
|
||||
// lv_image
|
||||
DEFINE_MODULE_SYMBOL(lv_image_create),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_set_src),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_set_scale),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_set_scale_x),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_set_scale_y),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_get_scale),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_get_scale_x),
|
||||
DEFINE_MODULE_SYMBOL(lv_image_get_scale_y),
|
||||
// lv_anim
|
||||
DEFINE_MODULE_SYMBOL(lv_anim_init),
|
||||
DEFINE_MODULE_SYMBOL(lv_anim_set_duration),
|
||||
|
||||
@@ -6,3 +6,12 @@ add_library(platform-posix OBJECT)
|
||||
target_sources(platform-posix PRIVATE ${SOURCES})
|
||||
#target_include_directories(platform-posix PUBLIC include/)
|
||||
target_link_libraries(platform-posix PUBLIC TactilityKernel)
|
||||
|
||||
# Routes every pthread_attr_setstack() call to __wrap_pthread_attr_setstack() in source/pthread_stack_wrap.c,
|
||||
# which no-ops it. see that file for why.
|
||||
# --wrap is a GNU ld option; Apple's linker doesn't support it, so that file instead self-registers a dyld interpose
|
||||
# for this symbol on Apple, needing no link flag. PUBLIC so every consumer of this OBJECT library
|
||||
# (Tactility, and every test target that links platform-posix directly) gets it applied too.
|
||||
if (NOT APPLE)
|
||||
target_link_options(platform-posix PUBLIC "-Wl,--wrap=pthread_attr_setstack")
|
||||
endif ()
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* Linked in with -Wl,--wrap=pthread_attr_setstack (see Tactility/CMakeLists.txt).
|
||||
* FreeRTOS's POSIX port hands every task a stack carved from its own heap (pvPortMalloc) via this call.
|
||||
* pthread_attr_setstack requires page alignment that pvPortMalloc doesn't guarantee.
|
||||
* When it succeeds anyway, the task's real pthread stack ends up living inside that small FreeRTOS heap region,
|
||||
* where a thread doing heavier stack work (e.g. Mesa GL shader compilation) can silently corrupt adjacent heap_4 objects.
|
||||
* No-op'ing the call instead leaves every task's pthread_attr_t at its pthread_attr_init() default,
|
||||
* so pthread_create() gives it a real, properly sized stack.
|
||||
*
|
||||
* FreeRTOS's POSIX port (Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/port.c)
|
||||
* hands every task a stack carved out of its own heap (pvPortMalloc) via this call.
|
||||
* pthread_attr_setstack requires page alignment, which pvPortMalloc doesn't guarantee;
|
||||
* when it happens to succeed anyway (allocator alignment can vary run to run), the
|
||||
* task's real pthread stack ends up living inside that small FreeRTOS heap region -
|
||||
* fine for typical embedded task code, but a desktop GL driver doing on-the-fly shader
|
||||
* compilation on that thread (e.g. Mesa on first frame present) can overflow it and
|
||||
* silently corrupt adjacent heap_4 objects.
|
||||
*
|
||||
* Wrapping the call out entirely (rather than patching the vendored port.c) leaves every
|
||||
* task's pthread_attr_t at its pthread_attr_init() default, so pthread_create() always
|
||||
* gives it a real, properly allocated default-size stack instead.
|
||||
* Linked via -Wl,--wrap=pthread_attr_setstack (this module's own CMakeLists.txt) on non-Apple
|
||||
* platforms; reused as a dyld interpose target below on Apple platforms, whose linker lacks --wrap.
|
||||
*/
|
||||
int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize) {
|
||||
(void)attr;
|
||||
@@ -23,3 +18,17 @@ int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t s
|
||||
(void)stacksize;
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
|
||||
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally.
|
||||
#define TT_DYLD_INTERPOSE(replacement, replacee) \
|
||||
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
|
||||
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
|
||||
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
|
||||
};
|
||||
|
||||
TT_DYLD_INTERPOSE(__wrap_pthread_attr_setstack, pthread_attr_setstack)
|
||||
|
||||
#endif
|
||||
|
||||
@@ -185,31 +185,9 @@ else ()
|
||||
# Exports Tactility's own symbols (-rdynamic) so a dlopen()ed app-posix-module app can resolve
|
||||
# calls back into it - the OS-native equivalent of app-esp32-module's custom symbol resolver.
|
||||
set_target_properties(Tactility PROPERTIES ENABLE_EXPORTS ON)
|
||||
# Routes every pthread_attr_setstack() call (FreeRTOS's POSIX port hands each task a stack
|
||||
# carved out of its own heap through this) to __wrap_pthread_attr_setstack() in
|
||||
# Platforms/platform-posix/source/pthread_stack_wrap.c, which no-ops it - see that file for why.
|
||||
# --wrap is a GNU ld option; Apple's linker doesn't support it.
|
||||
if (NOT APPLE)
|
||||
target_link_options(Tactility PRIVATE "-Wl,--wrap=pthread_attr_setstack")
|
||||
# Routes every read()/write()/close() call to Tactility/Source/AppStdioWrap.cpp's
|
||||
# __wrap_read/write/close(), which forward into app_io_read/write/close() - the fd-table
|
||||
# dispatch that lets an app's own stdio (e.g. a fileselection dialog's printf'd result
|
||||
# path) reach an AppStream a parent bound via app_manager_start_with_streams(). Mirrors
|
||||
# what ESP-IDF's build already does for the ESP32 target (see top-level CMakeLists.txt).
|
||||
target_link_options(Tactility PRIVATE "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close")
|
||||
# glibc's printf/fprintf/etc don't call the public write() symbol internally (they're
|
||||
# already compiled into libc.so, out of --wrap's reach), so the read/write/close wrap
|
||||
# above can't see them. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the
|
||||
# wrappable syscall stubs - so this block is POSIX-only. Wrapping these symbols instead
|
||||
# redirects OUR OWN calls to them (the only ones --wrap can rewrite) through
|
||||
# AppStdioWrap.cpp's __wrap_* functions, which check the target stream (stdout/stdin) and
|
||||
# fall back to the real libc function for any other FILE*.
|
||||
target_link_options(Tactility PRIVATE
|
||||
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
|
||||
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
|
||||
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
|
||||
)
|
||||
endif ()
|
||||
# pthread_attr_setstack and read/write/close/printf-family aren't wrapped here: linking
|
||||
# platform-posix and app-module already brings those wraps along (see their own
|
||||
# CMakeLists.txt files).
|
||||
endif ()
|
||||
|
||||
#
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
namespace tt::settings::display {
|
||||
enum class FontSize;
|
||||
}
|
||||
|
||||
namespace tt::lvgl {
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
@@ -12,6 +16,9 @@ static constexpr auto* PATH_PREFIX = "A:/";
|
||||
|
||||
bool isStarted();
|
||||
|
||||
/** Applies the selected semantic text size to the LVGL theme and future widgets. */
|
||||
void applyFontSize(settings::display::FontSize fontSize);
|
||||
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
|
||||
@@ -18,16 +18,34 @@ enum class ScreensaverType {
|
||||
Mystify,
|
||||
MatrixRain,
|
||||
StackChan,
|
||||
McpScreen,
|
||||
BibleVerse, // Launch the Bible Verse app (if installed)
|
||||
Count // Sentinel for bounds checking - must be last
|
||||
};
|
||||
|
||||
enum class FontSize {
|
||||
Small,
|
||||
Default,
|
||||
Large,
|
||||
Count
|
||||
};
|
||||
|
||||
struct DisplaySettings {
|
||||
Orientation orientation;
|
||||
FontSize fontSize = FontSize::Default;
|
||||
uint8_t gammaCurve;
|
||||
uint8_t backlightDuty;
|
||||
bool backlightTimeoutEnabled;
|
||||
uint32_t backlightTimeoutMs; // 0 = Never
|
||||
ScreensaverType screensaverType = ScreensaverType::BouncingBalls;
|
||||
// Night dim: when enabled and the local hour falls inside
|
||||
// [nightDimStartHour, nightDimEndHour), the screensaver runs at
|
||||
// nightDimDuty instead of backlightDuty. A start == end window
|
||||
// means the whole day.
|
||||
bool nightDimEnabled = false;
|
||||
uint8_t nightDimStartHour = 22;
|
||||
uint8_t nightDimEndHour = 7;
|
||||
uint8_t nightDimDuty = 20;
|
||||
};
|
||||
|
||||
/** Compares default settings with the function parameter to return the difference */
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
namespace tt::settings::mcp {
|
||||
|
||||
struct McpSettings {
|
||||
bool mcpEnabled = false; // Enable MCP server endpoints on system web server
|
||||
};
|
||||
|
||||
bool load(McpSettings& settings);
|
||||
McpSettings getDefault();
|
||||
McpSettings loadOrGetDefault();
|
||||
bool save(const McpSettings& settings);
|
||||
|
||||
} // namespace tt::settings::mcp
|
||||
@@ -6,7 +6,7 @@ namespace tt::settings {
|
||||
|
||||
struct SystemSettings {
|
||||
Language language = Language::en_US;
|
||||
bool timeFormat24h = true;
|
||||
bool timeFormat24h = false;
|
||||
std::string dateFormat = std::string("DD/MM/YYYY"); // MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, YYYY/MM/DD
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::appdetails {
|
||||
|
||||
void start(const std::string& appId);
|
||||
|
||||
} // namespace
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::apppackagedetails {
|
||||
|
||||
void start(const std::string& packageId);
|
||||
|
||||
} // namespace
|
||||
@@ -7,5 +7,6 @@ namespace tt::app::files {
|
||||
bool isSupportedAppFile(const std::string& filename);
|
||||
bool isSupportedImageFile(const std::string& filename);
|
||||
bool isSupportedTextFile(const std::string& filename);
|
||||
bool isSupportedAudioFile(const std::string& filename);
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
namespace tt::mcp {
|
||||
|
||||
struct McpSystemState {
|
||||
std::mutex mutex;
|
||||
bool overrideActive = false;
|
||||
|
||||
// UI elements when McpOverrideApp is active
|
||||
lv_obj_t* drawArea = nullptr;
|
||||
uint16_t* framebuffer = nullptr;
|
||||
size_t framebufferSize = 0;
|
||||
uint16_t displayWidth = 320;
|
||||
uint16_t displayHeight = 240;
|
||||
uint16_t drawWidth = 320;
|
||||
uint16_t drawHeight = 240;
|
||||
int drawColor = 1; // 0 = white, 1 = black
|
||||
|
||||
// Audio device status
|
||||
Device* i2sDevice = nullptr;
|
||||
Device* audioStreamDevice = nullptr;
|
||||
AudioStreamHandle audioHandle = nullptr;
|
||||
volatile bool audioBusy = false;
|
||||
volatile bool audioRunning = false; // Used to abort play/record loop
|
||||
|
||||
// Video streaming state
|
||||
volatile bool streamRunning = false;
|
||||
void* streamTaskHandle = nullptr; // use void* to avoid freertos header inclusion dependency
|
||||
uint32_t framesDrawn = 0;
|
||||
uint32_t tcpBytesReceived = 0;
|
||||
uint32_t lastDrawMs = 0;
|
||||
double lastFps = 0.0;
|
||||
};
|
||||
|
||||
McpSystemState& getState();
|
||||
|
||||
bool clearScreen(int color);
|
||||
bool drawText(const std::string& text, int x, int y, int size);
|
||||
bool drawRgb565(const uint8_t* data, size_t size, int x, int y, int w, int h);
|
||||
bool drawBmp(const uint8_t* data, size_t size, int x, int y);
|
||||
bool drawPbm(const uint8_t* data, size_t size, int x, int y);
|
||||
std::string getScreenshotPbmBase64();
|
||||
|
||||
bool playTone(int frequency, int durationMs, int volume, std::string& error);
|
||||
bool recordVoice(int durationSec, const std::string& filename, size_t& recordedBytes, std::string& error);
|
||||
bool playAudioFile(const std::string& filename, int volume, std::string& error);
|
||||
bool playWavMemory(const uint8_t* data, size_t size, int volume, std::string& error);
|
||||
bool playMp3File(const std::string& filename, int volume, std::string& error);
|
||||
bool playMp3Memory(const uint8_t* data, size_t size, int volume, std::string& error);
|
||||
|
||||
bool getBatteryStatus(double& voltage_v, int& percentage_pct, std::string& error);
|
||||
bool setLedColor(int r, int g, int b, const std::string& mode, std::string& error);
|
||||
bool getSensors(double& temp_c, double& hum_pct, std::string& imu_json, std::string& error);
|
||||
bool scanBleDevices(int duration_ms, std::string& devices_json, std::string& error);
|
||||
bool writeSdFile(const std::string& filename, const std::string& content, std::string& error);
|
||||
bool readSdFile(const std::string& filename, std::string& content, std::string& error);
|
||||
bool downloadSdFile(const std::string& url, const std::string& filename, std::string& error);
|
||||
|
||||
bool startVideoStreamServer();
|
||||
void stopVideoStreamServer();
|
||||
bool getVideoStreamStats(std::string& stats_json);
|
||||
|
||||
bool listApps(std::string& apps_json, std::string& error);
|
||||
bool runApp(const std::string& appId, std::string& error);
|
||||
bool listSdFiles(const std::string& directory, std::string& files_json, std::string& error);
|
||||
|
||||
} // namespace tt::mcp
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
#include <Tactility/settings/DisplaySettings.h>
|
||||
#include <Tactility/Timer.h>
|
||||
|
||||
#include <app/instance.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
@@ -29,9 +31,15 @@ class DisplayIdleService final : public Service {
|
||||
// Active screensaver instance
|
||||
std::unique_ptr<Screensaver> screensaver;
|
||||
|
||||
// Screensaver auto-off: turn off backlight after 5 minutes of screensaver
|
||||
// App launched as screensaver (Bible Verse): instance id, 0 when none.
|
||||
// A separate flag (not just the id) tracks intent, so a user-closed app
|
||||
// doesn't look like an active screensaver and vice versa.
|
||||
AppInstanceId screensaverAppInstanceId = 0;
|
||||
bool screensaverAppActive = false;
|
||||
|
||||
// Screensaver auto-off: turn off backlight after 30 minutes of screensaver
|
||||
static constexpr uint32_t TICK_INTERVAL_MS = 50;
|
||||
static constexpr uint32_t SCREENSAVER_AUTO_OFF_MS = 5 * 60 * 1000; // 5 minutes
|
||||
static constexpr uint32_t SCREENSAVER_AUTO_OFF_MS = 30 * 60 * 1000; // 30 minutes
|
||||
static constexpr int SCREENSAVER_AUTO_OFF_TICKS = SCREENSAVER_AUTO_OFF_MS / TICK_INTERVAL_MS;
|
||||
int screensaverActiveCounter = 0;
|
||||
bool backlightOff = false;
|
||||
@@ -63,6 +71,7 @@ public:
|
||||
* arbitrary threads while the timer is running.
|
||||
*/
|
||||
void stopScreensaver();
|
||||
void startMcpScreensaver();
|
||||
|
||||
/**
|
||||
* Check if the screensaver is currently active.
|
||||
|
||||
@@ -81,6 +81,12 @@ private:
|
||||
static error_t handleApiAppsInstall(struct HttpServerRequest* request, void* user_ctx);
|
||||
static error_t handleApiWifi(struct HttpServerRequest* request, void* user_ctx);
|
||||
static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx);
|
||||
static error_t handleApiSimTouch(struct HttpServerRequest* request, void* user_ctx);
|
||||
static error_t handleSimViewer(struct HttpServerRequest* request, void* user_ctx);
|
||||
static error_t handleApiMcp(struct HttpServerRequest* request, void* user_ctx);
|
||||
#ifdef ESP_PLATFORM
|
||||
static error_t handleApiScreenRaw(struct HttpServerRequest* request, void* user_ctx);
|
||||
#endif
|
||||
|
||||
// Dynamic asset serving
|
||||
static error_t handleAssets(struct HttpServerRequest* request, void* user_ctx);
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Paired with -Wl,--wrap=read/write/close - see Tactility/CMakeLists.txt (POSIX) and the
|
||||
// top-level CMakeLists.txt (ESP32) for where that's applied. On a platform where it isn't
|
||||
// (currently: macOS, whose linker doesn't support --wrap), these are simply never called - real
|
||||
// read()/write()/close() calls go straight through unredirected.
|
||||
#include <app/io.h>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
|
||||
return app_io_read(fd, buffer, size);
|
||||
}
|
||||
|
||||
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
|
||||
return app_io_write(fd, buffer, size);
|
||||
}
|
||||
|
||||
int __wrap_close(int fd) {
|
||||
return app_io_close(fd);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// region glibc stdio wraps
|
||||
//
|
||||
// glibc's printf/fprintf/etc are compiled into libc.so and call an internal, non-exported write()
|
||||
// alias - --wrap=write (above) can't reach that call, only calls WE make to the public symbol.
|
||||
// These wraps instead redirect calls WE make to printf/fprintf/etc, the same trick as read/write/
|
||||
// close above. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the wrappable syscall
|
||||
// stubs - so Tactility/CMakeLists.txt only applies the matching -Wl,--wrap= flags on POSIX.
|
||||
//
|
||||
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
|
||||
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
|
||||
// route every such call through this file's stdin/stdout check - a correctness risk for unrelated
|
||||
// code that isn't worth taking here. putc/getc are excluded too since glibc defines them as
|
||||
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
|
||||
|
||||
#if !defined(ESP_PLATFORM) && !defined(__APPLE__)
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" {
|
||||
int __real_vfprintf(FILE* stream, const char* format, va_list args);
|
||||
int __real_fputs(const char* s, FILE* stream);
|
||||
int __real_fputc(int c, FILE* stream);
|
||||
int __real_fgetc(FILE* stream);
|
||||
char* __real_fgets(char* buffer, int size, FILE* stream);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
void writeAllToStdout(const void* data, size_t size) {
|
||||
const auto* bytes = static_cast<const char*>(data);
|
||||
size_t remaining = size;
|
||||
while (remaining > 0) {
|
||||
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
|
||||
if (written <= 0) {
|
||||
break;
|
||||
}
|
||||
bytes += written;
|
||||
remaining -= static_cast<size_t>(written);
|
||||
}
|
||||
}
|
||||
|
||||
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
|
||||
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
|
||||
int formatToStdout(const char* format, va_list args) {
|
||||
char stackBuffer[256];
|
||||
va_list argsForStack;
|
||||
va_copy(argsForStack, args);
|
||||
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
|
||||
va_end(argsForStack);
|
||||
if (needed < 0) {
|
||||
return needed;
|
||||
}
|
||||
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
|
||||
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
|
||||
return needed;
|
||||
}
|
||||
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
|
||||
va_list argsForHeap;
|
||||
va_copy(argsForHeap, args);
|
||||
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
|
||||
va_end(argsForHeap);
|
||||
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
|
||||
return needed;
|
||||
}
|
||||
|
||||
int readOneFromStdin(char& out) {
|
||||
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
int __wrap_vprintf(const char* format, va_list args) {
|
||||
return formatToStdout(format, args);
|
||||
}
|
||||
|
||||
int __wrap_printf(const char* format, ...) {
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int result = formatToStdout(format, args);
|
||||
va_end(args);
|
||||
return result;
|
||||
}
|
||||
|
||||
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
|
||||
if (stream == stdout) {
|
||||
return formatToStdout(format, args);
|
||||
}
|
||||
return __real_vfprintf(stream, format, args);
|
||||
}
|
||||
|
||||
int __wrap_fprintf(FILE* stream, const char* format, ...) {
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
|
||||
va_end(args);
|
||||
return result;
|
||||
}
|
||||
|
||||
int __wrap_puts(const char* s) {
|
||||
writeAllToStdout(s, strlen(s));
|
||||
writeAllToStdout("\n", 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __wrap_fputs(const char* s, FILE* stream) {
|
||||
if (stream == stdout) {
|
||||
writeAllToStdout(s, strlen(s));
|
||||
return 0;
|
||||
}
|
||||
return __real_fputs(s, stream);
|
||||
}
|
||||
|
||||
int __wrap_putchar(int c) {
|
||||
auto ch = static_cast<char>(c);
|
||||
writeAllToStdout(&ch, 1);
|
||||
return c;
|
||||
}
|
||||
|
||||
int __wrap_fputc(int c, FILE* stream) {
|
||||
if (stream == stdout) {
|
||||
return __wrap_putchar(c);
|
||||
}
|
||||
return __real_fputc(c, stream);
|
||||
}
|
||||
|
||||
int __wrap_getchar() {
|
||||
char c;
|
||||
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
|
||||
}
|
||||
|
||||
int __wrap_fgetc(FILE* stream) {
|
||||
if (stream == stdin) {
|
||||
return __wrap_getchar();
|
||||
}
|
||||
return __real_fgetc(stream);
|
||||
}
|
||||
|
||||
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
|
||||
if (stream != stdin) {
|
||||
return __real_fgets(buffer, size, stream);
|
||||
}
|
||||
if (size <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
int i = 0;
|
||||
for (; i < size - 1; ++i) {
|
||||
char c;
|
||||
if (readOneFromStdin(c) != 1) {
|
||||
break;
|
||||
}
|
||||
buffer[i] = c;
|
||||
if (c == '\n') {
|
||||
++i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
buffer[i] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif // !ESP_PLATFORM && !__APPLE__
|
||||
|
||||
// endregion
|
||||
@@ -68,12 +68,12 @@ std::string getUserHomePath() {
|
||||
}
|
||||
|
||||
std::string getAppInstallPath(const std::string& appId) {
|
||||
assert(app_id_is_valid(appId.c_str()));
|
||||
assert(app_manifest_id_is_valid(appId.c_str()));
|
||||
return std::format("{}/{}", getAppInstallPath(), appId);
|
||||
}
|
||||
|
||||
std::string getAppUserPath(const std::string& appId) {
|
||||
assert(app_id_is_valid(appId.c_str()));
|
||||
assert(app_manifest_id_is_valid(appId.c_str()));
|
||||
return std::format("{}/app/{}", getUserHomePath(), appId);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/start.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/module.h>
|
||||
#include <app/start.h>
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/hal/SdCard.h>
|
||||
#include <Tactility/lvgl/KeyboardDeviceListener.h>
|
||||
#include <Tactility/lvgl/Lvgl.h>
|
||||
#include <Tactility/lvgl/Statusbar.h>
|
||||
#include <Tactility/lvgl/TrackballInit.h>
|
||||
#include <Tactility/lvgl/UsbHidInput.h>
|
||||
@@ -150,9 +151,9 @@ namespace app {
|
||||
namespace alertdialog { extern const ::AppManifest manifest; }
|
||||
namespace apphub { extern const ::AppManifest manifest; }
|
||||
namespace apphubdetails { extern const ::AppManifest manifest; }
|
||||
namespace appdetails { extern const ::AppManifest manifest; }
|
||||
namespace apppackagedetails { extern const ::AppManifest manifest; }
|
||||
namespace applist { extern const ::AppManifest manifest; }
|
||||
namespace appsettings { extern const ::AppManifest manifest; }
|
||||
namespace apppackagelist { extern const ::AppManifest manifest; }
|
||||
namespace audiosettings { extern const ::AppManifest manifest; }
|
||||
namespace boot { extern const ::AppManifest manifest; }
|
||||
namespace development { extern const ::AppManifest manifest; }
|
||||
@@ -189,9 +190,12 @@ namespace app {
|
||||
#ifdef ESP_PLATFORM
|
||||
namespace apwebserver { extern const ::AppManifest manifest; }
|
||||
namespace crashdiagnostics { extern const ::AppManifest manifest; }
|
||||
namespace mcpsettings { extern const ::AppManifest manifest; }
|
||||
#if CONFIG_TT_TDECK_WORKAROUND == 1
|
||||
namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now
|
||||
#endif
|
||||
#else
|
||||
namespace mcpsettings { extern const ::AppManifest manifest; }
|
||||
#endif
|
||||
|
||||
namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now
|
||||
@@ -212,11 +216,11 @@ static void registerInternalApps() {
|
||||
LOG_I(TAG, "Registering internal apps");
|
||||
|
||||
app_manager_add(&app::alertdialog::manifest);
|
||||
app_manager_add(&app::appdetails::manifest);
|
||||
app_manager_add(&app::apppackagedetails::manifest);
|
||||
app_manager_add(&app::apphub::manifest);
|
||||
app_manager_add(&app::apphubdetails::manifest);
|
||||
app_manager_add(&app::applist::manifest);
|
||||
app_manager_add(&app::appsettings::manifest);
|
||||
app_manager_add(&app::apppackagelist::manifest);
|
||||
if (service::audio::isAvailable()) {
|
||||
app_manager_add(&app::audiosettings::manifest);
|
||||
}
|
||||
@@ -250,11 +254,14 @@ static void registerInternalApps() {
|
||||
app_manager_add(&app::development::manifest);
|
||||
app_manager_add(&app::webserversettings::manifest);
|
||||
#ifdef ESP_PLATFORM
|
||||
app_manager_add(&app::mcpsettings::manifest);
|
||||
app_manager_add(&app::apwebserver::manifest);
|
||||
app_manager_add(&app::crashdiagnostics::manifest);
|
||||
#if defined(CONFIG_TT_TDECK_WORKAROUND)
|
||||
app_manager_add(&app::keyboardsettings::manifest);
|
||||
#endif
|
||||
#else
|
||||
app_manager_add(&app::mcpsettings::manifest);
|
||||
#endif
|
||||
|
||||
if (device_exists_of_type(&TRACKBALL_TYPE)) {
|
||||
@@ -433,6 +440,7 @@ static void onLvglStarted() {
|
||||
if (auto* display = lv_display_get_default(); display != nullptr) {
|
||||
auto displaySettings = settings::display::loadOrGetDefault();
|
||||
lv_display_set_rotation(display, settings::display::toLvglDisplayRotation(displaySettings.orientation));
|
||||
lvgl::applyFontSize(displaySettings.fontSize);
|
||||
}
|
||||
lvgl_unlock();
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/start.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/start.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user