Development service and sdk/app build scripting refactored (#695)

This commit is contained in:
Ken Van Hoeylandt 2026-10-15 17:27:06 +02:00 committed by GitHub
parent d0ad1b014c
commit a485dbbf94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 6758 additions and 898 deletions

View File

@ -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:

View File

@ -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:

16
.github/actions/build-tools/action.yml vendored Normal file
View File

@ -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

17
.github/actions/build-tt/action.yml vendored Normal file
View File

@ -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

16
.github/actions/publish-tt/action.yml vendored Normal file
View File

@ -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 }}

View File

@ -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

View File

@ -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-<device> 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:

View File

@ -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:

View File

@ -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)

View File

@ -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/<version>/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()

View File

@ -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()

View File

@ -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 <hostname>'"
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")

View File

@ -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/<version>, 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":

View File

@ -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 <hostname>" 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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

20
Buildscripts/release-current.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Releases the SDK of the current build and the tools to release/sdk/<version>/, 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")

View File

@ -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__":

View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Exports the tools that tt runs to release/sdk/<version>/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}")

41
Buildscripts/release-tt.py Executable file
View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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").

View File

@ -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)

@ -1 +1 @@
Subproject commit def7d2df2b0506d3d249334974f51e427c17a41c
Subproject commit 3a22924e0a9ddbbc8b0758881c33b3422a5cc20d

View File

@ -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,

View File

@ -83,7 +83,7 @@ extern const ::AppManifest manifest = {
.name = "cat",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};

View File

@ -14,7 +14,7 @@ extern const ::AppManifest manifest = {
.name = "clear",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -43,7 +43,7 @@ extern const ::AppManifest manifest = {
.name = "cp",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -22,7 +22,7 @@ extern const ::AppManifest manifest = {
.name = "date",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 2048 + 256, .desired_memory_capability = 0 },
};

View File

@ -29,7 +29,7 @@ extern const ::AppManifest manifest = {
.name = "df",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -25,7 +25,7 @@ extern const ::AppManifest manifest = {
.name = "du",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};

View File

@ -26,7 +26,7 @@ extern const ::AppManifest manifest = {
.name = "free",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 },
};

View File

@ -66,7 +66,7 @@ extern const ::AppManifest manifest = {
.name = "head",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -23,7 +23,7 @@ using IdList = std::vector<std::string, tt::OptExternalAllocator<std::string>>;
// 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<IdList*>(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<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -135,7 +135,7 @@ extern const ::AppManifest manifest = {
.name = "ls",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -39,7 +39,7 @@ extern const ::AppManifest manifest = {
.name = "mkdir",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 },
};

View File

@ -33,7 +33,7 @@ extern const ::AppManifest manifest = {
.name = "mv",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -86,7 +86,7 @@ extern const ::AppManifest manifest = {
.name = "printf",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -46,7 +46,7 @@ extern const ::AppManifest manifest = {
.name = "rm",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 },
};

View File

@ -96,7 +96,7 @@ extern const ::AppManifest manifest = {
.name = "tail",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -31,7 +31,7 @@ extern const ::AppManifest manifest = {
.name = "touch",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 },
};

View File

@ -106,7 +106,7 @@ extern const ::AppManifest manifest = {
.name = "wc",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};

View File

@ -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<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 },
};

View File

@ -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}
)

View File

@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.

View File

@ -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.h>`.
- `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)

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module development_module;
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <tactility/error.h>
#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

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <development/server.h>
#include <mbedtls/pk.h>
#include <mbedtls/x509_crt.h>
#include <stddef.h>
#include <stdint.h>
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]);

View File

@ -0,0 +1,102 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <development/server.h>
#include <tactility/error.h>
#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]);

View File

@ -0,0 +1,73 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <development/protocol.h>
#include <development/session.h>
#include <app/event.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/paths.h>
#include <cstdio>
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);

View File

@ -0,0 +1,62 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <development/protocol.h>
#include <development/server.h>
#include <app/manifest.h>
#include <service/manifest.h>
#include <mbedtls/ssl.h>
#include <stddef.h>
#include <stdint.h>
#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);

View File

