mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-10-16 18:35:06 +00:00
Development service and sdk/app build scripting refactored (#695)
This commit is contained in:
parent
d0ad1b014c
commit
a485dbbf94
10
.github/actions/build-sdk-posix/action.yml
vendored
10
.github/actions/build-sdk-posix/action.yml
vendored
@ -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:
|
||||
|
||||
10
.github/actions/build-sdk/action.yml
vendored
10
.github/actions/build-sdk/action.yml
vendored
@ -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
16
.github/actions/build-tools/action.yml
vendored
Normal 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
17
.github/actions/build-tt/action.yml
vendored
Normal 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
16
.github/actions/publish-tt/action.yml
vendored
Normal 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 }}
|
||||
43
.github/workflows/build.yml
vendored
43
.github/workflows/build.yml
vendored
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
61
Buildscripts/CDN/tactility.py
Normal file
61
Buildscripts/CDN/tactility.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
100
Buildscripts/TactilitySDK/Tools/_bootstrap.py
Executable file
100
Buildscripts/TactilitySDK/Tools/_bootstrap.py
Executable 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()
|
||||
23
Buildscripts/TactilitySDK/Tools/_device_host.py
Normal file
23
Buildscripts/TactilitySDK/Tools/_device_host.py
Normal 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")
|
||||
@ -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":
|
||||
26
Buildscripts/TactilitySDK/Tools/host.py
Executable file
26
Buildscripts/TactilitySDK/Tools/host.py
Executable 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()
|
||||
23
Buildscripts/TactilitySDK/Tools/pull.py
Executable file
23
Buildscripts/TactilitySDK/Tools/pull.py
Executable 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()
|
||||
23
Buildscripts/TactilitySDK/Tools/push.py
Executable file
23
Buildscripts/TactilitySDK/Tools/push.py
Executable 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()
|
||||
679
Buildscripts/TactilitySDK/Tools/shell.py
Executable file
679
Buildscripts/TactilitySDK/Tools/shell.py
Executable 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()
|
||||
320
Buildscripts/TactilitySDK/Tools/ui.py
Normal file
320
Buildscripts/TactilitySDK/Tools/ui.py
Normal 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()
|
||||
98
Buildscripts/TactilitySDK/Tools/update-sdk.py
Executable file
98
Buildscripts/TactilitySDK/Tools/update-sdk.py
Executable 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()
|
||||
61
Buildscripts/TactilitySDK/Tools/update-self.py
Executable file
61
Buildscripts/TactilitySDK/Tools/update-self.py
Executable 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
20
Buildscripts/release-current.py
Executable 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")
|
||||
@ -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__":
|
||||
|
||||
20
Buildscripts/release-tools-current.py
Executable file
20
Buildscripts/release-tools-current.py
Executable 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
41
Buildscripts/release-tt.py
Executable 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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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").
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
26
Modules/development-module/CMakeLists.txt
Normal file
26
Modules/development-module/CMakeLists.txt
Normal 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}
|
||||
)
|
||||
195
Modules/development-module/LICENSE-Apache-2.0.md
Normal file
195
Modules/development-module/LICENSE-Apache-2.0.md
Normal file
@ -0,0 +1,195 @@
|
||||
Apache License
|
||||
==============
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
### 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.
|
||||
|
||||
36
Modules/development-module/README.md
Normal file
36
Modules/development-module/README.md
Normal 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)
|
||||
14
Modules/development-module/include/development/module.h
Normal file
14
Modules/development-module/include/development/module.h
Normal 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
|
||||
88
Modules/development-module/include/development/server.h
Normal file
88
Modules/development-module/include/development/server.h
Normal 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
|
||||
27
Modules/development-module/private/development/credentials.h
Normal file
27
Modules/development-module/private/development/credentials.h
Normal 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]);
|
||||
102
Modules/development-module/private/development/protocol.h
Normal file
102
Modules/development-module/private/development/protocol.h
Normal 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]);
|
||||
73
Modules/development-module/private/development/requests.h
Normal file
73
Modules/development-module/private/development/requests.h
Normal 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);
|
||||
62
Modules/development-module/private/development/session.h
Normal file
62
Modules/development-module/private/development/session.h
Normal 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);
|
||||
24
Modules/development-module/private/development/trust.h
Normal file
24
Modules/development-module/private/development/trust.h
Normal 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);
|
||||
117
Modules/development-module/source/app_requests.cpp
Normal file
117
Modules/development-module/source/app_requests.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
238
Modules/development-module/source/credentials.cpp
Normal file
238
Modules/development-module/source/credentials.cpp
Normal 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;
|
||||
}
|
||||
55
Modules/development-module/source/module.cpp
Normal file
55
Modules/development-module/source/module.cpp
Normal 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,
|
||||
};
|
||||
|
||||
}
|
||||
124
Modules/development-module/source/protocol.cpp
Normal file
124
Modules/development-module/source/protocol.cpp
Normal 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);
|
||||
}
|
||||
193
Modules/development-module/source/requests.cpp
Normal file
193
Modules/development-module/source/requests.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
638
Modules/development-module/source/server.cpp
Normal file
638
Modules/development-module/source/server.cpp
Normal 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();
|
||||
}
|
||||
109
Modules/development-module/source/session.cpp
Normal file
109
Modules/development-module/source/session.cpp
Normal 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);
|
||||
}
|
||||
329
Modules/development-module/source/session_app.cpp
Normal file
329
Modules/development-module/source/session_app.cpp
Normal 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 },
|
||||
};
|
||||
86
Modules/development-module/source/sync.cpp
Normal file
86
Modules/development-module/source/sync.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
197
Modules/development-module/source/trust.cpp
Normal file
197
Modules/development-module/source/trust.cpp
Normal 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;
|
||||
}
|
||||
24
Modules/development-module/tests/CMakeLists.txt
Normal file
24
Modules/development-module/tests/CMakeLists.txt
Normal 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
|
||||
)
|
||||
47
Modules/development-module/tests/source/main.cpp
Normal file
47
Modules/development-module/tests/source/main.cpp
Normal 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;
|
||||
}
|
||||
100
Modules/development-module/tests/source/protocol_test.cpp
Normal file
100
Modules/development-module/tests/source/protocol_test.cpp
Normal 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);
|
||||
}
|
||||
1002
Modules/development-module/tests/source/server_test.cpp
Normal file
1002
Modules/development-module/tests/source/server_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ list(APPEND REQUIRES_LIST
|
||||
cjson-module
|
||||
cpp-symbols-module
|
||||
crypt-module
|
||||
development-module
|
||||
freertos-module
|
||||
gps-module
|
||||
graphics-module
|
||||
|
||||
30
Tactility/Private/Tactility/app/development/Fingerprint.h
Normal file
30
Tactility/Private/Tactility/app/development/Fingerprint.h
Normal 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
|
||||
@ -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();
|
||||
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
namespace tt::service::development {
|
||||
|
||||
void setEnableOnBoot(bool enable);
|
||||
|
||||
bool shouldEnableOnBoot();
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
177
Tactility/Source/app/developmentprompt/DevelopmentPrompt.cpp
Normal file
177
Tactility/Source/app/developmentprompt/DevelopmentPrompt.cpp
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
};
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ target_link_libraries(TactilityTests PRIVATE
|
||||
cjson-module
|
||||
cpp-symbols-module
|
||||
crypt-module
|
||||
development-module
|
||||
freertos-module
|
||||
gps-module
|
||||
graphics-module
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
344
Tests/Python/tt_test.py
Normal 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()
|
||||
@ -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
10
tt
Executable 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
9
tt.cmd
Executable 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
273
tt.py
Executable 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:])
|
||||
Loading…
x
Reference in New Issue
Block a user