diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml index abbc32465..b8b0a9fc5 100644 --- a/.github/actions/build-sdk-posix/action.yml +++ b/.github/actions/build-sdk-posix/action.yml @@ -25,14 +25,16 @@ runs: env: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} run: | - 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 + TACTILITY_PLATFORM_PATH="test_sdk/sdk/$(cat version.txt)/platform-posix-$TACTILITY_ARCH" + mkdir -p $TACTILITY_PLATFORM_PATH + cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK + mkdir -p test_sdk/sdk/$(cat version.txt)/tools + cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ - 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 -a posix-$TACTILITY_ARCH --local-sdk + run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python ../../tt.py app build -a posix-$TACTILITY_ARCH - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index e84375634..d42c1a679 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -35,15 +35,17 @@ 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="$(cat version.txt)-${{ inputs.arch }}" - mkdir -p test_sdk/$TACTILITY_SDK_NAME - cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME + TACTILITY_PLATFORM_PATH="test_sdk/sdk/$(cat version.txt)/platform-${{ inputs.arch }}" + mkdir -p $TACTILITY_PLATFORM_PATH + cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK + mkdir -p test_sdk/sdk/$(cat version.txt)/tools + cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ - name: 'Test Integration' uses: espressif/esp-idf-ci-action@v1 with: esp_idf_version: v6.1 target: ${{ inputs.arch }} - command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python ../../tactility.py build -a ${{ inputs.arch }} --local-sdk + command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python ../../tt.py app build -a ${{ inputs.arch }} - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/actions/build-tools/action.yml b/.github/actions/build-tools/action.yml new file mode 100644 index 000000000..4a02ceec5 --- /dev/null +++ b/.github/actions/build-tools/action.yml @@ -0,0 +1,16 @@ +name: Build Tools + +runs: + using: 'composite' + steps: + - name: 'Release' + shell: bash + run: | + python Buildscripts/release-tools-current.py + echo "TACTILITY_TOOLS_PATH=release/sdk/$(cat version.txt)/tools" >> "$GITHUB_ENV" + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: TactilityTools + path: ${{ env.TACTILITY_TOOLS_PATH }} + retention-days: 30 diff --git a/.github/actions/build-tt/action.yml b/.github/actions/build-tt/action.yml new file mode 100644 index 000000000..52aad6479 --- /dev/null +++ b/.github/actions/build-tt/action.yml @@ -0,0 +1,17 @@ +name: Build tt + +runs: + using: 'composite' + steps: + - name: 'Test' + shell: bash + run: python -m unittest discover -s Tests/Python -p '*_test.py' -v + - name: 'Release' + shell: bash + run: python Buildscripts/release-tt.py + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: tt + path: release/tt + retention-days: 30 diff --git a/.github/actions/publish-tt/action.yml b/.github/actions/publish-tt/action.yml new file mode 100644 index 000000000..d25375787 --- /dev/null +++ b/.github/actions/publish-tt/action.yml @@ -0,0 +1,16 @@ +name: Publish tt + +runs: + using: 'composite' + steps: + - name: 'Download tt' + uses: actions/download-artifact@v4 + with: + name: 'tt' + path: release/tt + - name: 'Install boto3' + shell: bash + run: pip install boto3 + - name: 'Upload files' + shell: bash + run: python Buildscripts/CDN/upload-sdk-files.py --tt release/tt ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c553df4c..d3846a243 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,22 @@ jobs: persist-credentials: false - name: "Build SDK" uses: ./.github/actions/build-sdk-posix + BuildTools: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: "Build Tools" + uses: ./.github/actions/build-tools + BuildTt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: "Build tt" + uses: ./.github/actions/build-tt GenerateDeviceMatrix: runs-on: ubuntu-latest needs: [ BuildSdkEsp32 ] @@ -65,7 +81,7 @@ jobs: arch: ${{ matrix.board.arch }} BundleArtifacts: runs-on: ubuntu-latest - needs: [ BuildFirmware, BuildSdkPosix ] + needs: [ BuildFirmware, BuildSdkPosix, BuildTools, BuildTt ] if: | (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) @@ -109,9 +125,34 @@ jobs: if: (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) steps: - uses: actions/checkout@v6 + # main only publishes -dev SDKs and tags only publish release SDKs, so merges after a release can't overwrite it + - name: "Check version" + id: version + shell: bash + run: | + version=$(cat version.txt) + if [[ "$GITHUB_REF" == refs/tags/v* && "$version" != *-dev ]] || [[ "$GITHUB_REF" == refs/heads/main && "$version" == *-dev ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::Not publishing SDK $version from $GITHUB_REF" + fi - name: "Publish SDKs" + if: steps.version.outputs.publish == 'true' env: CDN_ID: ${{ secrets.CDN_ID }} CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }} CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }} uses: ./.github/actions/publish-sdk + PublishTt: + runs-on: ubuntu-latest + needs: [ BuildTt, BundleArtifacts ] + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') + steps: + - uses: actions/checkout@v6 + - name: "Publish tt" + env: + CDN_ID: ${{ secrets.CDN_ID }} + CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }} + CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }} + uses: ./.github/actions/publish-tt diff --git a/Buildscripts/CDN/generate-firmware-files.py b/Buildscripts/CDN/generate-firmware-files.py index c3f99483c..437c00a13 100644 --- a/Buildscripts/CDN/generate-firmware-files.py +++ b/Buildscripts/CDN/generate-firmware-files.py @@ -186,7 +186,8 @@ def main(in_path: str, out_path: str, version: str): devices=[] ) for artifact_directory in artifact_directories: - if artifact_directory.endswith("-symbols") or artifact_directory.endswith(".bin") or artifact_directory.startswith("TactilitySDK-"): + # Only Tactility- artifacts are firmwares: SDK, tools and tt artifacts are published elsewhere + if artifact_directory.endswith("-symbols") or artifact_directory.endswith(".bin") or not artifact_directory.startswith("Tactility-"): continue device_id = artifact_directory.removeprefix("Tactility-") if not device_id: diff --git a/Buildscripts/CDN/generate-sdk-files.py b/Buildscripts/CDN/generate-sdk-files.py index c6609a7bc..93868e101 100644 --- a/Buildscripts/CDN/generate-sdk-files.py +++ b/Buildscripts/CDN/generate-sdk-files.py @@ -1,3 +1,4 @@ +import hashlib import subprocess from datetime import datetime, UTC import os @@ -5,8 +6,10 @@ import sys from dataclasses import dataclass, asdict import json import shutil +import zipfile VERBOSE = False +TOOLS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "Tools") @dataclass class SdkIndex: @@ -14,6 +17,7 @@ class SdkIndex: created: str gitCommit: str platforms: dict + tools: dict shell_color_red = "\033[91m" shell_color_orange = "\033[93m" @@ -40,6 +44,19 @@ def print_help(): def get_git_commit_hash(): return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip() +def create_tools_bundle(out_path: str): + """Bundles the tools that tt.py runs for this SDK version""" + if VERBOSE: + print(f"Archiving {TOOLS_PATH} to {out_path}/tools.zip") + archive_path = os.path.join(out_path, "tools.zip") + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: + for file_name in sorted(os.listdir(TOOLS_PATH)): + if file_name.endswith(".py"): + archive.write(os.path.join(TOOLS_PATH, file_name), file_name) + with open(archive_path, "rb") as archive: + sha256 = hashlib.sha256(archive.read()).hexdigest() + return {"file": "tools.zip", "sha256": sha256} + def main(in_path: str, out_path: str, version: str): if not os.path.exists(in_path): exit_with_error(f"Input path not found: {in_path}") @@ -51,7 +68,8 @@ def main(in_path: str, out_path: str, version: str): version=version, created=datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%S'), gitCommit=get_git_commit_hash(), - platforms={} + platforms={}, + tools=create_tools_bundle(out_path) ) for artifact_directory in artifact_directories: if VERBOSE: diff --git a/Buildscripts/CDN/tactility.py b/Buildscripts/CDN/tactility.py new file mode 100644 index 000000000..849591266 --- /dev/null +++ b/Buildscripts/CDN/tactility.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Replaces older versions of tactility.py: they update themselves to this script, which installs tt.""" + +import io +import os +import re +import sys +import urllib.request +import zipfile + +# Older versions of tactility.py compare this version with tactility.py.json to suggest an update +ttbuild_version = "8.0.0" +CDN_URL = "https://cdn.tactilityproject.org" +FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$") + +shell_color_orange = "\033[93m" +shell_color_red = "\033[91m" +shell_color_reset = "\033[m" + + +def print_error(message): + print(f"{shell_color_red}ERROR: {message}{shell_color_reset}") + + +def print_warning(message): + print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") + + +def install_tt(directory): + """Extracts sdk/tt.zip: tt.py and its wrappers""" + request = urllib.request.Request(f"{CDN_URL}/sdk/tt.zip", headers={"User-Agent": f"tactility.py {ttbuild_version}"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + archive_data = response.read() + with zipfile.ZipFile(io.BytesIO(archive_data)) as archive: + contents = {member.filename: archive.read(member) for member in archive.infolist()} + except (OSError, zipfile.BadZipFile) as error: + print_error(f"Failed to install tt: {error}") + return False + if not all(FILE_NAME_PATTERN.match(file_name) for file_name in contents): + print_error("Failed to install tt: invalid tt.zip") + return False + for file_name, content in contents.items(): + path = os.path.join(directory, file_name) + with open(path, "wb") as file: + file.write(content) + os.chmod(path, 0o755) + return True + + +if __name__ == "__main__": + directory = os.path.dirname(os.path.abspath(__file__)) + if not os.path.exists(os.path.join(directory, "tt.py")): + if not install_tt(directory): + sys.exit(1) + print(f"Installed tt in {directory}") + command = "tt" if os.name == "nt" else "./tt" + print_warning(f"tactility.py was replaced by tt: use tt from now on, e.g. '{command} app build'") + print_warning(f"Run '{command} help' for more info") + sys.exit(1) diff --git a/Buildscripts/CDN/upload-sdk-files.py b/Buildscripts/CDN/upload-sdk-files.py index a6942844e..37cb81426 100644 --- a/Buildscripts/CDN/upload-sdk-files.py +++ b/Buildscripts/CDN/upload-sdk-files.py @@ -17,15 +17,18 @@ def print_error(message): def print_help(): print("Usage: python upload-sdk-files.py [path] [version] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]") + print(" python upload-sdk-files.py --tt [path] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]") print("") print("Options:") print(" --index-only Upload only index.json") + print(" --tt Upload the tt files that Buildscripts/release-tt.py created in [path]") def exit_with_error(message): print_error(message) sys.exit(1) -TOOL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tactility.py") +# Older versions of tactility.py update themselves from sdk//tactility.py, which installs tt.py +TOOL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tactility.py") def create_tool_json(): with open(TOOL_PATH, "r") as file: @@ -34,16 +37,33 @@ def create_tool_json(): exit_with_error(f"ttbuild_version not found in {TOOL_PATH}") return json.dumps({"version": match.group(1)}, indent=2).encode("utf-8") -def main(path: str, version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool): - if not os.path.exists(path): - exit_with_error(f"Path not found: {path}") - s3 = boto3.client( +def create_s3_client(cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str): + return boto3.client( service_name="s3", endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com", aws_access_key_id=cloudflare_token_name, aws_secret_access_key=cloudflare_token_value, region_name="auto" ) + +def upload_launcher(path: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str): + """tt is independent of the SDK version, so it is stored next to the SDK version folders""" + s3 = create_s3_client(cloudflare_account_id, cloudflare_token_name, cloudflare_token_value) + # tt.json goes last, so it never announces a version that isn't uploaded yet + for file_name in ("tt.zip", "tt.json"): + file_path = os.path.join(path, file_name) + if not os.path.isfile(file_path): + exit_with_error(f"File not found: {file_path}") + print(f"Uploading {file_name} to sdk/{file_name}") + try: + s3.upload_file(file_path, "tactility", f"sdk/{file_name}") + except Exception as e: + exit_with_error(f"Failed to upload {file_name}: {str(e)}") + +def main(path: str, version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool): + if not os.path.exists(path): + exit_with_error(f"Path not found: {path}") + s3 = create_s3_client(cloudflare_account_id, cloudflare_token_name, cloudflare_token_value) files_to_upload = os.listdir(path) counter = 1 total = len(files_to_upload) @@ -76,6 +96,13 @@ if __name__ == "__main__": if "--help" in sys.argv: print_help() sys.exit() + if "--tt" in sys.argv: + arguments = [argument for argument in sys.argv[1:] if argument != "--tt"] + if len(arguments) < 4: + print_help() + sys.exit(1) + upload_launcher(arguments[0], arguments[1], arguments[2], arguments[3]) + sys.exit() # Argument validation if len(sys.argv) < 6: print_help() diff --git a/Buildscripts/TactilitySDK/Tools/_bootstrap.py b/Buildscripts/TactilitySDK/Tools/_bootstrap.py new file mode 100755 index 000000000..c0d19e5f7 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/_bootstrap.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Run by the tools at startup, when tt runs them. Checks the CDN for a newer tt and a newer build of the selected SDK at most every 30 minutes, and suggests how to update.""" + +import json +import os +import re +import sys +import time +import urllib.request + +CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") +STATE_PATH = os.path.join(os.path.expanduser("~"), ".tactility", "tt.json") +CHECK_INTERVAL_SECONDS = 30 * 60 + + +def parse_version(version): + return tuple(int(part) for part in re.findall(r"\d+", version)[:3]) + + +def read_state(): + try: + with open(STATE_PATH) as file: + state = json.load(file) + return state if isinstance(state, dict) else {} + except (OSError, ValueError): + return {} + + +def write_state(state): + try: + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, "w") as file: + json.dump(state, file) + except OSError: + pass + + +def fetch_json(url): + """Returns None when the CDN can't be reached""" + try: + request = urllib.request.Request(url, headers={"User-Agent": "tt bootstrap"}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.load(response) + except (OSError, ValueError): + return None + + +def check_cdn(state, sdk_version): + """Stores the latest tt version and SDK build in the state, keeping the previous ones when the CDN can't be reached""" + state["checked"] = time.time() + tt_json = fetch_json(f"{CDN_URL}/sdk/tt.json") + if isinstance(tt_json, dict): + state["version"] = tt_json.get("version") + if sdk_version is not None: + index = fetch_json(f"{CDN_URL}/sdk/{sdk_version}/index.json") + if isinstance(index, dict): + state.setdefault("sdk", {})[sdk_version] = index.get("gitCommit") + write_state(state) + + +def installed_commits(version_directory): + prefix = "platform-" + commits = [] + if not os.path.isdir(version_directory): + return commits + for name in os.listdir(version_directory): + platform_directory = os.path.join(version_directory, name) + if name.startswith(prefix) and os.path.isdir(os.path.join(platform_directory, "TactilitySDK")): + try: + with open(os.path.join(platform_directory, "commit")) as file: + commits.append(file.read().strip()) + except OSError: + commits.append(None) + return commits + + +def run(): + tt_version = os.environ.get("TT_VERSION") + if tt_version is None: + return + sdk_version = os.environ.get("TACTILITY_SDK_VERSION") + sdk_path = os.environ.get("TACTILITY_SDK_PATH") + # A local SDK isn't managed by tt + if os.environ.get("TT_LOCAL") == "1": + sdk_version = None + state = read_state() + is_unchecked_sdk = sdk_version is not None and sdk_version not in state.get("sdk", {}) + if time.time() - state.get("checked", 0) >= CHECK_INTERVAL_SECONDS or is_unchecked_sdk: + check_cdn(state, sdk_version) + latest = state.get("version") + if isinstance(latest, str) and parse_version(latest) > parse_version(tt_version): + print(f"tt: version {latest} is available (currently {tt_version}): run 'tt update-self'", file=sys.stderr) + latest_commit = state.get("sdk", {}).get(sdk_version) if sdk_version is not None else None + if latest_commit and sdk_path is not None and any(commit != latest_commit for commit in installed_commits(os.path.join(sdk_path, "sdk", sdk_version))): + print(f"tt: a newer build of SDK {sdk_version} is available: run 'tt update-sdk'", file=sys.stderr) + + +if __name__ == "__main__": + run() diff --git a/Buildscripts/TactilitySDK/Tools/_device_host.py b/Buildscripts/TactilitySDK/Tools/_device_host.py new file mode 100644 index 000000000..4d6033c4b --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/_device_host.py @@ -0,0 +1,23 @@ +# License: Apache License v2 +"""The device host that "tt host" stores for the directory that tt runs in.""" + +import os + +# Absolute, so the path stays the same when a tool changes its working directory +DEVICE_HOST_PATH = os.path.abspath(os.path.join(".tactility", "device_host")) +MISSING_HOST_MESSAGE = "no device host: pass -h/--host or run 'tt host '" + + +def read_device_host(): + """Returns None when no host is stored""" + try: + with open(DEVICE_HOST_PATH) as file: + return file.read().strip() or None + except OSError: + return None + + +def write_device_host(host): + os.makedirs(os.path.dirname(DEVICE_HOST_PATH), exist_ok=True) + with open(DEVICE_HOST_PATH, "w") as file: + file.write(f"{host}\n") diff --git a/tactility.py b/Buildscripts/TactilitySDK/Tools/app.py similarity index 66% rename from tactility.py rename to Buildscripts/TactilitySDK/Tools/app.py index 2f9bc5bb4..e77ef9d24 100755 --- a/tactility.py +++ b/Buildscripts/TactilitySDK/Tools/app.py @@ -1,32 +1,23 @@ #!/usr/bin/env python3 # License: Apache License v2 -import json import os import re import shutil import sys import subprocess -import time -import urllib.request -import zipfile -import requests import tarfile -from urllib.parse import urlparse -ttbuild_path = ".tactility" -ttbuild_version = "7.0.1" -ttbuild_cdn = "https://cdn.tactilityproject.org" -ttbuild_sdk_json_validity = 3600 # seconds -ttport = 6666 +import _device_host + +ttbuild_version = "8.0.0" verbose = False -use_local_sdk = False -local_base_path = None -http_timeout_seconds = 10 -# App install uploads the whole package over HTTP and the device only responds once it's -# fully received, extracted and registered; large packages (e.g. bundled fonts/assets) can -# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room. -install_timeout_seconds = 120 +# The development server client (shell.py) is next to this script +tool_directory = os.path.dirname(os.path.abspath(__file__)) +# The SDKs are in TACTILITY_SDK_PATH/sdk/, which tt sets along with the version it selected. +# Read once, because builds point TACTILITY_SDK_PATH at the platform SDK for CMake. +tt_sdk_version = os.environ.get("TACTILITY_SDK_VERSION") +sdk_root_path = os.environ.get("TACTILITY_SDK_PATH") or None shell_color_red = "\033[91m" shell_color_orange = "\033[93m" @@ -36,7 +27,8 @@ shell_color_cyan = "\033[36m" shell_color_reset = "\033[m" def print_help(): - print("Usage: python tactility.py [action] [options]") + command = "tt app" + print(f"Usage: {command} [action] [options]") print("") print("Actions:") print("") @@ -48,70 +40,41 @@ def print_help(): print("") print(" clean Clean the build folders") print("") - print(" clearcache Clear the SDK cache") - print("") - print(" updateself Update this tool") - print("") print(" run Run the application") print(" Parameters:") - print(" (required) --host, -h [input]") + print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print("") print(" install Install the application") print(" Parameters:") - print(" (required) --host, -h [input]") + print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print("") print(" uninstall Uninstall the application") print(" Parameters:") - print(" (required) --host, -h [input]") + print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print("") print(" bir Build, install then run.") print(" Parameters:") - print(" (required) --host, -h [input]") + print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print(" (optional) --architecture, -a [input]") print("") print(" brrr Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") print(" Parameters:") - print(" (required) --host, -h [input]") + print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print(" (optional) --architecture, -a [input]") print("") print("Options:") print(" -p, --path [input] Path to the app directory (defaults to current directory)") print(" --help Show this commandline info") - print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") print(" --skip-build Run everything except the idf.py/CMake commands") print(" --verbose Show extra console output") print("") print("Examples:") - print(" python tactility.py build") - print(" python tactility.py build --path Apps/Snake --architecture esp32s3 --verbose") - print(" python tactility.py bir --path Apps/Snake --host 192.168.1.50 --architecture esp32s3") + print(f" {command} build") + print(f" {command} build --path Apps/Snake --architecture esp32s3 --verbose") + print(f" {command} bir --path Apps/Snake --host 192.168.1.50 --architecture esp32s3") # region Core -def download_file(url, filepath): - global verbose - if verbose: - print(f"Downloading from {url} to {filepath}") - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - print_error(f"Unsupported URL scheme: {parsed.scheme}") - return False - request = urllib.request.Request( - url, - data=None, - headers={ - "User-Agent": f"Tactility Build Tool {ttbuild_version}" - } - ) - try: - with urllib.request.urlopen(request, timeout=30) as response, open(filepath, mode="wb") as file: - file.write(response.read()) - return True - except OSError as error: - if verbose: - print_error(f"Failed to fetch URL {url}\n{error}") - return False - def print_warning(message): print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}") @@ -133,9 +96,6 @@ def exit_with_error(message): print_error(message) sys.exit(1) -def get_url(host, path): - return f"http://{host}:{ttport}{path}" - def read_properties_file(path): properties = {} with open(path, "r") as file: @@ -222,7 +182,6 @@ ARG_SPECS = { "platform": ("-a", "--architecture", True), "verbose": (None, "--verbose", False), "skip-build": (None, "--skip-build", False), - "local-sdk": (None, "--local-sdk", False), "help": (None, "--help", False), } @@ -273,72 +232,17 @@ def parse_command_line(argv): #region SDK helpers -def get_tool_json_path(version): - return os.path.join(ttbuild_path, version, "tactility.py.json") - -def read_sdk_json(version): - json_file_path = get_tool_json_path(version) - with open(json_file_path) as json_file: - return json.load(json_file) - def get_sdk_dir(version, platform): - global use_local_sdk, local_base_path - if use_local_sdk: - base_path = local_base_path - if base_path is None: - exit_with_error("TACTILITY_SDK_PATH environment variable is not set") - sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") - sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") - if not os.path.isdir(sdk_dir): - exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}") - return os.path.abspath(sdk_dir) - else: - # Must be absolute: this is exported as $TACTILITY_SDK_PATH and included by each app's - # main/CMakeLists.txt, which ESP-IDF also re-processes in a separate `cmake -P` subprocess - # (tools/cmake/scripts/component_get_requirements.cmake) with its own working directory - - # a relative path here resolves against whatever CWD that subprocess happens to have. - return os.path.abspath(os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK")) + """Absolute, because builds export it as TACTILITY_SDK_PATH for CMake, which also runs in subprocesses with another working directory""" + return os.path.abspath(os.path.join(sdk_root_path, "sdk", version, f"platform-{platform}", "TactilitySDK")) -def validate_local_sdks(platforms, version): - if not use_local_sdk: - return - global local_base_path - base_path = local_base_path +def validate_sdks(platforms, version): for platform in platforms: - sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}") - sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK") + sdk_dir = get_sdk_dir(version, platform) if not os.path.isdir(sdk_dir): - exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}") + exit_with_error(f"SDK {version} for {platform} not found: {sdk_dir}") if not sdk_has_sdkconfig(version, platform): - exit_with_error(f"Local SDK for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Rebuild it with the current SDK release scripts.") - -def get_sdk_root_dir(version, platform): - global ttbuild_cdn - return os.path.join(ttbuild_path, f"{version}-{platform}") - -def get_sdk_url(version, file): - global ttbuild_cdn - return f"{ttbuild_cdn}/sdk/{version}/{file}" - -def sdk_exists(version, platform): - sdk_dir = get_sdk_dir(version, platform) - return os.path.isdir(sdk_dir) - -def should_update_tool_json(version): - json_filepath = get_tool_json_path(version) - if os.path.exists(json_filepath): - json_modification_time = os.path.getmtime(json_filepath) - now = time.time() - global ttbuild_sdk_json_validity - minimum_seconds_difference = ttbuild_sdk_json_validity - return (now - json_modification_time) > minimum_seconds_difference - else: - return True - -def update_tool_json(version): - json_filepath = get_tool_json_path(version) - os.makedirs(os.path.dirname(json_filepath), exist_ok=True) - return download_file(get_sdk_url(version, "tactility.py.json"), json_filepath) + exit_with_error(f"SDK {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") def get_sdk_sdkconfig_path(version, platform): return os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}") @@ -367,19 +271,6 @@ def validate_environment(platforms): exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") if not os.path.exists("manifest.properties"): exit_with_error("manifest.properties not found") - if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: - print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") - print_warning("If you want to use it, use the '--local-sdk' parameter") - elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: - exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") - -def validate_self(sdk_json): - if not "version" in sdk_json: - exit_with_error("Server returned invalid SDK data format (version not found)") - tool_version = sdk_json["version"] - if SemanticVersion.parse(ttbuild_version) < SemanticVersion.parse(tool_version): - print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") - print_warning(f"Run 'tactility.py updateself' to update.") #endregion Validation @@ -470,79 +361,6 @@ def get_manifest_target_platforms(manifest, requested_platform): #endregion Manifest -#region SDK download - -def safe_extract_zip(zip_ref, target_dir): - target_dir = os.path.realpath(target_dir) - for member in zip_ref.infolist(): - dest = os.path.realpath(os.path.join(target_dir, member.filename)) - if not dest.startswith(target_dir + os.sep): - raise ValueError(f"Invalid zip entry: {member.filename}") - zip_ref.extractall(target_dir) - -def sdk_download(version, platform, sdk_root_dir=None): - if sdk_root_dir is None: - sdk_root_dir = get_sdk_root_dir(version, platform) - os.makedirs(sdk_root_dir, exist_ok=True) - sdk_index_url = get_sdk_url(version, "index.json") - print(f"Downloading SDK version {version} for {platform}") - sdk_index_filepath = os.path.join(sdk_root_dir, "index.json") - if verbose: - print(f"Downloading {sdk_index_url} to {sdk_index_filepath}") - if not download_file(sdk_index_url, sdk_index_filepath): - # TODO: 404 check, print a more accurate error - print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.") - return False - with open(sdk_index_filepath) as sdk_index_json_file: - sdk_index_json = json.load(sdk_index_json_file) - sdk_platforms = sdk_index_json["platforms"] - if platform not in sdk_platforms: - print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}") - return False - sdk_platform_file = sdk_platforms[platform] - sdk_zip_source_url = get_sdk_url(version, sdk_platform_file) - sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip") - if verbose: - print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}") - if not download_file(sdk_zip_source_url, sdk_zip_target_filepath): - print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}") - return False - with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref: - safe_extract_zip(zip_ref, os.path.join(sdk_root_dir, "TactilitySDK")) - return True - -def sdk_download_all(version, platforms): - for platform in platforms: - if sdk_exists(version, platform) and not sdk_has_sdkconfig(version, platform): - print_warning(f"Cached SDK version {version} for {platform} is outdated (no sdkconfig.app.{platform}), downloading it again") - # Downloaded next to the cache, so a failed download keeps the cached SDK - sdk_root_dir = get_sdk_root_dir(version, platform) - replacement_root_dir = f"{sdk_root_dir}.new" - shutil.rmtree(replacement_root_dir, ignore_errors=True) - if not sdk_download(version, platform, replacement_root_dir): - shutil.rmtree(replacement_root_dir, ignore_errors=True) - return False - replacement_sdkconfig = os.path.join(replacement_root_dir, "TactilitySDK", f"sdkconfig.app.{platform}") - if not platform.startswith("posix") and not os.path.isfile(replacement_sdkconfig): - shutil.rmtree(replacement_root_dir, ignore_errors=True) - print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") - return False - shutil.rmtree(sdk_root_dir) - shutil.move(replacement_root_dir, sdk_root_dir) - continue - if not sdk_exists(version, platform): - if not sdk_download(version, platform): - return False - if not sdk_has_sdkconfig(version, platform): - print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.") - return False - else: - if verbose: - print(f"Using cached download for SDK version {version} and platform {platform}") - return True - -#endregion SDK download - #region CMakeLists scaffolding # Bump whenever CMAKELISTS_TEMPLATE below changes, so existing apps' generated CMakeLists.txt get @@ -818,10 +636,6 @@ def package_all(manifest, platforms): #endregion Packaging -def setup_environment(): - global ttbuild_path - os.makedirs(ttbuild_path, exist_ok=True) - def build_action(manifest, arguments): platform_arg = arguments.get("platform") skip_build = arguments.has("skip-build") @@ -829,19 +643,8 @@ def build_action(manifest, arguments): # Environment validation validate_environment(platforms_to_build) - if use_local_sdk: - global local_base_path - local_base_path = os.environ.get("TACTILITY_SDK_PATH") - validate_local_sdks(platforms_to_build, manifest["target.sdk"]) - - if not use_local_sdk: - sdk_json = read_sdk_json(manifest["target.sdk"]) - validate_self(sdk_json) - # Build sdk_version = manifest["target.sdk"] - if not use_local_sdk: - if not sdk_download_all(sdk_version, platforms_to_build): - exit_with_error("Failed to download one or more SDKs") + validate_sdks(platforms_to_build, sdk_version) # A multi-binary app builds each of its binaries in its own subdirectory as an independent # CMake project; a single-binary app has exactly one binary, built at the app root @@ -887,107 +690,58 @@ def clean_action(manifest): if not cleaned_any: print("Nothing to clean") -def clear_cache_action(): - if os.path.exists(ttbuild_path): - print_status_busy(f"Removing {ttbuild_path}/") - shutil.rmtree(ttbuild_path) - print_status_success(f"Removed {ttbuild_path}/") - else: - print("Nothing to clear") - -def update_self_action(manifest): - if download_file(get_sdk_url(manifest["target.sdk"], "tactility.py"), "tactility.py"): - print("Updated") - else: - exit_with_error("Update failed") - -def get_device_info(host): - print_status_busy(f"Requesting device info") - url = get_url(host, "/info") +def load_device_client(): + sys.path.insert(0, tool_directory) try: - response = requests.get(url, timeout=http_timeout_seconds) - if response.status_code != 200: - print_error("Run failed") - else: - print_status_success(f"Received device info:") - print(response.json()) - except requests.RequestException as e: - print_status_error(f"Device info request failed: {e}") + import shell + except ImportError: + exit_with_error(f"shell.py not found in {tool_directory}") + shell.PROGRAM_NAME = "tt app" + return shell + +def get_host(arguments): + host = arguments.get("host") or _device_host.read_device_host() + if host is None: + exit_with_error(_device_host.MISSING_HOST_MESSAGE) + return host def run_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") + host = get_host(arguments) app_id = get_primary_app_id(manifest) print_status_busy("Running") - url = get_url(host, "/app/run") - params = {'id': app_id} - try: - response = requests.post(url, params=params, timeout=http_timeout_seconds) - if response.status_code != 200: - print_error("Run failed") - else: - print_status_success("Running") - except requests.RequestException as e: - print_status_error(f"Running request failed: {e}") + device = load_device_client() + device.run(host, device.DEFAULT_PORT, app_id) + print_status_success("Running") def install_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") + host = get_host(arguments) package_path = package_name(manifest) if not os.path.isfile(package_path): print_status_error(f"Package not found: {package_path} (run 'build' first)") return False print_status_busy("Installing") - url = get_url(host, "/app/install") - try: - # Prepare multipart form data - with open(package_path, 'rb') as file: - files = { - 'app': file - } - response = requests.put(url, files=files, timeout=install_timeout_seconds) - if response.status_code != 200: - print_status_error("Install failed") - return False - else: - print_status_success("Installing") - return True - except requests.RequestException as e: - print_status_error(f"Install request failed: {e}") - return False - except IOError as e: - print_status_error(f"Install file error: {e}") - return False + device = load_device_client() + device.install(host, device.DEFAULT_PORT, package_path) + print_status_success("Installing") + return True def uninstall_action(manifest, arguments): - host = arguments.get("host") - if host is None: - exit_with_error("Missing required argument: --host") + host = get_host(arguments) app_id = get_package_id(manifest) print_status_busy("Uninstalling") - url = get_url(host, "/app/uninstall") - params = {'id': app_id} - try: - response = requests.put(url, params=params, timeout=http_timeout_seconds) - if response.status_code != 200: - print_status_error("Server responded that uninstall failed") - else: - print_status_success("Uninstalled") - except requests.RequestException as e: - print_status_error(f"Uninstall request failed: {e}") + device = load_device_client() + device.uninstall(host, device.DEFAULT_PORT, app_id) + print_status_success("Uninstalled") #region Main if __name__ == "__main__": + import _bootstrap + _bootstrap.run() print(f"Tactility Build System v{ttbuild_version}") - - # Anchor the cache to the invocation directory, before --path (below) can chdir into the app. - ttbuild_path = os.path.abspath(ttbuild_path) - # Same for a relative local SDK path. - if os.environ.get("TACTILITY_SDK_PATH") is not None: - os.environ["TACTILITY_SDK_PATH"] = os.path.abspath(os.environ["TACTILITY_SDK_PATH"]) + if sdk_root_path is None: + exit_with_error("TACTILITY_SDK_PATH is not set: run this tool as 'tt app'") + sdk_root_path = os.path.abspath(sdk_root_path) argv = sys.argv[1:] if len(argv) == 0: @@ -1004,7 +758,6 @@ if __name__ == "__main__": sys.exit(1) verbose = parsed_command.arguments.has("verbose") - use_local_sdk = parsed_command.arguments.has("local-sdk") app_path = parsed_command.arguments.get("path") if app_path is not None: @@ -1014,16 +767,12 @@ if __name__ == "__main__": print_status_success(f"Switching to app directory: {app_path}") os.chdir(app_path) - # Environment setup - setup_environment() if not os.path.isfile("manifest.properties"): exit_with_error("manifest.properties not found") manifest = read_manifest() validate_manifest(manifest) - # Update SDK cache (tactility.py.json) - sdk_version = manifest["target.sdk"] - if not use_local_sdk and should_update_tool_json(sdk_version) and not update_tool_json(sdk_version): - exit_with_error("Failed to retrieve SDK info") + if tt_sdk_version is not None and manifest["target.sdk"] != tt_sdk_version: + exit_with_error(f"App targets SDK {manifest['target.sdk']}, but tt selected SDK {tt_sdk_version}") # Actions action_arg = parsed_command.action if action_arg == "build": @@ -1031,10 +780,6 @@ if __name__ == "__main__": sys.exit(1) elif action_arg == "clean": clean_action(manifest) - elif action_arg == "clearcache": - clear_cache_action() - elif action_arg == "updateself": - update_self_action(manifest) elif action_arg == "run": run_action(manifest, parsed_command.arguments) elif action_arg == "install": diff --git a/Buildscripts/TactilitySDK/Tools/host.py b/Buildscripts/TactilitySDK/Tools/host.py new file mode 100755 index 000000000..8520692c7 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/host.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Shows or stores the host name of the device in .tactility/device_host. +"tt host" prints the host or "none", "tt host " stores it.""" + +import sys + +import _device_host + + +def main(): + if len(sys.argv) == 1: + print(_device_host.read_device_host() or "none") + elif len(sys.argv) == 2 and sys.argv[1].strip(): + host = sys.argv[1].strip() + _device_host.write_device_host(host) + print(f"Device host: {host}") + else: + print("usage: tt host [hostname]", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + main() diff --git a/Buildscripts/TactilitySDK/Tools/pull.py b/Buildscripts/TactilitySDK/Tools/pull.py new file mode 100755 index 000000000..f0b36bfc0 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/pull.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Copies a file from the device through its development server. Run it as "tt pull".""" + +import argparse +import os +import sys + +import shell + + +def main(): + shell.PROGRAM_NAME = "tt pull" if "TACTILITY_SDK_VERSION" in os.environ else "pull.py" + parser = argparse.ArgumentParser(prog=shell.PROGRAM_NAME, description="copy a file from the device", add_help=False) + shell.add_connection_arguments(parser) + shell.add_pull_arguments(parser) + sys.exit(shell.run_pull(shell.parse_arguments(parser))) + + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + main() diff --git a/Buildscripts/TactilitySDK/Tools/push.py b/Buildscripts/TactilitySDK/Tools/push.py new file mode 100755 index 000000000..cef092561 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/push.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Copies a file to the device through its development server. Run it as "tt push".""" + +import argparse +import os +import sys + +import shell + + +def main(): + shell.PROGRAM_NAME = "tt push" if "TACTILITY_SDK_VERSION" in os.environ else "push.py" + parser = argparse.ArgumentParser(prog=shell.PROGRAM_NAME, description="copy a file to the device", add_help=False) + shell.add_connection_arguments(parser) + shell.add_push_arguments(parser) + sys.exit(shell.run_push(shell.parse_arguments(parser))) + + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + main() diff --git a/Buildscripts/TactilitySDK/Tools/shell.py b/Buildscripts/TactilitySDK/Tools/shell.py new file mode 100755 index 000000000..a53febc81 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/shell.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Client for the Tactility development server (Modules/development-module).""" + +import argparse +import datetime +import getpass +import hashlib +import json +import os +import select +import signal +import socket +import ssl +import struct +import sys +import tempfile +import threading +import time + +import _device_host + +DEFAULT_PORT = 6667 +PROTOCOL_VERSION = 1 +CONFIG_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") +CLIENT_KEY_PATH = os.path.join(CONFIG_DIRECTORY, "client.key") +CLIENT_CERTIFICATE_PATH = os.path.join(CONFIG_DIRECTORY, "client.crt") +KNOWN_HOSTS_PATH = os.path.join(CONFIG_DIRECTORY, "known_hosts") +CONNECT_TIMEOUT_SECONDS = 10 +# Leaves time for the user to answer the prompt on the device +ACCEPT_TIMEOUT_SECONDS = 90 +PROMPT_HINT_DELAY_SECONDS = 1 +# How long the device may take to answer a request, and to answer an install, which extracts the whole package first +REQUEST_TIMEOUT_SECONDS = 10 +INSTALL_TIMEOUT_SECONDS = 120 + +# tt runs this tool as "tt shell" +PROGRAM_NAME = "tt shell" if "TACTILITY_SDK_VERSION" in os.environ else "shell.py" + +FRAME_DATA = 0x01 +FRAME_RESIZE = 0x02 +FRAME_EXIT = 0x03 +FRAME_SYNC_STAT = 0x10 +FRAME_SYNC_STAT_RESULT = 0x11 +FRAME_SYNC_PUSH = 0x12 +FRAME_SYNC_PULL = 0x13 +FRAME_DONE = 0x14 +FRAME_OK = 0x15 +FRAME_FAIL = 0x16 +FRAME_APP_INSTALL = 0x20 +FRAME_APP_UNINSTALL = 0x21 +FRAME_APP_RUN = 0x22 +SYNC_FILE_MISSING = 0 +SYNC_FILE_DIRECTORY = 2 +SYNC_READ_SIZE = 64 * 1024 +FRAME_HEADER_SIZE = 3 +COMMAND_MAX = 511 +FRAME_PAYLOAD_MAX = 1024 + + +def fail(message): + print(f"{PROGRAM_NAME}: {message}", file=sys.stderr) + sys.exit(1) + + +HIGHLIGHT_START = "\033[38;5;117m" +HIGHLIGHT_END = "\033[0m" + + +def use_color(stream): + return stream.isatty() and "NO_COLOR" not in os.environ + + +def format_fingerprint(fingerprint, stream=sys.stdout): + """Groups the fingerprint in words of 4 and highlights the first 2 words, which the device shows too""" + words = [fingerprint[i:i + 4] for i in range(0, len(fingerprint), 4)] + if use_color(stream) and len(words) > 2: + return f"{HIGHLIGHT_START}{' '.join(words[:2])}{HIGHLIGHT_END} {' '.join(words[2:])}" + return " ".join(words) + + +def certificate_fingerprint(der): + return hashlib.sha256(der).hexdigest() + + +# region Identity + +def ensure_client_identity(): + """Creates the self-signed client certificate that identifies this computer to devices""" + if os.path.isfile(CLIENT_KEY_PATH) and os.path.isfile(CLIENT_CERTIFICATE_PATH): + return + try: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + except ImportError: + fail("the 'cryptography' package is required: pip install cryptography") + + os.makedirs(CONFIG_DIRECTORY, exist_ok=True) + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, client_name())]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=365 * 50)) + .sign(key, hashes.SHA256()) + ) + key_bytes = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + descriptor = os.open(CLIENT_KEY_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "wb") as file: + file.write(key_bytes) + with open(CLIENT_CERTIFICATE_PATH, "wb") as file: + file.write(certificate.public_bytes(serialization.Encoding.PEM)) + print(f"Created client certificate in {CONFIG_DIRECTORY}") + + +def client_name(): + return f"{getpass.getuser()}@{socket.gethostname()}" + + +def client_fingerprint(): + with open(CLIENT_CERTIFICATE_PATH, "rb") as file: + der = ssl.PEM_cert_to_DER_cert(file.read().decode("ascii")) + return certificate_fingerprint(der) + +# endregion + +# region Known hosts + +def read_known_hosts(): + hosts = {} + if os.path.isfile(KNOWN_HOSTS_PATH): + with open(KNOWN_HOSTS_PATH) as file: + for line in file: + parts = line.split() + if len(parts) == 2: + hosts[parts[0]] = parts[1] + return hosts + + +def verify_device(host, port, fingerprint): + """Trust on first use: remembers the device fingerprint and refuses a device that changed it. + @return True when the user was asked to trust the device""" + address = f"{host}:{port}" + known = read_known_hosts().get(address) + if known == fingerprint: + return False + if known is not None: + fail( + f"the fingerprint of {address} changed!\n" + f" expected: {format_fingerprint(known, sys.stderr)}\n" + f" received: {format_fingerprint(fingerprint, sys.stderr)}\n" + f"This could mean that someone intercepts the connection, or that the device was reset.\n" + f"If you trust the device, remove {address} from {KNOWN_HOSTS_PATH}" + ) + print(f"The authenticity of {address} can't be established.") + print(f"Device fingerprint: {format_fingerprint(fingerprint)}") + print("Compare it with the fingerprint in the Development app on the device.") + try: + answer = input("Continue connecting? (y/N) ").strip().lower() + except EOFError: + print(flush=True) + fail(f"{address} is not a known device yet: run 'tt shell' in a terminal once to verify and trust its fingerprint") + if answer not in ("y", "yes"): + fail("aborted") + os.makedirs(CONFIG_DIRECTORY, exist_ok=True) + with open(KNOWN_HOSTS_PATH, "a") as file: + file.write(f"{address} {fingerprint}\n") + return True + +# endregion + +# region Connection + +# A device that disappears without closing the connection (power loss, Wi-Fi drop) is noticed after +# about KEEPALIVE_IDLE_SECONDS + KEEPALIVE_INTERVAL_SECONDS * KEEPALIVE_PROBES when the connection is idle, +# and after USER_TIMEOUT_SECONDS when sent data isn't acknowledged +KEEPALIVE_IDLE_SECONDS = 5 +KEEPALIVE_INTERVAL_SECONDS = 3 +KEEPALIVE_PROBES = 3 +USER_TIMEOUT_SECONDS = 15 + + +def enable_keepalive(raw_socket): + raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + if os.name == "nt": + raw_socket.ioctl(socket.SIO_KEEPALIVE_VALS, (1, KEEPALIVE_IDLE_SECONDS * 1000, KEEPALIVE_INTERVAL_SECONDS * 1000)) + return + # Linux names the idle time TCP_KEEPIDLE, macOS TCP_KEEPALIVE + idle_option = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None) + options = [ + (idle_option, KEEPALIVE_IDLE_SECONDS), + (getattr(socket, "TCP_KEEPINTVL", None), KEEPALIVE_INTERVAL_SECONDS), + (getattr(socket, "TCP_KEEPCNT", None), KEEPALIVE_PROBES), + (getattr(socket, "TCP_USER_TIMEOUT", None), USER_TIMEOUT_SECONDS * 1000), + ] + for option, value in options: + if option is not None: + try: + raw_socket.setsockopt(socket.IPPROTO_TCP, option, value) + except OSError: + pass + + +class Connection: + + def __init__(self, host, port): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Devices have self-signed certificates: verify_device() pins them instead + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.load_cert_chain(CLIENT_CERTIFICATE_PATH, CLIENT_KEY_PATH) + try: + raw_socket = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT_SECONDS) + enable_keepalive(raw_socket) + self.socket = context.wrap_socket(raw_socket) + except (OSError, ssl.SSLError) as error: + fail(f"failed to connect to {host}:{port}: {error}") + self.buffer = b"" + self.fingerprint = certificate_fingerprint(self.socket.getpeercert(binary_form=True)) + + def send(self, data): + try: + self.socket.sendall(data) + except (OSError, ssl.SSLError): + fail("the device closed the connection") + + def receive(self): + """@return False when the connection closed. Raises socket.timeout when the socket timeout passes.""" + try: + data = self.socket.recv(4096) + except socket.timeout: + raise + except (OSError, ssl.SSLError): + return False + if not data: + return False + self.buffer += data + return True + + def read_line(self, timeout): + self.socket.settimeout(timeout) + try: + while b"\n" not in self.buffer: + if not self.receive(): + return None + except socket.timeout: + return None + line, self.buffer = self.buffer.split(b"\n", 1) + return line.decode("utf-8", errors="replace") + + def send_frame(self, frame_type, payload=b""): + for offset in range(0, max(len(payload), 1), FRAME_PAYLOAD_MAX): + chunk = payload[offset:offset + FRAME_PAYLOAD_MAX] + self.send(struct.pack(">BH", frame_type, len(chunk)) + chunk) + + def pop_frame(self): + """@return (type, payload) or None when no complete frame was received yet""" + if len(self.buffer) < FRAME_HEADER_SIZE: + return None + frame_type, length = struct.unpack(">BH", self.buffer[:FRAME_HEADER_SIZE]) + if len(self.buffer) < FRAME_HEADER_SIZE + length: + return None + payload = self.buffer[FRAME_HEADER_SIZE:FRAME_HEADER_SIZE + length] + self.buffer = self.buffer[FRAME_HEADER_SIZE + length:] + return frame_type, payload + + def read_frame(self, timeout=REQUEST_TIMEOUT_SECONDS): + """Waits for a frame. @return (type, payload)""" + self.socket.settimeout(timeout) + try: + while (frame := self.pop_frame()) is None: + if not self.receive(): + fail("the device closed the connection") + except socket.timeout: + fail(f"the device did not answer within {timeout} seconds") + return frame + + def close(self): + try: + self.socket.close() + except OSError: + pass + + +def open_session(host, port, mode, columns=80, rows=24, command=None): + ensure_client_identity() + connection = Connection(host, port) + if verify_device(host, port, connection.fingerprint): + # The device closes a connection that doesn't greet it in time, which answering the question can take longer than + connection.close() + connection = Connection(host, port) + verify_device(host, port, connection.fingerprint) + + hello = {"protocol": PROTOCOL_VERSION, "mode": mode, "client": client_name(), "columns": columns, "rows": rows} + if command is not None: + hello["command"] = command + connection.send((json.dumps(hello, separators=(",", ":")) + "\n").encode("utf-8")) + + # A trusted client is answered right away, otherwise the device asks the user + line = connection.read_line(PROMPT_HINT_DELAY_SECONDS) + if line is None: + print(f"Client fingerprint: {format_fingerprint(client_fingerprint())}", flush=True) + print("Waiting for the device to accept the connection...", flush=True) + line = connection.read_line(ACCEPT_TIMEOUT_SECONDS) + if line is None: + fail("the device closed the connection or did not answer") + try: + status = json.loads(line) + except json.JSONDecodeError: + fail(f"unexpected answer: {line}") + if status.get("status") == "rejected": + fail("the device rejected the connection") + if status.get("status") != "accepted": + fail(f"the device refused the connection: {status.get('message', line)}") + connection.socket.settimeout(REQUEST_TIMEOUT_SECONDS) + return connection + +# endregion + +# region Shell + +def terminal_size(): + try: + size = os.get_terminal_size(sys.stdout.fileno()) + return size.columns, size.lines + except OSError: + return 80, 24 + + +def write_output(data, previous_was_cr): + """The device shell emits bare line feeds, which a raw terminal doesn't return the cursor for""" + output = bytearray() + for byte in data: + if byte == 0x0A and not previous_was_cr: + output += b"\r" + output.append(byte) + previous_was_cr = byte == 0x0D + sys.stdout.buffer.write(bytes(output)) + sys.stdout.buffer.flush() + return previous_was_cr + + +class RawTerminal: + """Sends every key to the device instead of letting the terminal edit lines, and lets it show escape sequences""" + + def __init__(self, enabled): + self.enabled = enabled + self.restore = None + + def __enter__(self): + if self.enabled: + self.restore = enter_windows_raw_mode() if os.name == "nt" else enter_posix_raw_mode() + return self + + def __exit__(self, *_): + if self.restore is not None: + self.restore() + + +def enter_posix_raw_mode(): + import termios + import tty + stdin = sys.stdin.fileno() + saved_attributes = termios.tcgetattr(stdin) + tty.setraw(stdin) + return lambda: termios.tcsetattr(stdin, termios.TCSADRAIN, saved_attributes) + + +def enter_windows_raw_mode(): + import ctypes + kernel32 = ctypes.windll.kernel32 + enable_processed_input, enable_line_input, enable_echo_input = 0x0001, 0x0002, 0x0004 + enable_virtual_terminal_input = 0x0200 + enable_virtual_terminal_processing = 0x0004 + utf8_code_page = 65001 + input_handle = kernel32.GetStdHandle(-10) + output_handle = kernel32.GetStdHandle(-11) + input_mode = ctypes.c_uint32() + output_mode = ctypes.c_uint32() + kernel32.GetConsoleMode(input_handle, ctypes.byref(input_mode)) + kernel32.GetConsoleMode(output_handle, ctypes.byref(output_mode)) + input_code_page = kernel32.GetConsoleCP() + # Keys arrive as the same escape sequences as on Linux and macOS, Ctrl+C included + raw_input_mode = (input_mode.value & ~(enable_processed_input | enable_line_input | enable_echo_input)) | enable_virtual_terminal_input + kernel32.SetConsoleMode(input_handle, raw_input_mode) + kernel32.SetConsoleMode(output_handle, output_mode.value | enable_virtual_terminal_processing) + kernel32.SetConsoleCP(utf8_code_page) + + def restore(): + kernel32.SetConsoleMode(input_handle, input_mode.value) + kernel32.SetConsoleMode(output_handle, output_mode.value) + kernel32.SetConsoleCP(input_code_page) + return restore + + +def start_input_reader(wake_socket): + """Reads stdin on a thread, because select() can't wait for it on Windows. None in the queue means end of input.""" + import queue + inputs = queue.Queue() + + def read_input(): + stdin = sys.stdin.fileno() + while True: + try: + data = os.read(stdin, FRAME_PAYLOAD_MAX) + except OSError: + data = b"" + inputs.put(data or None) + try: + wake_socket.send(b"i") + except BlockingIOError: + pass + except OSError: + return + if not data: + return + + threading.Thread(target=read_input, daemon=True).start() + return inputs + + +def run_shell(args): + """Opens an interactive shell, or runs a command like ssh does when one is given""" + command = " ".join(args.command) if args.command else None + if command is not None and len(command.encode("utf-8")) > COMMAND_MAX: + fail(f"the command is longer than {COMMAND_MAX} bytes") + columns, rows = terminal_size() + connection = open_session(args.host, args.port, "shell", columns, rows, command) + # A shell or command can be silent for as long as it likes + connection.socket.settimeout(None) + + # Input and resizes wake up the select() below through this socket pair + wake_read, wake_write = socket.socketpair() + # A full buffer already wakes up the loop, and a signal handler must never block + wake_write.setblocking(False) + has_resize_signal = hasattr(signal, "SIGWINCH") + previous_resize_handler = None + if has_resize_signal: + def on_resize(*_): + try: + wake_write.send(b"r") + except OSError: + pass + previous_resize_handler = signal.signal(signal.SIGWINCH, on_resize) + # Without a resize signal, the window size is checked this often + resize_poll_seconds = None if has_resize_signal else 0.5 + window_size = (columns, rows) + + # A command gets its input as is, an interactive shell gets every key + is_terminal = os.isatty(sys.stdin.fileno()) and command is None + exit_code = None + previous_was_cr = False + try: + with RawTerminal(is_terminal): + inputs = start_input_reader(wake_write) + while exit_code is None: + # Already decrypted data doesn't make the socket readable + timeout = 0 if connection.socket.pending() > 0 else resize_poll_seconds + readable, _, _ = select.select([connection.socket, wake_read], [], [], timeout) + + if wake_read in readable: + wake_read.recv(256) + while not inputs.empty(): + data = inputs.get() + if data is not None: + connection.send_frame(FRAME_DATA, data) + if terminal_size() != window_size: + window_size = terminal_size() + connection.send_frame(FRAME_RESIZE, struct.pack(">HH", *window_size)) + if connection.socket in readable or connection.socket.pending() > 0: + if not connection.receive(): + break + while (frame := connection.pop_frame()) is not None: + frame_type, payload = frame + if frame_type == FRAME_DATA: + previous_was_cr = write_output(payload, previous_was_cr) + elif frame_type == FRAME_EXIT and len(payload) == 4: + exit_code = struct.unpack(">i", payload)[0] + finally: + if has_resize_signal: + signal.signal(signal.SIGWINCH, previous_resize_handler) + connection.close() + wake_write.close() + wake_read.close() + + if exit_code is None: + print("\nConnection closed", file=sys.stderr) + return 1 + return exit_code + +# endregion + + +# region Sync + +def check_remote_path(path): + if not path.startswith("/"): + fail(f"remote path must be absolute: {path}") + + +def remote_stat(connection, path): + """@return (file type, size)""" + connection.send_frame(FRAME_SYNC_STAT, path.encode("utf-8")) + frame_type, payload = connection.read_frame() + if frame_type == FRAME_FAIL: + fail(f"{path}: {payload.decode('utf-8', errors='replace')}") + if frame_type != FRAME_SYNC_STAT_RESULT or len(payload) != 9: + fail("unexpected answer from the device") + return payload[0], struct.unpack(">Q", payload[1:])[0] + + +def send_file(connection, request_type, name, local_path): + """Sends a request that is followed by the file contents. @return the file size""" + size = 0 + connection.send_frame(request_type, name.encode("utf-8")) + with open(local_path, "rb") as file: + while data := file.read(SYNC_READ_SIZE): + connection.send_frame(FRAME_DATA, data) + size += len(data) + connection.send_frame(FRAME_DONE) + return size + + +def expect_ok(connection, subject, timeout=REQUEST_TIMEOUT_SECONDS): + frame_type, payload = connection.read_frame(timeout) + if frame_type == FRAME_FAIL: + fail(f"{subject}: {payload.decode('utf-8', errors='replace')}") + if frame_type != FRAME_OK: + fail("unexpected answer from the device") + + +def print_transfer(description, size, start): + duration = max(time.monotonic() - start, 0.001) + print(f"{description}: {size / duration / 1024:.1f} KB/s ({size} bytes in {duration:.3f}s)") + + +def run_push(args): + check_remote_path(args.remote_path) + if os.path.isdir(args.local_path): + fail(f"{args.local_path}: directories are not supported") + if not os.path.isfile(args.local_path): + fail(f"{args.local_path}: no such file") + + connection = open_session(args.host, args.port, "sync") + remote_path = args.remote_path + file_type, _ = remote_stat(connection, remote_path) + if file_type == SYNC_FILE_DIRECTORY or remote_path.endswith("/"): + remote_path = remote_path.rstrip("/") + "/" + os.path.basename(args.local_path) + + start = time.monotonic() + size = send_file(connection, FRAME_SYNC_PUSH, remote_path, args.local_path) + expect_ok(connection, remote_path) + connection.close() + print_transfer(f"{args.local_path} -> {remote_path}", size, start) + return 0 + + +def run_pull(args): + check_remote_path(args.remote_path) + connection = open_session(args.host, args.port, "sync") + file_type, _ = remote_stat(connection, args.remote_path) + if file_type == SYNC_FILE_MISSING: + fail(f"{args.remote_path}: no such file") + if file_type == SYNC_FILE_DIRECTORY: + fail(f"{args.remote_path}: directories are not supported") + + local_path = args.local_path + if os.path.isdir(local_path): + local_path = os.path.join(local_path, os.path.basename(args.remote_path)) + + start = time.monotonic() + size = 0 + connection.send_frame(FRAME_SYNC_PULL, args.remote_path.encode("utf-8")) + # Leaves an existing file untouched when the transfer fails + descriptor, temporary_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(local_path)), prefix=".tt-pull-") + try: + with os.fdopen(descriptor, "wb") as file: + while True: + frame_type, payload = connection.read_frame() + if frame_type == FRAME_DATA: + file.write(payload) + size += len(payload) + elif frame_type == FRAME_DONE: + break + elif frame_type == FRAME_FAIL: + fail(f"{args.remote_path}: {payload.decode('utf-8', errors='replace')}") + else: + fail("unexpected answer from the device") + os.replace(temporary_path, local_path) + finally: + if os.path.exists(temporary_path): + os.remove(temporary_path) + connection.close() + print_transfer(f"{args.remote_path} -> {local_path}", size, start) + return 0 + +# endregion + + +# region App + +def install(host, port, package_path): + """Installs an app package (.app file) on the device. Exits on failure.""" + if not os.path.isfile(package_path): + fail(f"{package_path}: no such file") + connection = open_session(host, port, "app") + try: + send_file(connection, FRAME_APP_INSTALL, os.path.basename(package_path), package_path) + expect_ok(connection, package_path, INSTALL_TIMEOUT_SECONDS) + finally: + connection.close() + return True + + +def app_request(host, port, request_type, app_id): + connection = open_session(host, port, "app") + try: + connection.send_frame(request_type, app_id.encode("utf-8")) + expect_ok(connection, app_id) + finally: + connection.close() + return True + + +def uninstall(host, port, package_id): + """Uninstalls a package from the device. Succeeds when it wasn't installed. Exits on failure.""" + return app_request(host, port, FRAME_APP_UNINSTALL, package_id) + + +def run(host, port, app_id): + """Starts an app on the device, or restarts it when it's in the foreground. Exits on failure.""" + return app_request(host, port, FRAME_APP_RUN, app_id) + + +def add_connection_arguments(parser): + """Adds --help, --host and --port: -h is the host, so the help can't use it""" + parser.add_argument("--help", action="help", help="show this help message and exit") + parser.add_argument("-h", "--host", help="device hostname or IP address (default: the host of 'tt host')") + parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, help=f"device port (default: {DEFAULT_PORT})") + + +def parse_arguments(parser): + """Parses the arguments of add_connection_arguments(), using the stored device host when --host is missing""" + args = parser.parse_args() + if args.host is None: + args.host = _device_host.read_device_host() + if args.host is None: + fail(_device_host.MISSING_HOST_MESSAGE) + return args + + +def add_push_arguments(parser): + parser.add_argument("local_path", help="file on this computer") + parser.add_argument("remote_path", help="absolute file or directory path on the device") + + +def add_pull_arguments(parser): + parser.add_argument("remote_path", help="absolute file path on the device") + parser.add_argument("local_path", nargs="?", default=".", help="file or directory on this computer (default: current directory)") + + +def main(): + parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="open a shell on the device, or run a command in it", add_help=False) + add_connection_arguments(parser) + parser.add_argument("command", nargs=argparse.REMAINDER, help="command to run instead of an interactive shell") + sys.exit(run_shell(parse_arguments(parser))) + + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + main() diff --git a/Buildscripts/TactilitySDK/Tools/ui.py b/Buildscripts/TactilitySDK/Tools/ui.py new file mode 100644 index 000000000..85d1cf7e7 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/ui.py @@ -0,0 +1,320 @@ +# Save this in your directory root as: tactility_tui.py +import json +import os +import re +import sys +import subprocess +import time +import urllib.request +import zipfile +import tarfile +from pathlib import Path +from urllib.parse import urlparse + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import Footer, Button, Log, ListView, ListItem, Label, Select, TabbedContent, TabPane, Input, Checkbox +from textual.binding import Binding + +import _device_host + +ttbuild_path = os.path.abspath(".tactility") +ttbuild_version = "6.0.0" +ttbuild_cdn = "https://cdn.tactilityproject.org" +ttbuild_sdk_json_validity = 3600 +ttport = 6666 +http_timeout_seconds = 10 +install_timeout_seconds = 120 + +# Active target logging pointers to feed real-time outputs straight to UI containers +active_ui_logger = None + +# app.py does the actual work: it is next to this script +APP_TOOL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.py") +ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") + +def resolve_espressif_environment(): + """Captures Espressif's custom paths and mirrors them globally.""" + activation_profile = r"C:\Espressif\tools\Microsoft.v6.1.PowerShell_profile.ps1" + if not os.path.isfile(activation_profile): + return + try: + cmd = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", activation_profile, "-e"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + for line in result.stdout.splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + key, sep, value = line.partition("=") + if key.strip() == "PATH": + os.environ["PATH"] = value.strip() + os.pathsep + os.environ.get("PATH", "") + else: + os.environ[key.strip()] = value.strip() + except Exception: + pass + +resolve_espressif_environment() + +# region Core Action Logic Engines (Imported from Original Utility Script) + +def tui_print(message): + """Intercepts and streams prints directly into active log panel views.""" + global active_ui_logger + if active_ui_logger: + active_ui_logger.write_line(str(message)) + else: + print(message) + +def download_file(url, filepath): + tui_print(f"⌛ Downloading from {url}...") + try: + request = urllib.request.Request(url, data=None, headers={"User-Agent": f"Tactility Build Tool {ttbuild_version}"}) + with urllib.request.urlopen(request, timeout=30) as response, open(filepath, mode="wb") as file: + file.write(response.read()) + return True + except Exception as e: + tui_print(f"❌ Failed to fetch URL {url}: {e}") + return False + +def read_properties_file(path): + properties = {} + if not os.path.isfile(path): + return properties + with open(path, "r") as file: + for line in file: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + continue + key, sep, value = line.partition("=") + properties[key.strip()] = value.strip() + return properties + +class SemanticVersion: + def __init__(self, major, minor, patch, tag=None): + self.major, self.minor, self.patch, self.tag = major, minor, patch, tag + @staticmethod + def parse(v_str): + match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", v_str) + if not match: return SemanticVersion(0,0,0) + return SemanticVersion(int(match.group(1)), int(match.group(2)), int(match.group(3)), match.group(4)) + def _numeric(self): return (self.major, self.minor, self.patch) + def is_older_release_of(self, other): return self._numeric() < other._numeric() + +# The last output line of the last subprocess, which tells why it failed +last_output_line = "" + +def run_native_subprocess(command_args, target_cwd): + """Executes active compiler builders while flushing out standard line streams instantly.""" + global last_output_line + last_output_line = "" + try: + shell_needed = sys.platform == "win32" + process = subprocess.Popen( + command_args, + # The dashboard owns the terminal, so tools can't ask questions + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=target_cwd, + shell=shell_needed, + text=True, + bufsize=1 # Forces immediate line un-buffering rules down to stdout + ) + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + # Progress lines are rewritten with a carriage return in a terminal, and colored + text = ANSI_ESCAPE_PATTERN.sub("", line.rstrip("\r\n")).split("\r")[-1] + tui_print(text) + if text.strip(): + last_output_line = text.strip() + process.wait() + return process.returncode == 0 + except Exception as e: + tui_print(f"❌ Subprocess crash encountered: {e}") + return False + +#endregion Core Action Logic Engines + +class TactilityTUI(App): + """The master native asynchronous dashboard controlling the compilation ecosystem.""" + + CSS = """ + Screen { background: #121212; } + #sidebar { width: 32; background: #1a1a1a; border-right: solid #333333; padding: 1; } + #main_content { padding: 1; } + #control_panel { height: 7; margin-bottom: 1; background: #161616; border: solid #333333; padding: 1 1 0 1; } + #network_settings { margin-left: 4; width: 35; } + .section_title { text-style: bold; color: #00ffcc; margin-bottom: 0; } + Horizontal Button { margin-right: 1; min-width: 14; height: 3; } + Log { background: #1e1e1e; color: #ffffff; overflow-x: hidden; text-wrap: wrap; } + TabbedContent, TabPane, ContentSwitcher { padding: 0; margin: 0; } + TabbedContent { margin-top: 1; height: 1fr; } + Input { height: 3; border: tall #333333; } + ListItem { padding: 1; } + ListItem:hover { background: #262626; } + ListItem.--on-selection { background: #00ffcc; color: #121212; text-style: bold; } + Checkbox { background: transparent; border: none; padding: 0; margin-bottom: 0; } + """ + + BINDINGS = [ + Binding("f5", "action_pipeline('build')", "Build (F5)"), + Binding("f6", "action_pipeline('install')", "Install (F6)"), + Binding("f7", "action_pipeline('run')", "Run (F7)"), + Binding("f8", "action_pipeline('bir')", "Full Cycle BIR (F8)"), + Binding("f9", "action_pipeline('clean')", "Clean Folders"), + Binding("q", "quit", "Quit Dashboard") + ] + + def __init__(self): + super().__init__() + self.selected_app_path = None + self.apps_list = [] + + def scan_apps_directory(self): + apps_root = Path("Apps") + if not apps_root.exists(): return [] + return sorted([p for p in apps_root.iterdir() if p.is_dir() and (p / "manifest.properties").exists()], key=lambda x: x.name) + + def compose(self) -> ComposeResult: + with Horizontal(): + with Vertical(id="sidebar"): + yield Label("📁 APP WORKSPACES", classes="section_title") + self.list_view = ListView(id="app_selector") + yield self.list_view + + yield Label("\n⚙️ TARGET ARCH", classes="section_title") + yield Select([("Auto-detect (Manifest)", "auto"), ("ESP32", "esp32"), ("ESP32-C6", "esp32c6"), ("ESP32-P4", "esp32p4"), ("ESP32-S3", "esp32s3"), ("POSIX", "posix-x86_64")], value="auto", id="arch_selector") + + yield Label("\n🛠️ BUILD MODIFIERS", classes="section_title") + yield Checkbox("Skip compiler steps", id="chk_skip_build") + yield Checkbox("Verbose log output", id="chk_verbose") + + with Vertical(id="main_content"): + with Horizontal(id="control_panel"): + with Vertical(): + yield Label("⚡ ACTIVE TASK CONTROLS", classes="section_title") + with Horizontal(): + yield Button("Build", id="btn_build", variant="success") + yield Button("Install", id="btn_install", variant="warning") + yield Button("Run", id="btn_run", variant="primary") + yield Button("Clean", id="btn_clean", variant="error") + with Vertical(id="network_settings"): + yield Label("🌐 REMOTE TARGET HOST", classes="section_title") + yield Input(value=_device_host.read_device_host() or "", placeholder="e.g. 192.168.1.50", id="host_input") + + with TabbedContent(id="tabs"): + with TabPane("📋 System Output Feed", id="tab_system"): yield Log(id="system_log") + with TabPane("🛠️ Active Build Pipeline Monitor", id="tab_pipeline"): yield Log(id="pipeline_log") + yield Footer() + + def on_mount(self) -> None: + self.apps_list = self.scan_apps_directory() + if not self.apps_list: return + for app_path in self.apps_list: + self.list_view.append(ListItem(Label(f"🔹 {app_path.name}"), id=f"app_{app_path.name}".replace(" ", "_"))) + self.list_view.index = 0 + self.selected_app_path = self.apps_list[0] + + def on_list_view_selected(self, event: ListView.Selected) -> None: + idx = self.list_view.index + if idx is not None and idx < len(self.apps_list): + self.selected_app_path = self.apps_list[idx] + self.query_one("#system_log", Log).write_line(f"🎯 Switched app context to: {str(self.selected_app_path)}") + + def on_button_pressed(self, event: Button.Pressed) -> None: + action_map = {"btn_build": "build", "btn_install": "install", "btn_run": "run", "btn_clean": "clean"} + a = action_map.get(event.button.id) + if a: self.execute_task_pipeline(a) + + def action_pipeline(self, action: str) -> None: self.execute_task_pipeline(action) + + def execute_task_pipeline(self, action: str) -> None: + """Handles safe, non-blocking task execution by separating UI management from compilation workers.""" + pipeline_log = self.query_one("#pipeline_log", Log) + tabs = self.query_one("#tabs", TabbedContent) + + # app.py falls back to the host of "tt host" when this is empty + host_ip = self.query_one("#host_input", Input).value.strip() + + # --- SAFE MAIN THREAD UI MANAGEMENT --- + # Mutating UI properties here on the main event loop avoids the RuntimeError entirely + tabs.active = "tab_pipeline" + pipeline_log.clear() + + # Capture all layout checkbox states safely before spawning the thread + skip_build = self.query_one("#chk_skip_build", Checkbox).value + verbose_mode = self.query_one("#chk_verbose", Checkbox).value + arch_select = self.query_one("#arch_selector", Select).value + + # Global logging variable reassignment + global active_ui_logger + active_ui_logger = pipeline_log + + # --- SPAWN THE THREAD WORKER --- + # Pass captured primitive values explicitly to isolate the thread worker from UI widgets + # The selection can change while the worker runs + selected_app_path = self.selected_app_path + self.run_worker( + lambda: self._native_thread_executor(action, selected_app_path, host_ip, skip_build, verbose_mode, arch_select), + thread=True + ) + + def _native_thread_executor(self, action: str, selected_app_path, host_ip: str, skip_build: bool, verbose_mode: bool, arch_select: str) -> None: + """Isolated background thread handler running purely synchronous script and compiler operations.""" + # Use safe tui_print wrappers to safely proxy text calls to the log widget across threads + tui_print(f"⚙️ Running native compiler pipeline operation: '{action.upper()}' on workspace path '{str(selected_app_path)}'...") + + app_dir = os.path.abspath(str(selected_app_path)) + manifest = read_properties_file(os.path.join(app_dir, "manifest.properties")) + + if not manifest: + tui_print("❌ Failed to parse manifest.properties!") + return + + # Ensure platform un-nesting evaluates strictly to a flat string token + if arch_select == "auto": + raw_platforms = manifest.get("target.platforms", "esp32s3").split(",") + platform = raw_platforms[0].strip() # Default cleanly to the first listed target architecture + else: + platform = arch_select + + def run_app_tool(arguments): + # Run from here with --path, so app.py finds the host of "tt host" in this directory + command = [sys.executable, APP_TOOL_PATH, action, "--path", app_dir, *arguments] + if verbose_mode: + command.append("--verbose") + return run_native_subprocess(command, os.getcwd()) + + host_arguments = ["--host", host_ip] if host_ip else [] + platform_arguments = ["--architecture", platform] + start_time = time.time() + + if action == "clean": + success = run_app_tool([]) + elif action == "build": + success = run_app_tool(platform_arguments + (["--skip-build"] if skip_build else [])) + elif action in ("install", "run"): + success = run_app_tool(host_arguments) + elif action in ("bir", "brrr"): + success = run_app_tool(host_arguments + platform_arguments) + else: + success = False + + duration = time.time() - start_time + + # Use call_from_thread to safely update the system_log widget from this background thread + if success: + tui_print(f"\n✅ Task pipeline finished successfully in {duration:.1f}s!") + self.call_from_thread(self.query_one("#system_log", Log).write_line, f"✅ Active job completed successfully: '{action}' ({duration:.1f}s)") + else: + tui_print("\n❌ Pipeline operation failed or sub-process exited with an error state.") + reason = f": {last_output_line}" if last_output_line else "" + self.call_from_thread(self.query_one("#system_log", Log).write_line, f"❌ Active job failed: '{action}'{reason}") + +if __name__ == "__main__": + import _bootstrap + _bootstrap.run() + TactilityTUI().run() diff --git a/Buildscripts/TactilitySDK/Tools/update-sdk.py b/Buildscripts/TactilitySDK/Tools/update-sdk.py new file mode 100755 index 000000000..693016210 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/update-sdk.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Downloads the installed platforms of the selected SDK again when the CDN has a newer build of it. Run it as "tt update-sdk".""" + +import io +import json +import os +import shutil +import sys +import urllib.request +import zipfile + +CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") + + +def fail(message): + print(f"tt update-sdk: {message}", file=sys.stderr) + sys.exit(1) + + +def fetch(url): + request = urllib.request.Request(url, headers={"User-Agent": "tt update-sdk"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + except OSError as error: + fail(f"failed to download {url}: {error}") + + +def read_commit(platform_directory): + try: + with open(os.path.join(platform_directory, "commit")) as file: + return file.read().strip() + except OSError: + return None + + +def installed_platforms(version_directory): + prefix = "platform-" + return sorted( + name[len(prefix):] for name in os.listdir(version_directory) + if name.startswith(prefix) and os.path.isdir(os.path.join(version_directory, name, "TactilitySDK")) + ) + + +def install_platform(platform_directory, archive_data, commit): + """Extracts next to the installed SDK and then replaces it, refusing entries outside of it""" + staging_directory = os.path.join(platform_directory, "TactilitySDK.new") + shutil.rmtree(staging_directory, ignore_errors=True) + os.makedirs(staging_directory) + root = os.path.realpath(staging_directory) + try: + with zipfile.ZipFile(io.BytesIO(archive_data)) as archive: + for member in archive.infolist(): + destination = os.path.realpath(os.path.join(root, member.filename)) + if not destination.startswith(root + os.sep): + fail(f"invalid entry in the SDK archive: {member.filename}") + archive.extractall(root) + except zipfile.BadZipFile: + shutil.rmtree(staging_directory, ignore_errors=True) + fail("the downloaded SDK archive is invalid") + sdk_directory = os.path.join(platform_directory, "TactilitySDK") + shutil.rmtree(sdk_directory, ignore_errors=True) + os.replace(staging_directory, sdk_directory) + with open(os.path.join(platform_directory, "commit"), "w") as file: + file.write(commit) + + +def main(): + version = os.environ.get("TACTILITY_SDK_VERSION") + sdk_path = os.environ.get("TACTILITY_SDK_PATH") + if version is None or sdk_path is None: + fail("run this tool as 'tt update-sdk'") + version_directory = os.path.join(sdk_path, "sdk", version) + if os.environ.get("TT_LOCAL") == "1": + fail("a local SDK can't be updated") + index_data = fetch(f"{CDN_URL}/sdk/{version}/index.json") + try: + index = json.loads(index_data) + except ValueError: + fail("the downloaded index.json is invalid") + commit = index.get("gitCommit", "") + available = index.get("platforms", {}) + updated = False + for platform in installed_platforms(version_directory): + platform_directory = os.path.join(version_directory, f"platform-{platform}") + if read_commit(platform_directory) == commit or platform not in available: + continue + print(f"Updating SDK {version} for {platform}") + install_platform(platform_directory, fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}"), commit) + updated = True + with open(os.path.join(version_directory, "index.json"), "wb") as file: + file.write(index_data) + print(f"SDK {version} was updated" if updated else f"SDK {version} is up to date") + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/TactilitySDK/Tools/update-self.py b/Buildscripts/TactilitySDK/Tools/update-self.py new file mode 100755 index 000000000..b9a6d2ad5 --- /dev/null +++ b/Buildscripts/TactilitySDK/Tools/update-self.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# License: Apache License v2 +"""Updates tt: replaces tt.py and its wrappers with the ones in sdk/tt.zip on the CDN. Run it as "tt update-self".""" + +import io +import os +import re +import sys +import urllib.request +import zipfile + +CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") +FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$") + + +def fail(message): + print(f"tt update-self: {message}", file=sys.stderr) + sys.exit(1) + + +def download_archive(): + request = urllib.request.Request(f"{CDN_URL}/sdk/tt.zip", headers={"User-Agent": "tt update-self"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = response.read() + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return {member.filename: archive.read(member) for member in archive.infolist()} + except OSError as error: + fail(f"failed to download tt.zip: {error}") + except zipfile.BadZipFile: + fail("the downloaded tt.zip is invalid") + + +def main(): + directory = os.environ.get("TT_DIRECTORY") + if directory is None: + fail("run this tool as 'tt update-self'") + contents = download_archive() + if "tt.py" not in contents or not all(FILE_NAME_PATTERN.match(file_name) for file_name in contents): + fail("the downloaded tt.zip is invalid") + try: + compile(contents["tt.py"], "tt.py", "exec") + except SyntaxError: + fail("the downloaded tt.py is invalid") + for file_name, content in contents.items(): + path = os.path.join(directory, file_name) + # A running tt.cmd is read while it executes, so an unchanged file is left alone + if os.path.isfile(path): + with open(path, "rb") as file: + if file.read() == content: + continue + temporary_path = f"{path}.new" + with open(temporary_path, "wb") as file: + file.write(content) + os.chmod(temporary_path, 0o755) + os.replace(temporary_path, path) + print("tt was updated") + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/release-current.py b/Buildscripts/release-current.py new file mode 100755 index 000000000..90451079a --- /dev/null +++ b/Buildscripts/release-current.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Releases the SDK of the current build and the tools to release/sdk//, for tt with TACTILITY_SDK_PATH=release.""" + +import os +import subprocess +import sys + +SCRIPT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + +def run(script_name): + result = subprocess.run([sys.executable, os.path.join(SCRIPT_DIRECTORY, script_name)], cwd=os.path.dirname(SCRIPT_DIRECTORY)) + if result.returncode != 0: + print(f"Error: {script_name} failed with return code {result.returncode}") + sys.exit(result.returncode) + + +if __name__ == "__main__": + run("release-sdk-current.py") + run("release-tools-current.py") diff --git a/Buildscripts/release-sdk-current.py b/Buildscripts/release-sdk-current.py index 041d516fe..45c596308 100755 --- a/Buildscripts/release-sdk-current.py +++ b/Buildscripts/release-sdk-current.py @@ -57,13 +57,11 @@ def main(): if not idf_target: print("Could not determine IDF target from sdkconfig") sys.exit(1) - # release/TactilitySDK/${version}-${idf_target}/TactilitySDK - sdk_path = os.path.join("release", "TactilitySDK", f"{version}-{idf_target}", "TactilitySDK") + sdk_path = os.path.join("release", "sdk", version, f"platform-{idf_target}", "TactilitySDK") run_release_script("release-sdk-esp32.py", sdk_path) else: - # release/TactilitySDK/${version}-posix-${arch}/TactilitySDK arch = platform.machine() - sdk_path = os.path.join("release", "TactilitySDK", f"{version}-posix-{arch}", "TactilitySDK") + sdk_path = os.path.join("release", "sdk", version, f"platform-posix-{arch}", "TactilitySDK") run_release_script("release-sdk-posix.py", sdk_path) if __name__ == "__main__": diff --git a/Buildscripts/release-tools-current.py b/Buildscripts/release-tools-current.py new file mode 100755 index 000000000..9d772e6d8 --- /dev/null +++ b/Buildscripts/release-tools-current.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Exports the tools that tt runs to release/sdk//tools/, for tt with TACTILITY_SDK_PATH=release.""" + +import os +import shutil + +ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +TOOLS_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "Tools") + + +if __name__ == "__main__": + with open(os.path.join(ROOT_PATH, "version.txt")) as file: + version = file.read().strip() + output_path = os.path.join(ROOT_PATH, "release", "sdk", version, "tools") + shutil.rmtree(output_path, ignore_errors=True) + os.makedirs(output_path) + for file_name in sorted(os.listdir(TOOLS_PATH)): + if file_name.endswith(".py"): + shutil.copy2(os.path.join(TOOLS_PATH, file_name), os.path.join(output_path, file_name)) + print(f"Exported the tools to {output_path}") diff --git a/Buildscripts/release-tt.py b/Buildscripts/release-tt.py new file mode 100755 index 000000000..435f35fba --- /dev/null +++ b/Buildscripts/release-tt.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Exports the CDN files of tt to release/tt/: tt.zip with tt.py and its wrappers, and tt.json with the version. They are uploaded to sdk/ on the CDN.""" + +import json +import os +import re +import shutil +import sys +import zipfile + +ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +LAUNCHER_PATH = os.path.join(ROOT_PATH, "tt.py") +# tt.py and the wrappers that run it: tt (Linux, macOS) and tt.cmd (Windows) +FILE_NAMES = ["tt.py", "tt", "tt.cmd"] +CDN_OUTPUT_PATH = os.path.join(ROOT_PATH, "release", "tt") + + +def export_launcher(): + with open(LAUNCHER_PATH) as file: + match = re.search(r'^TT_VERSION = "([^"]+)"', file.read(), re.MULTILINE) + if match is None: + print(f"TT_VERSION not found in {LAUNCHER_PATH}", file=sys.stderr) + sys.exit(1) + version = match.group(1) + shutil.rmtree(CDN_OUTPUT_PATH, ignore_errors=True) + os.makedirs(CDN_OUTPUT_PATH) + with zipfile.ZipFile(os.path.join(CDN_OUTPUT_PATH, "tt.zip"), "w", zipfile.ZIP_DEFLATED) as archive: + for file_name in FILE_NAMES: + info = zipfile.ZipInfo(file_name) + info.compress_type = zipfile.ZIP_DEFLATED + # Executable for tools that extract the archive themselves + info.external_attr = 0o100755 << 16 + with open(os.path.join(ROOT_PATH, file_name), "rb") as file: + archive.writestr(info, file.read()) + with open(os.path.join(CDN_OUTPUT_PATH, "tt.json"), "w") as file: + json.dump({"version": version}, file, indent=2) + print(f"Exported tt {version} to {CDN_OUTPUT_PATH}") + + +if __name__ == "__main__": + export_launcher() diff --git a/Buildscripts/sdkconfig-default.properties b/Buildscripts/sdkconfig-default.properties index e2772a6ae..be1f08403 100644 --- a/Buildscripts/sdkconfig-default.properties +++ b/Buildscripts/sdkconfig-default.properties @@ -30,6 +30,12 @@ CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y # Keep MBEDTLS_SSL_IN_CONTENT_LEN at its 16384 default: servers may send TLS records up to that # size, and a smaller buffer fails mid-transfer with MBEDTLS_ERR_SSL_INVALID_RECORD (-0x7200). CONFIG_MBEDTLS_DYNAMIC_BUFFER=y +# Keep the AES lookup tables as constants in flash instead of generating them in RAM +CONFIG_MBEDTLS_AES_ROM_TABLES=y +# The development server generates its own self-signed certificate +CONFIG_MBEDTLS_PK_WRITE_C=y +CONFIG_MBEDTLS_X509_CREATE_C=y +CONFIG_MBEDTLS_X509_CRT_WRITE_C=y # LVGL CONFIG_LV_USE_FS_STDIO=y CONFIG_LV_FS_STDIO_LETTER=65 diff --git a/CMakeLists.txt b/CMakeLists.txt index dffa1ef7b..8b4917a74 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,6 +169,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Modules/cjson-module) add_subdirectory(Modules/cpp-symbols-module) add_subdirectory(Modules/crypt-module) + add_subdirectory(Modules/development-module) add_subdirectory(Modules/freertos-module) add_subdirectory(Modules/gps-module) add_subdirectory(Modules/graphics-module) @@ -197,6 +198,8 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) # FreeRTOS set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "") set(FREERTOS_PORT GCC_POSIX CACHE STRING "") + # FreeRTOS 11 no longer picks a heap implementation by default + set(FREERTOS_HEAP 4 CACHE STRING "") add_subdirectory(Libraries/FreeRTOS-Kernel) target_compile_definitions(freertos_kernel PUBLIC "projCOVERAGE_TEST=0") # xTaskCreate() and vTaskDelete() are defined by freertos_task_hooks.c instead, see its header diff --git a/Devices/lilygo-t5-epd47-s3/device.properties b/Devices/lilygo-t5-epd47-s3/device.properties index 2d88fe9c7..ba0436a20 100644 --- a/Devices/lilygo-t5-epd47-s3/device.properties +++ b/Devices/lilygo-t5-epd47-s3/device.properties @@ -1,6 +1,6 @@ general.vendor=LilyGO general.name=T5 4.7 Inch E-Paper S3 -general.incubating=false +general.incubating=true hardware.target=ESP32S3 hardware.flashSize=16MB diff --git a/Devices/simulator/Source/FreeRTOSConfig.h b/Devices/simulator/Source/FreeRTOSConfig.h index bf0636e7e..c9fcf8f92 100644 --- a/Devices/simulator/Source/FreeRTOSConfig.h +++ b/Devices/simulator/Source/FreeRTOSConfig.h @@ -1,6 +1,12 @@ #pragma once +#ifdef __cplusplus +extern "C" { +#endif extern void vAssertCalled(unsigned long line, const char* const file); +#ifdef __cplusplus +} +#endif #define configUSE_PREEMPTION 1 #define configUSE_PORT_OPTIMISED_TASK_SELECTION 0 @@ -19,7 +25,7 @@ extern void vAssertCalled(unsigned long line, const char* const file); #define configUSE_ALTERNATIVE_API 0 /* Deprecated! */ #define configQUEUE_REGISTRY_SIZE 10 #define configUSE_QUEUE_SETS 1 -#define configUSE_TIME_SLICING 0 +#define configUSE_TIME_SLICING 1 // Same as ESP32: tasks of equal priority take turns #define configUSE_NEWLIB_REENTRANT 0 #define configENABLE_BACKWARD_COMPATIBILITY 1 #define configNUM_THREAD_LOCAL_STORAGE_POINTERS 5 diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 7127f06cf..510cfc9ac 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,7 +7,17 @@ ## Higher Priority -- Fix Wi-Fi so it actually connects to something +- Adding GPS: Crash when clicking "+" +- Update FreeRTOS-Kernel to 11.3.1 to fix hanging tests, then re-release apps. +- App Hub needs a commandline app +- BlueTooth as radio backend for MeshCore/Meshtastic? +- ESP-NOW as radio backend. Use it for MeshCore/Meshtastic? +- Find out why the first build of an app with a clean build cache folder always fails +- Export TactilityKernelCpp with SDK as optional library +- Fix BT: an esp32 device connecting to a peripheral isn't working +- "update all" functionality for app hub module, including commandline app. add button to app hub toolbar if there are 1 or more apps to update (does "update all"). +- Improve Wi-Fi mock so it actually works with connect/disconnect/scan list. It connects to literally anything from the scan list. +- Fix posix Wi-Fi so it actually connects to something - Support http/https on posix - Vi apps are both named "Vi" in the App Hub - Use GPS time to set/update the current time: when enabled, at boot, when wifi is not enabled, start the gps device and wait until a lock is acquired, then get the time and sync it. Then stop the GPS device. @@ -25,13 +35,14 @@ - Improve SPI kernel driver (implement read, write, transactions) - External app loading: Check the version of Tactility and check ESP target hardware to check for compatibility Check during installation process, but also when starting (SD card might have old app install from before Tactility OS update) -- Support direct installation of an `.app` file with `tactility.py install --app helloworld.app` +- Support direct installation of an `.app` file with `tt app install --app helloworld.app` - minitar/untarFile(): "entry->metadata.path" can escape its confined path (e.g. "../something") - xl9555 and tca9534 drivers: support `GPIO_FLAG_ACTIVE_LOW` on outputs in software, like tca95xx does. Afterwards the raw level reset handling in cst66xx and tca8418 can use ACTIVE_LOW too. ## Medium Priority +- A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti) - CL-32 display driver performance - Move more window management code from Tactility/ to window-manager - Implement signal/raise for terminal apps (for CTRL+C and window resize signals) @@ -59,6 +70,7 @@ ## Lower Priority +- Show device name clearly in on-device web server landing page (to identify tabs of multiple devices easier) - When Wi-Fi connects, check for app updates - Diceware app has large "+" and "-' buttons on Cardputer. It should be smaller. - lvgl-module has a keyboard.cpp that creates a `keyboard_group`. This group is set as the default group, so it can also work with trackball(= LVGL "encoder"). diff --git a/Documentation/releasing.md b/Documentation/releasing.md index 99a442b43..066354e72 100644 --- a/Documentation/releasing.md +++ b/Documentation/releasing.md @@ -6,6 +6,7 @@ 2. Merge the branch and wait for a build. 3. Push a tag e.g. `v1.2.3` - the `v` prefix is crucial for the pipelines. 4. Wait for the SDK and firmwares to be published on the CDN. + Only tags publish SDKs of a release version: `main` only publishes SDKs of a `-dev` version. 5. Test the firmwares on all devices with the web installer 6. Run `Buildscripts/gh-download-artifacts.py [actionId] --token [token]` and let it run in the background. Get a token from [here](https://github.com/settings/personal-access-tokens) diff --git a/Libraries/FreeRTOS-Kernel b/Libraries/FreeRTOS-Kernel index def7d2df2..3a22924e0 160000 --- a/Libraries/FreeRTOS-Kernel +++ b/Libraries/FreeRTOS-Kernel @@ -1 +1 @@ -Subproject commit def7d2df2b0506d3d249334974f51e427c17a41c +Subproject commit 3a22924e0a9ddbbc8b0758881c33b3422a5cc20d diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h index efe4ae306..e5bc47c65 100644 --- a/Modules/app-module/include/app/manifest.h +++ b/Modules/app-module/include/app/manifest.h @@ -28,9 +28,9 @@ enum AppCategory { /** Bit flags for AppManifest::flags. */ enum AppManifestFlags { - /** Excluded from generic app-browsing UIs (e.g. Launcher, Settings) */ + /** Excluded from generic app-browsing UIs (e.g. Launcher, Settings) and from shell commands */ APP_MANIFEST_FLAG_HIDDEN = 1 << 0, - /** No window-manager dependency. Safe to start from a context with no GUI available. */ + /** No window-manager dependency. Safe to start from a context with no GUI available. Listed as a shell command instead of in the Launcher. */ APP_MANIFEST_FLAG_HEADLESS = 1 << 1, /** * When the app's main task ends, release what its binary leaked: tasks are deleted after a grace period, diff --git a/Modules/coreutils-module/source/apps/cat.cpp b/Modules/coreutils-module/source/apps/cat.cpp index 0652b7992..df45c8c3b 100644 --- a/Modules/coreutils-module/source/apps/cat.cpp +++ b/Modules/coreutils-module/source/apps/cat.cpp @@ -83,7 +83,7 @@ extern const ::AppManifest manifest = { .name = "cat", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/clear.cpp b/Modules/coreutils-module/source/apps/clear.cpp index 211d91e4b..e9008bab0 100644 --- a/Modules/coreutils-module/source/apps/clear.cpp +++ b/Modules/coreutils-module/source/apps/clear.cpp @@ -14,7 +14,7 @@ extern const ::AppManifest manifest = { .name = "clear", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/cp.cpp b/Modules/coreutils-module/source/apps/cp.cpp index 19cc5a557..4d4ef6dda 100644 --- a/Modules/coreutils-module/source/apps/cp.cpp +++ b/Modules/coreutils-module/source/apps/cp.cpp @@ -43,7 +43,7 @@ extern const ::AppManifest manifest = { .name = "cp", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/date.cpp b/Modules/coreutils-module/source/apps/date.cpp index 6ef1662d0..211625084 100644 --- a/Modules/coreutils-module/source/apps/date.cpp +++ b/Modules/coreutils-module/source/apps/date.cpp @@ -22,7 +22,7 @@ extern const ::AppManifest manifest = { .name = "date", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 2048 + 256, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/df.cpp b/Modules/coreutils-module/source/apps/df.cpp index f46a370be..8200b0295 100644 --- a/Modules/coreutils-module/source/apps/df.cpp +++ b/Modules/coreutils-module/source/apps/df.cpp @@ -29,7 +29,7 @@ extern const ::AppManifest manifest = { .name = "df", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/du.cpp b/Modules/coreutils-module/source/apps/du.cpp index a74b35021..45cbdeb2c 100644 --- a/Modules/coreutils-module/source/apps/du.cpp +++ b/Modules/coreutils-module/source/apps/du.cpp @@ -25,7 +25,7 @@ extern const ::AppManifest manifest = { .name = "du", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/free.cpp b/Modules/coreutils-module/source/apps/free.cpp index 591b3d48c..fa4b89e68 100644 --- a/Modules/coreutils-module/source/apps/free.cpp +++ b/Modules/coreutils-module/source/apps/free.cpp @@ -26,7 +26,7 @@ extern const ::AppManifest manifest = { .name = "free", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 3072, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/head.cpp b/Modules/coreutils-module/source/apps/head.cpp index d5a1364fa..9b778ef1c 100644 --- a/Modules/coreutils-module/source/apps/head.cpp +++ b/Modules/coreutils-module/source/apps/head.cpp @@ -66,7 +66,7 @@ extern const ::AppManifest manifest = { .name = "head", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/help.cpp b/Modules/coreutils-module/source/apps/help.cpp index 13b93e134..c45fe0e85 100644 --- a/Modules/coreutils-module/source/apps/help.cpp +++ b/Modules/coreutils-module/source/apps/help.cpp @@ -23,7 +23,7 @@ using IdList = std::vector>; // printf() is wrapped on some platforms to route through the app I/O layer, which needs the // ledger too, so this only copies the id out; printing happens after the call returns below. void collectHeadlessId(const AppManifest* manifest, void* context) { - if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) == 0) { + if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) == 0 || (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) != 0) { return; } static_cast(context)->emplace_back(manifest->id); @@ -75,7 +75,7 @@ extern const ::AppManifest manifest = { .name = "help", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/ls.cpp b/Modules/coreutils-module/source/apps/ls.cpp index d80ceecc2..d8dea7d72 100644 --- a/Modules/coreutils-module/source/apps/ls.cpp +++ b/Modules/coreutils-module/source/apps/ls.cpp @@ -135,7 +135,7 @@ extern const ::AppManifest manifest = { .name = "ls", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/mkdir.cpp b/Modules/coreutils-module/source/apps/mkdir.cpp index 715d52ea0..68cc4130f 100644 --- a/Modules/coreutils-module/source/apps/mkdir.cpp +++ b/Modules/coreutils-module/source/apps/mkdir.cpp @@ -39,7 +39,7 @@ extern const ::AppManifest manifest = { .name = "mkdir", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 3072, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/mv.cpp b/Modules/coreutils-module/source/apps/mv.cpp index c419a324a..c44634a0a 100644 --- a/Modules/coreutils-module/source/apps/mv.cpp +++ b/Modules/coreutils-module/source/apps/mv.cpp @@ -33,7 +33,7 @@ extern const ::AppManifest manifest = { .name = "mv", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/printf.cpp b/Modules/coreutils-module/source/apps/printf.cpp index 52372944d..04f90ccb8 100644 --- a/Modules/coreutils-module/source/apps/printf.cpp +++ b/Modules/coreutils-module/source/apps/printf.cpp @@ -86,7 +86,7 @@ extern const ::AppManifest manifest = { .name = "printf", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/rm.cpp b/Modules/coreutils-module/source/apps/rm.cpp index f3ba3d1c9..b37cf63a3 100644 --- a/Modules/coreutils-module/source/apps/rm.cpp +++ b/Modules/coreutils-module/source/apps/rm.cpp @@ -46,7 +46,7 @@ extern const ::AppManifest manifest = { .name = "rm", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 3072, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/tail.cpp b/Modules/coreutils-module/source/apps/tail.cpp index be306a94f..2f51a97af 100644 --- a/Modules/coreutils-module/source/apps/tail.cpp +++ b/Modules/coreutils-module/source/apps/tail.cpp @@ -96,7 +96,7 @@ extern const ::AppManifest manifest = { .name = "tail", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/touch.cpp b/Modules/coreutils-module/source/apps/touch.cpp index 67c5a632d..03adbfc5e 100644 --- a/Modules/coreutils-module/source/apps/touch.cpp +++ b/Modules/coreutils-module/source/apps/touch.cpp @@ -31,7 +31,7 @@ extern const ::AppManifest manifest = { .name = "touch", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 3072, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/wc.cpp b/Modules/coreutils-module/source/apps/wc.cpp index 835842c83..aa6a6e84f 100644 --- a/Modules/coreutils-module/source/apps/wc.cpp +++ b/Modules/coreutils-module/source/apps/wc.cpp @@ -106,7 +106,7 @@ extern const ::AppManifest manifest = { .name = "wc", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 4096, .desired_memory_capability = 0 }, }; diff --git a/Modules/coreutils-module/source/apps/which.cpp b/Modules/coreutils-module/source/apps/which.cpp index ed8e7d598..75a3ea088 100644 --- a/Modules/coreutils-module/source/apps/which.cpp +++ b/Modules/coreutils-module/source/apps/which.cpp @@ -18,7 +18,8 @@ static int32_t main(int argc, char* argv[]) { for (int i = 1; i < argc; i++) { AppManifest manifest {}; const bool is_registered = app_manager_find_manifest(argv[i], &manifest) == ERROR_NONE - && (manifest.flags & APP_MANIFEST_FLAG_HEADLESS) != 0; + && (manifest.flags & APP_MANIFEST_FLAG_HEADLESS) != 0 + && (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0; if (is_registered) { if (manifest.location.type == APP_LOCATION_MEMORY) { puts("builtin"); @@ -39,7 +40,7 @@ extern const ::AppManifest manifest = { .name = "which", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 3072, .desired_memory_capability = 0 }, }; diff --git a/Modules/development-module/CMakeLists.txt b/Modules/development-module/CMakeLists.txt new file mode 100644 index 000000000..38adfcae1 --- /dev/null +++ b/Modules/development-module/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +list(APPEND REQUIRES_LIST + TactilityKernel + TactilityKernelCpp + app-module + service-module + crypt-module + cjson-module + mbedtls +) + +if (DEFINED ENV{ESP_IDF_VERSION}) + list(APPEND REQUIRES_LIST lwip) +endif () + +tactility_add_module(development-module + SRCS ${SOURCE_FILES} + PRIV_INCLUDE_DIRS private/ + INCLUDE_DIRS include/ + REQUIRES ${REQUIRES_LIST} +) diff --git a/Modules/development-module/LICENSE-Apache-2.0.md b/Modules/development-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Modules/development-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Modules/development-module/README.md b/Modules/development-module/README.md new file mode 100644 index 000000000..e73c2e9ea --- /dev/null +++ b/Modules/development-module/README.md @@ -0,0 +1,36 @@ +# development-module + +Development server (`development-server`): a TLS server for remote development access: a remote shell, file transfer and app management. +The C API is in ``. + +- `development_server_set_enabled()` starts or stops listening on TCP port 6667 and persists the choice for the next boot. +- The device has a self-signed certificate, which is generated on first use. + `development_server_get_fingerprint()` returns its SHA-256 fingerprint, so a client can verify that it connects to the right device. +- Clients authenticate with their own self-signed certificate (mutual TLS). + Unknown clients are accepted or rejected by the user through the `DEVELOPMENT_SERVER_PROMPT_APP_ID` app, which the firmware provides. + The user can choose to always trust a client. +- At most 3 sessions are active at a time, and at most 1 of them is a `shell` session. + +## Protocol + +1. The client connects with TLS 1.2 and presents its certificate. +2. The client sends a JSON line: `{"protocol":1,"mode":"shell","client":"user@host","columns":80,"rows":24}` + In `shell` mode, an optional `"command"` (at most 511 bytes) runs that command like `sh -c` instead of an interactive shell. +3. The server answers with a JSON line once the client is accepted: `{"status":"accepted"}`, `{"status":"rejected"}` or `{"status":"error","message":"..."}` +4. In `shell` mode, both sides then send frames: `[type u8][length u16 big-endian][payload]`, with at most 1024 payload bytes. + - `0x01` data: terminal input from the client, terminal output from the server + - `0x02` resize: from the client, payload is `[columns u16][rows u16]` (big-endian) + - `0x03` exit: from the server, payload is the shell's exit code as `i32` (big-endian), after which the server closes the connection +5. In `sync` mode, the client sends requests with the same frames. Paths are absolute and UTF-8 encoded. + - `0x10` stat `[path]`: answered with `0x11` `[type u8][size u64]`, where the type is 0 for missing, 1 for a file and 2 for a directory + - `0x12` push `[path]`, then `0x01` data frames, then `0x14` done: answered with `0x15` ok, or `0x16` fail `[message]`. + Parent directories are created. A failed or interrupted push removes the file. + - `0x13` pull `[path]`: answered with `0x01` data frames and `0x14` done, or with `0x16` fail `[message]` +6. In `app` mode, the client manages apps with the same frames: + - `0x20` install `[file name]`, then `0x01` data frames with the `.app` package, then `0x14` done: answered with `0x15` ok, or `0x16` fail `[message]` + - `0x21` uninstall `[package id]`: answered with `0x15` ok, also when the package isn't installed, or `0x16` fail `[message]` + - `0x22` run `[app id]`: starts the app, or restarts it when it's in the foreground. Answered with `0x15` ok, or `0x16` fail `[message]` + +`Buildscripts/TactilitySDK/Tools/shell.py` is a client for this server. App developers run it as `tt shell`, and copy files with `tt push` and `tt pull`. + +License: [Apache v2.0](LICENSE-Apache-2.0.md) diff --git a/Modules/development-module/include/development/module.h b/Modules/development-module/include/development/module.h new file mode 100644 index 000000000..59e8699da --- /dev/null +++ b/Modules/development-module/include/development/module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module development_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/development-module/include/development/server.h b/Modules/development-module/include/development/server.h new file mode 100644 index 000000000..32ef9dfb8 --- /dev/null +++ b/Modules/development-module/include/development/server.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define DEVELOPMENT_SERVER_SERVICE_ID "development-server" + +#define DEVELOPMENT_SERVER_DEFAULT_PORT 6667 + +/** Hex SHA-256 of a DER certificate, excluding null terminator */ +#define DEVELOPMENT_SERVER_FINGERPRINT_LENGTH 64 + +/** Maximum length of the name that a client reports, excluding null terminator */ +#define DEVELOPMENT_SERVER_CLIENT_NAME_LENGTH 63 + +/** + * The app that asks the user to accept or reject an incoming connection. + * It is started as a child of the connection's session, with argv: + * [0] the peer IP address + * [1] the client name, as reported by the client + * [2] the client certificate fingerprint (see DEVELOPMENT_SERVER_FINGERPRINT_LENGTH) + * [3] the requested mode (e.g. "shell") + * Its result must be a DevelopmentPromptResult. Any other result rejects the connection. + */ +#define DEVELOPMENT_SERVER_PROMPT_APP_ID "DevelopmentPrompt" + +enum DevelopmentPromptResult { + DEVELOPMENT_PROMPT_REJECT = 0, + DEVELOPMENT_PROMPT_ACCEPT_ONCE = 1, + DEVELOPMENT_PROMPT_ACCEPT_ALWAYS = 2, +}; + +/** + * Starts or stops listening for connections, and persists the choice for the next boot. + * @retval ERROR_INVALID_STATE the service is not running + * @retval ERROR_RESOURCE the listener could not be started + * @retval ERROR_NONE on success + */ +error_t development_server_set_enabled(bool enabled); + +/** @return true when the server is listening for connections */ +bool development_server_is_enabled(void); + +/** + * Writes the fingerprint of the device certificate, which clients use to identify the device. + * @param[out] out_fingerprint at least DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1 bytes + * @retval ERROR_INVALID_STATE the service is not running + * @retval ERROR_BUFFER_OVERFLOW @a out_size is too small + * @retval ERROR_NONE on success + */ +error_t development_server_get_fingerprint(char* out_fingerprint, size_t out_size); + +/** @return the TCP port that the server listens on */ +unsigned short development_server_get_port(void); + +/** + * @param[in] fingerprint see DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + * @param[in] name the client name, as reported by the client when it was trusted + * @return true to continue with the next client + */ +typedef bool (*DevelopmentTrustedClientVisitor)(const char* fingerprint, const char* name, void* context); + +/** + * Calls @a visitor for every client that the user chose to always accept, in the order that they were added. + * @warning @a visitor must not call other trusted client functions + * @retval ERROR_INVALID_STATE the service is not running + * @retval ERROR_NONE on success + */ +error_t development_server_for_each_trusted_client(DevelopmentTrustedClientVisitor visitor, void* context); + +/** + * Stops trusting a client, so its next connection asks the user again. Active sessions continue. + * @retval ERROR_INVALID_STATE the service is not running + * @retval ERROR_NOT_FOUND the client isn't trusted + * @retval ERROR_NONE on success + */ +error_t development_server_remove_trusted_client(const char* fingerprint); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/development-module/private/development/credentials.h b/Modules/development-module/private/development/credentials.h new file mode 100644 index 000000000..efe138db5 --- /dev/null +++ b/Modules/development-module/private/development/credentials.h @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +#include +#include + +struct DevelopmentCredentials { + mbedtls_x509_crt certificate; + mbedtls_pk_context key; + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; +}; + +/** + * Loads the device certificate and key from @a directory, or generates and stores them there first. + * @param[out] credentials must be released with development_credentials_free(), also on failure + */ +error_t development_credentials_load(const char* directory, DevelopmentCredentials* credentials); + +void development_credentials_free(DevelopmentCredentials* credentials); + +/** Writes the hex SHA-256 of @a der, which is DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1 bytes with null terminator */ +error_t development_fingerprint(const uint8_t* der, size_t length, char out_fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]); diff --git a/Modules/development-module/private/development/protocol.h b/Modules/development-module/private/development/protocol.h new file mode 100644 index 000000000..e7084d172 --- /dev/null +++ b/Modules/development-module/private/development/protocol.h @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include + +#define DEVELOPMENT_PROTOCOL_VERSION 1 + +/** Maximum length of the hello and status lines, including the newline */ +#define DEVELOPMENT_PROTOCOL_LINE_MAX 1024 + +#define DEVELOPMENT_FRAME_HEADER_SIZE 3 +#define DEVELOPMENT_FRAME_PAYLOAD_MAX 1024 + +#define DEVELOPMENT_MODE_LENGTH 15 +#define DEVELOPMENT_CLIENT_NAME_LENGTH DEVELOPMENT_SERVER_CLIENT_NAME_LENGTH +#define DEVELOPMENT_COMMAND_LENGTH 511 + +enum DevelopmentFrameType : uint8_t { + DEVELOPMENT_FRAME_DATA = 0x01, + // Shell mode + DEVELOPMENT_FRAME_RESIZE = 0x02, + DEVELOPMENT_FRAME_EXIT = 0x03, + // Sync and app mode + DEVELOPMENT_FRAME_DONE = 0x14, + DEVELOPMENT_FRAME_OK = 0x15, + DEVELOPMENT_FRAME_FAIL = 0x16, + // Sync mode + DEVELOPMENT_FRAME_SYNC_STAT = 0x10, + DEVELOPMENT_FRAME_SYNC_STAT_RESULT = 0x11, + DEVELOPMENT_FRAME_SYNC_PUSH = 0x12, + DEVELOPMENT_FRAME_SYNC_PULL = 0x13, + // App mode + DEVELOPMENT_FRAME_APP_INSTALL = 0x20, + DEVELOPMENT_FRAME_APP_UNINSTALL = 0x21, + DEVELOPMENT_FRAME_APP_RUN = 0x22, +}; + +enum DevelopmentSyncFileType : uint8_t { + DEVELOPMENT_SYNC_FILE_MISSING = 0, + DEVELOPMENT_SYNC_FILE_REGULAR = 1, + DEVELOPMENT_SYNC_FILE_DIRECTORY = 2, +}; + +struct DevelopmentHello { + int protocol; + char mode[DEVELOPMENT_MODE_LENGTH + 1]; + char client[DEVELOPMENT_CLIENT_NAME_LENGTH + 1]; + uint16_t columns; + uint16_t rows; + /** Shell mode only: runs this command instead of an interactive shell when not empty */ + char command[DEVELOPMENT_COMMAND_LENGTH + 1]; +}; + +enum class DevelopmentHelloError { + None, + Malformed, + UnsupportedProtocol, + UnsupportedMode, +}; + +/** + * Parses and validates the JSON hello line that a client sends after the TLS handshake. + * @param[in] json the line, without its newline + * @param[out] out_message the reason, for anything but DevelopmentHelloError::None + */ +DevelopmentHelloError development_protocol_parse_hello(const char* json, size_t length, DevelopmentHello* out_hello, const char** out_message); + +/** + * Writes the JSON status line that answers a hello, including its newline. + * @param[in] status "accepted", "rejected" or "error" + * @param[in] message only written when not NULL + * @return the line length, or 0 when @a out_size is too small + */ +size_t development_protocol_format_status(const char* status, const char* message, char* out_line, size_t out_size); + +struct DevelopmentFrame { + DevelopmentFrameType type; + const uint8_t* payload; + uint16_t length; +}; + +enum class DevelopmentFrameParseResult { + Ok, + Incomplete, + Invalid, +}; + +/** + * Parses the first frame in @a data. + * @param[out] out_frame payload points into @a data + * @param[out] out_consumed the size of the parsed frame + * @return DevelopmentFrameParseResult::Invalid when the frame is longer than DEVELOPMENT_FRAME_PAYLOAD_MAX + */ +DevelopmentFrameParseResult development_frame_parse(const uint8_t* data, size_t size, DevelopmentFrame* out_frame, size_t* out_consumed); + +/** Writes a frame header for a payload of @a length bytes */ +void development_frame_write_header(DevelopmentFrameType type, uint16_t length, uint8_t out_header[DEVELOPMENT_FRAME_HEADER_SIZE]); diff --git a/Modules/development-module/private/development/requests.h b/Modules/development-module/private/development/requests.h new file mode 100644 index 000000000..6e64c6bec --- /dev/null +++ b/Modules/development-module/private/development/requests.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include +#include + +#include + +enum class DevelopmentRequestMode { + Sync, + App, +}; + +struct DevelopmentRequestContext; + +/** + * Called when a push received its file completely and closed it. + * @return an error message, or nullptr on success + */ +typedef const char* (*DevelopmentPushCompleteFn)(DevelopmentRequestContext& context, const char* path); + +struct DevelopmentRequestContext { + DevelopmentSession* session; + AppEventSubscription* subscription; + bool* closeRequested; + /** Scratch space for handlers, DEVELOPMENT_FRAME_PAYLOAD_MAX bytes */ + uint8_t* buffer; + // A push keeps consuming its DATA frames until DONE, also when it failed, and then reports its result + bool pushing = false; + FILE* pushFile = nullptr; + const char* pushError = nullptr; + char pushPath[FILE_MAX_PATH_STRING_LENGTH] = {}; + DevelopmentPushCompleteFn pushComplete = nullptr; + bool pushIsTemporary = false; +}; + +/** + * Serves requests of @a mode until the client disconnects or the app is asked to close. + * @param[in,out] close_requested set when an APP_EVENT_CLOSE arrives + */ +void development_requests_run(DevelopmentSession* session, TaskEventGroup* event_group, AppEventSubscription* subscription, bool* close_requested, DevelopmentRequestMode mode); + +/** + * Starts receiving a file into @a path. The result is sent when the client sends DONE. + * @param[in] error when not nullptr, the push fails with this message + * @param[in] complete optional, see DevelopmentPushCompleteFn + * @param[in] temporary removes the file after @a complete, also on success + */ +void development_requests_start_push(DevelopmentRequestContext& context, const char* path, const char* error, DevelopmentPushCompleteFn complete, bool temporary); + +/** Copies the frame payload as a null-terminated string. @return false when it's empty, too long or contains a null character */ +bool development_requests_read_string(const DevelopmentFrame& frame, char* out_string, size_t out_size); + +/** Like development_requests_read_string(), but also requires an absolute path */ +bool development_requests_read_path(const DevelopmentFrame& frame, char* out_path); + +bool development_requests_send_ok(DevelopmentRequestContext& context); + +bool development_requests_send_fail(DevelopmentRequestContext& context, const char* message); + +/** Handles APP_EVENT_CLOSE, for handlers that run for a long time */ +void development_requests_poll_events(DevelopmentRequestContext& context); + +/** @return false when the connection must close */ +bool development_sync_handle(DevelopmentRequestContext& context, const DevelopmentFrame& frame); + +/** @return false when the connection must close */ +bool development_app_handle(DevelopmentRequestContext& context, const DevelopmentFrame& frame); diff --git a/Modules/development-module/private/development/session.h b/Modules/development-module/private/development/session.h new file mode 100644 index 000000000..77c2f84f1 --- /dev/null +++ b/Modules/development-module/private/development/session.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include + +#include + +#include +#include + +#define DEVELOPMENT_SESSION_APP_ID "development-session" + +/** Shell sessions are limited to 1, as each one also runs a shell app */ +#define DEVELOPMENT_SERVER_MAX_SESSIONS 3 + +/** An accepted TLS connection whose client sent a valid hello */ +struct DevelopmentSession { + int socket; + mbedtls_ssl_context ssl; + /** Set by the TLS verify callback, empty when the client sent no certificate */ + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; + char peer_address[16]; + DevelopmentHello hello; + bool trusted; + /** Index of the server slot that tracks this session */ + int slot; + /** Holds a whole frame, so it's sent as a single TLS record */ + uint8_t transmit[DEVELOPMENT_FRAME_HEADER_SIZE + DEVELOPMENT_FRAME_PAYLOAD_MAX]; +}; + +extern "C" const struct AppManifest development_session_app_manifest; + +extern "C" const struct ServiceManifest development_server_service_manifest; + +/** @return a session that wraps @a socket, or NULL when out of memory */ +DevelopmentSession* development_session_alloc(int socket, const mbedtls_ssl_config* config); + +/** Sends a TLS close_notify when possible, then releases the session and its socket */ +void development_session_free(DevelopmentSession* session); + +/** Writes all of @a data, waiting at most @a timeout_ms when the socket is not writable */ +bool development_session_write(DevelopmentSession* session, const void* data, size_t length, uint32_t timeout_ms); + +bool development_session_send_status(DevelopmentSession* session, const char* status, const char* message); + +bool development_session_send_frame(DevelopmentSession* session, DevelopmentFrameType type, const void* payload, uint16_t length); + +/** Hands the session that the listener accepted in @a slot to its session app. @return NULL when there is none */ +DevelopmentSession* development_server_take_session(int slot); + +/** Releases a session from development_server_take_session(), so the server accepts a new one */ +void development_server_finish_session(DevelopmentSession* session); + +/** Accepts future connections that use the certificate with @a fingerprint without asking */ +error_t development_server_trust(const char* fingerprint, const char* name); + +/** Sets the port for the next time the server starts listening. For testing purposes. */ +void development_server_set_port(uint16_t port); diff --git a/Modules/development-module/private/development/trust.h b/Modules/development-module/private/development/trust.h new file mode 100644 index 000000000..498b0a773 --- /dev/null +++ b/Modules/development-module/private/development/trust.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +/** @return true when @a fingerprint was stored with development_trust_add() in @a directory */ +bool development_trust_contains(const char* directory, const char* fingerprint); + +/** + * Stores @a fingerprint in @a directory, so its connections are accepted without asking the user. + * @param[in] name the client name, as reported by the client + */ +error_t development_trust_add(const char* directory, const char* fingerprint, const char* name); + +/** + * Removes @a fingerprint from @a directory. + * @retval ERROR_NOT_FOUND it wasn't stored + */ +error_t development_trust_remove(const char* directory, const char* fingerprint); + +/** Calls @a visitor for every stored client, in the order that they were added */ +error_t development_trust_for_each(const char* directory, DevelopmentTrustedClientVisitor visitor, void* context); diff --git a/Modules/development-module/source/app_requests.cpp b/Modules/development-module/source/app_requests.cpp new file mode 100644 index 000000000..c1e7e43c0 --- /dev/null +++ b/Modules/development-module/source/app_requests.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +namespace { + +constexpr auto* TAG = "DevelopmentApp"; + +/** The file name only names the temporary file, so it must not point elsewhere */ +bool isSafeFileName(const char* name) { + return strstr(name, "..") == nullptr && strchr(name, '/') == nullptr && strchr(name, '\\') == nullptr; +} + +const char* installReceivedPackage(DevelopmentRequestContext& /*context*/, const char* path) { + // The tar parser aborts the firmware on archives that aren't made of 512-byte blocks + struct stat info {}; + if (stat(path, &info) != 0 || info.st_size == 0 || info.st_size % 512 != 0) { + return "invalid package"; + } + const error_t error = app_install(path); + if (error == ERROR_NONE) { + LOG_I(TAG, "Installed %s", path); + return nullptr; + } + LOG_W(TAG, "Failed to install %s: %d", path, error); + return error == ERROR_INVALID_ARGUMENT ? "invalid package" : "failed to install"; +} + +void handleInstall(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + char name[FILE_MAX_PATH_STRING_LENGTH]; + char directory[FILE_MAX_PATH_STRING_LENGTH]; + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!development_requests_read_string(frame, name, sizeof(name)) || !isSafeFileName(name)) { + development_requests_start_push(context, nullptr, "invalid file name", nullptr, true); + return; + } + if (paths_get_temp_path(directory, sizeof(directory)) != ERROR_NONE) { + development_requests_start_push(context, nullptr, "no temporary directory", nullptr, true); + return; + } + const int written = snprintf(path, sizeof(path), "%s/%s", directory, name); + if (written < 0 || static_cast(written) >= sizeof(path)) { + development_requests_start_push(context, nullptr, "invalid file name", nullptr, true); + return; + } + development_requests_start_push(context, path, nullptr, installReceivedPackage, true); +} + +bool handleUninstall(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + AppId id; + if (!development_requests_read_string(frame, id, sizeof(id))) { + return development_requests_send_fail(context, "invalid id"); + } + PackageManifest package; + if (app_manager_find_package(id, &package) != ERROR_NONE) { + LOG_I(TAG, "Uninstall %s: not installed", id); + return development_requests_send_ok(context); + } + if (app_uninstall(id) != ERROR_NONE) { + LOG_W(TAG, "Failed to uninstall %s", id); + return development_requests_send_fail(context, "failed to uninstall"); + } + LOG_I(TAG, "Uninstalled %s", id); + return development_requests_send_ok(context); +} + +bool handleRun(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + AppId id; + if (!development_requests_read_string(frame, id, sizeof(id))) { + return development_requests_send_fail(context, "invalid id"); + } + AppStartContext startContext; + if (app_start_context_from_id(id, &startContext) != ERROR_NONE) { + return development_requests_send_fail(context, "not found"); + } + + // Restarts the app when it's already in the foreground. It might close between both calls. + AppId topmostId; + AppInstanceId instanceId; + if (app_manager_get_topmost_app_id(topmostId, sizeof(topmostId)) == ERROR_NONE && + app_manager_get_topmost_instance_id(&instanceId) == ERROR_NONE && + strcmp(topmostId, id) == 0) { + app_manager_stop(instanceId); + } + + if (app_start_with_context(&startContext, &instanceId) != ERROR_NONE) { + return development_requests_send_fail(context, "failed to start"); + } + LOG_I(TAG, "Started %s", id); + return development_requests_send_ok(context); +} + +} // namespace + +bool development_app_handle(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + switch (frame.type) { + case DEVELOPMENT_FRAME_APP_INSTALL: + handleInstall(context, frame); + return true; + case DEVELOPMENT_FRAME_APP_UNINSTALL: + return handleUninstall(context, frame); + case DEVELOPMENT_FRAME_APP_RUN: + return handleRun(context, frame); + default: + return false; + } +} diff --git a/Modules/development-module/source/credentials.cpp b/Modules/development-module/source/credentials.cpp new file mode 100644 index 000000000..99b2ea548 --- /dev/null +++ b/Modules/development-module/source/credentials.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace { + +constexpr auto* TAG = "DevelopmentCredentials"; + +constexpr auto* KEY_FILE = "server.key"; +constexpr auto* CERTIFICATE_FILE = "server.crt"; +constexpr auto* SUBJECT_NAME = "CN=Tactility,O=Tactility"; + +constexpr size_t IV_SIZE = 16; +constexpr size_t BLOCK_SIZE = 16; +constexpr size_t KEY_DER_MAX = 256; +constexpr size_t KEY_FILE_MAX = IV_SIZE + KEY_DER_MAX + BLOCK_SIZE; +constexpr size_t CERTIFICATE_DER_MAX = 1024; + +constexpr MemoryPolicy MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +bool getPath(const char* directory, const char* file, char* outPath, size_t outSize) { + const int written = snprintf(outPath, outSize, "%s/%s", directory, file); + return written > 0 && static_cast(written) < outSize; +} + +bool writeFile(const char* directory, const char* file, const uint8_t* data, size_t length) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!getPath(directory, file, path, sizeof(path))) { + return false; + } + FILE* handle = fopen(path, "wb"); + if (handle == nullptr) { + LOG_E(TAG, "Failed to open %s", path); + return false; + } + const bool written = fwrite(data, 1, length, handle) == length; + return fclose(handle) == 0 && written; +} + +bool readFile(const char* directory, const char* file, uint8_t* data, size_t* inOutLength) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + return getPath(directory, file, path, sizeof(path)) && file_read_binary(path, data, inOutLength) == ERROR_NONE; +} + +// File layout: IV, then the encrypted [u16 big-endian DER length][DER][zero padding] +bool writeKey(const char* directory, const uint8_t* der, size_t derLength) { + uint8_t plain[KEY_DER_MAX + BLOCK_SIZE] = {}; + plain[0] = static_cast(derLength >> 8); + plain[1] = static_cast(derLength & 0xFF); + memcpy(plain + 2, der, derLength); + const size_t encryptedLength = ((derLength + 2 + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE; + + uint8_t file[KEY_FILE_MAX]; + crypt_generate_iv(file); + const bool encrypted = crypt_encrypt(file, plain, file + IV_SIZE, encryptedLength) == 0; + mbedtls_platform_zeroize(plain, sizeof(plain)); + return encrypted && writeFile(directory, KEY_FILE, file, IV_SIZE + encryptedLength); +} + +bool readKey(const char* directory, uint8_t* outDer, size_t* outLength) { + uint8_t file[KEY_FILE_MAX]; + size_t fileLength = sizeof(file); + if (!readFile(directory, KEY_FILE, file, &fileLength) || fileLength <= IV_SIZE || (fileLength - IV_SIZE) % BLOCK_SIZE != 0) { + return false; + } + uint8_t plain[KEY_DER_MAX + BLOCK_SIZE]; + const size_t encryptedLength = fileLength - IV_SIZE; + if (crypt_decrypt(file, file + IV_SIZE, plain, encryptedLength) != 0) { + return false; + } + const size_t derLength = (static_cast(plain[0]) << 8) | plain[1]; + const bool valid = derLength > 0 && derLength + 2 <= encryptedLength; + if (valid) { + memcpy(outDer, plain + 2, derLength); + *outLength = derLength; + } + mbedtls_platform_zeroize(plain, sizeof(plain)); + return valid; +} + +bool generateKey(mbedtls_pk_context* pk) { + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1)); + psa_set_key_bits(&attributes, 256); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_SIGN_HASH | PSA_KEY_USAGE_EXPORT); + psa_set_key_algorithm(&attributes, PSA_ALG_ECDSA(PSA_ALG_SHA_256)); + + mbedtls_svc_key_id_t keyId = MBEDTLS_SVC_KEY_ID_INIT; + if (psa_generate_key(&attributes, &keyId) != PSA_SUCCESS) { + return false; + } + const bool copied = mbedtls_pk_copy_from_psa(keyId, pk) == 0; + psa_destroy_key(keyId); + return copied; +} + +bool writeCertificate(mbedtls_pk_context* pk, uint8_t* buffer, size_t bufferSize, const uint8_t** outDer, size_t* outLength) { + uint8_t serial[16]; + if (psa_generate_random(serial, sizeof(serial)) != PSA_SUCCESS) { + return false; + } + // Keeps the serial number positive + serial[0] &= 0x7F; + + mbedtls_x509write_cert writer; + mbedtls_x509write_crt_init(&writer); + mbedtls_x509write_crt_set_version(&writer, MBEDTLS_X509_CRT_VERSION_3); + mbedtls_x509write_crt_set_md_alg(&writer, MBEDTLS_MD_SHA256); + mbedtls_x509write_crt_set_subject_key(&writer, pk); + mbedtls_x509write_crt_set_issuer_key(&writer, pk); + int result = mbedtls_x509write_crt_set_serial_raw(&writer, serial, sizeof(serial)); + if (result == 0) result = mbedtls_x509write_crt_set_subject_name(&writer, SUBJECT_NAME); + if (result == 0) result = mbedtls_x509write_crt_set_issuer_name(&writer, SUBJECT_NAME); + // Devices often have no real-time clock, so the validity covers any plausible date + if (result == 0) result = mbedtls_x509write_crt_set_validity(&writer, "20000101000000", "20991231235959"); + if (result == 0) result = mbedtls_x509write_crt_der(&writer, buffer, bufferSize); + mbedtls_x509write_crt_free(&writer); + if (result <= 0) { + LOG_E(TAG, "Failed to write certificate: -0x%04x", static_cast(-result)); + return false; + } + // The DER is written at the end of the buffer + *outLength = static_cast(result); + *outDer = buffer + bufferSize - *outLength; + return true; +} + +bool generate(const char* directory) { + LOG_I(TAG, "Generating device certificate"); + auto* buffer = static_cast(memory_alloc_with_policy(KEY_DER_MAX + CERTIFICATE_DER_MAX, &MEMORY_POLICY)); + if (buffer == nullptr) { + return false; + } + uint8_t* keyBuffer = buffer; + uint8_t* certificateBuffer = buffer + KEY_DER_MAX; + + mbedtls_pk_context pk; + mbedtls_pk_init(&pk); + bool success = generateKey(&pk); + + const uint8_t* certificateDer = nullptr; + size_t certificateLength = 0; + success = success && writeCertificate(&pk, certificateBuffer, CERTIFICATE_DER_MAX, &certificateDer, &certificateLength); + + int keyLength = success ? mbedtls_pk_write_key_der(&pk, keyBuffer, KEY_DER_MAX) : 0; + success = keyLength > 0 && + writeKey(directory, keyBuffer + KEY_DER_MAX - keyLength, static_cast(keyLength)) && + writeFile(directory, CERTIFICATE_FILE, certificateDer, certificateLength); + + mbedtls_pk_free(&pk); + mbedtls_platform_zeroize(keyBuffer, KEY_DER_MAX); + memory_free(buffer); + return success; +} + +bool parse(const char* directory, DevelopmentCredentials* credentials) { + auto* buffer = static_cast(memory_alloc_with_policy(KEY_DER_MAX + CERTIFICATE_DER_MAX, &MEMORY_POLICY)); + if (buffer == nullptr) { + return false; + } + uint8_t* keyDer = buffer; + uint8_t* certificateDer = buffer + KEY_DER_MAX; + size_t keyLength = 0; + size_t certificateLength = CERTIFICATE_DER_MAX; + const bool success = readKey(directory, keyDer, &keyLength) && + readFile(directory, CERTIFICATE_FILE, certificateDer, &certificateLength) && + mbedtls_pk_parse_key(&credentials->key, keyDer, keyLength, nullptr, 0) == 0 && + mbedtls_x509_crt_parse_der(&credentials->certificate, certificateDer, certificateLength) == 0 && + development_fingerprint(certificateDer, certificateLength, credentials->fingerprint) == ERROR_NONE; + mbedtls_platform_zeroize(keyDer, KEY_DER_MAX); + memory_free(buffer); + return success; +} + +void reset(DevelopmentCredentials* credentials) { + development_credentials_free(credentials); + mbedtls_x509_crt_init(&credentials->certificate); + mbedtls_pk_init(&credentials->key); + credentials->fingerprint[0] = '\0'; +} + +} // namespace + +error_t development_credentials_load(const char* directory, DevelopmentCredentials* credentials) { + mbedtls_x509_crt_init(&credentials->certificate); + mbedtls_pk_init(&credentials->key); + credentials->fingerprint[0] = '\0'; + + if (psa_crypto_init() != PSA_SUCCESS) { + return ERROR_RESOURCE; + } + + if (parse(directory, credentials)) { + return ERROR_NONE; + } + reset(credentials); + + const error_t directoryError = directory_make(directory, true); + if (directoryError != ERROR_NONE && directoryError != ERROR_ALREADY_EXISTS) { + LOG_E(TAG, "Failed to create %s", directory); + return ERROR_RESOURCE; + } + if (!generate(directory) || !parse(directory, credentials)) { + LOG_E(TAG, "Failed to create device certificate"); + return ERROR_RESOURCE; + } + return ERROR_NONE; +} + +void development_credentials_free(DevelopmentCredentials* credentials) { + mbedtls_x509_crt_free(&credentials->certificate); + mbedtls_pk_free(&credentials->key); +} + +error_t development_fingerprint(const uint8_t* der, size_t length, char out_fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]) { + uint8_t hash[32]; + size_t hashLength = 0; + if (psa_hash_compute(PSA_ALG_SHA_256, der, length, hash, sizeof(hash), &hashLength) != PSA_SUCCESS || hashLength != sizeof(hash)) { + return ERROR_RESOURCE; + } + for (size_t i = 0; i < sizeof(hash); i++) { + snprintf(&out_fingerprint[i * 2], 3, "%02x", hash[i]); + } + return ERROR_NONE; +} diff --git a/Modules/development-module/source/module.cpp b/Modules/development-module/source/module.cpp new file mode 100644 index 000000000..d27ad6dd3 --- /dev/null +++ b/Modules/development-module/source/module.cpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include +#include + +#include +#include + +extern "C" { + +static error_t start() { + error_t error = app_manager_add(&development_session_app_manifest); + if (error != ERROR_NONE) { + return error; + } + error = service_manager_add(&development_server_service_manifest, /*auto_start=*/true); + if (error != ERROR_NONE) { + app_manager_remove(development_session_app_manifest.id); + } + return error; +} + +static error_t stop() { + const error_t error = service_manager_stop(development_server_service_manifest.id); + if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { + return error; + } + service_manager_remove(development_server_service_manifest.id); + app_manager_remove(development_session_app_manifest.id); + return ERROR_NONE; +} + +static const ModuleSymbol development_module_symbols[] = { + DEFINE_MODULE_SYMBOL(development_server_set_enabled), + DEFINE_MODULE_SYMBOL(development_server_is_enabled), + DEFINE_MODULE_SYMBOL(development_server_get_fingerprint), + DEFINE_MODULE_SYMBOL(development_server_get_port), + DEFINE_MODULE_SYMBOL(development_server_for_each_trusted_client), + DEFINE_MODULE_SYMBOL(development_server_remove_trusted_client), + MODULE_SYMBOL_TERMINATOR +}; + +Module development_module = { + .name = "development", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = development_module_symbols, + .internal = nullptr, +}; + +} diff --git a/Modules/development-module/source/protocol.cpp b/Modules/development-module/source/protocol.cpp new file mode 100644 index 000000000..165e25fdd --- /dev/null +++ b/Modules/development-module/source/protocol.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +namespace { + +constexpr uint16_t DEFAULT_COLUMNS = 80; +constexpr uint16_t DEFAULT_ROWS = 24; + +bool copyString(const cJSON* item, char* out, size_t outSize) { + if (!cJSON_IsString(item) || item->valuestring == nullptr) { + return false; + } + const size_t length = strlen(item->valuestring); + if (length >= outSize) { + return false; + } + memcpy(out, item->valuestring, length + 1); + return true; +} + +uint16_t readDimension(const cJSON* item, uint16_t fallback) { + if (!cJSON_IsNumber(item) || item->valuedouble < 1 || item->valuedouble > UINT16_MAX) { + return fallback; + } + return static_cast(item->valuedouble); +} + +} // namespace + +DevelopmentHelloError development_protocol_parse_hello(const char* json, size_t length, DevelopmentHello* out_hello, const char** out_message) { + cJSON* root = cJSON_ParseWithLength(json, length); + if (root == nullptr || !cJSON_IsObject(root)) { + cJSON_Delete(root); + *out_message = "malformed hello"; + return DevelopmentHelloError::Malformed; + } + + DevelopmentHello hello {}; + const cJSON* protocol = cJSON_GetObjectItemCaseSensitive(root, "protocol"); + if (!cJSON_IsNumber(protocol) || + !copyString(cJSON_GetObjectItemCaseSensitive(root, "mode"), hello.mode, sizeof(hello.mode)) || + !copyString(cJSON_GetObjectItemCaseSensitive(root, "client"), hello.client, sizeof(hello.client))) { + cJSON_Delete(root); + *out_message = "malformed hello"; + return DevelopmentHelloError::Malformed; + } + hello.protocol = protocol->valueint; + hello.columns = readDimension(cJSON_GetObjectItemCaseSensitive(root, "columns"), DEFAULT_COLUMNS); + hello.rows = readDimension(cJSON_GetObjectItemCaseSensitive(root, "rows"), DEFAULT_ROWS); + const cJSON* command = cJSON_GetObjectItemCaseSensitive(root, "command"); + const bool hasCommand = command != nullptr; + if (hasCommand && !copyString(command, hello.command, sizeof(hello.command))) { + cJSON_Delete(root); + *out_message = "malformed hello"; + return DevelopmentHelloError::Malformed; + } + cJSON_Delete(root); + + if (hello.protocol != DEVELOPMENT_PROTOCOL_VERSION) { + *out_message = "unsupported protocol version"; + return DevelopmentHelloError::UnsupportedProtocol; + } + if (strcmp(hello.mode, "shell") != 0 && strcmp(hello.mode, "sync") != 0 && strcmp(hello.mode, "app") != 0) { + *out_message = "unsupported mode"; + return DevelopmentHelloError::UnsupportedMode; + } + if (hasCommand && strcmp(hello.mode, "shell") != 0) { + *out_message = "malformed hello"; + return DevelopmentHelloError::Malformed; + } + + *out_hello = hello; + *out_message = nullptr; + return DevelopmentHelloError::None; +} + +size_t development_protocol_format_status(const char* status, const char* message, char* out_line, size_t out_size) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return 0; + } + cJSON_AddStringToObject(root, "status", status); + if (message != nullptr) { + cJSON_AddStringToObject(root, "message", message); + } + // The newline is appended below, so one byte is held back for it + const bool printed = out_size >= 2 && cJSON_PrintPreallocated(root, out_line, static_cast(out_size - 1), false); + cJSON_Delete(root); + if (!printed) { + return 0; + } + const size_t length = strlen(out_line); + out_line[length] = '\n'; + out_line[length + 1] = '\0'; + return length + 1; +} + +DevelopmentFrameParseResult development_frame_parse(const uint8_t* data, size_t size, DevelopmentFrame* out_frame, size_t* out_consumed) { + if (size < DEVELOPMENT_FRAME_HEADER_SIZE) { + return DevelopmentFrameParseResult::Incomplete; + } + const uint16_t length = static_cast((data[1] << 8) | data[2]); + if (length > DEVELOPMENT_FRAME_PAYLOAD_MAX) { + return DevelopmentFrameParseResult::Invalid; + } + if (size < DEVELOPMENT_FRAME_HEADER_SIZE + length) { + return DevelopmentFrameParseResult::Incomplete; + } + out_frame->type = static_cast(data[0]); + out_frame->payload = data + DEVELOPMENT_FRAME_HEADER_SIZE; + out_frame->length = length; + *out_consumed = DEVELOPMENT_FRAME_HEADER_SIZE + length; + return DevelopmentFrameParseResult::Ok; +} + +void development_frame_write_header(DevelopmentFrameType type, uint16_t length, uint8_t out_header[DEVELOPMENT_FRAME_HEADER_SIZE]) { + out_header[0] = type; + out_header[1] = static_cast(length >> 8); + out_header[2] = static_cast(length & 0xFF); +} diff --git a/Modules/development-module/source/requests.cpp b/Modules/development-module/source/requests.cpp new file mode 100644 index 000000000..d7c90753c --- /dev/null +++ b/Modules/development-module/source/requests.cpp @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include + +#include + +namespace { + +constexpr auto* TAG = "DevelopmentRequests"; + +// Bounds the wait for socket input, which doesn't signal the event group +constexpr uint32_t POLL_INTERVAL_MS = 20; +constexpr size_t RECEIVE_BUFFER_SIZE = DEVELOPMENT_FRAME_HEADER_SIZE + DEVELOPMENT_FRAME_PAYLOAD_MAX; + +constexpr MemoryPolicy MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +struct Buffers { + uint8_t receive[RECEIVE_BUFFER_SIZE]; + uint8_t scratch[DEVELOPMENT_FRAME_PAYLOAD_MAX]; +}; + +bool createParentDirectory(const char* path) { + char parent[FILE_MAX_PATH_STRING_LENGTH]; + strcpy(parent, path); + char* separator = strrchr(parent, '/'); + if (separator == nullptr || separator == parent) { + return true; + } + *separator = '\0'; + if (directory_exists(parent)) { + return true; + } + const error_t error = directory_make(parent, true); + return error == ERROR_NONE || error == ERROR_ALREADY_EXISTS; +} + +void writePush(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + if (context.pushFile != nullptr && fwrite(frame.payload, 1, frame.length, context.pushFile) != frame.length) { + context.pushError = "failed to write file"; + } +} + +/** Closes the pushed file, and removes it when the push failed or the file is temporary */ +void closePush(DevelopmentRequestContext& context) { + if (context.pushFile != nullptr) { + if (fclose(context.pushFile) != 0 && context.pushError == nullptr) { + context.pushError = "failed to write file"; + } + context.pushFile = nullptr; + if (context.pushError == nullptr && context.pushComplete != nullptr) { + context.pushError = context.pushComplete(context, context.pushPath); + } + if (context.pushError != nullptr || context.pushIsTemporary) { + remove(context.pushPath); + } + } + context.pushing = false; +} + +bool finishPush(DevelopmentRequestContext& context) { + closePush(context); + if (context.pushError != nullptr) { + LOG_W(TAG, "Push to %s failed: %s", context.pushPath, context.pushError); + return development_requests_send_fail(context, context.pushError); + } + LOG_I(TAG, "Pushed %s", context.pushPath); + return development_requests_send_ok(context); +} + +bool handleFrame(DevelopmentRequestContext& context, const DevelopmentFrame& frame, DevelopmentRequestMode mode) { + if (context.pushing) { + switch (frame.type) { + case DEVELOPMENT_FRAME_DATA: + writePush(context, frame); + return true; + case DEVELOPMENT_FRAME_DONE: + return finishPush(context); + default: + return false; + } + } + switch (mode) { + case DevelopmentRequestMode::Sync: + return development_sync_handle(context, frame); + case DevelopmentRequestMode::App: + return development_app_handle(context, frame); + } + return false; +} + +/** @return false when the connection must close */ +bool processFrames(DevelopmentRequestContext& context, Buffers* buffers, size_t& receiveLength, DevelopmentRequestMode mode) { + size_t offset = 0; + bool connected = true; + while (connected) { + DevelopmentFrame frame {}; + size_t consumed = 0; + const auto result = development_frame_parse(buffers->receive + offset, receiveLength - offset, &frame, &consumed); + if (result == DevelopmentFrameParseResult::Incomplete) { + break; + } + connected = result == DevelopmentFrameParseResult::Ok && handleFrame(context, frame, mode); + offset += consumed; + } + memmove(buffers->receive, buffers->receive + offset, receiveLength - offset); + receiveLength -= offset; + return connected; +} + +} // namespace + +void development_requests_run(DevelopmentSession* session, TaskEventGroup* event_group, AppEventSubscription* subscription, bool* close_requested, DevelopmentRequestMode mode) { + auto* buffers = static_cast(memory_alloc_with_policy(sizeof(Buffers), &MEMORY_POLICY)); + if (buffers == nullptr) { + LOG_E(TAG, "Out of memory"); + return; + } + DevelopmentRequestContext context { session, subscription, close_requested, buffers->scratch }; + size_t receiveLength = 0; + + bool connected = true; + while (connected && !*close_requested) { + const int result = mbedtls_ssl_read(&session->ssl, buffers->receive + receiveLength, RECEIVE_BUFFER_SIZE - receiveLength); + if (result > 0) { + receiveLength += static_cast(result); + connected = processFrames(context, buffers, receiveLength, mode); + } else if (result == MBEDTLS_ERR_SSL_WANT_READ || result == MBEDTLS_ERR_SSL_WANT_WRITE) { + task_event_group_wait_any(event_group, nullptr, pdMS_TO_TICKS(POLL_INTERVAL_MS)); + } else { + connected = false; + } + development_requests_poll_events(context); + } + + if (context.pushing) { + context.pushError = "interrupted"; + closePush(context); + } + memory_free(buffers); +} + +void development_requests_start_push(DevelopmentRequestContext& context, const char* path, const char* error, DevelopmentPushCompleteFn complete, bool temporary) { + context.pushing = true; + context.pushFile = nullptr; + context.pushError = error; + context.pushComplete = complete; + context.pushIsTemporary = temporary; + context.pushPath[0] = '\0'; + if (error != nullptr) { + return; + } + strcpy(context.pushPath, path); + if (directory_exists(path)) { + context.pushError = "is a directory"; + } else if (!createParentDirectory(path)) { + context.pushError = "failed to create parent directory"; + } else if ((context.pushFile = fopen(path, "wb")) == nullptr) { + context.pushError = "failed to open file"; + } +} + +bool development_requests_read_string(const DevelopmentFrame& frame, char* out_string, size_t out_size) { + if (frame.length == 0 || frame.length >= out_size || memchr(frame.payload, '\0', frame.length) != nullptr) { + return false; + } + memcpy(out_string, frame.payload, frame.length); + out_string[frame.length] = '\0'; + return true; +} + +bool development_requests_read_path(const DevelopmentFrame& frame, char* out_path) { + return frame.length > 0 && frame.payload[0] == '/' && development_requests_read_string(frame, out_path, FILE_MAX_PATH_STRING_LENGTH); +} + +bool development_requests_send_ok(DevelopmentRequestContext& context) { + return development_session_send_frame(context.session, DEVELOPMENT_FRAME_OK, nullptr, 0); +} + +bool development_requests_send_fail(DevelopmentRequestContext& context, const char* message) { + return development_session_send_frame(context.session, DEVELOPMENT_FRAME_FAIL, message, static_cast(strlen(message))); +} + +void development_requests_poll_events(DevelopmentRequestContext& context) { + AppEvent event {}; + while (app_event_poll(context.subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + *context.closeRequested = true; + } + } +} diff --git a/Modules/development-module/source/server.cpp b/Modules/development-module/source/server.cpp new file mode 100644 index 000000000..6e172fd5d --- /dev/null +++ b/Modules/development-module/source/server.cpp @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +constexpr auto* TAG = "DevelopmentServer"; + +constexpr auto* SETTINGS_FILE = "server.properties"; +constexpr auto* KEY_ENABLE_ON_BOOT = "enableOnBoot"; + +constexpr int LISTEN_BACKLOG = 2; +constexpr uint32_t ACCEPT_POLL_TIMEOUT_MS = 200; +constexpr uint32_t SOCKET_POLL_TIMEOUT_MS = 200; +constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 10000; +constexpr size_t LISTENER_STACK_SIZE = 8192; + +constexpr MemoryPolicy MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +struct SessionSlot { + bool active; + bool shell; + /** Set until the session app takes the session */ + DevelopmentSession* pending; + AppInstanceId appId; +}; + +struct ServerData { + char directory[FILE_MAX_PATH_STRING_LENGTH] = {}; + DevelopmentCredentials credentials {}; + bool credentialsLoaded = false; + mbedtls_ssl_config config {}; + Thread* listener = nullptr; + int listenFd = -1; + uint16_t boundPort = 0; + std::atomic stopRequested = false; + // Guarded by stateMutex + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1] = {}; + bool listening = false; + SessionSlot slots[DEVELOPMENT_SERVER_MAX_SESSIONS] = {}; +}; + +struct GlobalMutex { + Mutex handle {}; + GlobalMutex() { mutex_construct(&handle); } + ~GlobalMutex() { mutex_destruct(&handle); } +}; + +// Serializes starting and stopping. Held while waiting for the listener and session app to stop, +// so those must never take it. +GlobalMutex controlMutex; +// Guards the server pointer and the fields of ServerData that the listener and session app share. +GlobalMutex stateMutex; + +ServerData* server = nullptr; +uint16_t configuredPort = DEVELOPMENT_SERVER_DEFAULT_PORT; + +void lockState() { mutex_lock(&stateMutex.handle); } +void unlockState() { mutex_unlock(&stateMutex.handle); } + +// region Settings + +bool getSettingsPath(const ServerData* data, char* outPath, size_t outSize) { + const int written = snprintf(outPath, outSize, "%s/%s", data->directory, SETTINGS_FILE); + return written > 0 && static_cast(written) < outSize; +} + +bool loadEnableOnBoot(const ServerData* data) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!getSettingsPath(data, path, sizeof(path))) { + return false; + } + if (!path_exists(path)) { + return false; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return false; + } + char value[8] = {}; + const bool enabled = properties_file_get(file, KEY_ENABLE_ON_BOOT, value, sizeof(value)) == ERROR_NONE && strcmp(value, "true") == 0; + properties_file_close(file); + return enabled; +} + +void saveEnableOnBoot(const ServerData* data, bool enabled) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + const error_t directoryError = directory_make(data->directory, true); + if ((directoryError != ERROR_NONE && directoryError != ERROR_ALREADY_EXISTS) || !getSettingsPath(data, path, sizeof(path))) { + LOG_E(TAG, "Failed to save settings"); + return; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path); + return; + } + properties_file_set(file, KEY_ENABLE_ON_BOOT, enabled ? "true" : "false"); + if (properties_file_close(file) != ERROR_NONE) { + LOG_E(TAG, "Failed to save %s", path); + } +} + +// endregion + +// region Connections + +int verifyClientCertificate(void* context, mbedtls_x509_crt* certificate, int depth, uint32_t* flags) { + // Clients use self-signed certificates: the user decides whether to trust them + if (depth == 0) { + auto* session = static_cast(context); + if (development_fingerprint(certificate->raw.p, certificate->raw.len, session->fingerprint) != ERROR_NONE) { + session->fingerprint[0] = '\0'; + } + } + *flags = 0; + return 0; +} + +bool isExpired(TickType_t start, uint32_t timeoutMs) { + return xTaskGetTickCount() - start > pdMS_TO_TICKS(timeoutMs); +} + +bool handshake(ServerData* data, DevelopmentSession* session) { + const TickType_t start = xTaskGetTickCount(); + int result; + while ((result = mbedtls_ssl_handshake(&session->ssl)) != 0) { + if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) { + LOG_W(TAG, "Handshake with %s failed: -0x%04x", session->peer_address, static_cast(-result)); + return false; + } + if (data->stopRequested || isExpired(start, HANDSHAKE_TIMEOUT_MS)) { + LOG_W(TAG, "Handshake with %s timed out", session->peer_address); + return false; + } + } + return true; +} + +// Reads byte by byte, so nothing after the newline is consumed +bool readLine(ServerData* data, DevelopmentSession* session, char* outLine, size_t outSize, size_t* outLength) { + const TickType_t start = xTaskGetTickCount(); + size_t length = 0; + while (true) { + unsigned char character; + const int result = mbedtls_ssl_read(&session->ssl, &character, 1); + if (result == 1) { + if (character == '\n') { + outLine[length] = '\0'; + *outLength = length; + return true; + } + if (length + 1 >= outSize) { + return false; + } + outLine[length++] = static_cast(character); + } else if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) { + return false; + } else if (data->stopRequested || isExpired(start, HANDSHAKE_TIMEOUT_MS)) { + return false; + } + } +} + +bool readHello(ServerData* data, DevelopmentSession* session) { + auto* line = static_cast(memory_alloc_with_policy(DEVELOPMENT_PROTOCOL_LINE_MAX, &MEMORY_POLICY)); + if (line == nullptr) { + return false; + } + size_t length = 0; + bool valid = false; + if (readLine(data, session, line, DEVELOPMENT_PROTOCOL_LINE_MAX, &length)) { + const char* message = nullptr; + if (development_protocol_parse_hello(line, length, &session->hello, &message) == DevelopmentHelloError::None) { + valid = true; + } else { + LOG_W(TAG, "Invalid hello from %s: %s", session->peer_address, message); + development_session_send_status(session, "error", message); + } + } + memory_free(line); + return valid; +} + +/** @return the reason why the session can't start now, or nullptr after reserving a slot for it */ +const char* reserveSlot(ServerData* data, DevelopmentSession* session) { + const bool shell = strcmp(session->hello.mode, "shell") == 0; + lockState(); + int freeSlot = -1; + bool shellBusy = false; + for (int i = 0; i < DEVELOPMENT_SERVER_MAX_SESSIONS; i++) { + const SessionSlot& slot = data->slots[i]; + if (!slot.active && freeSlot < 0) { + freeSlot = i; + } + shellBusy = shellBusy || (shell && slot.active && slot.shell); + } + const char* error = shellBusy ? "shell busy" : (freeSlot < 0 ? "busy" : nullptr); + if (error == nullptr) { + data->slots[freeSlot] = { .active = true, .shell = shell, .pending = session, .appId = 0 }; + session->slot = freeSlot; + } + unlockState(); + return error; +} + +void releaseSlot(ServerData* data, int slot) { + lockState(); + data->slots[slot] = {}; + unlockState(); +} + +bool startSessionApp(ServerData* data, int slot) { + char slotArgument[4]; + snprintf(slotArgument, sizeof(slotArgument), "%d", slot); + const char* arguments[] = { slotArgument }; + AppStartContext context = app_start_context_for_manifest(&development_session_app_manifest); + app_start_context_set_arguments_ext(&context, 1, arguments); + AppInstanceId appId = 0; + if (app_start_with_context(&context, &appId) != ERROR_NONE) { + return false; + } + lockState(); + // Only this thread reserves slots, so an inactive slot means that the app already finished its session + if (data->slots[slot].active) { + data->slots[slot].appId = appId; + } + unlockState(); + return true; +} + +void handleConnection(ServerData* data, int socket, const sockaddr_in& address) { + const timeval timeout { .tv_sec = 0, .tv_usec = SOCKET_POLL_TIMEOUT_MS * 1000 }; + setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + DevelopmentSession* session = development_session_alloc(socket, &data->config); + if (session == nullptr) { + LOG_E(TAG, "Out of memory"); + close(socket); + return; + } + inet_ntop(AF_INET, &address.sin_addr, session->peer_address, sizeof(session->peer_address)); + mbedtls_ssl_set_verify(&session->ssl, verifyClientCertificate, session); + + if (!handshake(data, session) || !readHello(data, session)) { + development_session_free(session); + return; + } + + if (session->fingerprint[0] == '\0') { + LOG_W(TAG, "Client %s has no certificate", session->peer_address); + development_session_send_status(session, "error", "client certificate required"); + development_session_free(session); + return; + } + + session->trusted = development_trust_contains(data->directory, session->fingerprint); + + const char* busy = reserveSlot(data, session); + if (busy != nullptr) { + development_session_send_status(session, "error", busy); + development_session_free(session); + return; + } + + const int flags = fcntl(socket, F_GETFL, 0); + fcntl(socket, F_SETFL, flags | O_NONBLOCK); + + LOG_I(TAG, "Connection from %s (%s)", session->peer_address, session->hello.client); + if (!startSessionApp(data, session->slot)) { + LOG_E(TAG, "Failed to start session"); + releaseSlot(data, session->slot); + development_session_send_status(session, "error", "internal error"); + development_session_free(session); + } +} + +int32_t listenerMain(void* context) { + auto* data = static_cast(context); + while (!data->stopRequested) { + fd_set readFds; + FD_ZERO(&readFds); + FD_SET(data->listenFd, &readFds); + timeval timeout { .tv_sec = 0, .tv_usec = ACCEPT_POLL_TIMEOUT_MS * 1000 }; + if (select(data->listenFd + 1, &readFds, nullptr, nullptr, &timeout) <= 0) { + continue; + } + sockaddr_in address {}; + socklen_t addressLength = sizeof(address); + const int socket = accept(data->listenFd, reinterpret_cast(&address), &addressLength); + if (socket >= 0) { + handleConnection(data, socket, address); + } + } + return 0; +} + +// endregion + +// region Lifecycle + +error_t loadCredentials(ServerData* data) { + if (data->credentialsLoaded) { + return ERROR_NONE; + } + const error_t error = development_credentials_load(data->directory, &data->credentials); + if (error != ERROR_NONE) { + development_credentials_free(&data->credentials); + return error; + } + + mbedtls_ssl_config_init(&data->config); + int result = mbedtls_ssl_config_defaults(&data->config, MBEDTLS_SSL_IS_SERVER, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); + if (result == 0) { + mbedtls_ssl_conf_authmode(&data->config, MBEDTLS_SSL_VERIFY_OPTIONAL); + mbedtls_ssl_conf_min_tls_version(&data->config, MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_max_tls_version(&data->config, MBEDTLS_SSL_VERSION_TLS1_2); + result = mbedtls_ssl_conf_own_cert(&data->config, &data->credentials.certificate, &data->credentials.key); + } + if (result != 0) { + LOG_E(TAG, "Failed to configure TLS: -0x%04x", static_cast(-result)); + mbedtls_ssl_config_free(&data->config); + development_credentials_free(&data->credentials); + return ERROR_RESOURCE; + } + data->credentialsLoaded = true; + lockState(); + memcpy(data->fingerprint, data->credentials.fingerprint, sizeof(data->fingerprint)); + unlockState(); + LOG_I(TAG, "Device fingerprint %s", data->credentials.fingerprint); + return ERROR_NONE; +} + +error_t startListening(ServerData* data) { + if (data->listener != nullptr) { + return ERROR_NONE; + } + error_t error = loadCredentials(data); + if (error != ERROR_NONE) { + return error; + } + + const int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return ERROR_RESOURCE; + } + int reuse = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_port = htons(configuredPort); + address.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(fd, reinterpret_cast(&address), sizeof(address)) != 0 || listen(fd, LISTEN_BACKLOG) != 0) { + LOG_E(TAG, "Failed to listen on port %u", static_cast(configuredPort)); + close(fd); + return ERROR_RESOURCE; + } + socklen_t addressLength = sizeof(address); + getsockname(fd, reinterpret_cast(&address), &addressLength); + + data->listenFd = fd; + data->boundPort = ntohs(address.sin_port); + data->stopRequested = false; + data->listener = thread_alloc_full("development-server", LISTENER_STACK_SIZE, listenerMain, data, -1); + if (data->listener != nullptr) { + // Socket calls block outside the scheduler on POSIX, so a higher priority would starve other tasks there + thread_set_priority(data->listener, THREAD_PRIORITY_IDLE); + } + if (data->listener == nullptr || thread_start(data->listener) != ERROR_NONE) { + if (data->listener != nullptr) { + thread_free(data->listener); + data->listener = nullptr; + } + close(fd); + data->listenFd = -1; + return ERROR_RESOURCE; + } + + lockState(); + data->listening = true; + unlockState(); + LOG_I(TAG, "Listening on port %u", static_cast(data->boundPort)); + return ERROR_NONE; +} + +void stopListening(ServerData* data) { + if (data->listener != nullptr) { + data->stopRequested = true; + thread_join(data->listener, portMAX_DELAY, pdMS_TO_TICKS(10)); + thread_free(data->listener); + data->listener = nullptr; + close(data->listenFd); + data->listenFd = -1; + } + + AppInstanceId appIds[DEVELOPMENT_SERVER_MAX_SESSIONS]; + lockState(); + data->listening = false; + for (int i = 0; i < DEVELOPMENT_SERVER_MAX_SESSIONS; i++) { + appIds[i] = data->slots[i].appId; + } + unlockState(); + + for (const AppInstanceId appId : appIds) { + if (appId != 0 && app_manager_get_state(appId) != APP_INSTANCE_STATE_STOPPED) { + app_manager_stop(appId); + } + } + + // Sessions that no app took yet + for (int i = 0; i < DEVELOPMENT_SERVER_MAX_SESSIONS; i++) { + lockState(); + DevelopmentSession* pending = data->slots[i].pending; + if (pending != nullptr) { + data->slots[i] = {}; + } + unlockState(); + if (pending != nullptr) { + development_session_free(pending); + } + } +} + +void* createService(const ServiceManifest* /*manifest*/) { + return tt::newObject(); +} + +void destroyService(const ServiceManifest* /*manifest*/, void* data) { + tt::deleteObject(static_cast(data)); +} + +error_t onStart(ServiceInstance* /*instance*/, void* context) { + auto* data = static_cast(context); + if (data == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + const error_t error = service_paths_get_user_data_directory(DEVELOPMENT_SERVER_SERVICE_ID, data->directory, sizeof(data->directory)); + if (error != ERROR_NONE) { + return error; + } + + mutex_lock(&controlMutex.handle); + lockState(); + server = data; + unlockState(); + if (loadEnableOnBoot(data) && startListening(data) != ERROR_NONE) { + LOG_E(TAG, "Failed to start"); + } + mutex_unlock(&controlMutex.handle); + return ERROR_NONE; +} + +void onStop(ServiceInstance* /*instance*/, void* context) { + auto* data = static_cast(context); + mutex_lock(&controlMutex.handle); + stopListening(data); + lockState(); + server = nullptr; + unlockState(); + lockState(); + data->fingerprint[0] = '\0'; + unlockState(); + if (data->credentialsLoaded) { + mbedtls_ssl_config_free(&data->config); + development_credentials_free(&data->credentials); + data->credentialsLoaded = false; + } + mutex_unlock(&controlMutex.handle); +} + +// endregion + +} // namespace + +extern "C" const ServiceManifest development_server_service_manifest = { + .id = DEVELOPMENT_SERVER_SERVICE_ID, + .create_service = createService, + .destroy_service = destroyService, + .on_start = onStart, + .on_stop = onStop, +}; + +extern "C" { + +error_t development_server_set_enabled(bool enabled) { + mutex_lock(&controlMutex.handle); + lockState(); + ServerData* data = server; + unlockState(); + if (data == nullptr) { + mutex_unlock(&controlMutex.handle); + return ERROR_INVALID_STATE; + } + error_t error = ERROR_NONE; + if (enabled) { + error = startListening(data); + } else { + stopListening(data); + } + saveEnableOnBoot(data, enabled); + mutex_unlock(&controlMutex.handle); + return error; +} + +bool development_server_is_enabled() { + lockState(); + const bool listening = server != nullptr && server->listening; + unlockState(); + return listening; +} + +error_t development_server_get_fingerprint(char* out_fingerprint, size_t out_size) { + if (out_size < DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1) { + return ERROR_BUFFER_OVERFLOW; + } + // Avoids waiting for the control mutex once the credentials are loaded + lockState(); + const bool loaded = server != nullptr && server->fingerprint[0] != '\0'; + if (loaded) { + memcpy(out_fingerprint, server->fingerprint, sizeof(server->fingerprint)); + } + unlockState(); + if (loaded) { + return ERROR_NONE; + } + + mutex_lock(&controlMutex.handle); + lockState(); + ServerData* data = server; + unlockState(); + error_t error = data != nullptr ? loadCredentials(data) : ERROR_INVALID_STATE; + if (error == ERROR_NONE) { + memcpy(out_fingerprint, data->credentials.fingerprint, DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1); + } + mutex_unlock(&controlMutex.handle); + return error; +} + +unsigned short development_server_get_port() { + lockState(); + const uint16_t port = (server != nullptr && server->listening) ? server->boundPort : configuredPort; + unlockState(); + return port; +} + +} // extern "C" + +DevelopmentSession* development_server_take_session(int slot) { + if (slot < 0 || slot >= DEVELOPMENT_SERVER_MAX_SESSIONS) { + return nullptr; + } + lockState(); + DevelopmentSession* session = nullptr; + if (server != nullptr) { + session = server->slots[slot].pending; + server->slots[slot].pending = nullptr; + } + unlockState(); + return session; +} + +void development_server_finish_session(DevelopmentSession* session) { + const int slot = session->slot; + development_session_free(session); + lockState(); + if (server != nullptr) { + server->slots[slot] = {}; + } + unlockState(); +} + +namespace { + +bool getDirectory(char* outDirectory) { + lockState(); + const bool running = server != nullptr; + if (running) { + strcpy(outDirectory, server->directory); + } + unlockState(); + return running; +} + +} // namespace + +error_t development_server_trust(const char* fingerprint, const char* name) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + return getDirectory(directory) ? development_trust_add(directory, fingerprint, name) : ERROR_INVALID_STATE; +} + +extern "C" error_t development_server_for_each_trusted_client(DevelopmentTrustedClientVisitor visitor, void* context) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + return getDirectory(directory) ? development_trust_for_each(directory, visitor, context) : ERROR_INVALID_STATE; +} + +extern "C" error_t development_server_remove_trusted_client(const char* fingerprint) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + return getDirectory(directory) ? development_trust_remove(directory, fingerprint) : ERROR_INVALID_STATE; +} + +void development_server_set_port(uint16_t port) { + lockState(); + configuredPort = port; + unlockState(); +} diff --git a/Modules/development-module/source/session.cpp b/Modules/development-module/source/session.cpp new file mode 100644 index 000000000..b2917bd48 --- /dev/null +++ b/Modules/development-module/source/session.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include +#include + +#include +#include + +namespace { + +constexpr MemoryPolicy MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; +constexpr uint32_t STATUS_TIMEOUT_MS = 5000; +constexpr uint32_t FRAME_TIMEOUT_MS = 5000; +constexpr TickType_t WRITE_RETRY_DELAY = pdMS_TO_TICKS(5); + +int bioSend(void* context, const unsigned char* data, size_t length) { + const int socket = *static_cast(context); + const ssize_t sent = send(socket, data, length, MSG_NOSIGNAL); + if (sent >= 0) { + return static_cast(sent); + } + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { + return MBEDTLS_ERR_SSL_WANT_WRITE; + } + return MBEDTLS_ERR_NET_SEND_FAILED; +} + +int bioReceive(void* context, unsigned char* data, size_t length) { + const int socket = *static_cast(context); + const ssize_t received = recv(socket, data, length, 0); + if (received > 0) { + return static_cast(received); + } + if (received == 0) { + return MBEDTLS_ERR_NET_CONN_RESET; + } + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { + return MBEDTLS_ERR_SSL_WANT_READ; + } + return MBEDTLS_ERR_NET_RECV_FAILED; +} + +} // namespace + +DevelopmentSession* development_session_alloc(int socket, const mbedtls_ssl_config* config) { + auto* session = static_cast(memory_calloc_with_policy(1, sizeof(DevelopmentSession), &MEMORY_POLICY)); + if (session == nullptr) { + return nullptr; + } + session->socket = socket; + mbedtls_ssl_init(&session->ssl); + if (mbedtls_ssl_setup(&session->ssl, config) != 0) { + mbedtls_ssl_free(&session->ssl); + memory_free(session); + return nullptr; + } + mbedtls_ssl_set_bio(&session->ssl, &session->socket, bioSend, bioReceive, nullptr); + return session; +} + +void development_session_free(DevelopmentSession* session) { + mbedtls_ssl_close_notify(&session->ssl); + mbedtls_ssl_free(&session->ssl); + close(session->socket); + memory_free(session); +} + +bool development_session_write(DevelopmentSession* session, const void* data, size_t length, uint32_t timeout_ms) { + const auto* bytes = static_cast(data); + const TickType_t start = xTaskGetTickCount(); + size_t offset = 0; + while (offset < length) { + const int result = mbedtls_ssl_write(&session->ssl, bytes + offset, length - offset); + if (result > 0) { + offset += static_cast(result); + } else if (result == MBEDTLS_ERR_SSL_WANT_WRITE || result == MBEDTLS_ERR_SSL_WANT_READ) { + if (xTaskGetTickCount() - start > pdMS_TO_TICKS(timeout_ms)) { + return false; + } + vTaskDelay(WRITE_RETRY_DELAY); + } else { + return false; + } + } + return true; +} + +bool development_session_send_status(DevelopmentSession* session, const char* status, const char* message) { + char line[256]; + const size_t length = development_protocol_format_status(status, message, line, sizeof(line)); + return length > 0 && development_session_write(session, line, length, STATUS_TIMEOUT_MS); +} + +bool development_session_send_frame(DevelopmentSession* session, DevelopmentFrameType type, const void* payload, uint16_t length) { + if (length > DEVELOPMENT_FRAME_PAYLOAD_MAX) { + return false; + } + development_frame_write_header(type, length, session->transmit); + if (length > 0) { + memcpy(session->transmit + DEVELOPMENT_FRAME_HEADER_SIZE, payload, length); + } + return development_session_write(session, session->transmit, DEVELOPMENT_FRAME_HEADER_SIZE + length, FRAME_TIMEOUT_MS); +} diff --git a/Modules/development-module/source/session_app.cpp b/Modules/development-module/source/session_app.cpp new file mode 100644 index 000000000..f299ebe78 --- /dev/null +++ b/Modules/development-module/source/session_app.cpp @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include + +#ifdef ESP_PLATFORM +#include +#endif + +// A task with its stack in PSRAM can't write to internal flash, unless flash operations keep the cache enabled +#ifdef CONFIG_SPI_FLASH_AUTO_SUSPEND +constexpr uint16_t STACK_MEMORY_CAPABILITY = MEMORY_CAPABILITY_EXTERNAL; +#else +constexpr uint16_t STACK_MEMORY_CAPABILITY = 0; +#endif + +namespace { + +constexpr auto* TAG = "DevelopmentSession"; + +constexpr uint32_t PROMPT_TIMEOUT_MS = 60000; +constexpr uint32_t PROMPT_POLL_INTERVAL_MS = 200; +// Bounds the wait for socket input, which doesn't signal the event group +constexpr uint32_t PUMP_INTERVAL_MS = 20; +// How long the shell gets to exit by itself after its stdin closed +constexpr uint32_t SHELL_EXIT_TIMEOUT_MS = 2000; + +constexpr size_t STDIN_BUFFER_SIZE = 256; +constexpr size_t STDOUT_BUFFER_SIZE = 1024; +constexpr size_t RECEIVE_BUFFER_SIZE = DEVELOPMENT_FRAME_HEADER_SIZE + DEVELOPMENT_FRAME_PAYLOAD_MAX; + +constexpr MemoryPolicy MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +struct Buffers { + uint8_t stdinBuffer[STDIN_BUFFER_SIZE]; + uint8_t stdoutBuffer[STDOUT_BUFFER_SIZE]; + uint8_t receive[RECEIVE_BUFFER_SIZE]; + uint8_t drain[DEVELOPMENT_FRAME_PAYLOAD_MAX]; +}; + +struct Events { + TaskEventGroup group {}; + AppEventSubscription subscription {}; + bool closeRequested = false; +}; + +bool isExpired(TickType_t start, uint32_t timeoutMs) { + return xTaskGetTickCount() - start > pdMS_TO_TICKS(timeoutMs); +} + +DevelopmentPromptResult prompt(DevelopmentSession* session, AppInstanceId self, Events& events) { + AppStartContext context; + if (app_start_context_from_id(DEVELOPMENT_SERVER_PROMPT_APP_ID, &context) != ERROR_NONE) { + LOG_W(TAG, "No prompt app found"); + return DEVELOPMENT_PROMPT_REJECT; + } + const char* arguments[] = { session->peer_address, session->hello.client, session->fingerprint, session->hello.mode }; + app_start_context_set_arguments_ext(&context, 4, arguments); + app_start_context_set_parent(&context, self); + AppInstanceId promptId = 0; + if (app_start_with_context(&context, &promptId) != ERROR_NONE) { + return DEVELOPMENT_PROMPT_REJECT; + } + + DevelopmentPromptResult result = DEVELOPMENT_PROMPT_REJECT; + const TickType_t start = xTaskGetTickCount(); + bool answered = false; + while (!answered && !events.closeRequested && !isExpired(start, PROMPT_TIMEOUT_MS)) { + task_event_group_wait_any(&events.group, nullptr, pdMS_TO_TICKS(PROMPT_POLL_INTERVAL_MS)); + AppEvent event {}; + while (app_event_poll(&events.subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_RESULT && event.result.launch_id == promptId) { + answered = true; + if (event.result.result == DEVELOPMENT_PROMPT_ACCEPT_ONCE || event.result.result == DEVELOPMENT_PROMPT_ACCEPT_ALWAYS) { + result = static_cast(event.result.result); + } + } else if (event.type == APP_EVENT_CLOSE) { + events.closeRequested = true; + } + } + } + app_manager_stop(promptId); + return result; +} + +struct Bridge { + DevelopmentSession* session; + Buffers* buffers; + AppStream* stdinStream; + AppStream* stdoutStream; + size_t receiveLength = 0; + // Bytes of the first DATA frame in the receive buffer that the shell's stdin already accepted + size_t dataOffset = 0; + bool peerClosed = false; +}; + +// @return false when the frame couldn't be handled completely yet +bool handleFrame(Bridge& bridge, const DevelopmentFrame& frame) { + switch (frame.type) { + case DEVELOPMENT_FRAME_DATA: { + const size_t remaining = frame.length - bridge.dataOffset; + const size_t written = app_stream_write(bridge.stdinStream, frame.payload + bridge.dataOffset, remaining); + if (written < remaining) { + bridge.dataOffset += written; + return false; + } + bridge.dataOffset = 0; + return true; + } + case DEVELOPMENT_FRAME_RESIZE: + if (frame.length == 4) { + const auto columns = static_cast((frame.payload[0] << 8) | frame.payload[1]); + const auto rows = static_cast((frame.payload[2] << 8) | frame.payload[3]); + app_stream_set_window_size(bridge.stdoutStream, columns, rows); + } + return true; + default: + return true; + } +} + +void processFrames(Bridge& bridge) { + size_t offset = 0; + while (true) { + DevelopmentFrame frame {}; + size_t consumed = 0; + const auto result = development_frame_parse(bridge.buffers->receive + offset, bridge.receiveLength - offset, &frame, &consumed); + if (result == DevelopmentFrameParseResult::Invalid) { + LOG_W(TAG, "Invalid frame from %s", bridge.session->peer_address); + bridge.peerClosed = true; + break; + } + if (result == DevelopmentFrameParseResult::Incomplete || !handleFrame(bridge, frame)) { + break; + } + offset += consumed; + } + if (offset > 0) { + memmove(bridge.buffers->receive, bridge.buffers->receive + offset, bridge.receiveLength - offset); + bridge.receiveLength -= offset; + } +} + +void receive(Bridge& bridge) { + while (!bridge.peerClosed && bridge.receiveLength < RECEIVE_BUFFER_SIZE) { + const int result = mbedtls_ssl_read(&bridge.session->ssl, bridge.buffers->receive + bridge.receiveLength, RECEIVE_BUFFER_SIZE - bridge.receiveLength); + if (result > 0) { + bridge.receiveLength += static_cast(result); + processFrames(bridge); + } else if (result == MBEDTLS_ERR_SSL_WANT_READ || result == MBEDTLS_ERR_SSL_WANT_WRITE) { + break; + } else { + bridge.peerClosed = true; + } + } + // Retries a DATA frame that didn't fit in stdin before + if (!bridge.peerClosed && bridge.receiveLength > 0) { + processFrames(bridge); + } +} + +void sendOutput(Bridge& bridge) { + size_t length; + while ((length = app_stream_read(bridge.stdoutStream, bridge.buffers->drain, sizeof(bridge.buffers->drain))) > 0) { + if (!bridge.peerClosed && !development_session_send_frame(bridge.session, DEVELOPMENT_FRAME_DATA, bridge.buffers->drain, static_cast(length))) { + bridge.peerClosed = true; + } + } +} + +void runShell(DevelopmentSession* session, AppInstanceId self, Events& events) { + auto* buffers = static_cast(memory_alloc_with_policy(sizeof(Buffers), &MEMORY_POLICY)); + if (buffers == nullptr) { + LOG_E(TAG, "Out of memory"); + return; + } + + AppStream stdinStream {}; + AppStream stdoutStream {}; + const AppWindowSize windowSize { session->hello.columns, session->hello.rows }; + AppStreamBinding bindings[] = { + { STDIN_FILENO, &stdinStream, buffers->stdinBuffer, sizeof(buffers->stdinBuffer), &events.group, {}, -1 }, + { STDOUT_FILENO, &stdoutStream, buffers->stdoutBuffer, sizeof(buffers->stdoutBuffer), &events.group, windowSize, STDERR_FILENO }, + }; + + AppStartContext context; + AppInstanceId shellId = 0; + error_t error = app_start_context_from_id("shell", &context); + if (error == ERROR_NONE) { + // A command runs like sh -c instead of an interactive shell + const char* commandArguments[] = { "shell", "-c", session->hello.command }; + if (session->hello.command[0] != '\0') { + app_start_context_set_arguments_ext(&context, 3, commandArguments); + } + app_start_context_set_streams(&context, bindings, sizeof(bindings) / sizeof(bindings[0])); + app_start_context_set_parent(&context, self); + error = app_start_with_context(&context, &shellId); + } + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to start shell"); + memory_free(buffers); + return; + } + + Bridge bridge { session, buffers, &stdinStream, &stdoutStream }; + bool stdinClosed = false; + bool shellDone = false; + int32_t exitCode = 0; + TickType_t stdinClosedTime = 0; + + while (!shellDone) { + task_event_group_wait_any(&events.group, nullptr, pdMS_TO_TICKS(PUMP_INTERVAL_MS)); + + receive(bridge); + sendOutput(bridge); + + AppEvent event {}; + while (app_event_poll(&events.subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_RESULT && event.result.launch_id == shellId) { + shellDone = true; + exitCode = event.result.result; + } else if (event.type == APP_EVENT_CLOSE) { + events.closeRequested = true; + } + } + + // Closing stdin unblocks a shell that waits for input, like a closed pipe does + if ((bridge.peerClosed || events.closeRequested) && !stdinClosed) { + app_stream_close(&stdinStream); + stdinClosed = true; + stdinClosedTime = xTaskGetTickCount(); + } + if (stdinClosed && !shellDone && isExpired(stdinClosedTime, SHELL_EXIT_TIMEOUT_MS)) { + break; + } + } + + // The shell may have written its last output right before exiting + sendOutput(bridge); + if (shellDone && !bridge.peerClosed) { + const uint8_t code[4] = { + static_cast(static_cast(exitCode) >> 24), + static_cast(static_cast(exitCode) >> 16), + static_cast(static_cast(exitCode) >> 8), + static_cast(static_cast(exitCode)), + }; + development_session_send_frame(session, DEVELOPMENT_FRAME_EXIT, code, sizeof(code)); + } + + // Unsubscribing guarantees that the shell no longer uses the streams before they go out of scope + app_stream_unsubscribe(&stdinStream); + app_stream_unsubscribe(&stdoutStream); + app_manager_stop(shellId); + memory_free(buffers); +} + +// argv[0] is the server slot of the session +int32_t main(int argc, char* argv[]) { + DevelopmentSession* session = argc == 1 ? development_server_take_session(atoi(argv[0])) : nullptr; + if (session == nullptr) { + return 2; + } + + Events events; + task_event_group_construct(&events.group); + if (app_event_subscribe(&events.subscription, &events.group) != ERROR_NONE) { + LOG_E(TAG, "Failed to start session"); + development_session_send_status(session, "error", "internal error"); + task_event_group_destruct(&events.group); + development_server_finish_session(session); + return 2; + } + + const AppInstanceId self = app_scheduler_current_app_id(); + bool accepted = session->trusted; + if (!accepted) { + const DevelopmentPromptResult result = prompt(session, self, events); + accepted = result != DEVELOPMENT_PROMPT_REJECT; + if (result == DEVELOPMENT_PROMPT_ACCEPT_ALWAYS && development_server_trust(session->fingerprint, session->hello.client) != ERROR_NONE) { + LOG_E(TAG, "Failed to store trusted client"); + } + } + + if (!accepted) { + LOG_I(TAG, "Rejected %s", session->peer_address); + development_session_send_status(session, "rejected", nullptr); + } else if (development_session_send_status(session, "accepted", nullptr)) { + LOG_I(TAG, "Accepted %s for %s", session->peer_address, session->hello.mode); + if (strcmp(session->hello.mode, "sync") == 0) { + development_requests_run(session, &events.group, &events.subscription, &events.closeRequested, DevelopmentRequestMode::Sync); + } else if (strcmp(session->hello.mode, "app") == 0) { + development_requests_run(session, &events.group, &events.subscription, &events.closeRequested, DevelopmentRequestMode::App); + } else { + runShell(session, self, events); + } + } + + app_event_unsubscribe(&events.subscription); + task_event_group_destruct(&events.group); + development_server_finish_session(session); + LOG_I(TAG, "Session ended"); + return 0; +} + +} // namespace + +extern "C" const AppManifest development_session_app_manifest = { + .id = DEVELOPMENT_SESSION_APP_ID, + .name = "Development Session", + .category = APP_CATEGORY_SYSTEM, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, + .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 6144, .desired_memory_capability = STACK_MEMORY_CAPABILITY }, +}; diff --git a/Modules/development-module/source/sync.cpp b/Modules/development-module/source/sync.cpp new file mode 100644 index 000000000..409873f80 --- /dev/null +++ b/Modules/development-module/source/sync.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +namespace { + +constexpr auto* TAG = "DevelopmentSync"; + +bool handleStat(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!development_requests_read_path(frame, path)) { + return development_requests_send_fail(context, "invalid path"); + } + uint8_t result[9] = {}; + struct stat info {}; + if (stat(path, &info) == 0) { + result[0] = S_ISDIR(info.st_mode) ? DEVELOPMENT_SYNC_FILE_DIRECTORY : DEVELOPMENT_SYNC_FILE_REGULAR; + const auto size = static_cast(info.st_size); + for (int i = 0; i < 8; i++) { + result[1 + i] = static_cast(size >> (56 - i * 8)); + } + } + return development_session_send_frame(context.session, DEVELOPMENT_FRAME_SYNC_STAT_RESULT, result, sizeof(result)); +} + +void handlePush(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (development_requests_read_path(frame, path)) { + development_requests_start_push(context, path, nullptr, nullptr, false); + } else { + development_requests_start_push(context, nullptr, "invalid path", nullptr, false); + } +} + +bool handlePull(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!development_requests_read_path(frame, path)) { + return development_requests_send_fail(context, "invalid path"); + } + if (directory_exists(path)) { + return development_requests_send_fail(context, "is a directory"); + } + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return development_requests_send_fail(context, "failed to open file"); + } + bool connected = true; + size_t length; + while (connected && (length = fread(context.buffer, 1, DEVELOPMENT_FRAME_PAYLOAD_MAX, file)) > 0) { + connected = development_session_send_frame(context.session, DEVELOPMENT_FRAME_DATA, context.buffer, static_cast(length)); + development_requests_poll_events(context); + if (*context.closeRequested) { + connected = false; + } + } + const bool failed = ferror(file) != 0; + fclose(file); + if (!connected) { + return false; + } + if (failed) { + return development_requests_send_fail(context, "failed to read file"); + } + LOG_I(TAG, "Pulled %s", path); + return development_session_send_frame(context.session, DEVELOPMENT_FRAME_DONE, nullptr, 0); +} + +} // namespace + +bool development_sync_handle(DevelopmentRequestContext& context, const DevelopmentFrame& frame) { + switch (frame.type) { + case DEVELOPMENT_FRAME_SYNC_STAT: + return handleStat(context, frame); + case DEVELOPMENT_FRAME_SYNC_PUSH: + handlePush(context, frame); + return true; + case DEVELOPMENT_FRAME_SYNC_PULL: + return handlePull(context, frame); + default: + return false; + } +} diff --git a/Modules/development-module/source/trust.cpp b/Modules/development-module/source/trust.cpp new file mode 100644 index 000000000..6809c3a4f --- /dev/null +++ b/Modules/development-module/source/trust.cpp @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr auto* TAG = "DevelopmentTrust"; +constexpr auto* TRUST_FILE = "trusted_clients.txt"; +constexpr auto* TRUST_FILE_TEMPORARY = "trusted_clients.txt.tmp"; + +// File format: one " " line per client +constexpr size_t LINE_MAX_LENGTH = DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1 + DEVELOPMENT_SERVER_CLIENT_NAME_LENGTH; + +struct GlobalMutex { + Mutex handle {}; + GlobalMutex() { mutex_construct(&handle); } + ~GlobalMutex() { mutex_destruct(&handle); } +}; + +// Sessions and the UI read and change the file concurrently +GlobalMutex fileMutex; + +bool getPath(const char* directory, const char* file, char* outPath, size_t outSize) { + const int written = snprintf(outPath, outSize, "%s/%s", directory, file); + return written > 0 && static_cast(written) < outSize; +} + +/** + * Splits a line into its fingerprint and name, in place. + * @return false when the line isn't valid + */ +bool parseLine(char* line, const char** outName) { + line[strcspn(line, "\r\n")] = '\0'; + const size_t length = strlen(line); + if (length < DEVELOPMENT_SERVER_FINGERPRINT_LENGTH || + (length > DEVELOPMENT_SERVER_FINGERPRINT_LENGTH && line[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH] != ' ')) { + return false; + } + line[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH] = '\0'; + *outName = length > DEVELOPMENT_SERVER_FINGERPRINT_LENGTH ? line + DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1 : ""; + return true; +} + +/** @return false when the file couldn't be read, which is fine when it doesn't exist */ +bool forEachUnlocked(const char* directory, DevelopmentTrustedClientVisitor visitor, void* context) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!getPath(directory, TRUST_FILE, path, sizeof(path))) { + return false; + } + FILE* file = fopen(path, "r"); + if (file == nullptr) { + return false; + } + char line[LINE_MAX_LENGTH + 2]; + while (fgets(line, sizeof(line), file) != nullptr) { + const char* name; + if (parseLine(line, &name) && !visitor(line, name, context)) { + break; + } + } + fclose(file); + return true; +} + +bool containsUnlocked(const char* directory, const char* fingerprint) { + struct Search { + const char* fingerprint; + bool found; + } search { fingerprint, false }; + forEachUnlocked(directory, [](const char* storedFingerprint, const char*, void* context) { + auto* search = static_cast(context); + search->found = strcmp(storedFingerprint, search->fingerprint) == 0; + return !search->found; + }, &search); + return search.found; +} + +/** Copies @a name without characters that would break the file format */ +void sanitizeName(const char* name, char* outName) { + size_t i = 0; + for (; name[i] != '\0' && i < DEVELOPMENT_SERVER_CLIENT_NAME_LENGTH; i++) { + outName[i] = (name[i] < ' ' || name[i] == 0x7F) ? '?' : name[i]; + } + outName[i] = '\0'; +} + +error_t addUnlocked(const char* directory, const char* fingerprint, const char* name) { + if (containsUnlocked(directory, fingerprint)) { + return ERROR_NONE; + } + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (!getPath(directory, TRUST_FILE, path, sizeof(path))) { + return ERROR_BUFFER_OVERFLOW; + } + const error_t directoryError = directory_make(directory, true); + if (directoryError != ERROR_NONE && directoryError != ERROR_ALREADY_EXISTS) { + return ERROR_RESOURCE; + } + FILE* file = fopen(path, "a"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path); + return ERROR_RESOURCE; + } + char safeName[DEVELOPMENT_SERVER_CLIENT_NAME_LENGTH + 1]; + sanitizeName(name, safeName); + const bool written = fprintf(file, "%s %s\n", fingerprint, safeName) > 0; + return (fclose(file) == 0 && written) ? ERROR_NONE : ERROR_RESOURCE; +} + +/** Rewrites the file without @a fingerprint, through a temporary file so a failure keeps the old one */ +error_t removeUnlocked(const char* directory, const char* fingerprint) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + char temporaryPath[FILE_MAX_PATH_STRING_LENGTH]; + if (!getPath(directory, TRUST_FILE, path, sizeof(path)) || !getPath(directory, TRUST_FILE_TEMPORARY, temporaryPath, sizeof(temporaryPath))) { + return ERROR_BUFFER_OVERFLOW; + } + FILE* temporary = fopen(temporaryPath, "w"); + if (temporary == nullptr) { + return ERROR_RESOURCE; + } + struct Rewrite { + const char* fingerprint; + FILE* file; + bool found; + bool failed; + } rewrite { fingerprint, temporary, false, false }; + forEachUnlocked(directory, [](const char* storedFingerprint, const char* name, void* context) { + auto* rewrite = static_cast(context); + if (strcmp(storedFingerprint, rewrite->fingerprint) == 0) { + rewrite->found = true; + } else if (fprintf(rewrite->file, "%s %s\n", storedFingerprint, name) < 0) { + rewrite->failed = true; + } + return !rewrite->failed; + }, &rewrite); + if (fclose(temporary) != 0) { + rewrite.failed = true; + } + if (rewrite.failed || !rewrite.found) { + remove(temporaryPath); + return rewrite.failed ? ERROR_RESOURCE : ERROR_NOT_FOUND; + } + if (rename(temporaryPath, path) != 0) { + // Some filesystems don't replace an existing file + if (remove(path) != 0 || rename(temporaryPath, path) != 0) { + remove(temporaryPath); + return ERROR_RESOURCE; + } + } + return ERROR_NONE; +} + +} // namespace + +bool development_trust_contains(const char* directory, const char* fingerprint) { + if (strlen(fingerprint) != DEVELOPMENT_SERVER_FINGERPRINT_LENGTH) { + return false; + } + mutex_lock(&fileMutex.handle); + const bool contains = containsUnlocked(directory, fingerprint); + mutex_unlock(&fileMutex.handle); + return contains; +} + +error_t development_trust_add(const char* directory, const char* fingerprint, const char* name) { + if (strlen(fingerprint) != DEVELOPMENT_SERVER_FINGERPRINT_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + mutex_lock(&fileMutex.handle); + const error_t error = addUnlocked(directory, fingerprint, name); + mutex_unlock(&fileMutex.handle); + return error; +} + +error_t development_trust_remove(const char* directory, const char* fingerprint) { + if (strlen(fingerprint) != DEVELOPMENT_SERVER_FINGERPRINT_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + mutex_lock(&fileMutex.handle); + const error_t error = removeUnlocked(directory, fingerprint); + mutex_unlock(&fileMutex.handle); + return error; +} + +error_t development_trust_for_each(const char* directory, DevelopmentTrustedClientVisitor visitor, void* context) { + mutex_lock(&fileMutex.handle); + forEachUnlocked(directory, visitor, context); + mutex_unlock(&fileMutex.handle); + return ERROR_NONE; +} diff --git a/Modules/development-module/tests/CMakeLists.txt b/Modules/development-module/tests/CMakeLists.txt new file mode 100644 index 000000000..5bdb0d55f --- /dev/null +++ b/Modules/development-module/tests/CMakeLists.txt @@ -0,0 +1,24 @@ +project(DevelopmentModuleTests) + +enable_language(C CXX ASM) + +file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) +add_executable(DevelopmentModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) + +target_include_directories(DevelopmentModuleTests PRIVATE + ${DOCTESTINC} + ${PROJECT_SOURCE_DIR}/../private +) + +add_test(NAME DevelopmentModuleTests COMMAND DevelopmentModuleTests) + +target_link_libraries(DevelopmentModuleTests PUBLIC + TactilityKernel + TactilityKernelCpp + platform-posix + app-module + app-posix-module + service-module + development-module + mbedtls +) diff --git a/Modules/development-module/tests/source/main.cpp b/Modules/development-module/tests/source/main.cpp new file mode 100644 index 000000000..d71cfd08b --- /dev/null +++ b/Modules/development-module/tests/source/main.cpp @@ -0,0 +1,47 @@ +#define DOCTEST_CONFIG_IMPLEMENT +#include "doctest.h" +#include + +#include +#include +#include + +typedef struct { + int argc; + char** argv; + int result; +} TestTaskData; + +extern "C" struct Module platform_posix_module; + +void test_task(void* parameter) { + auto* data = (TestTaskData*)parameter; + + doctest::Context context; + context.applyCommandLine(data->argc, data->argv); + context.setOption("no-breaks", true); + + Module* dts_modules[] = { &platform_posix_module, nullptr }; + DtsDevice dts_devices[] = { DTS_DEVICE_TERMINATOR }; + check(kernel_init(dts_modules, dts_devices) == ERROR_NONE); + + data->result = context.run(); + + vTaskEndScheduler(); + vTaskDelete(nullptr); +} + +int main(int argc, char** argv) { + TestTaskData data = { + .argc = argc, + .argv = argv, + .result = 0 + }; + + if (xTaskCreate(test_task, "test_task", 16384, &data, 1, nullptr) != pdPASS) { + return 1; + } + + vTaskStartScheduler(); + return data.result; +} diff --git a/Modules/development-module/tests/source/protocol_test.cpp b/Modules/development-module/tests/source/protocol_test.cpp new file mode 100644 index 000000000..187cf24cd --- /dev/null +++ b/Modules/development-module/tests/source/protocol_test.cpp @@ -0,0 +1,100 @@ +#include "doctest.h" + +#include + +#include +#include + +namespace { + +DevelopmentHelloError parse(const std::string& json, DevelopmentHello& hello) { + const char* message = nullptr; + const DevelopmentHelloError error = development_protocol_parse_hello(json.c_str(), json.size(), &hello, &message); + CHECK_EQ(error == DevelopmentHelloError::None, message == nullptr); + return error; +} + +} // namespace + +TEST_CASE("development_protocol_parse_hello accepts a shell hello") { + DevelopmentHello hello {}; + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"shell","client":"ken@laptop","columns":120,"rows":40})", hello), DevelopmentHelloError::None); + CHECK_EQ(hello.protocol, 1); + CHECK_EQ(std::string(hello.mode), "shell"); + CHECK_EQ(std::string(hello.client), "ken@laptop"); + CHECK_EQ(hello.columns, 120); + CHECK_EQ(hello.rows, 40); +} + +TEST_CASE("development_protocol_parse_hello accepts sync and app hellos") { + DevelopmentHello hello {}; + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"sync","client":"c"})", hello), DevelopmentHelloError::None); + CHECK_EQ(std::string(hello.mode), "sync"); + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"app","client":"c"})", hello), DevelopmentHelloError::None); + CHECK_EQ(std::string(hello.mode), "app"); +} + +TEST_CASE("development_protocol_parse_hello accepts a command in shell mode only") { + DevelopmentHello hello {}; + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"shell","client":"c","command":"echo hi"})", hello), DevelopmentHelloError::None); + CHECK_EQ(std::string(hello.command), "echo hi"); + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"shell","client":"c"})", hello), DevelopmentHelloError::None); + CHECK_EQ(std::string(hello.command), ""); + CHECK_EQ(parse(R"({"protocol":1,"mode":"sync","client":"c","command":"echo hi"})", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":1,"mode":"shell","client":"c","command":3})", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":1,"mode":"shell","client":"c","command":")" + std::string(DEVELOPMENT_COMMAND_LENGTH + 1, 'x') + "\"}", hello), DevelopmentHelloError::Malformed); +} + +TEST_CASE("development_protocol_parse_hello defaults a missing or invalid window size") { + DevelopmentHello hello {}; + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"shell","client":"c","columns":0})", hello), DevelopmentHelloError::None); + CHECK_EQ(hello.columns, 80); + CHECK_EQ(hello.rows, 24); +} + +TEST_CASE("development_protocol_parse_hello rejects invalid hellos") { + DevelopmentHello hello {}; + CHECK_EQ(parse("not json", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse("[]", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"mode":"shell","client":"c"})", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":1,"client":"c"})", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":1,"mode":"shell"})", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":1,"mode":"shell","client":")" + std::string(DEVELOPMENT_CLIENT_NAME_LENGTH + 1, 'x') + "\"}", hello), DevelopmentHelloError::Malformed); + CHECK_EQ(parse(R"({"protocol":2,"mode":"shell","client":"c"})", hello), DevelopmentHelloError::UnsupportedProtocol); + CHECK_EQ(parse(R"({"protocol":1,"mode":"ftp","client":"c"})", hello), DevelopmentHelloError::UnsupportedMode); +} + +TEST_CASE("development_protocol_format_status writes a JSON line") { + char line[64]; + const size_t length = development_protocol_format_status("error", "busy", line, sizeof(line)); + CHECK_EQ(std::string(line, length), "{\"status\":\"error\",\"message\":\"busy\"}\n"); + + CHECK_EQ(development_protocol_format_status("accepted", nullptr, line, sizeof(line)), strlen("{\"status\":\"accepted\"}\n")); + CHECK_EQ(development_protocol_format_status("accepted", nullptr, line, 8), 0); +} + +TEST_CASE("development_frame_parse handles complete, partial and oversized frames") { + uint8_t data[DEVELOPMENT_FRAME_HEADER_SIZE + 3 + DEVELOPMENT_FRAME_HEADER_SIZE]; + development_frame_write_header(DEVELOPMENT_FRAME_DATA, 3, data); + memcpy(data + DEVELOPMENT_FRAME_HEADER_SIZE, "abc", 3); + development_frame_write_header(DEVELOPMENT_FRAME_EXIT, 0, data + DEVELOPMENT_FRAME_HEADER_SIZE + 3); + + DevelopmentFrame frame {}; + size_t consumed = 0; + CHECK_EQ(development_frame_parse(data, 2, &frame, &consumed), DevelopmentFrameParseResult::Incomplete); + CHECK_EQ(development_frame_parse(data, DEVELOPMENT_FRAME_HEADER_SIZE + 2, &frame, &consumed), DevelopmentFrameParseResult::Incomplete); + + REQUIRE_EQ(development_frame_parse(data, sizeof(data), &frame, &consumed), DevelopmentFrameParseResult::Ok); + CHECK_EQ(frame.type, DEVELOPMENT_FRAME_DATA); + CHECK_EQ(std::string(reinterpret_cast(frame.payload), frame.length), "abc"); + CHECK_EQ(consumed, DEVELOPMENT_FRAME_HEADER_SIZE + 3); + + REQUIRE_EQ(development_frame_parse(data + consumed, sizeof(data) - consumed, &frame, &consumed), DevelopmentFrameParseResult::Ok); + CHECK_EQ(frame.type, DEVELOPMENT_FRAME_EXIT); + CHECK_EQ(frame.length, 0); + CHECK_EQ(consumed, DEVELOPMENT_FRAME_HEADER_SIZE); + + uint8_t oversized[DEVELOPMENT_FRAME_HEADER_SIZE]; + development_frame_write_header(DEVELOPMENT_FRAME_DATA, DEVELOPMENT_FRAME_PAYLOAD_MAX + 1, oversized); + CHECK_EQ(development_frame_parse(oversized, sizeof(oversized), &frame, &consumed), DevelopmentFrameParseResult::Invalid); +} diff --git a/Modules/development-module/tests/source/server_test.cpp b/Modules/development-module/tests/source/server_test.cpp new file mode 100644 index 000000000..eb90cea2c --- /dev/null +++ b/Modules/development-module/tests/source/server_test.cpp @@ -0,0 +1,1002 @@ +#include "doctest.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +extern ServiceManifest app_internal_loader_service_manifest; + +namespace { + +const char* TEST_ROOT = "/tmp/tactility_development_test"; +constexpr uint32_t TIMEOUT_MS = 10000; + +// region Fixture + +error_t fakeMount(void*) { return ERROR_NONE; } +error_t fakeUnmount(void*) { return ERROR_NONE; } +bool fakeIsMounted(void*) { return false; } + +error_t fakeGetPath(void* data, char* out_path, size_t out_path_size) { + const char* path = static_cast(data); + if (strlen(path) + 1 > out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + strcpy(out_path, path); + return ERROR_NONE; +} + +const FileSystemApi fakeDataApi = { + .mount = fakeMount, + .unmount = fakeUnmount, + .is_mounted = fakeIsMounted, + .get_path = fakeGetPath, +}; + +std::atomic promptResult { DEVELOPMENT_PROMPT_REJECT }; +std::atomic promptCount { 0 }; +char promptMode[16] = {}; +char promptFingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1] = {}; + +int32_t promptMain(int argc, char* argv[]) { + promptCount++; + if (argc == 4) { + strncpy(promptMode, argv[3], sizeof(promptMode) - 1); + strncpy(promptFingerprint, argv[2], sizeof(promptFingerprint) - 1); + } + return promptResult; +} + +const AppManifest promptManifest = { + .id = DEVELOPMENT_SERVER_PROMPT_APP_ID, + .name = "Prompt", + .category = APP_CATEGORY_SYSTEM, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(promptMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 4096, .desired_memory_capability = 0 }, +}; + +void writeOut(const std::string& text) { + app_io_write(STDOUT_FILENO, text.data(), text.size()); +} + +// Echoes lines. "exit N" exits with code N and "size" prints the window size. "-c " prints the command and exits with 7. +int32_t shellMain(int argc, char* argv[]) { + if (argc == 3 && std::string(argv[1]) == "-c") { + writeOut("command:" + std::string(argv[2]) + "\n"); + return 7; + } + std::string line; + char character; + while (app_io_read(STDIN_FILENO, &character, 1) == 1) { + if (character != '\n') { + line += character; + continue; + } + if (line.starts_with("exit ")) { + return atoi(line.c_str() + 5); + } + if (line == "size") { + AppWindowSize size {}; + app_io_ioctl(STDOUT_FILENO, APP_IOCTL_GET_WINDOW_SIZE, &size); + writeOut("size " + std::to_string(size.columns) + "x" + std::to_string(size.rows) + "\n"); + } else { + writeOut("echo:" + line + "\n"); + } + line.clear(); + } + return 0; +} + +const AppManifest shellManifest = { + .id = "shell", + .name = "Shell", + .category = APP_CATEGORY_SYSTEM, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(shellMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 4096, .desired_memory_capability = 0 }, +}; + +void ensureFixture() { + static bool created = false; + if (created) { + return; + } + created = true; + + std::filesystem::remove_all(TEST_ROOT); + std::filesystem::create_directories(TEST_ROOT); + REQUIRE_NE(file_system_add("data", &fakeDataApi, const_cast(TEST_ROOT)), nullptr); + if (service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID) == nullptr) { + REQUIRE_EQ(service_manager_add(&app_internal_loader_service_manifest, true), ERROR_NONE); + } + REQUIRE_EQ(app_manager_add(&promptManifest), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&shellManifest), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&development_session_app_manifest), ERROR_NONE); + + development_server_set_port(0); + REQUIRE_EQ(service_manager_add(&development_server_service_manifest, true), ERROR_NONE); + REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE); +} + +/** A client certificate, stored in its own directory like the device certificate */ +struct Identity { + DevelopmentCredentials credentials {}; + + explicit Identity(const char* name) { + const std::string directory = std::string(TEST_ROOT) + "/clients/" + name; + REQUIRE_EQ(development_credentials_load(directory.c_str(), &credentials), ERROR_NONE); + } + + ~Identity() { development_credentials_free(&credentials); } +}; + +Identity& identity(int index) { + static Identity first("first"); + static Identity second("second"); + static Identity third("third"); + static Identity fourth("fourth"); + static Identity fifth("fifth"); + switch (index) { + case 0: return first; + case 1: return second; + case 2: return third; + case 3: return fourth; + default: return fifth; + } +} + +bool isExpired(TickType_t start, uint32_t timeoutMs) { + return xTaskGetTickCount() - start > pdMS_TO_TICKS(timeoutMs); +} + +// endregion + +// region Client + +int bioSend(void* context, const unsigned char* data, size_t length) { + const ssize_t sent = send(*static_cast(context), data, length, MSG_NOSIGNAL); + if (sent >= 0) { + return static_cast(sent); + } + return (errno == EAGAIN || errno == EINTR) ? MBEDTLS_ERR_SSL_WANT_WRITE : MBEDTLS_ERR_NET_SEND_FAILED; +} + +int bioReceive(void* context, unsigned char* data, size_t length) { + const ssize_t received = recv(*static_cast(context), data, length, 0); + if (received > 0) { + return static_cast(received); + } + if (received == 0) { + return MBEDTLS_ERR_NET_CONN_RESET; + } + return (errno == EAGAIN || errno == EINTR) ? MBEDTLS_ERR_SSL_WANT_READ : MBEDTLS_ERR_NET_RECV_FAILED; +} + +class TestClient { + + int socketFd = -1; + mbedtls_ssl_config config {}; + mbedtls_ssl_context ssl {}; + std::string received; + bool closed = false; + + // @return false on timeout or when the connection closed + bool receiveMore(TickType_t start, uint32_t timeoutMs) { + unsigned char buffer[512]; + while (!isExpired(start, timeoutMs)) { + const int result = mbedtls_ssl_read(&ssl, buffer, sizeof(buffer)); + if (result > 0) { + received.append(reinterpret_cast(buffer), result); + return true; + } + if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) { + closed = true; + return false; + } + } + return false; + } + +public: + + explicit TestClient(Identity* identity) { + mbedtls_ssl_config_init(&config); + mbedtls_ssl_init(&ssl); + REQUIRE_EQ(mbedtls_ssl_config_defaults(&config, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT), 0); + mbedtls_ssl_conf_authmode(&config, MBEDTLS_SSL_VERIFY_NONE); + mbedtls_ssl_conf_max_tls_version(&config, MBEDTLS_SSL_VERSION_TLS1_2); + if (identity != nullptr) { + REQUIRE_EQ(mbedtls_ssl_conf_own_cert(&config, &identity->credentials.certificate, &identity->credentials.key), 0); + } + REQUIRE_EQ(mbedtls_ssl_setup(&ssl, &config), 0); + } + + ~TestClient() { + if (socketFd >= 0) { + mbedtls_ssl_close_notify(&ssl); + close(socketFd); + } + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&config); + } + + bool connect() { + socketFd = socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(socketFd >= 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_port = htons(development_server_get_port()); + inet_pton(AF_INET, "127.0.0.1", &address.sin_addr); + int result; + do { + result = ::connect(socketFd, reinterpret_cast(&address), sizeof(address)); + } while (result != 0 && errno == EINTR); + REQUIRE_EQ(result, 0); + timeval timeout { .tv_sec = 0, .tv_usec = 100000 }; + setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + mbedtls_ssl_set_bio(&ssl, &socketFd, bioSend, bioReceive, nullptr); + + const TickType_t start = xTaskGetTickCount(); + while ((result = mbedtls_ssl_handshake(&ssl)) != 0) { + if ((result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) || isExpired(start, TIMEOUT_MS)) { + return false; + } + } + return true; + } + + std::string serverFingerprint() { + const mbedtls_x509_crt* certificate = mbedtls_ssl_get_peer_cert(&ssl); + REQUIRE_NE(certificate, nullptr); + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; + REQUIRE_EQ(development_fingerprint(certificate->raw.p, certificate->raw.len, fingerprint), ERROR_NONE); + return fingerprint; + } + + void write(const std::string& data) { + size_t offset = 0; + const TickType_t start = xTaskGetTickCount(); + while (offset < data.size()) { + const int result = mbedtls_ssl_write(&ssl, reinterpret_cast(data.data()) + offset, data.size() - offset); + if (result > 0) { + offset += static_cast(result); + } else { + REQUIRE((result == MBEDTLS_ERR_SSL_WANT_WRITE || result == MBEDTLS_ERR_SSL_WANT_READ)); + REQUIRE_FALSE(isExpired(start, TIMEOUT_MS)); + } + } + } + + /** @param[in] extraFields JSON fields to add, starting with a comma */ + void sendHello(const std::string& mode = "shell", int protocol = DEVELOPMENT_PROTOCOL_VERSION, const std::string& extraFields = "") { + write(R"({"protocol":)" + std::to_string(protocol) + R"(,"mode":")" + mode + R"(","client":"test","columns":100,"rows":30)" + extraFields + "}\n"); + } + + std::string readLine() { + const TickType_t start = xTaskGetTickCount(); + size_t newline; + while ((newline = received.find('\n')) == std::string::npos) { + if (!receiveMore(start, TIMEOUT_MS)) { + return {}; + } + } + std::string line = received.substr(0, newline); + received.erase(0, newline + 1); + return line; + } + + void sendFrame(DevelopmentFrameType type, const std::string& payload) { + uint8_t header[DEVELOPMENT_FRAME_HEADER_SIZE]; + development_frame_write_header(type, static_cast(payload.size()), header); + write(std::string(reinterpret_cast(header), sizeof(header)) + payload); + } + + void sendResize(uint16_t columns, uint16_t rows) { + const char payload[4] = { + static_cast(columns >> 8), static_cast(columns & 0xFF), + static_cast(rows >> 8), static_cast(rows & 0xFF) + }; + sendFrame(DEVELOPMENT_FRAME_RESIZE, std::string(payload, sizeof(payload))); + } + + /** @return false on timeout or when the connection closed */ + bool readFrame(DevelopmentFrameType& outType, std::string& outPayload) { + const TickType_t start = xTaskGetTickCount(); + while (true) { + DevelopmentFrame frame {}; + size_t consumed = 0; + const auto result = development_frame_parse(reinterpret_cast(received.data()), received.size(), &frame, &consumed); + REQUIRE_NE(result, DevelopmentFrameParseResult::Invalid); + if (result == DevelopmentFrameParseResult::Ok) { + outType = frame.type; + outPayload.assign(reinterpret_cast(frame.payload), frame.length); + received.erase(0, consumed); + return true; + } + if (!receiveMore(start, TIMEOUT_MS)) { + return false; + } + } + } + + /** Reads DATA frames until the output contains @a text */ + bool readOutputUntil(const std::string& text) { + std::string output; + DevelopmentFrameType type; + std::string payload; + while (output.find(text) == std::string::npos) { + if (!readFrame(type, payload) || type != DEVELOPMENT_FRAME_DATA) { + return false; + } + output += payload; + } + return true; + } + + /** @return true when the server closes the connection within the timeout */ + bool waitForClose() { + const TickType_t start = xTaskGetTickCount(); + while (!closed && !isExpired(start, TIMEOUT_MS)) { + receiveMore(start, TIMEOUT_MS); + } + return closed; + } + + /** Closes the connection without a TLS close_notify */ + void drop() { + close(socketFd); + socketFd = -1; + } +}; + +std::string readHostFile(const std::string& path); + +/** Connects and sends a hello. @return the status line */ +std::string connectAndGreet(TestClient& client, const std::string& mode = "shell", int protocol = DEVELOPMENT_PROTOCOL_VERSION, const std::string& extraFields = "") { + REQUIRE(client.connect()); + client.sendHello(mode, protocol, extraFields); + return client.readLine(); +} + +/** Connects and sends a hello, retrying while a previous session still ends, as that happens asynchronously */ +std::unique_ptr greetWhenIdle(Identity* identity, std::string& outStatus, const std::string& mode = "shell", const std::string& extraFields = "") { + const TickType_t start = xTaskGetTickCount(); + while (true) { + auto client = std::make_unique(identity); + outStatus = connectAndGreet(*client, mode, DEVELOPMENT_PROTOCOL_VERSION, extraFields); + if (outStatus.find("busy") == std::string::npos) { + return client; + } + REQUIRE_FALSE(isExpired(start, TIMEOUT_MS)); + delay_millis(50); + } +} + +constexpr auto* ACCEPTED = R"({"status":"accepted"})"; +constexpr auto* REJECTED = R"({"status":"rejected"})"; + +// endregion + +} // namespace + +TEST_CASE("a client that is accepted once gets a shell") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + promptCount = 0; + + std::string status; + auto client = greetWhenIdle(&identity(0), status); + REQUIRE_EQ(status, ACCEPTED); + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; + REQUIRE_EQ(development_server_get_fingerprint(fingerprint, sizeof(fingerprint)), ERROR_NONE); + CHECK_EQ(client->serverFingerprint(), std::string(fingerprint)); + CHECK_EQ(promptCount, 1); + CHECK_EQ(std::string(promptMode), "shell"); + CHECK_EQ(std::string(promptFingerprint), std::string(identity(0).credentials.fingerprint)); + + client->sendFrame(DEVELOPMENT_FRAME_DATA, "hello\n"); + CHECK(client->readOutputUntil("echo:hello\n")); + + client->sendFrame(DEVELOPMENT_FRAME_DATA, "size\n"); + CHECK(client->readOutputUntil("size 100x30\n")); + client->sendResize(132, 43); + client->sendFrame(DEVELOPMENT_FRAME_DATA, "size\n"); + CHECK(client->readOutputUntil("size 132x43\n")); + + client->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 3\n"); + DevelopmentFrameType type; + std::string payload; + REQUIRE(client->readFrame(type, payload)); + CHECK_EQ(type, DEVELOPMENT_FRAME_EXIT); + CHECK_EQ(payload, std::string("\0\0\0\3", 4)); + CHECK(client->waitForClose()); +} + +TEST_CASE("a client that is accepted once is asked for again") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + promptCount = 0; + std::string firstStatus; + auto first = greetWhenIdle(&identity(0), firstStatus); + REQUIRE_EQ(firstStatus, ACCEPTED); + first->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 0\n"); + CHECK(first->waitForClose()); + + std::string secondStatus; + auto second = greetWhenIdle(&identity(0), secondStatus); + REQUIRE_EQ(secondStatus, ACCEPTED); + CHECK_EQ(promptCount, 2); +} + +TEST_CASE("a rejected client is disconnected") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_REJECT; + + std::string clientStatus; + auto client = greetWhenIdle(&identity(0), clientStatus); + CHECK_EQ(clientStatus, REJECTED); + CHECK(client->waitForClose()); +} + +TEST_CASE("a prompt result that is not a known answer rejects the client") { + ensureFixture(); + promptResult = 7; + + std::string clientStatus; + auto client = greetWhenIdle(&identity(0), clientStatus); + CHECK_EQ(clientStatus, REJECTED); +} + +TEST_CASE("a client that is always accepted is not asked for again, also after a restart") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS; + + promptCount = 0; + std::string firstStatus; + auto first = greetWhenIdle(&identity(1), firstStatus); + REQUIRE_EQ(firstStatus, ACCEPTED); + CHECK_EQ(promptCount, 1); + first->sendFrame(DEVELOPMENT_FRAME_DATA, "exit 0\n"); + CHECK(first->waitForClose()); + + char fingerprintBefore[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; + REQUIRE_EQ(development_server_get_fingerprint(fingerprintBefore, sizeof(fingerprintBefore)), ERROR_NONE); + REQUIRE_EQ(service_manager_stop(DEVELOPMENT_SERVER_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(service_manager_start(DEVELOPMENT_SERVER_SERVICE_ID), ERROR_NONE); + // The enabled state was persisted + REQUIRE(development_server_is_enabled()); + char fingerprintAfter[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1]; + REQUIRE_EQ(development_server_get_fingerprint(fingerprintAfter, sizeof(fingerprintAfter)), ERROR_NONE); + CHECK_EQ(std::string(fingerprintBefore), std::string(fingerprintAfter)); + + promptResult = DEVELOPMENT_PROMPT_REJECT; + std::string secondStatus; + auto second = greetWhenIdle(&identity(1), secondStatus); + REQUIRE_EQ(secondStatus, ACCEPTED); + CHECK_EQ(promptCount, 1); + second->sendFrame(DEVELOPMENT_FRAME_DATA, "hi\n"); + CHECK(second->readOutputUntil("echo:hi\n")); +} + +TEST_CASE("a client without certificate is refused") { + ensureFixture(); + TestClient anonymous(nullptr); + const std::string status = connectAndGreet(anonymous); + CHECK_NE(status.find("client certificate required"), std::string::npos); + CHECK(anonymous.waitForClose()); +} + +TEST_CASE("an unsupported mode or protocol version is refused") { + ensureFixture(); + promptCount = 0; + + TestClient ftp(&identity(0)); + CHECK_EQ(connectAndGreet(ftp, "ftp"), R"({"status":"error","message":"unsupported mode"})"); + + TestClient future(&identity(0)); + CHECK_EQ(connectAndGreet(future, "shell", 2), R"({"status":"error","message":"unsupported protocol version"})"); + CHECK_EQ(promptCount, 0); +} + +TEST_CASE("a shell hello with a command runs it with -c and reports its exit code") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + std::string status; + auto client = greetWhenIdle(&identity(0), status, "shell", R"(,"command":"ls -la /data")"); + REQUIRE_EQ(status, ACCEPTED); + CHECK(client->readOutputUntil("command:ls -la /data\n")); + DevelopmentFrameType type; + std::string payload; + REQUIRE(client->readFrame(type, payload)); + CHECK_EQ(type, DEVELOPMENT_FRAME_EXIT); + CHECK_EQ(payload, std::string("\0\0\0\7", 4)); + CHECK(client->waitForClose()); +} + +TEST_CASE("a second shell is refused while a shell is active, other modes are not") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + std::string firstStatus; + auto first = greetWhenIdle(&identity(0), firstStatus); + REQUIRE_EQ(firstStatus, ACCEPTED); + + TestClient other(&identity(0)); + CHECK_EQ(connectAndGreet(other), R"({"status":"error","message":"shell busy"})"); + + TestClient sync(&identity(0)); + CHECK_EQ(connectAndGreet(sync, "sync"), ACCEPTED); + + first->sendFrame(DEVELOPMENT_FRAME_DATA, "still here\n"); + CHECK(first->readOutputUntil("echo:still here\n")); +} + +TEST_CASE("at most 3 sessions run at once") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + std::string status; + auto shell = greetWhenIdle(&identity(0), status); + REQUIRE_EQ(status, ACCEPTED); + auto sync = greetWhenIdle(&identity(0), status, "sync"); + REQUIRE_EQ(status, ACCEPTED); + auto app = greetWhenIdle(&identity(0), status, "app"); + REQUIRE_EQ(status, ACCEPTED); + + TestClient fourth(&identity(0)); + CHECK_EQ(connectAndGreet(fourth, "sync"), R"({"status":"error","message":"busy"})"); + + // All sessions keep working + shell->sendFrame(DEVELOPMENT_FRAME_DATA, "one\n"); + CHECK(shell->readOutputUntil("echo:one\n")); + std::string message; + sync->sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, "/"); + DevelopmentFrameType type; + REQUIRE(sync->readFrame(type, message)); + CHECK_EQ(type, DEVELOPMENT_FRAME_SYNC_STAT_RESULT); + app->sendFrame(DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.not.installed"); + REQUIRE(app->readFrame(type, message)); + CHECK_EQ(type, DEVELOPMENT_FRAME_OK); + + REQUIRE_EQ(development_server_set_enabled(false), ERROR_NONE); + CHECK(shell->waitForClose()); + CHECK(sync->waitForClose()); + CHECK(app->waitForClose()); + REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE); +} + +TEST_CASE("clients that are always accepted at the same time are all stored") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS; + + // Both hellos are sent before either answer is read, so both sessions prompt and store concurrently. + // The listener handles one handshake and hello at a time. + TestClient first(&identity(3)); + TestClient second(&identity(4)); + REQUIRE(first.connect()); + first.sendHello("sync"); + REQUIRE(second.connect()); + second.sendHello("sync"); + REQUIRE_EQ(first.readLine(), ACCEPTED); + REQUIRE_EQ(second.readLine(), ACCEPTED); + + char directory[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(service_paths_get_user_data_directory(DEVELOPMENT_SERVER_SERVICE_ID, directory, sizeof(directory)), ERROR_NONE); + const std::string trusted = readHostFile(std::string(directory) + "/trusted_clients.txt"); + CHECK_NE(trusted.find(identity(3).credentials.fingerprint), std::string::npos); + CHECK_NE(trusted.find(identity(4).credentials.fingerprint), std::string::npos); +} + +TEST_CASE("a session ends when its client disconnects") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + { + std::string firstStatus; + auto first = greetWhenIdle(&identity(0), firstStatus); + REQUIRE_EQ(firstStatus, ACCEPTED); + first->sendFrame(DEVELOPMENT_FRAME_DATA, "partial line without newline"); + first->drop(); + } + + std::string secondStatus; + auto second = greetWhenIdle(&identity(0), secondStatus); + REQUIRE_EQ(secondStatus, ACCEPTED); + second->sendFrame(DEVELOPMENT_FRAME_DATA, "again\n"); + CHECK(second->readOutputUntil("echo:again\n")); +} + +TEST_CASE("disabling the server ends the active session") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + + std::string clientStatus; + auto client = greetWhenIdle(&identity(0), clientStatus); + REQUIRE_EQ(clientStatus, ACCEPTED); + + REQUIRE_EQ(development_server_set_enabled(false), ERROR_NONE); + CHECK_FALSE(development_server_is_enabled()); + CHECK(client->waitForClose()); + + REQUIRE_EQ(development_server_set_enabled(true), ERROR_NONE); + CHECK(development_server_is_enabled()); +} + +// region Sync + +namespace { + +std::string syncPath(const std::string& child) { + return std::string(TEST_ROOT) + "/sync/" + child; +} + +std::string readHostFile(const std::string& path) { + FILE* file = fopen(path.c_str(), "rb"); + REQUIRE_NE(file, nullptr); + std::string content; + char buffer[4096]; + size_t length; + while ((length = fread(buffer, 1, sizeof(buffer), file)) > 0) { + content.append(buffer, length); + } + fclose(file); + return content; +} + +std::unique_ptr openSync() { + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + std::string status; + auto client = greetWhenIdle(&identity(0), status, "sync"); + REQUIRE_EQ(status, ACCEPTED); + return client; +} + +/** @return the reply frame type, with its payload in @a outPayload */ +DevelopmentFrameType push(TestClient& client, const std::string& path, const std::string& content, std::string& outPayload, DevelopmentFrameType request = DEVELOPMENT_FRAME_SYNC_PUSH) { + client.sendFrame(request, path); + for (size_t offset = 0; offset < content.size(); offset += DEVELOPMENT_FRAME_PAYLOAD_MAX) { + client.sendFrame(DEVELOPMENT_FRAME_DATA, content.substr(offset, DEVELOPMENT_FRAME_PAYLOAD_MAX)); + } + client.sendFrame(DEVELOPMENT_FRAME_DONE, ""); + DevelopmentFrameType type; + REQUIRE(client.readFrame(type, outPayload)); + return type; +} + +/** @return the frame type that ended the transfer: DONE, or FAIL with its message in @a outContent */ +DevelopmentFrameType pull(TestClient& client, const std::string& path, std::string& outContent) { + client.sendFrame(DEVELOPMENT_FRAME_SYNC_PULL, path); + outContent.clear(); + DevelopmentFrameType type; + std::string payload; + while (true) { + REQUIRE(client.readFrame(type, payload)); + if (type == DEVELOPMENT_FRAME_FAIL) { + outContent = payload; + } + if (type != DEVELOPMENT_FRAME_DATA) { + return type; + } + outContent += payload; + } +} + +/** @return the file type, with its size in @a outSize */ +uint8_t stat(TestClient& client, const std::string& path, uint64_t& outSize) { + client.sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, path); + DevelopmentFrameType type; + std::string payload; + REQUIRE(client.readFrame(type, payload)); + REQUIRE_EQ(type, DEVELOPMENT_FRAME_SYNC_STAT_RESULT); + REQUIRE_EQ(payload.size(), 9); + outSize = 0; + for (int i = 1; i < 9; i++) { + outSize = (outSize << 8) | static_cast(payload[i]); + } + return static_cast(payload[0]); +} + +std::string makeContent(size_t size) { + std::string content(size, '\0'); + for (size_t i = 0; i < size; i++) { + content[i] = static_cast((i * 31 + i / 7) & 0xFF); + } + return content; +} + +} // namespace + +TEST_CASE("sync pushes, stats and pulls a file") { + ensureFixture(); + auto client = openSync(); + const std::string path = syncPath("created/parents/file.bin"); + const std::string content = makeContent(100 * 1024 + 17); + + std::string payload; + REQUIRE_EQ(push(*client, path, content, payload), DEVELOPMENT_FRAME_OK); + CHECK_EQ(readHostFile(path), content); + + uint64_t size = 0; + CHECK_EQ(stat(*client, path, size), DEVELOPMENT_SYNC_FILE_REGULAR); + CHECK_EQ(size, content.size()); + CHECK_EQ(stat(*client, syncPath("created"), size), DEVELOPMENT_SYNC_FILE_DIRECTORY); + CHECK_EQ(stat(*client, syncPath("missing"), size), DEVELOPMENT_SYNC_FILE_MISSING); + + std::string pulled; + REQUIRE_EQ(pull(*client, path, pulled), DEVELOPMENT_FRAME_DONE); + CHECK_EQ(pulled, content); + + // Overwrites, and handles empty files + REQUIRE_EQ(push(*client, path, "", payload), DEVELOPMENT_FRAME_OK); + REQUIRE_EQ(pull(*client, path, pulled), DEVELOPMENT_FRAME_DONE); + CHECK(pulled.empty()); +} + +TEST_CASE("sync reports failures and keeps the connection usable") { + ensureFixture(); + auto client = openSync(); + std::string payload; + std::string pulled; + + REQUIRE_EQ(pull(*client, syncPath("missing"), pulled), DEVELOPMENT_FRAME_FAIL); + REQUIRE_EQ(pull(*client, "relative/path", pulled), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(pulled, "invalid path"); + + std::filesystem::create_directories(syncPath("directory")); + REQUIRE_EQ(pull(*client, syncPath("directory"), pulled), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(pulled, "is a directory"); + REQUIRE_EQ(push(*client, syncPath("directory"), "data", payload), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(payload, "is a directory"); + + REQUIRE_EQ(push(*client, syncPath("after-failures.txt"), "still works", payload), DEVELOPMENT_FRAME_OK); + CHECK_EQ(readHostFile(syncPath("after-failures.txt")), "still works"); +} + +TEST_CASE("sync removes a partially pushed file when the client disconnects") { + ensureFixture(); + const std::string path = syncPath("partial.bin"); + { + auto client = openSync(); + // The file exists before the interrupted push, so its removal is observable + std::string payload; + REQUIRE_EQ(push(*client, path, makeContent(500), payload), DEVELOPMENT_FRAME_OK); + client->sendFrame(DEVELOPMENT_FRAME_SYNC_PUSH, path); + client->sendFrame(DEVELOPMENT_FRAME_DATA, makeContent(300)); + client->drop(); + } + // The next session starts once the interrupted one ended + auto client = openSync(); + CHECK_FALSE(std::filesystem::exists(path)); +} + +TEST_CASE("sync rejects an unexpected frame by closing the connection") { + ensureFixture(); + auto client = openSync(); + client->sendFrame(DEVELOPMENT_FRAME_RESIZE, "1234"); + CHECK(client->waitForClose()); +} + +// endregion + +// region App + +namespace { + +std::atomic runCount { 0 }; + +int32_t runnableMain(int, char*[]) { + runCount++; + return 0; +} + +const AppManifest runnableManifest = { + .id = "devtest.runnable", + .name = "Runnable", + .category = APP_CATEGORY_USER, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(runnableMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 4096, .desired_memory_capability = 0 }, +}; + +void ensureAppFixture() { + ensureFixture(); + static bool added = false; + if (!added) { + added = true; + REQUIRE_EQ(app_manager_add(&runnableManifest), ERROR_NONE); + } +} + +void addTarEntry(std::string& tar, const std::string& name, const std::string& content) { + char header[512] = {}; + strncpy(header, name.c_str(), 99); + snprintf(header + 100, 8, "%07o", 0644); + snprintf(header + 108, 8, "%07o", 0); + snprintf(header + 116, 8, "%07o", 0); + snprintf(header + 124, 12, "%011o", static_cast(content.size())); + snprintf(header + 136, 12, "%011o", 0); + memset(header + 148, ' ', 8); + header[156] = '0'; + memcpy(header + 257, "ustar", 6); + memcpy(header + 263, "00", 2); + unsigned checksum = 0; + for (char character : header) { + checksum += static_cast(character); + } + snprintf(header + 148, 8, "%06o", checksum); + header[155] = ' '; + tar.append(header, sizeof(header)); + tar += content; + tar.append((512 - content.size() % 512) % 512, '\0'); +} + +std::string makePackage() { + std::string tar; + addTarEntry(tar, "manifest.properties", + "manifest.version=0.3\n" + "id=devtest.package\n" + "version.name=1.0.0\n" + "version.code=1\n" + "target.sdk=" TT_VERSION "\n" + "app.0.id=devtest.package.app\n" + "app.0.name=Packaged\n" + "app.0.binary=app\n"); + addTarEntry(tar, "app", "not a real binary"); + tar.append(1024, '\0'); + return tar; +} + +std::unique_ptr openApp() { + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; + std::string status; + auto client = greetWhenIdle(&identity(0), status, "app"); + REQUIRE_EQ(status, ACCEPTED); + return client; +} + +/** @return the answer type, with a FAIL message in @a outMessage */ +DevelopmentFrameType request(TestClient& client, DevelopmentFrameType type, const std::string& payload, std::string& outMessage) { + client.sendFrame(type, payload); + DevelopmentFrameType answer; + REQUIRE(client.readFrame(answer, outMessage)); + return answer; +} + +std::string temporaryDirectory() { + char path[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(paths_get_temp_path(path, sizeof(path)), ERROR_NONE); + return path; +} + +bool isDirectoryEmptyOrMissing(const std::string& path) { + return !std::filesystem::exists(path) || std::filesystem::is_empty(path); +} + +} // namespace + +TEST_CASE("app mode runs an app and reports unknown ones") { + ensureAppFixture(); + auto client = openApp(); + std::string message; + const int before = runCount; + REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_RUN, runnableManifest.id, message), DEVELOPMENT_FRAME_OK); + const TickType_t start = xTaskGetTickCount(); + while (runCount == before && !isExpired(start, TIMEOUT_MS)) { + delay_millis(10); + } + CHECK_EQ(runCount, before + 1); + + REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_RUN, "devtest.missing", message), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(message, "not found"); +} + +TEST_CASE("app mode installs and uninstalls a package") { + ensureAppFixture(); + auto client = openApp(); + std::string message; + + REQUIRE_EQ(push(*client, "devtest.app", makePackage(), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_OK); + AppManifest manifest; + CHECK_EQ(app_manager_find_manifest("devtest.package.app", &manifest), ERROR_NONE); + CHECK(isDirectoryEmptyOrMissing(temporaryDirectory())); + + REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.package", message), DEVELOPMENT_FRAME_OK); + CHECK_NE(app_manager_find_manifest("devtest.package.app", &manifest), ERROR_NONE); + // Uninstalling a package that isn't installed succeeds + REQUIRE_EQ(request(*client, DEVELOPMENT_FRAME_APP_UNINSTALL, "devtest.package", message), DEVELOPMENT_FRAME_OK); +} + +TEST_CASE("app mode refuses invalid packages and file names") { + ensureAppFixture(); + auto client = openApp(); + std::string message; + + REQUIRE_EQ(push(*client, "garbage.app", makeContent(3000), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(message, "invalid package"); + CHECK(isDirectoryEmptyOrMissing(temporaryDirectory())); + + REQUIRE_EQ(push(*client, "../escape.app", makePackage(), message, DEVELOPMENT_FRAME_APP_INSTALL), DEVELOPMENT_FRAME_FAIL); + CHECK_EQ(message, "invalid file name"); +} + +TEST_CASE("each mode only accepts its own requests") { + ensureAppFixture(); + { + auto client = openApp(); + client->sendFrame(DEVELOPMENT_FRAME_SYNC_STAT, "/"); + CHECK(client->waitForClose()); + } + auto client = openSync(); + client->sendFrame(DEVELOPMENT_FRAME_APP_RUN, runnableManifest.id); + CHECK(client->waitForClose()); +} + +// endregion + +TEST_CASE("trusted clients are listed with their name and can be removed") { + ensureFixture(); + promptResult = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS; + std::string status; + auto client = greetWhenIdle(&identity(4), status, "sync"); + REQUIRE_EQ(status, ACCEPTED); + client.reset(); + + const std::string fingerprint = identity(4).credentials.fingerprint; + struct Found { + const std::string& fingerprint; + std::string name; + bool found; + } found { fingerprint, {}, false }; + REQUIRE_EQ(development_server_for_each_trusted_client([](const char* storedFingerprint, const char* name, void* context) { + auto* found = static_cast(context); + if (found->fingerprint == storedFingerprint) { + found->found = true; + found->name = name; + } + return true; + }, &found), ERROR_NONE); + REQUIRE(found.found); + CHECK_EQ(found.name, "test"); + + REQUIRE_EQ(development_server_remove_trusted_client(fingerprint.c_str()), ERROR_NONE); + CHECK_EQ(development_server_remove_trusted_client(fingerprint.c_str()), ERROR_NOT_FOUND); + + // The removed client is asked for again + promptResult = DEVELOPMENT_PROMPT_REJECT; + auto rejected = greetWhenIdle(&identity(4), status, "sync"); + CHECK_EQ(status, REJECTED); +} diff --git a/Modules/freertos-module/source/module.cpp b/Modules/freertos-module/source/module.cpp index f7c372d33..6d582ff94 100644 --- a/Modules/freertos-module/source/module.cpp +++ b/Modules/freertos-module/source/module.cpp @@ -117,7 +117,13 @@ static const ModuleSymbol SYMBOLS[] = { #if configSUPPORT_STATIC_ALLOCATION == 1 DEFINE_MODULE_SYMBOL(xTimerCreateStatic), #endif +#if tskKERNEL_VERSION_MAJOR >= 11 + // A macro over these two functions since FreeRTOS 11 + DEFINE_MODULE_SYMBOL(xTimerGenericCommandFromTask), + DEFINE_MODULE_SYMBOL(xTimerGenericCommandFromISR), +#else DEFINE_MODULE_SYMBOL(xTimerGenericCommand), +#endif DEFINE_MODULE_SYMBOL(xTimerIsTimerActive), DEFINE_MODULE_SYMBOL(xTimerGetExpiryTime), DEFINE_MODULE_SYMBOL(xTimerPendFunctionCall), diff --git a/Modules/mesh-module/source/mesh_command.cpp b/Modules/mesh-module/source/mesh_command.cpp index 3c372014c..cd6da30fd 100644 --- a/Modules/mesh-module/source/mesh_command.cpp +++ b/Modules/mesh-module/source/mesh_command.cpp @@ -251,7 +251,7 @@ const AppManifest mesh_command_manifest = { .name = "mesh", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(mesh_command_main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; diff --git a/Modules/wifi-module/source/wifi_command.cpp b/Modules/wifi-module/source/wifi_command.cpp index c9680af8f..3a9dce915 100644 --- a/Modules/wifi-module/source/wifi_command.cpp +++ b/Modules/wifi-module/source/wifi_command.cpp @@ -424,7 +424,7 @@ const AppManifest wifi_command_manifest = { .name = "wifi", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(wifi_command_main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, + .flags = APP_MANIFEST_FLAG_HEADLESS, .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index c21afd6c3..07eba82e9 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -38,6 +38,7 @@ list(APPEND REQUIRES_LIST cjson-module cpp-symbols-module crypt-module + development-module freertos-module gps-module graphics-module diff --git a/Tactility/Private/Tactility/app/development/Fingerprint.h b/Tactility/Private/Tactility/app/development/Fingerprint.h new file mode 100644 index 000000000..887bb1683 --- /dev/null +++ b/Tactility/Private/Tactility/app/development/Fingerprint.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace tt::app::development { + +constexpr size_t FINGERPRINT_WORD_LENGTH = 4; + +/** Groups a fingerprint in words of 4 characters: "xxxx xxxx xxxx ..." */ +inline std::string formatFingerprint(const std::string& fingerprint) { + std::string result; + for (size_t i = 0; i < fingerprint.size(); i += FINGERPRINT_WORD_LENGTH) { + if (i > 0) { + result += ' '; + } + result += fingerprint.substr(i, FINGERPRINT_WORD_LENGTH); + } + return result; +} + +/** Shows the first 2 words of a fingerprint: "xxxx xxxx ..." */ +inline std::string formatShortFingerprint(const std::string& fingerprint) { + if (fingerprint.size() <= FINGERPRINT_WORD_LENGTH * 2) { + return formatFingerprint(fingerprint); + } + return formatFingerprint(fingerprint.substr(0, FINGERPRINT_WORD_LENGTH * 2)) + " ..."; +} + +} // namespace tt::app::development diff --git a/Tactility/Private/Tactility/service/development/DevelopmentService.h b/Tactility/Private/Tactility/service/development/DevelopmentService.h deleted file mode 100644 index 35220a96f..000000000 --- a/Tactility/Private/Tactility/service/development/DevelopmentService.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include - -#include - -#include - -namespace tt::service::development { - -class DevelopmentService final : public Service { - - RecursiveMutex mutex; - std::string deviceResponse; - struct HttpServer* httpServer = nullptr; - - void startServer(); - void stopServer(); - - static error_t handleGetInfo(struct HttpServerRequest* request, void* user_ctx); - static error_t handleAppRun(struct HttpServerRequest* request, void* user_ctx); - static error_t handleAppInstall(struct HttpServerRequest* request, void* user_ctx); - static error_t handleAppUninstall(struct HttpServerRequest* request, void* user_ctx); - -public: - - DevelopmentService(); - ~DevelopmentService() override; - - // region Overrides - - bool onStart(ServiceContext& service) override; - - void onStop(ServiceContext& service) override; - - // endregion Overrides - - // region Internal API - - /** - * Enabling the service means that the user is willing to start the web server. - * @return true when the service is enabled - */ - bool isEnabled() const; - - /** - * Enabling the service means that the user is willing to start the web server. - * @param[in] enabled - */ - void setEnabled(bool enabled); -}; - -std::shared_ptr findService(); - -} diff --git a/Tactility/Private/Tactility/service/development/DevelopmentSettings.h b/Tactility/Private/Tactility/service/development/DevelopmentSettings.h deleted file mode 100644 index d6e2f821a..000000000 --- a/Tactility/Private/Tactility/service/development/DevelopmentSettings.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once - -namespace tt::service::development { - -void setEnableOnBoot(bool enable); - -bool shouldEnableOnBoot(); - -} diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index dde780f4d..02b138b93 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -45,6 +45,7 @@ namespace app { namespace audiosettings { extern const ::AppManifest manifest; } namespace boot { extern const ::AppManifest manifest; } namespace development { extern const ::AppManifest manifest; } + namespace developmentprompt { extern const ::AppManifest manifest; } namespace display { extern const ::AppManifest manifest; } namespace files { extern const ::AppManifest manifest; } namespace fileselection { extern const ::AppManifest manifest; } @@ -147,6 +148,7 @@ static void registerInternalApps() { app_manager_add(&app::wifimanage::manifest); app_manager_add(&app::development::manifest); + app_manager_add(&app::developmentprompt::manifest); app_manager_add(&app::webserversettings::manifest); #ifdef ESP_PLATFORM app_manager_add(&app::apwebserver::manifest); diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index dd3c83b4b..f540112bd 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #if TT_FEATURE_MESH #include @@ -33,7 +34,6 @@ namespace tt { namespace service { // Primary namespace audio { extern const ServiceManifest manifest; } - namespace development { extern const ServiceManifest manifest; } #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) namespace espnow { extern const ServiceManifest manifest; } #endif @@ -60,7 +60,7 @@ void registerAndStartServices() { check(module_ensure_started(&mesh_meshcore_module) == ERROR_NONE); check(module_ensure_started(&mesh_meshtastic_module) == ERROR_NONE); #endif - addService(service::development::manifest); + check(module_ensure_started(&development_module) == ERROR_NONE); addService(service::webserver::manifest); #if defined(__linux__) || defined(__APPLE__) check(posix_services_start() == ERROR_NONE); diff --git a/Tactility/Source/app/development/Development.cpp b/Tactility/Source/app/development/Development.cpp index fe5e9ad15..a5aacbb48 100644 --- a/Tactility/Source/app/development/Development.cpp +++ b/Tactility/Source/app/development/Development.cpp @@ -1,8 +1,9 @@ #include +#include #include #include -#include -#include + +#include #include #include @@ -13,18 +14,22 @@ #include #include +#include #include #include #include #include +#include #include -#include #include #include +#include #include #include +#include +#include namespace tt::app::development { @@ -34,13 +39,31 @@ extern const ::AppManifest manifest; namespace { +constexpr TickType_t TRUSTED_CLIENTS_REFRESH_INTERVAL = pdMS_TO_TICKS(1000); + +struct TrustedClient { + std::string fingerprint; + std::string name; + + bool operator==(const TrustedClient&) const = default; +}; + struct Context { uint32_t appInstanceId; lv_obj_t* enableSwitch = nullptr; - lv_obj_t* enableOnBootSwitch = nullptr; lv_obj_t* statusLabel = nullptr; - std::shared_ptr service; + lv_obj_t* trustedCard = nullptr; + // The server is enabled on the app task, as that can generate a certificate + TaskEventGroup* eventGroup = nullptr; + uint32_t enableBit = 0; + std::atomic enableRequested = false; + // File IO for trusted clients happens on the app task + uint32_t removeBit = 0; + ::Mutex removalMutex {}; + std::vector pendingRemovals; + // Guarded by the LVGL lock, so createWidgets() can rebuild the list from it + std::vector trustedClients; std::unique_ptr timer; }; @@ -53,31 +76,10 @@ void onBackPressed(lv_event_t* event) { } void onEnableSwitchChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); auto* widget = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); - auto* ctx = static_cast(lv_event_get_user_data(event)); - bool is_changed = is_on != ctx->service->isEnabled(); - if (is_changed) { - ctx->service->setEnabled(is_on); - } - } -} - -void onEnableOnBootSwitchChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); - auto* widget = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); - bool is_changed = is_on != service::development::shouldEnableOnBoot(); - if (is_changed) { - // Dispatch it, so file IO doesn't block the UI - getMainDispatcher().dispatch([is_on] { - service::development::setEnableOnBoot(is_on); - }); - } - } + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->enableRequested = lv_obj_has_state(widget, LV_STATE_CHECKED); + task_event_group_signal(ctx->eventGroup, ctx->enableBit); } void updateViewState(Context* ctx) { @@ -90,7 +92,7 @@ void updateViewState(Context* ctx) { device_put(wifi_device); } - if (!ctx->service->isEnabled()) { + if (!development_server_is_enabled()) { lv_label_set_text(ctx->statusLabel, "Service disabled"); } else if (station_state != WIFI_STATION_STATE_CONNECTED) { lv_label_set_text(ctx->statusLabel, "Waiting for connection..."); @@ -98,12 +100,95 @@ void updateViewState(Context* ctx) { if (ip[0] == '\0') { lv_label_set_text(ctx->statusLabel, "Waiting for IP..."); } else { - const std::string status = std::format("Available at {}", ip); + char fingerprint[DEVELOPMENT_SERVER_FINGERPRINT_LENGTH + 1] = {}; + development_server_get_fingerprint(fingerprint, sizeof(fingerprint)); + const std::string status = std::format("Available at {}\nFingerprint: {}", ip, formatShortFingerprint(fingerprint)); lv_label_set_text(ctx->statusLabel, status.c_str()); } } } +void onRemoveTrustedClientPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* button = static_cast(lv_event_get_target(event)); + const auto index = reinterpret_cast(lv_obj_get_user_data(button)); + if (index >= ctx->trustedClients.size()) { + return; + } + mutex_lock(&ctx->removalMutex); + ctx->pendingRemovals.push_back(ctx->trustedClients[index].fingerprint); + mutex_unlock(&ctx->removalMutex); + task_event_group_signal(ctx->eventGroup, ctx->removeBit); +} + +void rebuildTrustedClients(Context* ctx) { + lv_obj_clean(ctx->trustedCard); + if (ctx->trustedClients.empty()) { + lv_obj_t* none_label = lv_label_create(ctx->trustedCard); + lv_label_set_text(none_label, "None"); + return; + } + for (size_t index = 0; index < ctx->trustedClients.size(); index++) { + const TrustedClient& client = ctx->trustedClients[index]; + + lv_obj_t* row = lv_obj_create(ctx->trustedCard); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scrollable(row, false); + lvgl::obj_set_style_bg_invisible(row); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + + lv_obj_t* label = lv_label_create(row); + const std::string text = std::format("{}\n{}", client.name.empty() ? "Unknown" : client.name, formatShortFingerprint(client.fingerprint)); + lv_label_set_text(label, text.c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_flex_grow(label, 1); + + lv_obj_t* button = lv_button_create(row); + lv_obj_t* button_label = lv_label_create(button); + lv_label_set_text(button_label, LV_SYMBOL_TRASH); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(index))); + lv_obj_add_event_cb(button, onRemoveTrustedClientPressed, LV_EVENT_SHORT_CLICKED, ctx); + } +} + +std::vector loadTrustedClients() { + std::vector clients; + development_server_for_each_trusted_client([](const char* fingerprint, const char* name, void* context) { + static_cast*>(context)->push_back({ fingerprint, name }); + return true; + }, &clients); + return clients; +} + +/** Shows the trusted clients when they changed */ +void refreshTrustedClients(Context* ctx, WindowId window) { + std::vector clients = loadTrustedClients(); + lvgl_lock(); + if (clients != ctx->trustedClients) { + ctx->trustedClients = std::move(clients); + if (window_manager_get_state(window) == WINDOW_STATE_GRANTED) { + rebuildTrustedClients(ctx); + } + } + lvgl_unlock(); +} + +void removePendingTrustedClients(Context* ctx) { + mutex_lock(&ctx->removalMutex); + std::vector removals = std::move(ctx->pendingRemovals); + ctx->pendingRemovals.clear(); + mutex_unlock(&ctx->removalMutex); + for (const auto& fingerprint : removals) { + const error_t error = development_server_remove_trusted_client(fingerprint.c_str()); + if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { + LOG_E(TAG, "Failed to remove trusted client"); + } + } +} + void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); @@ -117,7 +202,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar); lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx); - if (ctx->service->isEnabled()) { + if (development_server_is_enabled()) { lv_obj_add_state(ctx->enableSwitch, LV_STATE_CHECKED); } else { lv_obj_remove_state(ctx->enableSwitch, LV_STATE_CHECKED); @@ -132,50 +217,26 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT); lvgl::obj_set_style_bg_invisible(content_wrapper); - // Enable on boot - - auto* enable_card = lvgl_card_create(content_wrapper); - lv_obj_set_size(enable_card, LV_PCT(100), LV_SIZE_CONTENT); - - lv_obj_t* enable_wrapper = lv_obj_create(enable_card); - lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(enable_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(enable_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_scrollable(enable_wrapper, false); - lvgl::obj_set_style_bg_invisible(enable_wrapper); - lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT); - - lv_obj_t* enable_label = lv_label_create(enable_wrapper); - lv_label_set_text(enable_label, "Enable on boot"); - lv_obj_set_flex_grow(enable_label, 1); - - ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper); - lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx); - if (service::development::shouldEnableOnBoot()) { - lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); - } - - // Status and warning + // Status auto* status_card = lvgl_card_create(content_wrapper); lv_obj_set_size(status_card, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(status_card, LV_FLEX_FLOW_COLUMN); ctx->statusLabel = lv_label_create(status_card); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_label_set_long_mode(ctx->statusLabel, LV_LABEL_LONG_WRAP); - auto warning_label = lv_label_create(status_card); - lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection."); - lv_obj_set_width(warning_label, LV_PCT(100)); - lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP); - // The monochrome theme has no error colour - if (!lvgl_theme_is_mono()) { - LvglThemeSettings theme_settings; - lvgl_theme_get_settings(&theme_settings); - lv_obj_set_style_text_color(warning_label, theme_settings.color_error, LV_STATE_DEFAULT); - } + // Trusted clients + + lv_obj_t* trusted_label = lv_label_create(content_wrapper); + lv_label_set_text(trusted_label, "Trusted"); + + ctx->trustedCard = lvgl_card_create(content_wrapper); + lv_obj_set_size(ctx->trustedCard, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->trustedCard, LV_FLEX_FLOW_COLUMN); + lvgl_grid_navigation_add(ctx->trustedCard); + rebuildTrustedClients(ctx); updateViewState(ctx); } @@ -184,20 +245,17 @@ int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); Context ctx {}; ctx.appInstanceId = appInstanceId; - ctx.service = service::development::findService(); - - if (ctx.service == nullptr) { - LOG_E(TAG, "Service not found"); - // No window/subscription was ever created - matches the old model, where onCreate() - // aborting the app meant onShow() was never called either. - return 0; - } + mutex_construct(&ctx.removalMutex); + ctx.trustedClients = loadTrustedClients(); TaskEventGroup event_group {}; task_event_group_construct(&event_group); AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + check(task_event_group_claim_bit(&event_group, &ctx.enableBit) == ERROR_NONE); + check(task_event_group_claim_bit(&event_group, &ctx.removeBit) == ERROR_NONE); + ctx.eventGroup = &event_group; WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); @@ -218,7 +276,21 @@ int32_t appMain(int argc, char* argv[]) { bool shouldClose = false; while (!shouldClose) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); + uint32_t flags = 0; + // The timeout picks up clients that were trusted while this app is open + task_event_group_wait_any(&event_group, &flags, TRUSTED_CLIENTS_REFRESH_INTERVAL); + + if ((flags & ctx.enableBit) != 0) { + const bool enable = ctx.enableRequested; + if (enable != development_server_is_enabled() && development_server_set_enabled(enable) != ERROR_NONE) { + LOG_E(TAG, "Failed to change the development server"); + } + } + + if ((flags & ctx.removeBit) != 0) { + removePendingTrustedClients(&ctx); + } + refreshTrustedClients(&ctx, window); AppEvent event {}; while (app_event_poll(&sub, &event) == ERROR_NONE) { @@ -240,7 +312,10 @@ int32_t appMain(int argc, char* argv[]) { window_manager_remove(window); check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_release_bit(&event_group, ctx.enableBit); + task_event_group_release_bit(&event_group, ctx.removeBit); task_event_group_destruct(&event_group); + mutex_destruct(&ctx.removalMutex); return 0; } diff --git a/Tactility/Source/app/developmentprompt/DevelopmentPrompt.cpp b/Tactility/Source/app/developmentprompt/DevelopmentPrompt.cpp new file mode 100644 index 000000000..983f2676d --- /dev/null +++ b/Tactility/Source/app/developmentprompt/DevelopmentPrompt.cpp @@ -0,0 +1,177 @@ +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include + +#include +#include + +namespace tt::app::developmentprompt { + +constexpr auto* TAG = "DevelopmentPrompt"; + +namespace { + +struct Context { + uint32_t appInstanceId = 0; + // argv: see DEVELOPMENT_SERVER_PROMPT_APP_ID + char** argv = nullptr; + TaskEventGroup* eventGroup = nullptr; + uint32_t detailsBit = 0; + // Written by the button callbacks before they emit APP_EVENT_CLOSE, so a dismissed prompt rejects + DevelopmentPromptResult result = DEVELOPMENT_PROMPT_REJECT; +}; + +void onAcceptPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->result = DEVELOPMENT_PROMPT_ACCEPT_ALWAYS; + app_event_emit_close(ctx->appInstanceId); +} + +void onRejectPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->result = DEVELOPMENT_PROMPT_REJECT; + app_event_emit_close(ctx->appInstanceId); +} + +void onDetailsPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->detailsBit); +} + +lv_obj_t* createButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, Context* ctx) { + lv_obj_t* button = lv_button_create(parent); + lv_obj_t* label = lv_label_create(button); + lv_label_set_text(label, text); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, ctx); + return button; +} + +lv_obj_t* createRow(lv_obj_t* parent) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_scrollable(row, false); + lvgl::obj_set_style_bg_invisible(row); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + return row; +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + char** argv = ctx->argv; + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lvgl_toolbar_create(parent, "Remote access"); + + lv_obj_t* content = lv_obj_create(parent); + lv_obj_set_width(content, LV_PCT(100)); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); + lvgl::obj_set_style_bg_invisible(content); + + lv_obj_t* message_label = lv_label_create(content); + lv_obj_set_width(message_label, LV_PCT(100)); + lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP); + const std::string message = std::format("{} at {} requests {} access.", argv[1], argv[0], argv[3]); + lv_label_set_text(message_label, message.c_str()); + + lv_obj_t* fingerprint_row = createRow(content); + lv_obj_set_flex_align(fingerprint_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_t* fingerprint_label = lv_label_create(fingerprint_row); + const std::string fingerprint = std::format("Fingerprint: {}", development::formatShortFingerprint(argv[2])); + lv_label_set_text(fingerprint_label, fingerprint.c_str()); + lv_label_set_long_mode(fingerprint_label, LV_LABEL_LONG_WRAP); + lv_obj_set_flex_grow(fingerprint_label, 1); + createButton(fingerprint_row, "...", onDetailsPressed, ctx); + + lv_obj_t* button_row = createRow(parent); + lv_obj_set_flex_align(button_row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(button_row, 12, LV_STATE_DEFAULT); + lv_obj_set_style_pad_ver(button_row, 4, LV_STATE_DEFAULT); + createButton(button_row, "Accept", onAcceptPressed, ctx); + createButton(button_row, "Reject", onRejectPressed, ctx); + lvgl_grid_navigation_add(button_row); +} + +int32_t appMain(int argc, char* argv[]) { + if (argc != 4) { + LOG_E(TAG, "Expected 4 arguments, got %d", argc); + return DEVELOPMENT_PROMPT_REJECT; + } + + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + ctx.argv = argv; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + AppEventSubscription subscription {}; + check(app_event_subscribe(&subscription, &eventGroup) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.detailsBit) == ERROR_NONE); + ctx.eventGroup = &eventGroup; + + WindowId window = window_manager_create(ctx.appInstanceId, createWidgets, &ctx); + + uint32_t detailsDialogId = 0; + bool done = false; + while (!done) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if ((flags & ctx.detailsBit) != 0 && detailsDialogId == 0) { + detailsDialogId = alertdialog::start(ctx.appInstanceId, "Fingerprint", development::formatFingerprint(argv[2])); + } + + AppEvent event {}; + while (app_event_poll(&subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_RESULT && event.result.launch_id == detailsDialogId) { + app_manager_stop(detailsDialogId); + detailsDialogId = 0; + } else if (event.type == APP_EVENT_CLOSE) { + done = true; + } + } + } + + if (detailsDialogId != 0) { + app_manager_stop(detailsDialogId); + } + window_manager_remove(window); + check(app_event_unsubscribe(&subscription) == ERROR_NONE); + task_event_group_release_bit(&eventGroup, ctx.detailsBit); + task_event_group_destruct(&eventGroup); + return ctx.result; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = DEVELOPMENT_SERVER_PROMPT_APP_ID, + .name = "Development Prompt", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = {} +}; + +} // namespace tt::app::developmentprompt diff --git a/Tactility/Source/app/files/FilesApp.cpp b/Tactility/Source/app/files/FilesApp.cpp index 458f2bd18..57ce02454 100644 --- a/Tactility/Source/app/files/FilesApp.cpp +++ b/Tactility/Source/app/files/FilesApp.cpp @@ -78,8 +78,8 @@ extern const ::AppManifest manifest = { .name = "Files", .category = APP_CATEGORY_SYSTEM, .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = {} + .flags = 0, + .stack = {}, }; } // namespace diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index 37e8d9de7..514d2bd6e 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -39,11 +39,6 @@ constexpr IconEntry ICONS[] = { {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, }; -// Hidden apps that are still shown in the launcher -constexpr const char* SHOWN_HIDDEN_APP_IDS[] = { - "tactility.files", -}; - const char* appIcon(const ::AppManifest* manifest) { for (const auto& entry : ICONS) { if (!strcmp(manifest->id, entry.id)) return entry.icon; @@ -55,10 +50,14 @@ bool isShown(const ::AppManifest& manifest) { if (manifest.category != APP_CATEGORY_USER && manifest.category != APP_CATEGORY_SYSTEM) { return false; } - if ((manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) { - return true; + if ((manifest.flags & APP_MANIFEST_FLAG_HEADLESS) != 0) { + return false; } - return std::ranges::any_of(SHOWN_HIDDEN_APP_IDS, [&](const char* id) { return strcmp(manifest.id, id) == 0; }); + if ((manifest.flags & APP_MANIFEST_FLAG_HIDDEN) != 0) { + return false; + } + + return true; } void collectManifest(const ::AppManifest* manifest, void* context) { diff --git a/Tactility/Source/app/shell/Shell.cpp b/Tactility/Source/app/shell/Shell.cpp index ed1ca4108..253d7b834 100644 --- a/Tactility/Source/app/shell/Shell.cpp +++ b/Tactility/Source/app/shell/Shell.cpp @@ -78,7 +78,7 @@ void forEachCommand(void* context, void (*callback)(const Command&, void*)) { } gathered; // First gather everything, so we don't keep the ledger lock while calling callback() app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { - if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) == 0) { + if ((manifest->flags & APP_MANIFEST_FLAG_HEADLESS) == 0 || (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) != 0) { return; } auto& gathered = *static_cast(context); diff --git a/Tactility/Source/app/shell/main.cpp b/Tactility/Source/app/shell/main.cpp index b05967f5f..ab7d921e3 100644 --- a/Tactility/Source/app/shell/main.cpp +++ b/Tactility/Source/app/shell/main.cpp @@ -12,9 +12,21 @@ #include #include +#include #include #include +#ifdef ESP_PLATFORM +#include +#endif + +// A task with its stack in PSRAM can't write to internal flash, unless flash operations keep the cache enabled +#ifdef CONFIG_SPI_FLASH_AUTO_SUSPEND +constexpr uint16_t STACK_MEMORY_CAPABILITY = MEMORY_CAPABILITY_EXTERNAL; +#else +constexpr uint16_t STACK_MEMORY_CAPABILITY = 0; +#endif + namespace tt::app::shell { /* @@ -38,9 +50,21 @@ int getTerminalColumns() { return 0; } -int main(int, char*[]) { - LineEditor::setTerminalColumns(getTerminalColumns()); +/** Whether argv is " -c [arguments...]", which runs the command instead of a script or session */ +bool isCommandMode(int argc, char* argv[]) { + return argc >= 3 && strcmp(argv[1], "-c") == 0; +} +/** Runs the command of " -c [name [arguments...]]": like sh -c, the arguments after it become $0..$N */ +int runCommandMode(int argc, char* argv[]) { + char* defaultArguments[] = { argv[0] }; + if (argc > 3) { + return Shell::runScriptSource(argv[2], argc - 3, argv + 3); + } + return Shell::runScriptSource(argv[2], 1, defaultArguments); +} + +int main(int argc, char* argv[]) { // Unbuffered so typed characters and command output appear as they are written rather than at // the next newline or flush. This app's stdout is a pipe, not a tty, so libc would otherwise // fully buffer it. @@ -48,6 +72,14 @@ int main(int, char*[]) { Shell::init(); + if (isCommandMode(argc, argv)) { + const int status = runCommandMode(argc, argv); + Shell::shutdown(); + return status; + } + + LineEditor::setTerminalColumns(getTerminalColumns()); + puts("Type 'exit' to quit shell."); // Heap-allocated: its history buffer is several KB, which the interpreter needs on the stack @@ -92,13 +124,17 @@ extern const ::AppManifest manifest = { .name = "Shell", .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, - .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, - .stack = { .depth = 8192, .desired_memory_capability = 0 }, + .flags = APP_MANIFEST_FLAG_HEADLESS, + .stack = { .depth = 8192, .desired_memory_capability = STACK_MEMORY_CAPABILITY }, }; static int32_t shMain(int argc, char* argv[]) { + if (isCommandMode(argc, argv)) { + return runCommandMode(argc, argv); + } + if (argc < 2) { - printf("usage: sh