@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <development/server.h>
#include <tactility/error.h>
/** @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);

View File

@ -0,0 +1,117 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/requests.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/start.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <sys/stat.h>
#include <cstring>
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<size_t>(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;
}
}

View File

@ -0,0 +1,238 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/credentials.h>
#include <crypt/crypt.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/memory.h>
#include <tactility/paths.h>
#include <mbedtls/platform_util.h>
#include <mbedtls/x509_crt.h>
#include <psa/crypto.h>
#include <cstdio>
#include <cstring>
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<size_t>(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<uint8_t>(derLength >> 8);
plain[1] = static_cast<uint8_t>(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<size_t>(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<unsigned>(-result));
return false;
}
// The DER is written at the end of the buffer
*outLength = static_cast<size_t>(result);
*outDer = buffer + bufferSize - *outLength;
return true;
}
bool generate(const char* directory) {
LOG_I(TAG, "Generating device certificate");
auto* buffer = static_cast<uint8_t*>(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<size_t>(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<uint8_t*>(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;
}

View File

@ -0,0 +1,55 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/module.h>
#include <development/server.h>
#include <development/session.h>
#include <app/manager.h>
#include <service/manager.h>
#include <tactility/error.h>
#include <tactility/module.h>
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,
};
}

View File

@ -0,0 +1,124 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/protocol.h>
#include <cJSON.h>
#include <cstring>
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<uint16_t>(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<int>(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<uint16_t>((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<DevelopmentFrameType>(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<uint8_t>(length >> 8);
out_header[2] = static_cast<uint8_t>(length & 0xFF);
}

View File

@ -0,0 +1,193 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/requests.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/memory.h>
#include <cstring>
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<Buffers*>(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<size_t>(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<uint16_t>(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;
}
}
}

View File

@ -0,0 +1,638 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/server.h>
#include <development/credentials.h>
#include <development/protocol.h>
#include <development/session.h>
#include <development/trust.h>
#include <app/manager.h>
#include <app/start.h>
#include <service/manifest.h>
#include <service/paths.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/thread.h>
#include <tactility/filesystem/fs.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
#include <tactility/memory.h>
#include <tactility/paths.h>
#include <tactility/properties_file.h>
#include <TactilityCpp/Memory.h>
#include <mbedtls/ssl.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstring>
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<bool> 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<size_t>(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<DevelopmentSession*>(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<unsigned>(-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<char>(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<char*>(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<ServerData*>(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<sockaddr*>(&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<unsigned>(-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<sockaddr*>(&address), sizeof(address)) != 0 || listen(fd, LISTEN_BACKLOG) != 0) {
LOG_E(TAG, "Failed to listen on port %u", static_cast<unsigned>(configuredPort));
close(fd);
return ERROR_RESOURCE;
}
socklen_t addressLength = sizeof(address);
getsockname(fd, reinterpret_cast<sockaddr*>(&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<unsigned>(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<ServerData>();
}
void destroyService(const ServiceManifest* /*manifest*/, void* data) {
tt::deleteObject(static_cast<ServerData*>(data));
}
error_t onStart(ServiceInstance* /*instance*/, void* context) {
auto* data = static_cast<ServerData*>(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<ServerData*>(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();
}

View File

@ -0,0 +1,109 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/session.h>
#include <tactility/freertos/task.h>
#include <tactility/memory.h>
#include <mbedtls/net_sockets.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
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<int*>(context);
const ssize_t sent = send(socket, data, length, MSG_NOSIGNAL);
if (sent >= 0) {
return static_cast<int>(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<int*>(context);
const ssize_t received = recv(socket, data, length, 0);
if (received > 0) {
return static_cast<int>(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<DevelopmentSession*>(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<const unsigned char*>(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<size_t>(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);
}

View File

@ -0,0 +1,329 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/session.h>
#include <development/requests.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <app/stream.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
#include <tactility/memory.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#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<DevelopmentPromptResult>(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<uint16_t>((frame.payload[0] << 8) | frame.payload[1]);
const auto rows = static_cast<uint16_t>((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<size_t>(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<uint16_t>(length))) {
bridge.peerClosed = true;
}
}
}
void runShell(DevelopmentSession* session, AppInstanceId self, Events& events) {
auto* buffers = static_cast<Buffers*>(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<uint8_t>(static_cast<uint32_t>(exitCode) >> 24),
static_cast<uint8_t>(static_cast<uint32_t>(exitCode) >> 16),
static_cast<uint8_t>(static_cast<uint32_t>(exitCode) >> 8),
static_cast<uint8_t>(static_cast<uint32_t>(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<void*>(main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 6144, .desired_memory_capability = STACK_MEMORY_CAPABILITY },
};

View File

@ -0,0 +1,86 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/requests.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <sys/stat.h>
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<uint64_t>(info.st_size);
for (int i = 0; i < 8; i++) {
result[1 + i] = static_cast<uint8_t>(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<uint16_t>(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;
}
}

View File

@ -0,0 +1,197 @@
// SPDX-License-Identifier: Apache-2.0
#include <development/trust.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <cstdio>
#include <cstring>
namespace {
constexpr auto* TAG = "DevelopmentTrust";
constexpr auto* TRUST_FILE = "trusted_clients.txt";
constexpr auto* TRUST_FILE_TEMPORARY = "trusted_clients.txt.tmp";
// File format: one "<fingerprint> <client name>" 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<size_t>(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<Search*>(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<Rewrite*>(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;
}

View File

@ -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
)

View File

@ -0,0 +1,47 @@
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include <tactility/check.h>
#include <tactility/dts.h>
#include <tactility/freertos/task.h>
#include <tactility/kernel_init.h>
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;
}

View File

@ -0,0 +1,100 @@
#include "doctest.h"
#include <development/protocol.h>
#include <cstring>
#include <string>
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<const char*>(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);
}

File diff suppressed because it is too large Load Diff

View File

@ -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),

View File

@ -251,7 +251,7 @@ const AppManifest mesh_command_manifest = {
.name = "mesh",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(mesh_command_main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};

View File

@ -424,7 +424,7 @@ const AppManifest wifi_command_manifest = {
.name = "wifi",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(wifi_command_main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};

View File

@ -38,6 +38,7 @@ list(APPEND REQUIRES_LIST
cjson-module
cpp-symbols-module
crypt-module
development-module
freertos-module
gps-module
graphics-module

View File

@ -0,0 +1,30 @@
#pragma once
#include <cstddef>
#include <string>
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

View File

@ -1,55 +0,0 @@
#pragma once
#include <Tactility/service/Service.h>
#include <Tactility/RecursiveMutex.h>
#include <http/server.h>
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<DevelopmentService> findService();
}

View File

@ -1,9 +0,0 @@
#pragma once
namespace tt::service::development {
void setEnableOnBoot(bool enable);
bool shouldEnableOnBoot();
}

View File

@ -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);

View File

@ -10,6 +10,7 @@
#include <location/module.h>
#include <app_hub/module.h>
#include <development/module.h>
#include <wifi/module.h>
#if TT_FEATURE_MESH
#include <mesh/module.h>
@ -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);

View File

@ -1,8 +1,9 @@
#include <Tactility/Tactility.h>
#include <Tactility/app/development/Fingerprint.h>
#include <Tactility/Timer.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/service/development/DevelopmentService.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <development/server.h>
#include <app/event.h>
#include <app/manager.h>
@ -13,18 +14,22 @@
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/device.h>
#include <tactility/drivers/wifi.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
#include <atomic>
#include <cstring>
#include <format>
#include <string>
#include <vector>
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::development::DevelopmentService> 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<bool> enableRequested = false;
// File IO for trusted clients happens on the app task
uint32_t removeBit = 0;
::Mutex removalMutex {};
std::vector<std::string> pendingRemovals;
// Guarded by the LVGL lock, so createWidgets() can rebuild the list from it
std::vector<TrustedClient> trustedClients;
std::unique_ptr<Timer> 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_obj_t*>(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<Context*>(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_obj_t*>(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<Context*>(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<Context*>(lv_event_get_user_data(event));
auto* button = static_cast<lv_obj_t*>(lv_event_get_target(event));
const auto index = reinterpret_cast<uintptr_t>(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<void*>(static_cast<uintptr_t>(index)));
lv_obj_add_event_cb(button, onRemoveTrustedClientPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
}
std::vector<TrustedClient> loadTrustedClients() {
std::vector<TrustedClient> clients;
development_server_for_each_trusted_client([](const char* fingerprint, const char* name, void* context) {
static_cast<std::vector<TrustedClient>*>(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<TrustedClient> 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<std::string> 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<Context*>(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;
}

View File

@ -0,0 +1,177 @@
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/development/Fingerprint.h>
#include <Tactility/lvgl/Style.h>
#include <development/server.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/toolbar.h>
#include <format>
#include <string>
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<Context*>(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<Context*>(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<Context*>(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<Context*>(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<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
};
} // namespace tt::app::developmentprompt

View File

@ -78,8 +78,8 @@ extern const ::AppManifest manifest = {
.name = "Files",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
.flags = 0,
.stack = {},
};
} // namespace

View File

@ -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) {

View File

@ -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<Gathered*>(context);

View File

@ -12,9 +12,21 @@
#include <tactility/freertos/freertos.h>
#include <cstdio>
#include <cstring>
#include <new>
#include <unistd.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#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 "<name> -c <command> [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 "<name> -c <command> [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<void*>(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 <script> [args...]\n");
printf("usage: sh <script> [args...]\n sh -c <command> [name [args...]]\n");
return 1;
}
@ -122,7 +158,7 @@ extern const ::AppManifest sh_manifest = {
.name = "sh",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(shMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 8192, .desired_memory_capability = 0 },
};

View File

@ -1,301 +0,0 @@
#include <app/install.h>
#include <app/manager.h>
#include <app/start.h>
#include <tactility/log.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <Tactility/network/HttpServerReq.h>
#include <Tactility/network/HttpdReq.h>
#include <Tactility/network/Url.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/development/DevelopmentService.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <cstring>
#include <format>
#include <iterator>
#include <sstream>
namespace tt::service::development {
extern const ServiceManifest manifest;
constexpr auto* TAG = "DevService";
DevelopmentService::DevelopmentService() {
HttpServerRequestHandler handlers[] = {
{ .uri = "/info", .method = HTTP_METHOD_GET, .callback = handleGetInfo, .user_ctx = this },
{ .uri = "/app/run", .method = HTTP_METHOD_POST, .callback = handleAppRun, .user_ctx = this },
{ .uri = "/app/install", .method = HTTP_METHOD_PUT, .callback = handleAppInstall, .user_ctx = this },
{ .uri = "/app/uninstall", .method = HTTP_METHOD_PUT, .callback = handleAppUninstall, .user_ctx = this },
};
HttpServerConfig config {
.port = 6666,
.address = "0.0.0.0",
.stack_size = 5120,
.handlers = handlers,
.handler_count = std::size(handlers),
};
httpServer = http_server_alloc(&config);
if (httpServer == nullptr) {
LOG_E(TAG, "Failed to allocate http server");
}
}
DevelopmentService::~DevelopmentService() {
http_server_free(httpServer);
}
bool DevelopmentService::onStart(ServiceContext& service) {
std::stringstream stream;
stream << "{";
#ifdef ESP_PLATFORM
stream << "\"cpuFamily\":\"" << CONFIG_IDF_TARGET << "\", ";
#else
stream << "\"cpuFamily\":\"" << CONFIG_TT_DEVICE_ID << "\", ";
#endif
stream << "\"osVersion\":\"" << TT_VERSION << "\", ";
stream << "\"protocolVersion\":\"1.0.0\"";
stream << "}";
deviceResponse = stream.str();
setEnabled(shouldEnableOnBoot());
return true;
}
void DevelopmentService::onStop(ServiceContext& service) {
setEnabled(false);
}
// region Enable/disable
void DevelopmentService::setEnabled(bool enabled) {
if (httpServer == nullptr) {
return;
}
auto lock = mutex.asScopedLock();
lock.lock();
if (enabled) {
if (!http_server_is_started(httpServer)) {
http_server_start(httpServer);
}
} else {
if (http_server_is_started(httpServer)) {
http_server_stop(httpServer);
}
}
}
bool DevelopmentService::isEnabled() const {
if (httpServer == nullptr) {
return false;
}
auto lock = mutex.asScopedLock();
lock.lock();
return http_server_is_started(httpServer);
}
// region endpoints
error_t DevelopmentService::handleGetInfo(HttpServerRequest* request, void* user_ctx) {
LOG_I(TAG, "GET /device");
auto* service = static_cast<DevelopmentService*>(user_ctx);
http_server_request_set_content_type(request, "application/json");
if (http_server_request_send_string(request, service->deviceResponse.c_str()) != ERROR_NONE) {
LOG_W(TAG, "Failed to send response body");
return ERROR_UNDEFINED;
}
LOG_I(TAG, "[200] /device");
return ERROR_NONE;
}
error_t DevelopmentService::handleAppRun(HttpServerRequest* request, void*) {
LOG_I(TAG, "POST /app/run");
std::string query;
if (!network::getQueryOrSendError(request, query)) {
return ERROR_UNDEFINED;
}
auto parameters = network::parseUrlQuery(query);
auto id_key_pos = parameters.find("id");
if (id_key_pos == parameters.end()) {
LOG_W(TAG, "[400] /app/run id not specified");
http_server_request_send_error(request, 400, "id not specified");
return ERROR_UNDEFINED;
}
char app_id[32];
AppInstanceId instance_id;
// Warning: possible app closure between getting app id and instance id
if (
app_manager_get_topmost_app_id(app_id, sizeof(app_id)) == ERROR_NONE &&
app_manager_get_topmost_instance_id(&instance_id) == ERROR_NONE
) {
if (strcmp(id_key_pos->second.c_str(), app_id) == 0) {
app_manager_stop(instance_id);
}
}
AppStartContext context;
if (app_start_context_from_id(id_key_pos->second.c_str(), &context) == ERROR_NONE) {
app_start_with_context(&context, &instance_id);
}
LOG_I(TAG, "[200] /app/run %s", id_key_pos->second.c_str());
http_server_request_send(request, nullptr, 0);
return ERROR_NONE;
}
error_t DevelopmentService::handleAppInstall(HttpServerRequest* request, void*) {
LOG_I(TAG, "PUT /app/install");
std::string boundary;
if (!network::getMultiPartBoundaryOrSendError(request, boundary)) {
return ERROR_UNDEFINED;
}
size_t content_left = http_server_request_get_content_length(request);
// Skip newline after reading boundary
auto content_headers_data = network::receiveTextUntil(request, "\r\n\r\n");
if (content_headers_data.empty()) {
http_server_request_send_error(request, 400, "Multipart form error: preamble too long or unterminated");
return ERROR_UNDEFINED;
}
content_left -= content_headers_data.length();
auto content_header_lines = string::split(content_headers_data, "\r\n");
std::vector<std::string> content_headers;
for (auto& line : content_header_lines) {
if (!line.empty()) {
content_headers.push_back(line);
}
}
auto content_disposition_map = network::parseContentDisposition(content_headers);
if (content_disposition_map.empty()) {
http_server_request_send_error(request, 400, "Multipart form error: invalid content disposition");
return ERROR_UNDEFINED;
}
auto name_entry = content_disposition_map.find("name");
auto filename_entry = content_disposition_map.find("filename");
if (
name_entry == content_disposition_map.end() ||
filename_entry == content_disposition_map.end() ||
name_entry->second != "app"
) {
http_server_request_send_error(request, 400, "Multipart form error: name or filename parameter missing or mismatching");
return ERROR_UNDEFINED;
}
// Receive boundary
auto boundary_and_newlines_after_file = std::format("\r\n--{}--\r\n", boundary);
auto file_size = content_left - boundary_and_newlines_after_file.length();
// Create tmp directory
const std::string tmp_path = getTempPath();
if (!file::findOrCreateDirectory(tmp_path, 0777)) {
http_server_request_send_error(request, 500, "Failed to create temp path");
return ERROR_UNDEFINED;
}
std::string safe_name = file::getLastPathSegment(filename_entry->second);
if (safe_name.empty() || safe_name.find("..") != std::string::npos ||
safe_name.find('/') != std::string::npos || safe_name.find('\\') != std::string::npos) {
http_server_request_send_error(request, 400, "invalid filename");
return ERROR_UNDEFINED;
}
auto file_path = std::format("{}/{}", tmp_path, safe_name);
if (network::receiveFile(request, file_size, file_path) != file_size) {
file::deleteFile(file_path);
http_server_request_send_error(request, 500, "Failed to receive file");
return ERROR_UNDEFINED;
}
content_left -= file_size;
// Read and verify part
if (!network::readAndDiscardOrSendError(request, boundary_and_newlines_after_file)) {
return ERROR_UNDEFINED;
}
content_left -= boundary_and_newlines_after_file.length();
if (content_left != 0) {
LOG_W(TAG, "We have more bytes at the end of the request parsing?!");
}
if (app_install(file_path.c_str()) != ERROR_NONE) {
http_server_request_send_error(request, 500, "Failed to install");
return ERROR_UNDEFINED;
}
if (!file::deleteFile(file_path)) {
LOG_W(TAG, "Failed to delete %s", file_path.c_str());
}
LOG_I(TAG, "[200] /app/install -> %s", file_path.c_str());
http_server_request_send(request, nullptr, 0);
return ERROR_NONE;
}
error_t DevelopmentService::handleAppUninstall(HttpServerRequest* request, void*) {
LOG_I(TAG, "PUT /app/uninstall");
std::string query;
if (!network::getQueryOrSendError(request, query)) {
return ERROR_UNDEFINED;
}
auto parameters = network::parseUrlQuery(query);
auto id_key_pos = parameters.find("id");
if (id_key_pos == parameters.end()) {
LOG_W(TAG, "[400] /app/uninstall id not specified");
http_server_request_send_error(request, 400, "id not specified");
return ERROR_UNDEFINED;
}
AppManifest manifest;
if (app_manager_find_manifest(id_key_pos->second.c_str(), &manifest) != ERROR_NONE) {
LOG_I(TAG, "[200] /app/uninstall %s (app wasn't installed)", id_key_pos->second.c_str());
http_server_request_send(request, nullptr, 0);
return ERROR_NONE;
}
if (app_uninstall(id_key_pos->second.c_str()) == ERROR_NONE) {
LOG_I(TAG, "[200] /app/uninstall %s", id_key_pos->second.c_str());
http_server_request_send(request, nullptr, 0);
return ERROR_NONE;
} else {
LOG_W(TAG, "[500] /app/uninstall %s", id_key_pos->second.c_str());
http_server_request_send_error(request, 500, "Failed to uninstall");
return ERROR_UNDEFINED;
}
}
// endregion
std::shared_ptr<DevelopmentService> findService() {
return std::static_pointer_cast<DevelopmentService>(
findServiceById(manifest.id)
);
}
extern const ServiceManifest manifest = {
.id = "tactility.development",
.createService = create<DevelopmentService>
};
}

View File

@ -1,73 +0,0 @@
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <map>
#include <string>
#include <app/paths.h>
#include <tactility/log.h>
namespace tt::service::development {
constexpr auto* TAG = "DevSettings";
static std::string getSettingsFilePath() {
char path[256];
if (app_paths_get_user_data_path("tactility.development", "development.properties", path, sizeof(path)) != ERROR_NONE) {
return "";
}
return path;
}
constexpr auto* SETTINGS_KEY_ENABLE_ON_BOOT = "enableOnBoot";
struct DevelopmentSettings {
bool enableOnBoot;
};
static bool load(DevelopmentSettings& settings) {
auto settings_path = getSettingsFilePath();
if (!file::isFile(settings_path)) {
return false;
}
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(settings_path, map)) {
return false;
}
if (!map.contains(SETTINGS_KEY_ENABLE_ON_BOOT)) {
return false;
}
auto enable_on_boot_string = map[SETTINGS_KEY_ENABLE_ON_BOOT];
settings.enableOnBoot = (enable_on_boot_string == "true");
return true;
}
static bool save(const DevelopmentSettings& settings) {
std::map<std::string, std::string> map;
map[SETTINGS_KEY_ENABLE_ON_BOOT] = settings.enableOnBoot ? "true" : "false";
auto settings_path = getSettingsFilePath();
if (!file::findOrCreateParentDirectory(settings_path, 0755)) {
LOG_E(TAG, "Failed to create parent dir for %s", settings_path.c_str());
return false;
}
return file::savePropertiesFile(settings_path, map);
}
void setEnableOnBoot(bool enable) {
DevelopmentSettings properties { .enableOnBoot = enable };
if (!save(properties)) {
LOG_E(TAG, "Failed to save %s", getSettingsFilePath().c_str());
}
}
bool shouldEnableOnBoot() {
DevelopmentSettings settings;
if (!load(settings)) {
return false;
}
return settings.enableOnBoot;
}
}

View File

@ -48,6 +48,7 @@ target_link_libraries(TactilityTests PRIVATE
cjson-module
cpp-symbols-module
crypt-module
development-module
freertos-module
gps-module
graphics-module

View File

@ -73,3 +73,16 @@ TEST_CASE("shell completion: installed headless apps complete by their binary na
app_manager_remove("test.shell.windowed");
app_manager_remove("test.shell.installed");
}
TEST_CASE("shell completion: hidden headless apps are not offered") {
AppManifest hidden {};
snprintf(hidden.id, sizeof(hidden.id), "test.shell.hidden");
snprintf(hidden.name, sizeof(hidden.name), "Hidden");
hidden.flags = APP_MANIFEST_FLAG_HEADLESS | APP_MANIFEST_FLAG_HIDDEN;
hidden.location = { APP_LOCATION_PATH, const_cast<char*>("/nonexistent/bin/posix-x86_64/hiddencmd.so") };
REQUIRE_EQ(app_manager_add(&hidden), ERROR_NONE);
CHECK_EQ(complete("hiddenc"), "<none>");
app_manager_remove("test.shell.hidden");
}

View File

@ -26,5 +26,10 @@ TEST_CASE("microsecond time should be accurate within 1 tick") {
auto end_time = get_micros_since_boot();
auto difference = (end_time - start_time) / 1000;
CHECK_EQ(difference >= 99, true);
#ifdef ESP_PLATFORM
CHECK_EQ(difference <= 101, true);
#else
// The simulator's tick thread sleeps 1 ms per tick plus its own overhead, so ticks run up to about 10% slower than the clock
CHECK_EQ(difference <= 125, true);
#endif
}

View File

@ -11,6 +11,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-hub-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-hub-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/development-module/tests ${CMAKE_CURRENT_BINARY_DIR}/development-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/binfont-module/tests ${CMAKE_CURRENT_BINARY_DIR}/binfont-module)
@ -30,6 +31,7 @@ add_dependencies(build-tests TactilityKernelTests)
add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests AppHubModuleTests)
add_dependencies(build-tests DevelopmentModuleTests)
add_dependencies(build-tests AppPosixModuleTests)
add_dependencies(build-tests HttpModuleTests)
add_dependencies(build-tests BinFontModuleTests)

344
Tests/Python/tt_test.py Normal file
View File

@ -0,0 +1,344 @@
"""Tests for tt.py against a local fake CDN. Run with: python3 -m unittest discover -s Tests/Python -p '*_test.py'"""
import functools
import hashlib
import http.server
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
import zipfile
TT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "tt.py"))
TOOLS_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "Buildscripts", "TactilitySDK", "Tools"))
VERSION = "0.8.0-dev"
ECHO_TOOL = """import json, os, sys
import _bootstrap
_bootstrap.run()
print(json.dumps({"arguments": sys.argv[1:], "version": os.environ.get("TACTILITY_SDK_VERSION"), "path": os.environ.get("TACTILITY_SDK_PATH"), "cwd": os.getcwd()}))
sys.exit(3)
"""
def read_tool(file_name):
with open(os.path.join(TOOLS_PATH, file_name)) as file:
return file.read()
def make_zip(files):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name, content in files.items():
archive.writestr(name, content)
return buffer.getvalue()
class QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
class TtTest(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp()
self.cdn = os.path.join(self.root, "cdn")
self.home = os.path.join(self.root, "home")
self.work = os.path.join(self.root, "work")
os.makedirs(self.home)
os.makedirs(self.work)
self.publish_sdk(self.default_tools())
handler = functools.partial(QuietHandler, directory=self.cdn)
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
shutil.rmtree(self.root)
def default_tools(self):
return {name: read_tool(name) for name in ("update-self.py", "update-sdk.py", "_bootstrap.py")} | {"echo.py": ECHO_TOOL}
def publish_sdk(self, tools, commit="commit1", sdk_content="# sdk"):
version_directory = os.path.join(self.cdn, "sdk", VERSION)
os.makedirs(version_directory, exist_ok=True)
tools_zip = make_zip(tools)
with open(os.path.join(version_directory, "tools.zip"), "wb") as file:
file.write(tools_zip)
with open(os.path.join(version_directory, "TactilitySDK-posix.zip"), "wb") as file:
file.write(make_zip({"TactilitySDK.cmake": sdk_content}))
index = {
"version": VERSION,
"gitCommit": commit,
"platforms": {"posix": "TactilitySDK-posix.zip"},
"tools": {"file": "tools.zip", "sha256": hashlib.sha256(tools_zip).hexdigest()},
}
with open(os.path.join(version_directory, "index.json"), "w") as file:
json.dump(index, file)
def write_manifest(self, directory, version=VERSION, platforms="posix"):
os.makedirs(directory, exist_ok=True)
with open(os.path.join(directory, "manifest.properties"), "w") as file:
file.write(f"[target]\ntarget.sdk={version}\ntarget.platforms={platforms}\n")
def run_tt(self, *arguments, tt_path=TT_PATH, environment=None):
extra_environment = environment or {}
environment = dict(os.environ)
environment.pop("TACTILITY_SDK_PATH", None)
environment.update(extra_environment)
environment["HOME"] = self.home
environment["TT_CDN"] = f"http://127.0.0.1:{self.server.server_address[1]}"
environment.pop("TACTILITY_SDK_VERSION", None)
return subprocess.run([sys.executable, tt_path, *arguments], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60)
def sdk_path(self, *parts):
return os.path.join(self.home, ".tactility", "sdk", VERSION, *parts)
def expire_index(self):
os.utime(self.sdk_path("index.json"), (0, 0))
def test_forwards_arguments_exit_code_and_environment(self):
self.write_manifest(self.work)
result = self.run_tt("echo", "a", "b c")
self.assertEqual(result.returncode, 3, result.stderr)
output = json.loads(result.stdout)
self.assertEqual(output["arguments"], ["a", "b c"])
self.assertEqual(output["version"], VERSION)
self.assertEqual(output["path"], os.path.join(self.home, ".tactility"))
self.assertEqual(os.path.realpath(output["cwd"]), os.path.realpath(self.work))
self.assertTrue(os.path.isfile(self.sdk_path("platform-posix", "TactilitySDK", "TactilitySDK.cmake")))
self.assertTrue(os.path.isfile(self.sdk_path("tools", "echo.py")))
def test_current_directory_beats_apps_directories(self):
self.write_manifest(self.work)
self.write_manifest(os.path.join(self.work, "Apps", "a"), version="9.9.9")
self.assertEqual(self.run_tt("echo").returncode, 3)
def test_apps_directory_beats_lowercase_apps_and_sorts(self):
self.write_manifest(os.path.join(self.work, "Apps", "b"), version="9.9.9")
self.write_manifest(os.path.join(self.work, "Apps", "a"))
self.write_manifest(os.path.join(self.work, "apps", "a"), version="9.9.9")
self.assertEqual(self.run_tt("echo").returncode, 3)
def test_lowercase_apps_directory(self):
self.write_manifest(os.path.join(self.work, "apps", "a"))
self.assertEqual(self.run_tt("echo").returncode, 3)
def test_arguments_are_checked_before_the_manifest(self):
shutil.rmtree(self.cdn)
result = self.run_tt("help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Usage: tt", result.stdout)
result = self.run_tt("../x")
self.assertEqual(result.returncode, 1)
self.assertIn("command not found: ../x", result.stderr)
self.assertNotIn("manifest", result.stderr)
def test_missing_manifest_fails(self):
result = self.run_tt("echo")
self.assertEqual(result.returncode, 1)
self.assertIn("no manifest.properties found", result.stderr)
def test_unknown_and_invalid_commands_are_not_found(self):
self.write_manifest(self.work)
for command in ("nope", "../echo", "Echo"):
result = self.run_tt(command)
self.assertEqual(result.returncode, 1)
self.assertIn(f"command not found: {command}", result.stderr)
def test_help_lists_commands(self):
self.write_manifest(self.work)
result = self.run_tt()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("echo", result.stdout)
self.assertIn(VERSION, result.stdout)
def test_changed_tools_are_downloaded_again(self):
self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3)
self.publish_sdk({"echo.py": "import sys\nsys.exit(4)\n"})
self.assertEqual(self.run_tt("echo").returncode, 3)
self.expire_index()
self.assertEqual(self.run_tt("echo").returncode, 4)
def test_failed_download_keeps_the_cache(self):
self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3)
shutil.rmtree(self.cdn)
self.expire_index()
result = self.run_tt("echo")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertIn("cached", result.stderr)
def test_tools_with_a_wrong_checksum_are_refused(self):
self.write_manifest(self.work)
with open(os.path.join(self.cdn, "sdk", VERSION, "tools.zip"), "wb") as file:
file.write(make_zip({"echo.py": "tampered"}))
result = self.run_tt("echo")
self.assertEqual(result.returncode, 1)
self.assertIn("checksum", result.stderr)
def test_local_uses_tactility_sdk_path(self):
self.write_manifest(self.work)
local_path = os.path.join(self.root, "local")
os.makedirs(os.path.join(local_path, "sdk", VERSION, "tools"))
for name, content in self.default_tools().items():
with open(os.path.join(local_path, "sdk", VERSION, "tools", name), "w") as file:
file.write(content)
shutil.rmtree(self.cdn)
result = self.run_tt("echo", "a", environment={"TACTILITY_SDK_PATH": local_path})
self.assertEqual(result.returncode, 3, result.stderr)
self.assertIn(f"using the local SDK at {local_path}", result.stderr)
output = json.loads(result.stdout)
self.assertEqual(output["arguments"], ["a"])
self.assertEqual(output["path"], local_path)
self.assertEqual(output["version"], VERSION)
self.assertFalse(os.path.exists(self.sdk_path()))
def test_empty_tactility_sdk_path_counts_as_unset(self):
self.write_manifest(self.work)
result = self.run_tt("echo", environment={"TACTILITY_SDK_PATH": ""})
self.assertEqual(result.returncode, 3, result.stderr)
self.assertNotIn("local SDK", result.stderr)
self.assertEqual(json.loads(result.stdout)["path"], os.path.join(self.home, ".tactility"))
def test_local_tools_without_tactility_sdk_path_are_not_used(self):
self.write_manifest(self.work)
result = self.run_tt("echo")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertNotIn("local SDK", result.stderr)
self.assertEqual(json.loads(result.stdout)["path"], os.path.join(self.home, ".tactility"))
def publish_tt(self, files):
with open(os.path.join(self.cdn, "sdk", "tt.zip"), "wb") as file:
file.write(make_zip(files))
with open(os.path.join(self.cdn, "sdk", "tt.json"), "w") as file:
json.dump({"version": "1.0.1"}, file)
def test_update_replaces_itself_and_installs_wrappers(self):
tt_copy = os.path.join(self.root, "tt.py")
shutil.copy(TT_PATH, tt_copy)
self.publish_tt({"tt.py": "# updated\n", "tt": "#!/bin/sh\n", "tt.cmd": "@echo off\r\n"})
self.write_manifest(self.work)
result = self.run_tt("update-self", tt_path=tt_copy)
self.assertEqual(result.returncode, 0, result.stderr)
with open(tt_copy) as file:
self.assertEqual(file.read(), "# updated\n")
self.assertTrue(os.access(os.path.join(self.root, "tt"), os.X_OK))
self.assertTrue(os.access(os.path.join(self.root, "tt.cmd"), os.X_OK))
self.assertTrue(os.access(tt_copy, os.X_OK))
self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt", "tt.cmd", "tt.py", "work"])
def test_update_rejects_invalid_python(self):
tt_copy = os.path.join(self.root, "tt.py")
shutil.copy(TT_PATH, tt_copy)
self.publish_tt({"tt.py": "def broken(:\n", "tt": "#!/bin/sh\n"})
self.write_manifest(self.work)
result = self.run_tt("update-self", tt_path=tt_copy)
self.assertEqual(result.returncode, 1)
with open(tt_copy) as file, open(TT_PATH) as original:
self.assertEqual(file.read(), original.read())
self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt.py", "work"])
def test_update_rejects_unsafe_file_names(self):
tt_copy = os.path.join(self.root, "tt.py")
shutil.copy(TT_PATH, tt_copy)
self.publish_tt({"tt.py": "# updated\n", "../evil": "x"})
self.write_manifest(self.work)
result = self.run_tt("update-self", tt_path=tt_copy)
self.assertEqual(result.returncode, 1)
self.assertIn("invalid", result.stderr)
def test_wrapper_runs_tt(self):
self.write_manifest(self.work)
wrapper = os.path.join(os.path.dirname(TT_PATH), "tt")
environment = dict(os.environ, HOME=self.home, TT_CDN=f"http://127.0.0.1:{self.server.server_address[1]}")
result = subprocess.run([wrapper, "echo", "x"], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 3, result.stderr)
self.assertEqual(json.loads(result.stdout)["arguments"], ["x"])
def publish_tt_version(self, version):
with open(os.path.join(self.cdn, "sdk", "tt.json"), "w") as file:
json.dump({"version": version}, file)
def test_bootstrap_suggests_update(self):
self.write_manifest(self.work)
self.publish_tt_version("99.0.0")
result = self.run_tt("echo")
self.assertEqual(result.returncode, 3)
self.assertIn("version 99.0.0 is available", result.stderr)
self.assertIn("tt update-self", result.stderr)
def test_bootstrap_checks_at_most_every_30_minutes(self):
self.write_manifest(self.work)
self.publish_tt_version("1.0.0")
self.assertNotIn("is available", self.run_tt("echo").stderr)
self.publish_tt_version("99.0.0")
self.assertNotIn("is available", self.run_tt("echo").stderr)
state_path = os.path.join(self.home, ".tactility", "tt.json")
with open(state_path) as file:
state = json.load(file)
state["checked"] -= 31 * 60
with open(state_path, "w") as file:
json.dump(state, file)
self.assertIn("version 99.0.0 is available", self.run_tt("echo").stderr)
def expire_update_check(self):
state_path = os.path.join(self.home, ".tactility", "tt.json")
with open(state_path) as file:
state = json.load(file)
state["checked"] -= 31 * 60
with open(state_path, "w") as file:
json.dump(state, file)
def read_sdk_file(self):
with open(self.sdk_path("platform-posix", "TactilitySDK", "TactilitySDK.cmake")) as file:
return file.read()
def test_newer_sdk_build_is_suggested_and_installed_by_update_sdk(self):
self.write_manifest(self.work)
self.assertNotIn("update-sdk", self.run_tt("echo").stderr)
self.publish_sdk(self.default_tools(), commit="commit2", sdk_content="# sdk 2")
self.assertNotIn("update-sdk", self.run_tt("echo").stderr)
self.expire_update_check()
self.assertIn("run 'tt update-sdk'", self.run_tt("echo").stderr)
self.assertEqual(self.read_sdk_file(), "# sdk")
result = self.run_tt("update-sdk")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(f"SDK {VERSION} was updated", result.stdout)
self.assertEqual(self.read_sdk_file(), "# sdk 2")
self.assertNotIn("update-sdk", self.run_tt("echo").stderr)
self.assertIn("is up to date", self.run_tt("update-sdk").stdout)
def test_local_sdk_is_not_updated(self):
self.write_manifest(self.work)
local_path = os.path.join(self.root, "local")
os.makedirs(os.path.join(local_path, "sdk", VERSION, "platform-posix", "TactilitySDK"))
os.makedirs(os.path.join(local_path, "sdk", VERSION, "tools"))
for name, content in self.default_tools().items():
with open(os.path.join(local_path, "sdk", VERSION, "tools", name), "w") as file:
file.write(content)
result = self.run_tt("echo", environment={"TACTILITY_SDK_PATH": local_path})
self.assertEqual(result.returncode, 3, result.stderr)
self.assertNotIn("update-sdk", result.stderr)
result = self.run_tt("update-sdk", environment={"TACTILITY_SDK_PATH": local_path})
self.assertEqual(result.returncode, 1)
self.assertIn("local SDK", result.stderr)
def test_internal_tools_are_not_commands(self):
self.write_manifest(self.work)
result = self.run_tt("_bootstrap")
self.assertEqual(result.returncode, 1)
self.assertIn("command not found", result.stderr)
self.assertNotIn("_bootstrap", self.run_tt("help").stdout)
if __name__ == "__main__":
unittest.main()

View File

@ -252,6 +252,8 @@ def write_spiram_variables(output_file, device_properties: dict):
# Reduce IRAM usage
output_file.write("CONFIG_SPIRAM_USE_MALLOC=y\n")
output_file.write("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y\n")
# TLS sessions (handshakes, record buffers) otherwise take internal RAM
output_file.write("CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y\n")
# Performance improvements
if idf_target == "esp32s3":
apply_fix = get_property_or_default(device_properties, "hardware.spiRamXipDisabled", "false").lower()

10
tt Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# Runs tt.py from the directory of this script, on Linux and macOS
directory=$(dirname "$0")
if command -v python3 >/dev/null 2>&1; then
exec python3 "$directory/tt.py" "$@"
elif command -v python >/dev/null 2>&1; then
exec python "$directory/tt.py" "$@"
fi
echo "tt: Python 3 not found" >&2
exit 1

9
tt.cmd Executable file
View File

@ -0,0 +1,9 @@
@echo off
rem Runs tt.py from the directory of this script, on Windows
where py >nul 2>nul
if %ERRORLEVEL% equ 0 (
py -3 "%~dp0tt.py" %*
) else (
python "%~dp0tt.py" %*
)
exit /b %ERRORLEVEL%

273
tt.py Executable file
View File

@ -0,0 +1,273 @@
#!/usr/bin/env python3
# License: Apache License v2
"""Runs the Tactility developer tools of the SDK version that an app targets.
"tt <command> [arguments]" runs <command>.py from the tools of that SDK version.
When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them.
Both ~/.tactility and TACTILITY_SDK_PATH hold sdk/<version>/tools/ and sdk/<version>/platform-<platform>/TactilitySDK/.
The tt (Linux, macOS) and tt.cmd (Windows) wrappers next to this script run it.
The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import zipfile
TT_VERSION = "1.0.0"
CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org")
HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility")
INDEX_VALIDITY_SECONDS = 3600
COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$")
def fail(message):
print(f"tt: {message}", file=sys.stderr)
sys.exit(1)
def warn(message):
print(f"tt: {message}", file=sys.stderr)
def download(url, path):
"""Downloads to a temporary file next to path, so a failed download keeps the existing file"""
request = urllib.request.Request(url, headers={"User-Agent": f"tt.py {TT_VERSION}"})
partial_path = f"{path}.part"
try:
with urllib.request.urlopen(request, timeout=30) as response, open(partial_path, "wb") as file:
shutil.copyfileobj(response, file)
except OSError as error:
if os.path.exists(partial_path):
os.remove(partial_path)
warn(f"failed to download {url}: {error}")
return False
os.replace(partial_path, path)
return True
def read_properties(path):
properties = {}
with open(path) as file:
for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, separator, value = line.partition("=")
if separator:
properties[key.strip()] = value.strip()
return properties
def find_manifest():
if os.path.isfile("manifest.properties"):
return "manifest.properties"
for apps_directory in ("Apps", "apps"):
if not os.path.isdir(apps_directory):
continue
for name in sorted(os.listdir(apps_directory)):
path = os.path.join(apps_directory, name, "manifest.properties")
if os.path.isfile(path):
return path
return None
def resolve_target():
"""Returns the SDK version and platforms of the app manifest"""
manifest_path = find_manifest()
if manifest_path is None:
fail("no manifest.properties found in . or Apps/*/ or apps/*/")
manifest = read_properties(manifest_path)
version = manifest.get("target.sdk")
if not version:
fail(f"target.sdk not found in {manifest_path}")
if not re.match(r"^[0-9A-Za-z.+-]+$", version) or ".." in version:
fail(f"invalid target.sdk in {manifest_path}: {version}")
platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()]
return version, platforms
def sdk_url(version, file_name):
return f"{CDN_URL}/sdk/{version}/{file_name}"
def extract_zip(zip_path, target_directory):
"""Extracts into a new directory and then replaces target_directory, refusing entries outside of it"""
staging_directory = f"{target_directory}.new"
shutil.rmtree(staging_directory, ignore_errors=True)
os.makedirs(staging_directory)
root = os.path.realpath(staging_directory)
with zipfile.ZipFile(zip_path) as archive:
for member in archive.infolist():
destination = os.path.realpath(os.path.join(root, member.filename))
if not destination.startswith(root + os.sep):
shutil.rmtree(staging_directory, ignore_errors=True)
fail(f"invalid entry in {zip_path}: {member.filename}")
archive.extractall(root)
shutil.rmtree(target_directory, ignore_errors=True)
os.replace(staging_directory, target_directory)
def file_sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as file:
for block in iter(lambda: file.read(65536), b""):
digest.update(block)
return digest.hexdigest()
def load_index(version, version_directory):
index_path = os.path.join(version_directory, "index.json")
is_fresh = os.path.isfile(index_path) and time.time() - os.path.getmtime(index_path) < INDEX_VALIDITY_SECONDS
if not is_fresh and not download(sdk_url(version, "index.json"), index_path):
if not os.path.isfile(index_path):
fail(f"SDK {version} is not available: check your internet connection and that this SDK version exists")
warn(f"using the cached information of SDK {version}")
with open(index_path) as file:
return json.load(file)
def version_path(sdk_path, version):
return os.path.join(sdk_path, "sdk", version)
def install_tools(version, version_directory, index):
tools = index.get("tools")
if tools is None:
fail(f"SDK {version} has no tools")
tools_directory = os.path.join(version_directory, "tools")
hash_path = os.path.join(version_directory, "tools.sha256")
if os.path.isdir(tools_directory) and os.path.isfile(hash_path):
with open(hash_path) as file:
if file.read().strip() == tools["sha256"]:
return
print(f"Downloading the tools of SDK {version}", file=sys.stderr)
zip_path = os.path.join(version_directory, "tools.zip")
if not download(sdk_url(version, tools["file"]), zip_path):
if os.path.isdir(tools_directory):
warn(f"using the cached tools of SDK {version}")
return
fail(f"failed to download the tools of SDK {version}")
if file_sha256(zip_path) != tools["sha256"]:
os.remove(zip_path)
fail(f"the tools of SDK {version} don't match their checksum")
extract_zip(zip_path, tools_directory)
os.remove(zip_path)
with open(hash_path, "w") as file:
file.write(tools["sha256"])
def install_platforms(version, version_directory, index, platforms):
available = index.get("platforms", {})
for platform in platforms:
platform_directory = os.path.join(version_directory, f"platform-{platform}")
if os.path.isdir(os.path.join(platform_directory, "TactilitySDK")):
continue
if platform not in available:
fail(f"platform {platform} is not available in SDK {version}")
print(f"Downloading SDK {version} for {platform}", file=sys.stderr)
zip_path = os.path.join(version_directory, f"platform-{platform}.zip")
if not download(sdk_url(version, available[platform]), zip_path):
fail(f"failed to download SDK {version} for {platform}")
extract_zip(zip_path, os.path.join(platform_directory, "TactilitySDK"))
os.remove(zip_path)
# The commit of the SDK build, to find out when a -dev version was published again
with open(os.path.join(platform_directory, "commit"), "w") as file:
file.write(index.get("gitCommit", ""))
def prepare_sdk(version, platforms):
version_directory = version_path(HOME_DIRECTORY, version)
os.makedirs(version_directory, exist_ok=True)
index = load_index(version, version_directory)
install_tools(version, version_directory, index)
install_platforms(version, version_directory, index, platforms)
return version_directory
def is_command(name):
"""Tools named _*.py are internal to the tools and are never commands"""
return COMMAND_PATTERN.match(name) is not None and not name.startswith("_")
def list_commands(tools_directory):
return sorted(name[:-3] for name in os.listdir(tools_directory) if name.endswith(".py") and is_command(name[:-3]))
def print_help(version, tools_directory):
"""Without an app manifest, the version and its commands are unknown"""
print("Usage: tt <command> [arguments]")
print("")
if version is None:
print("Run this in an app folder, or in a folder with Apps/*/ or apps/*/, to list the commands of its SDK")
else:
print(f"SDK {version}, commands:")
for command in list_commands(tools_directory):
print(f" {command}")
print("")
print("The SDK and tools of TACTILITY_SDK_PATH are used when it is set")
def tool_environment(version, sdk_path, is_local):
environment = dict(os.environ)
environment["TACTILITY_SDK_VERSION"] = version
environment["TACTILITY_SDK_PATH"] = sdk_path
environment["TT_DIRECTORY"] = os.path.dirname(os.path.abspath(__file__))
environment["TT_VERSION"] = TT_VERSION
# The SDK in TACTILITY_SDK_PATH is managed by tt unless it is local
if is_local:
environment["TT_LOCAL"] = "1"
else:
environment.pop("TT_LOCAL", None)
return environment
def run_tool(tool_path, arguments, environment):
command = [sys.executable, tool_path, *arguments]
if os.name == "posix":
sys.stdout.flush()
sys.stderr.flush()
os.execve(sys.executable, command, environment)
sys.exit(subprocess.run(command, env=environment).returncode)
def main(arguments):
command = arguments[0] if arguments else None
is_help = command is None or command in ("help", "--help")
if not is_help and not is_command(command):
fail(f"command not found: {command}")
if is_help and find_manifest() is None:
print_help(None, None)
return
version, platforms = resolve_target()
# Empty counts as unset, like after "export TACTILITY_SDK_PATH="
local_path = os.environ.get("TACTILITY_SDK_PATH") or None
is_local = local_path is not None
if is_local:
sdk_path = os.path.abspath(local_path)
print(f"tt: using the local SDK at {sdk_path}", file=sys.stderr)
tools_directory = os.path.join(version_path(sdk_path, version), "tools")
if not os.path.isdir(tools_directory):
fail(f"tools not found: {tools_directory}")
else:
sdk_path = HOME_DIRECTORY
tools_directory = os.path.join(prepare_sdk(version, platforms), "tools")
environment = tool_environment(version, sdk_path, is_local)
if is_help:
print_help(version, tools_directory)
return
tool_path = os.path.join(tools_directory, f"{command}.py")
if not os.path.isfile(tool_path):
fail(f"command not found: {command}")
run_tool(tool_path, arguments[1:], environment)
if __name__ == "__main__":
main(sys.argv[1:])