diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml index 5e6bd1df9..566730876 100644 --- a/.github/actions/build-sdk-posix/action.yml +++ b/.github/actions/build-sdk-posix/action.yml @@ -42,7 +42,7 @@ runs: 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/ - python3 -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')" + cp -r Buildscripts/TactilitySDK/NewProject test_sdk/sdk/$(cat version.txt)/tools/NewProject - name: 'Test Integration' shell: bash env: @@ -54,12 +54,12 @@ runs: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} run: | SDK_VERSION=$(cat version.txt) - # The answers are: generate, SDK version, project type, package id, app id and app name. An empty line takes the default. + # The answers are: SDK version, project type, package id, app id and app name. An empty line takes the default. # A module has no app id or app name, so the extra lines are ignored. for type in "" "terminal" "module"; do project_path="$RUNNER_TEMP/new-project-${type:-gui}" mkdir -p "$project_path" - (cd "$project_path" && printf 'y\n%s\n%s\n\n\n\n' "$SDK_VERSION" "$type" | TACTILITY_SDK_PATH="$GITHUB_WORKSPACE/test_sdk" python3 "$GITHUB_WORKSPACE/tt.py" app build -a $TACTILITY_ARCH) + (cd "$project_path" && printf '%s\n%s\n\n\n\n' "$SDK_VERSION" "$type" | TACTILITY_SDK_PATH="$GITHUB_WORKSPACE/test_sdk" python3 "$GITHUB_WORKSPACE/tt.py" app create && TACTILITY_SDK_PATH="$GITHUB_WORKSPACE/test_sdk" python3 "$GITHUB_WORKSPACE/tt.py" app build -a $TACTILITY_ARCH) # The project must have produced a binary for the manifest's entry test -f "$project_path/build/package-intermediate/bin/$TACTILITY_ARCH/main.so" done diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index 1ea7394e0..4bb7d54b8 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -40,7 +40,7 @@ runs: 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/ - python -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')" + cp -r Buildscripts/TactilitySDK/NewProject test_sdk/sdk/$(cat version.txt)/tools/NewProject - name: 'Test Integration' uses: espressif/esp-idf-ci-action@v1 with: diff --git a/Buildscripts/CDN/generate-sdk-files.py b/Buildscripts/CDN/generate-sdk-files.py index a7149a866..c482229c8 100644 --- a/Buildscripts/CDN/generate-sdk-files.py +++ b/Buildscripts/CDN/generate-sdk-files.py @@ -8,10 +8,9 @@ import shutil import zipfile -from new_project import create_new_project_zip - VERBOSE = False TOOLS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "Tools") +NEW_PROJECT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "NewProject") @dataclass class SdkIndex: @@ -20,7 +19,6 @@ class SdkIndex: gitCommit: str platforms: dict tools: dict - newProject: dict simulators: dict shell_color_red = "\033[91m" @@ -49,7 +47,7 @@ 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""" + """Bundles the tools that tt.py runs for this SDK version, with the project templates of "tt app create" in NewProject/""" if VERBOSE: print(f"Archiving {TOOLS_PATH} to {out_path}/tools.zip") archive_path = os.path.join(out_path, "tools.zip") @@ -57,6 +55,11 @@ def create_tools_bundle(out_path: str): 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) + for directory, directory_names, file_names in os.walk(NEW_PROJECT_PATH): + directory_names.sort() + for file_name in sorted(file_names): + file_path = os.path.join(directory, file_name) + archive.write(file_path, os.path.join("NewProject", os.path.relpath(file_path, NEW_PROJECT_PATH))) with open(archive_path, "rb") as archive: sha256 = hashlib.sha256(archive.read()).hexdigest() return {"file": "tools.zip", "sha256": sha256} @@ -84,7 +87,6 @@ def main(in_path: str, out_path: str, version: str): gitCommit=get_git_commit_hash(), platforms={}, tools=create_tools_bundle(out_path), - newProject=create_new_project_zip(out_path), simulators={} ) for artifact_directory in artifact_directories: diff --git a/Buildscripts/CDN/new_project.py b/Buildscripts/CDN/new_project.py deleted file mode 100644 index a7d7204bc..000000000 --- a/Buildscripts/CDN/new_project.py +++ /dev/null @@ -1,19 +0,0 @@ -import hashlib -import os -import zipfile - -NEW_PROJECT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "NewProject") -ARCHIVE_NAME = "new-project.zip" - -def create_new_project_zip(out_path: str): - """Zips the project templates that tt.py generates a new app from. Returns the entry of index.json.""" - archive_path = os.path.join(out_path, ARCHIVE_NAME) - with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: - for directory, directory_names, file_names in os.walk(NEW_PROJECT_PATH): - directory_names.sort() - for file_name in sorted(file_names): - file_path = os.path.join(directory, file_name) - archive.write(file_path, os.path.relpath(file_path, NEW_PROJECT_PATH)) - with open(archive_path, "rb") as archive: - sha256 = hashlib.sha256(archive.read()).hexdigest() - return {"file": ARCHIVE_NAME, "sha256": sha256} diff --git a/Buildscripts/TactilitySDK/Tools/app.py b/Buildscripts/TactilitySDK/Tools/app.py index a92b97fa5..72c23db88 100755 --- a/Buildscripts/TactilitySDK/Tools/app.py +++ b/Buildscripts/TactilitySDK/Tools/app.py @@ -10,6 +10,9 @@ import _device_host +ID_PATTERN = re.compile(r"^[0-9A-Za-z.]{5,32}$") +NAME_PATTERN = re.compile(r"^[0-9A-Za-z -]{2,32}$") +PROJECT_VARIANTS = ("gui", "terminal", "module") ttbuild_version = "8.0.0" verbose = False # Compile the ESP-IDF and SDK components through ccache (see TactilitySDK.esp32.cmake) @@ -34,6 +37,9 @@ def print_help(): print("") print("Actions:") print("") + print(" create Create a new app or module project in the current directory.") + print(" Asks for the project type, package id, app id and app name.") + print("") print(" build Build the app. Optionally specify a platform.") print(" Supported platforms are lower case. Example: esp32s3") print(" Supported platforms are read from manifest.properties (target.platforms=all for every installed SDK platform)") @@ -72,6 +78,7 @@ def print_help(): print(" --verbose Show extra console output") print("") print("Examples:") + print(f" {command} create") 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") @@ -791,6 +798,58 @@ def uninstall_action(manifest, arguments): device.uninstall(host, device.DEFAULT_PORT, app_id) print_status_success("Uninstalled") +#region Create + +def ask(question, default, is_valid, description): + """Asks until the answer is valid. An empty answer or the end of the input gives the default.""" + while True: + try: + answer = input(f"{question} [{default}]: ").strip() + except EOFError: + answer = "" + if not answer: + return default + if is_valid(answer): + return answer + print_error(description) + +def create_action(): + if tt_sdk_version is None: + exit_with_error("TACTILITY_SDK_VERSION is not set: run this tool as 'tt app create'") + if os.path.exists("manifest.properties") or os.path.exists("main"): + exit_with_error("This folder already has a manifest.properties or main/") + templates_path = os.path.join(tool_directory, "NewProject") + if not os.path.isdir(templates_path): + exit_with_error(f"SDK {tt_sdk_version} has no new project templates") + variant = ask("Project type (gui, terminal or module)", "gui", lambda answer: answer in PROJECT_VARIANTS, "Use gui, terminal or module") + package_id = ask("Package id", "example.helloworld", ID_PATTERN.match, "Use 5 to 32 letters, digits and dots") + app_id = app_name = "" + if variant != "module": + app_id = ask("App id", "helloworld", ID_PATTERN.match, "Use 5 to 32 letters, digits and dots") + app_name = ask("App name", "Hello World", NAME_PATTERN.match, "Use 2 to 32 letters, digits, spaces and dashes") + placeholders = (("@SDK_VERSION@", tt_sdk_version), ("@PACKAGE_ID@", package_id), ("@APP_ID@", app_id), ("@APP_NAME@", app_name)) + variant_path = os.path.join(templates_path, variant) + if not os.path.isfile(os.path.join(variant_path, "manifest.properties")): + exit_with_error(f"SDK {tt_sdk_version} has no {variant} project template") + for directory, directory_names, file_names in os.walk(variant_path): + directory_names.sort() + for file_name in sorted(file_names): + source = os.path.join(directory, file_name) + relative_path = os.path.relpath(source, variant_path) + with open(source, "rb") as file: + content = file.read() + if relative_path == "manifest.properties": + text = content.decode("utf-8") + for placeholder, value in placeholders: + text = text.replace(placeholder, value) + content = text.encode("utf-8") + os.makedirs(os.path.dirname(relative_path) or ".", exist_ok=True) + with open(relative_path, "wb") as file: + file.write(content) + print(f"Created {relative_path}") + +#endregion Create + #region Main if __name__ == "__main__": @@ -818,6 +877,11 @@ def uninstall_action(manifest, arguments): verbose = parsed_command.arguments.has("verbose") use_ccache = not parsed_command.arguments.has("no-ccache") + # Creating a project and installing an app file don't need an app project + if parsed_command.action == "create": + create_action() + sys.exit() + # Installing an app file doesn't need an app project if parsed_command.arguments.has("app-file"): if not install_action(None, parsed_command.arguments): diff --git a/Buildscripts/TactilitySDK/Tools/host.py b/Buildscripts/TactilitySDK/Tools/host.py deleted file mode 100755 index 8520692c7..000000000 --- a/Buildscripts/TactilitySDK/Tools/host.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# License: Apache License v2 -"""Shows or stores the host name of the device in .tactility/device_host. -"tt host" prints the host or "none", "tt host " stores it.""" - -import sys - -import _device_host - - -def main(): - if len(sys.argv) == 1: - print(_device_host.read_device_host() or "none") - elif len(sys.argv) == 2 and sys.argv[1].strip(): - host = sys.argv[1].strip() - _device_host.write_device_host(host) - print(f"Device host: {host}") - else: - print("usage: tt host [hostname]", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - import _bootstrap - _bootstrap.run() - main() diff --git a/Buildscripts/release-tools-current.py b/Buildscripts/release-tools-current.py index f0bc5ce9a..745297a07 100755 --- a/Buildscripts/release-tools-current.py +++ b/Buildscripts/release-tools-current.py @@ -1,15 +1,13 @@ #!/usr/bin/env python3 -"""Exports the tools that tt runs to release/sdk//tools/ and the new project to release/sdk//new-project.zip, for tt with TACTILITY_SDK_PATH=release.""" +"""Exports the tools that tt runs, with the new project templates in NewProject/, to release/sdk//tools/, for tt with TACTILITY_SDK_PATH=release.""" import os import shutil import sys ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) -sys.path.insert(0, os.path.join(ROOT_PATH, "Buildscripts", "CDN")) - -from new_project import create_new_project_zip TOOLS_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "Tools") +NEW_PROJECT_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "NewProject") if __name__ == "__main__": @@ -21,6 +19,5 @@ 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)) - # Next to the tools, where tt looks for the new project of a local SDK - create_new_project_zip(os.path.dirname(output_path)) + shutil.copytree(NEW_PROJECT_PATH, os.path.join(output_path, "NewProject")) print(f"Exported the tools to {output_path}") diff --git a/Buildscripts/test-new-project.sh b/Buildscripts/test-new-project.sh index 5c435f9a7..9cb188651 100755 --- a/Buildscripts/test-new-project.sh +++ b/Buildscripts/test-new-project.sh @@ -13,8 +13,9 @@ rm -rf "$project" mkdir -p "$project" cd "$project" -# The answers are: generate, SDK version, project type and package id. An empty line takes the default. -printf 'y\n%s\nmodule\n\n' "$version" | TACTILITY_SDK_PATH="$root/test_sdk" python "$root/tt.py" app build -a "$platform" +# The answers are: SDK version, project type and package id. An empty line takes the default. +printf '%s\nmodule\n\n' "$version" | TACTILITY_SDK_PATH="$root/test_sdk" python "$root/tt.py" app create +TACTILITY_SDK_PATH="$root/test_sdk" python "$root/tt.py" app build -a "$platform" # The entry point of a module is renamed by the ELF build. A binary without one is tiny. test "$(stat -c %s "build/package-intermediate/bin/$platform/main.elf")" -gt 600 diff --git a/Modules/development-module/README.md b/Modules/development-module/README.md index e73c2e9ea..65571f90b 100644 --- a/Modules/development-module/README.md +++ b/Modules/development-module/README.md @@ -31,6 +31,10 @@ The C API is in ``. - `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]` +7. In `info` mode, no client certificate is needed and the client isn't prompted for. The server answers with one JSON line and closes the connection: + `{"status":"accepted","info":{"vendor":"LilyGO","name":"T-Deck","id":"lilygo-tdeck","version":"0.8.0"}}` + The vendor is empty for devices without one. + `Buildscripts/TactilitySDK/Tools/shell.py` is a client for this server. App developers run it as `tt shell`, and copy files with `tt push` and `tt pull`. License: [Apache v2.0](LICENSE-Apache-2.0.md) diff --git a/Modules/development-module/private/development/protocol.h b/Modules/development-module/private/development/protocol.h index e7084d172..cc8bc9fd2 100644 --- a/Modules/development-module/private/development/protocol.h +++ b/Modules/development-module/private/development/protocol.h @@ -78,6 +78,12 @@ DevelopmentHelloError development_protocol_parse_hello(const char* json, size_t */ size_t development_protocol_format_status(const char* status, const char* message, char* out_line, size_t out_size); +/** + * Writes the JSON line that answers an "info" hello, including its newline. + * @return the line length, or 0 when @a out_size is too small + */ +size_t development_protocol_format_info(const char* vendor, const char* name, const char* id, const char* version, char* out_line, size_t out_size); + struct DevelopmentFrame { DevelopmentFrameType type; const uint8_t* payload; diff --git a/Modules/development-module/private/development/session.h b/Modules/development-module/private/development/session.h index 77c2f84f1..9e48b7726 100644 --- a/Modules/development-module/private/development/session.h +++ b/Modules/development-module/private/development/session.h @@ -47,6 +47,9 @@ bool development_session_write(DevelopmentSession* session, const void* data, si bool development_session_send_status(DevelopmentSession* session, const char* status, const char* message); +/** Sends the "accepted" line of an "info" hello, with the device information */ +bool development_session_send_info(DevelopmentSession* session, const char* vendor, const char* name, const char* id, const char* version); + 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 */ diff --git a/Modules/development-module/source/protocol.cpp b/Modules/development-module/source/protocol.cpp index 165e25fdd..5291e4e52 100644 --- a/Modules/development-module/source/protocol.cpp +++ b/Modules/development-module/source/protocol.cpp @@ -64,7 +64,7 @@ DevelopmentHelloError development_protocol_parse_hello(const char* json, size_t *out_message = "unsupported protocol version"; return DevelopmentHelloError::UnsupportedProtocol; } - if (strcmp(hello.mode, "shell") != 0 && strcmp(hello.mode, "sync") != 0 && strcmp(hello.mode, "app") != 0) { + if (strcmp(hello.mode, "shell") != 0 && strcmp(hello.mode, "sync") != 0 && strcmp(hello.mode, "app") != 0 && strcmp(hello.mode, "info") != 0) { *out_message = "unsupported mode"; return DevelopmentHelloError::UnsupportedMode; } @@ -99,6 +99,29 @@ size_t development_protocol_format_status(const char* status, const char* messag return length + 1; } +size_t development_protocol_format_info(const char* vendor, const char* name, const char* id, const char* version, char* out_line, size_t out_size) { + cJSON* root = cJSON_CreateObject(); + if (root == nullptr) { + return 0; + } + const bool status_added = cJSON_AddStringToObject(root, "status", "accepted") != nullptr; + cJSON* info = status_added ? cJSON_AddObjectToObject(root, "info") : nullptr; + const bool info_added = info != nullptr + && cJSON_AddStringToObject(info, "vendor", vendor) != nullptr + && cJSON_AddStringToObject(info, "name", name) != nullptr + && cJSON_AddStringToObject(info, "id", id) != nullptr + && cJSON_AddStringToObject(info, "version", version) != nullptr; + const bool printed = info_added && out_size >= 2 && cJSON_PrintPreallocated(root, out_line, static_cast(out_size), 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; diff --git a/Modules/development-module/source/server.cpp b/Modules/development-module/source/server.cpp index fe5de01cc..e223f8fda 100644 --- a/Modules/development-module/source/server.cpp +++ b/Modules/development-module/source/server.cpp @@ -280,6 +280,13 @@ void handleConnection(ServerData* data, int socket, const sockaddr_in& address) return; } + // The device information isn't sensitive, so it's handed out without a client certificate or trust + if (strcmp(session->hello.mode, "info") == 0) { + development_session_send_info(session, CONFIG_TT_DEVICE_VENDOR, CONFIG_TT_DEVICE_NAME_SIMPLE, CONFIG_TT_DEVICE_ID, TT_VERSION); + 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"); diff --git a/Modules/development-module/source/session.cpp b/Modules/development-module/source/session.cpp index b2917bd48..6fa16dfce 100644 --- a/Modules/development-module/source/session.cpp +++ b/Modules/development-module/source/session.cpp @@ -97,6 +97,12 @@ bool development_session_send_status(DevelopmentSession* session, const char* st return length > 0 && development_session_write(session, line, length, STATUS_TIMEOUT_MS); } +bool development_session_send_info(DevelopmentSession* session, const char* vendor, const char* name, const char* id, const char* version) { + char line[256]; + const size_t length = development_protocol_format_info(vendor, name, id, version, 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; diff --git a/Modules/development-module/tests/source/protocol_test.cpp b/Modules/development-module/tests/source/protocol_test.cpp index 187cf24cd..ac4661d71 100644 --- a/Modules/development-module/tests/source/protocol_test.cpp +++ b/Modules/development-module/tests/source/protocol_test.cpp @@ -34,6 +34,33 @@ TEST_CASE("development_protocol_parse_hello accepts sync and app hellos") { CHECK_EQ(std::string(hello.mode), "app"); } +TEST_CASE("development_protocol_parse_hello accepts an info hello") { + DevelopmentHello hello {}; + REQUIRE_EQ(parse(R"({"protocol":1,"mode":"info","client":"c"})", hello), DevelopmentHelloError::None); + CHECK_EQ(std::string(hello.mode), "info"); + CHECK_EQ(parse(R"({"protocol":1,"mode":"info","client":"c","command":"echo hi"})", hello), DevelopmentHelloError::Malformed); +} + +TEST_CASE("development_protocol_format_info writes the device information") { + char line[256]; + const size_t length = development_protocol_format_info("LilyGO", "T-Deck", "lilygo-tdeck", "0.8.0", line, sizeof(line)); + CHECK_EQ(std::string(line, length), "{\"status\":\"accepted\",\"info\":{\"vendor\":\"LilyGO\",\"name\":\"T-Deck\",\"id\":\"lilygo-tdeck\",\"version\":\"0.8.0\"}}\n"); + CHECK_EQ(development_protocol_format_info("a", "b", "c", "d", line, 10), 0); +} + +TEST_CASE("development_protocol_format_info fits exactly in the smallest buffer that holds the line and its terminator") { + char probe[512]; + const size_t length = development_protocol_format_info("LilyGO", "T-Deck", "lilygo-tdeck", "0.8.0", probe, sizeof(probe)); + REQUIRE_GT(length, 0); + for (size_t size = 2; size < length + 4; size++) { + std::string guarded(size + 8, 'x'); + const size_t written = development_protocol_format_info("LilyGO", "T-Deck", "lilygo-tdeck", "0.8.0", guarded.data(), size); + INFO("size ", size); + CHECK_EQ(written > 0, size >= length + 1); + CHECK_EQ(guarded.substr(size), std::string(8, 'x')); + } +} + 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); diff --git a/Modules/development-module/tests/source/server_test.cpp b/Modules/development-module/tests/source/server_test.cpp index eb90cea2c..535f15081 100644 --- a/Modules/development-module/tests/source/server_test.cpp +++ b/Modules/development-module/tests/source/server_test.cpp @@ -411,6 +411,17 @@ constexpr auto* REJECTED = R"({"status":"rejected"})"; } // namespace +TEST_CASE("an info hello is answered without a prompt") { + ensureFixture(); + promptCount = 0; + + TestClient client(&identity(0)); + const std::string line = connectAndGreet(client, "info"); + CHECK_NE(line.find(R"("status":"accepted")"), std::string::npos); + CHECK_NE(line.find(std::string(R"("id":")") + CONFIG_TT_DEVICE_ID + "\""), std::string::npos); + CHECK_EQ(promptCount, 0); +} + TEST_CASE("a client that is accepted once gets a shell") { ensureFixture(); promptResult = DEVELOPMENT_PROMPT_ACCEPT_ONCE; diff --git a/Tests/Python/tt_test.py b/Tests/Python/tt_test.py index 326c01aa6..3f8f3b7cb 100644 --- a/Tests/Python/tt_test.py +++ b/Tests/Python/tt_test.py @@ -148,104 +148,264 @@ def test_missing_manifest_fails(self): self.assertEqual(result.returncode, 1) self.assertIn("no manifest.properties found", result.stderr) - def publish_new_project(self, files=None, checksum=None): + def publish_new_project(self, files=None): files = files or { "gui/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\n", "gui/main/Source/main.c": "// gui\n", "terminal/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\napp.0.headless=true\n", "terminal/main/Source/main.c": "// terminal\n", } - version_directory = os.path.join(self.cdn, "sdk", VERSION) - archive = make_zip(files) - with open(os.path.join(version_directory, "new-project.zip"), "wb") as file: - file.write(archive) - index_path = os.path.join(version_directory, "index.json") - with open(index_path) as file: - index = json.load(file) - index["newProject"] = {"file": "new-project.zip", "sha256": checksum or hashlib.sha256(archive).hexdigest()} - with open(index_path, "w") as file: - json.dump(index, file) + tools = self.default_tools() | {"app.py": read_tool("app.py"), "_device_host.py": read_tool("_device_host.py")} + tools |= {f"NewProject/{name}": content for name, content in files.items()} + self.publish_sdk(tools) def read_work_file(self, *parts): with open(os.path.join(self.work, *parts)) as file: return file.read() - def test_new_project_uses_defaults_and_the_gui_variant(self): + def test_app_create_uses_defaults_and_the_gui_variant(self): self.publish_new_project() - result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") - self.assertEqual(result.returncode, 3, result.stderr) + result = self.run_tt("app", "create", input_text=f"{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=example.helloworld\napp.0.id=helloworld\napp.0.name=Hello World\n") self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// gui\n") - self.assertEqual(json.loads(result.stdout)["version"], VERSION) - def test_new_project_with_answers_and_the_terminal_variant(self): + def test_app_create_with_answers_and_the_terminal_variant(self): self.publish_new_project() - result = self.run_tt("echo", input_text=f"yes\n{VERSION}\nterminal\nmy.package\nmyapp\nMy App\n") - self.assertEqual(result.returncode, 3, result.stderr) + result = self.run_tt("app", "create", input_text=f"{VERSION}\nterminal\nmy.package\nmyapp\nMy App\n") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=my.package\napp.0.id=myapp\napp.0.name=My App\napp.0.headless=true\n") self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// terminal\n") - def test_new_project_module_variant_skips_the_app_questions(self): + def test_app_create_module_variant_skips_the_app_questions(self): self.publish_new_project(files={ "gui/manifest.properties": "gui\n", "module/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\nmodule.0.binary=main\n", "module/main/Source/main.c": "// module\n", }) - result = self.run_tt("echo", input_text=f"y\n{VERSION}\nmodule\nmy.package\n") - self.assertEqual(result.returncode, 3, result.stderr) + result = self.run_tt("app", "create", input_text=f"{VERSION}\nmodule\nmy.package\n") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=my.package\nmodule.0.binary=main\n") self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// module\n") - self.assertNotIn("App id", result.stderr) + self.assertNotIn("App id", result.stdout) - def test_new_project_asks_again_for_invalid_answers(self): + def test_app_create_asks_again_for_invalid_answers(self): self.publish_new_project() - result = self.run_tt("echo", input_text=f"y\n..\n{VERSION}\nplugin\n\nab\nmy.package\n\nM\nMy App\n") - self.assertEqual(result.returncode, 3, result.stderr) + result = self.run_tt("app", "create", input_text=f"..\n{VERSION}\nplugin\n\nab\nmy.package\n\nM\nMy App\n") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertIn("invalid SDK version", result.stderr) - self.assertIn("use gui, terminal or module", result.stderr) - self.assertIn("use 5 to 32 letters, digits and dots", result.stderr) - self.assertIn("use 2 to 32 letters, digits, spaces and dashes", result.stderr) + self.assertIn("Use gui, terminal or module", result.stdout) + self.assertIn("Use 5 to 32 letters, digits and dots", result.stdout) + self.assertIn("Use 2 to 32 letters, digits, spaces and dashes", result.stdout) self.assertIn("id=my.package", self.read_work_file("manifest.properties")) - def test_new_project_rejects_a_wrong_checksum(self): - self.publish_new_project(checksum="0" * 64) - result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + def test_app_create_needs_the_sdk_to_have_templates(self): + self.publish_new_project(files={"other/manifest.properties": "x\n"}) + result = self.run_tt("app", "create", input_text=f"{VERSION}\n\n\n\n\n") self.assertEqual(result.returncode, 1) - self.assertIn("doesn't match its checksum", result.stderr) - self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties"))) + self.assertIn("has no gui project template", result.stdout) + self.assertFalse(os.path.exists(os.path.join(self.work, "main"))) - def test_new_project_needs_the_sdk_to_have_one(self): - result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") - self.assertEqual(result.returncode, 1) - self.assertIn("has no new project", result.stderr) - - def test_new_project_does_not_overwrite(self): + def test_app_create_from_the_local_sdk(self): + shutil.rmtree(self.cdn) + tools = os.path.join(self.root, "local", "sdk", VERSION, "tools") + os.makedirs(os.path.join(tools, "NewProject", "gui", "main")) + for name in ("app.py", "_bootstrap.py", "_device_host.py"): + shutil.copy(os.path.join(TOOLS_PATH, name), tools) + with open(os.path.join(tools, "NewProject", "gui", "manifest.properties"), "w") as file: + file.write("target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\n") + with open(os.path.join(tools, "NewProject", "gui", "main", "main.c"), "w") as file: + file.write("// local\n") + result = self.run_tt("app", "create", environment={"TACTILITY_SDK_PATH": os.path.join(self.root, "local")}, input_text=f"{VERSION}\n\n\n\n\n") + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("using the local SDK", result.stderr) + self.assertIn(f"target.sdk={VERSION}", self.read_work_file("manifest.properties")) + self.assertEqual(self.read_work_file("main", "main.c"), "// local\n") + + def test_app_create_does_not_overwrite(self): self.publish_new_project() os.makedirs(os.path.join(self.work, "main")) - result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n") + result = self.run_tt("app", "create", input_text=f"{VERSION}\n\n\n\n\n") self.assertEqual(result.returncode, 1) - self.assertIn("already has", result.stderr) - - def test_new_project_from_the_local_sdk(self): - local = os.path.join(self.root, "local") - os.makedirs(os.path.join(local, "sdk", VERSION, "tools")) - with open(os.path.join(local, "sdk", VERSION, "tools", "echo.py"), "w") as file: - file.write(ECHO_TOOL) - shutil.copy(os.path.join(TOOLS_PATH, "_bootstrap.py"), os.path.join(local, "sdk", VERSION, "tools")) - with open(os.path.join(local, "sdk", VERSION, "new-project.zip"), "wb") as file: - file.write(make_zip({"gui/manifest.properties": "target.sdk=@SDK_VERSION@\n"})) - result = self.run_tt("echo", environment={"TACTILITY_SDK_PATH": local}, input_text=f"y\n{VERSION}\n\n\n\n\n") - self.assertEqual(result.returncode, 3, result.stderr) - self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\n") + self.assertIn("already has", result.stdout) + + def test_host_is_built_in_and_needs_no_sdk(self): + shutil.rmtree(self.cdn) + result = self.run_tt("host") + self.assertEqual((result.returncode, result.stdout.strip()), (0, "none"), result.stderr) + result = self.run_tt("host", "127.0.0.1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Device host: 127.0.0.1", result.stdout) + self.assertEqual(self.read_work_file(".tactility", "device_host").strip(), "127.0.0.1") + result = self.run_tt("host") + self.assertEqual(result.stdout.strip(), "127.0.0.1") + result = self.run_tt("host", "a", "b") + self.assertEqual(result.returncode, 1) + self.assertIn("usage: tt host", result.stderr) + + def test_sdk_is_built_in_and_stores_the_version(self): + shutil.rmtree(self.cdn) + result = self.run_tt("sdk") + self.assertEqual((result.returncode, result.stdout.strip()), (0, "none"), result.stderr) + result = self.run_tt("sdk", "1.2.3") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.read_work_file(".tactility", "sdk_version").strip(), "1.2.3") + self.assertEqual(self.run_tt("sdk").stdout.strip(), f"1.2.3 (from local setting {os.path.join('.tactility', 'sdk_version')})") + self.write_manifest(self.work) + output = self.run_tt("sdk").stdout + self.assertIn(f"{VERSION} (from project manifest.properties)", output) + self.assertIn("1.2.3", output) + self.assertIn("not used", output) + os.remove(os.path.join(self.work, "manifest.properties")) + for arguments in (("sdk", "../x"), ("sdk", "1", "2")): + result = self.run_tt(*arguments) + self.assertEqual(result.returncode, 1) + self.assertIn("usage: tt sdk", result.stderr) + + def test_sdk_shows_the_device_version_when_it_leads(self): + import contextlib + import importlib.util + spec = importlib.util.spec_from_file_location("tt_module", TT_PATH) + tt = importlib.util.module_from_spec(spec) + spec.loader.exec_module(tt) + tt.query_device_info = lambda host: {"vendor": "V", "name": "N", "id": "i", "version": "4.5.6"} + previous = os.getcwd() + os.chdir(self.work) + try: + tt.write_stored(tt.DEVICE_HOST_PATH, "10.0.0.2") + output = io.StringIO() + with contextlib.redirect_stdout(output): + tt.sdk_command([]) + self.assertEqual(output.getvalue().strip(), "4.5.6 (from device 10.0.0.2)") + tt.write_stored(tt.SDK_VERSION_PATH, "1.2.3") + output = io.StringIO() + with contextlib.redirect_stdout(output): + tt.sdk_command([]) + self.assertIn("1.2.3 (from local setting", output.getvalue()) + finally: + os.chdir(previous) + + def load_tt_module(self): + import importlib.util + spec = importlib.util.spec_from_file_location("tt_module", TT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_device_reply_is_bounded_by_size_and_time(self): + import time + tt = self.load_tt_module() + + class Trickle: + """Sends one byte per call, never a newline""" + def __init__(self): + self.calls = 0 + def settimeout(self, _): + pass + def recv(self, size): + self.calls += 1 + time.sleep(0.01) + return b"x" + + trickle = Trickle() + self.assertIsNone(tt.read_bounded_line(trickle, time.monotonic() + 0.1)) + self.assertLess(trickle.calls, 50) + trickle = Trickle() + self.assertIsNone(tt.read_bounded_line(trickle, time.monotonic() + 60)) + self.assertEqual(trickle.calls, tt.DEVICE_REPLY_MAX_BYTES) + + class Reply: + def settimeout(self, _): + pass + def recv(self, size): + return b'{"a":1}\nrest' + self.assertEqual(tt.read_bounded_line(Reply(), time.monotonic() + 5), b'{"a":1}') + + def test_incomplete_device_info_is_rejected(self): + import contextlib + import time + tt = self.load_tt_module() + replies = [ + b'{"status":"accepted","info":{"version":"1.0.0"}}\n', + b'{"status":"accepted","info":{"vendor":"V","name":"N","id":"i"}}\n', + b'{"status":"accepted","info":{"vendor":"V","name":"N","id":3,"version":"1.0.0"}}\n', + b'{"status":"accepted","info":[]}\n', + ] + for reply in replies: + class Connection: + def __enter__(self): + return self + def __exit__(self, *_): + pass + class Secure(Connection): + def sendall(self, _): + pass + def settimeout(self, _): + pass + def recv(self, size, reply=reply): + return reply + class Context: + def __init__(self, *_): + pass + def wrap_socket(self, *_, **__): + return Secure() + tt.socket.create_connection = lambda *_: Connection() + tt.ssl.SSLContext = Context + errors = io.StringIO() + with contextlib.redirect_stderr(errors): + self.assertIsNone(tt.query_device_info("h"), reply) + self.assertIn("could not read the device info", errors.getvalue()) + + def test_sdk_validates_the_manifest_and_stored_version(self): + shutil.rmtree(self.cdn) + self.run_tt("sdk", "1.2.3") + for content, message in (("[target]\n", "target.sdk not found in manifest.properties"), ("target.sdk=\n", "target.sdk not found"), ("target.sdk=a/b\n", "invalid target.sdk in manifest.properties: a/b")): + with open(os.path.join(self.work, "manifest.properties"), "w") as file: + file.write(content) + result = self.run_tt("sdk") + self.assertEqual(result.returncode, 1, content) + self.assertIn(message, result.stderr) + self.assertNotIn("1.2.3", result.stdout) + os.remove(os.path.join(self.work, "manifest.properties")) + with open(os.path.join(self.work, ".tactility", "sdk_version"), "w") as file: + file.write("a/b\n") + result = self.run_tt("sdk") + self.assertNotIn("local setting", result.stdout) + + def test_host_does_not_query_the_old_host(self): + shutil.rmtree(self.cdn) + self.run_tt("host", "192.0.2.1") + result = self.run_tt("host", "127.0.0.1") + self.assertNotIn("192.0.2.1", result.stderr) def test_without_a_project_the_sdk_version_can_be_given(self): - result = self.run_tt("echo", "a", input_text=f"n\n{VERSION}\n") + result = self.run_tt("echo", "a", input_text=f"{VERSION}\n") self.assertEqual(result.returncode, 3, result.stderr) self.assertEqual(json.loads(result.stdout)["version"], VERSION) self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties"))) + def test_chosen_sdk_version_is_stored_in_the_current_folder(self): + result = self.run_tt("echo", input_text=f"{VERSION}\n") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(self.read_work_file(".tactility", "sdk_version").strip(), VERSION) + result = self.run_tt("echo") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(json.loads(result.stdout)["version"], VERSION) + self.assertNotIn("SDK version to use", result.stderr) + self.assertFalse(os.path.exists(os.path.join(self.home, ".tactility", "sdk_version"))) + + def test_project_sdk_beats_the_stored_sdk_version(self): + os.makedirs(os.path.join(self.work, ".tactility")) + with open(os.path.join(self.work, ".tactility", "sdk_version"), "w") as file: + file.write("9.9.9\n") + self.write_manifest(self.work) + result = self.run_tt("echo") + self.assertEqual(result.returncode, 3, result.stderr) + self.assertEqual(json.loads(result.stdout)["version"], VERSION) + self.assertNotIn("9.9.9", result.stderr) + def test_without_a_project_and_version_it_fails(self): - result = self.run_tt("echo", input_text="n\n\n") + result = self.run_tt("echo", input_text="\n") self.assertEqual(result.returncode, 1) self.assertIn("no manifest.properties found", result.stderr) diff --git a/tt.py b/tt.py index ca4fe6a32..e009387bd 100755 --- a/tt.py +++ b/tt.py @@ -7,30 +7,35 @@ Both ~/.tactility and TACTILITY_SDK_PATH hold sdk//tools/ and sdk//platform-/TactilitySDK/. The tt (Linux, macOS) and tt.ps1 (Windows PowerShell) wrappers next to this script run it. The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties. +Without a manifest, the SDK version that was chosen before is used (.tactility/sdk_version in the current folder), +else the version of the device that "tt host" stored, else it asks for one. +"tt host" and "tt sdk" are built in, because they need no SDK: they show or store the device host (.tactility/device_host) and the SDK version (.tactility/sdk_version). """ import hashlib -import io import json import os import re import shutil +import socket +import ssl import subprocess import sys import time import urllib.request import zipfile -TT_VERSION = "1.3.1" +TT_VERSION = "1.4.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 +DEVICE_PORT = 6667 +DEVICE_HOST_PATH = os.path.join(".tactility", "device_host") +SDK_VERSION_PATH = os.path.join(".tactility", "sdk_version") +DEVICE_TIMEOUT_SECONDS = 5 +DEVICE_REPLY_MAX_BYTES = 1024 COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$") VERSION_PATTERN = re.compile(r"^[0-9A-Za-z.+-]+$") -ID_PATTERN = re.compile(r"^[0-9A-Za-z.]{5,32}$") -NAME_PATTERN = re.compile(r"^[0-9A-Za-z -]{2,32}$") -NEW_PROJECT_ARCHIVE = "new-project.zip" -PROJECT_VARIANTS = ("gui", "terminal", "module") def fail(message): @@ -88,16 +93,23 @@ def is_valid_version(version): return VERSION_PATTERN.match(version) is not None and ".." not in version +def read_line(): + """Reads from the input one byte at a time, so the command that tt starts still gets the rest of a piped input""" + line = bytearray() + while True: + byte = os.read(0, 1) + if not byte or byte == b"\n": + return line.decode("utf-8", errors="replace").strip() + line += byte + + def ask(question, default=None, is_valid=None, description="invalid value"): """Asks until the answer is valid. An empty answer or the end of the input gives the default. Returns None when there is none.""" suffix = f" [{default}]" if default is not None else "" while True: - try: - # On stderr, so the prompts don't end up in the output of the command - print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True) - answer = input().strip() - except EOFError: - answer = "" + # On stderr, so the prompts don't end up in the output of the command + print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True) + answer = read_line() if not answer: return default if is_valid is None or is_valid(answer): @@ -105,105 +117,157 @@ def ask(question, default=None, is_valid=None, description="invalid value"): print(f"tt: {description}", file=sys.stderr) -def ask_yes_no(question, default): - answer = ask(f"{question} [{'Y/n' if default else 'y/N'}]") - if answer is None: - return default - return answer.lower() in ("y", "yes") +def read_stored(path): + try: + with open(path) as file: + return file.read().strip() or None + except OSError: + return None + + +def write_stored(path, value): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + file.write(f"{value}\n") -def read_new_project_archive(version): - """Returns the content of the new project archive of the SDK version, from the local SDK or else the CDN""" - local_path = os.environ.get("TACTILITY_SDK_PATH") or None - if local_path is not None: - archive_path = os.path.join(version_path(os.path.abspath(local_path), version), NEW_PROJECT_ARCHIVE) - if not os.path.isfile(archive_path): - fail(f"new project not found: {archive_path}") - with open(archive_path, "rb") as file: - return file.read() - version_directory = version_path(HOME_DIRECTORY, version) - os.makedirs(version_directory, exist_ok=True) - entry = load_index(version, version_directory).get("newProject") - if entry is None: - fail(f"SDK {version} has no new project") - archive_path = os.path.join(version_directory, NEW_PROJECT_ARCHIVE) - if not download(sdk_url(version, entry["file"]), archive_path): - fail(f"failed to download the new project of SDK {version}") +def read_device_host(): + return read_stored(DEVICE_HOST_PATH) + + +def read_bounded_line(connection, deadline): + """Returns the first line of the connection, or None when it has no newline within the size limit or before the deadline""" + data = bytearray() + while b"\n" not in data: + remaining = deadline - time.monotonic() + if remaining <= 0 or len(data) >= DEVICE_REPLY_MAX_BYTES: + return None + connection.settimeout(remaining) + chunk = connection.recv(DEVICE_REPLY_MAX_BYTES - len(data)) + if not chunk: + return None + data += chunk + return bytes(data.split(b"\n", 1)[0]) + + +def query_device_info(host): + """Returns the info of the device (vendor, name, id and version), or None when it can't be read""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # The info is public and the device has a self-signed certificate + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + hello = {"protocol": 1, "mode": "info", "client": "tt"} + deadline = time.monotonic() + DEVICE_TIMEOUT_SECONDS try: - with open(archive_path, "rb") as file: - content = file.read() - finally: - os.remove(archive_path) - if hashlib.sha256(content).hexdigest() != entry["sha256"]: - fail(f"the new project of SDK {version} doesn't match its checksum") - return content - - -def generate_project(version, package_id, app_id, app_name, variant): - """Extracts the new project of the SDK version into . and fills in the answers""" - root = os.path.realpath(".") - created = [] - with zipfile.ZipFile(io.BytesIO(read_new_project_archive(version))) as archive: - for member in archive.infolist(): - if member.is_dir() or not member.filename.startswith(f"{variant}/"): - continue - relative_path = member.filename[len(variant) + 1:] - destination = os.path.realpath(os.path.join(root, relative_path)) - if not destination.startswith(root + os.sep): - fail(f"invalid entry in the new project: {member.filename}") - content = archive.read(member) - if relative_path == "manifest.properties": - text = content.decode("utf-8") - for placeholder, value in (("@SDK_VERSION@", version), ("@PACKAGE_ID@", package_id), ("@APP_ID@", app_id), ("@APP_NAME@", app_name)): - text = text.replace(placeholder, value) - content = text.encode("utf-8") - os.makedirs(os.path.dirname(destination), exist_ok=True) - with open(destination, "wb") as file: - file.write(content) - created.append(relative_path) - if "manifest.properties" not in created: - fail(f"the new project of SDK {version} has no {variant} variant") - return created - - -def offer_new_project(): - """Asks to generate a new app or module project. Returns the manifest path when it did, else None""" - if not ask_yes_no("Generate a new project in this folder?", False): + with socket.create_connection((host, DEVICE_PORT), DEVICE_TIMEOUT_SECONDS) as connection: + with context.wrap_socket(connection, server_hostname=host) as secure: + secure.sendall((json.dumps(hello, separators=(",", ":")) + "\n").encode("utf-8")) + line = read_bounded_line(secure, deadline) + if line is None: + print(f"tt: could not read the device info of {host}: no complete reply in time", file=sys.stderr) + return None + info = json.loads(line)["info"] + if not isinstance(info, dict) or not all(isinstance(info.get(key), str) for key in ("vendor", "name", "id", "version")) or not is_valid_version(info["version"]): + print(f"tt: could not read the device info of {host}: incomplete reply", file=sys.stderr) + return None + return info + except (OSError, ValueError, KeyError, TypeError) as error: + print(f"tt: could not read the device info of {host}: {error}", file=sys.stderr) return None - if os.path.exists("manifest.properties") or os.path.exists("main"): - fail("this folder already has a manifest.properties or main/") - version = ask("SDK version to use", None, is_valid_version, "invalid SDK version") - if version is None: - fail("no SDK version given") - variant = ask("Project type (gui, terminal or module)", "gui", lambda answer: answer in PROJECT_VARIANTS, "use gui, terminal or module") - package_id = ask("Package id", "example.helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") - app_id = app_name = "" - if variant != "module": - app_id = ask("App id", "helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots") - app_name = ask("App name", "Hello World", NAME_PATTERN.match, "use 2 to 32 letters, digits, spaces and dashes") - for path in generate_project(version, package_id, app_id, app_name, variant): - print(f"Created {path}", file=sys.stderr) - return "manifest.properties" + + +def print_device_info(info): + name = f"{info['vendor']} {info['name']}" if info["vendor"] else info["name"] + print(f"tt: using device {name} ({info['id']}) with Tactility {info['version']}", file=sys.stderr) + + +def resolve_device_target(): + """Returns the SDK version of the stored device, or None""" + host = read_device_host() + if host is None: + return None + info = query_device_info(host) + if info is None: + return None + print_device_info(info) + return info["version"] + + +def host_command(arguments): + """Shows the stored device host, or stores one and checks that the device answers""" + if not arguments: + print(read_device_host() or "none") + elif len(arguments) == 1 and arguments[0].strip(): + host = arguments[0].strip() + write_stored(DEVICE_HOST_PATH, host) + print(f"Device host: {host}") + info = query_device_info(host) + if info is not None: + print_device_info(info) + else: + fail("usage: tt host [hostname]") + + +def read_project_properties(manifest_path): + """Returns the properties of the manifest and its SDK version. Fails when the version is missing or invalid.""" + manifest = read_properties(manifest_path) + version = manifest.get("target.sdk") + if not version: + fail(f"target.sdk not found in {manifest_path}") + if not is_valid_version(version): + fail(f"invalid target.sdk in {manifest_path}: {version}") + return manifest, version + + +def read_stored_sdk_version(): + """Returns the stored SDK version, or None when it is missing or invalid""" + version = read_stored(SDK_VERSION_PATH) + return version if version is not None and is_valid_version(version) else None + + +def sdk_command(arguments): + """Shows the SDK version with its source, or stores one. A project's SDK version still takes precedence.""" + if not arguments: + stored_version = read_stored_sdk_version() + manifest_path = find_manifest() + if manifest_path: + project_version = read_project_properties(manifest_path)[1] + print(f"{project_version} (from project {manifest_path})") + if stored_version: + print(f"{stored_version} in {SDK_VERSION_PATH} is not used while a project is found") + elif stored_version: + print(f"{stored_version} (from local setting {SDK_VERSION_PATH})") + else: + host = read_device_host() + info = query_device_info(host) if host else None + print(f"{info['version']} (from device {host})" if info else "none") + elif len(arguments) == 1 and is_valid_version(arguments[0]): + write_stored(SDK_VERSION_PATH, arguments[0]) + print(f"SDK version: {arguments[0]}") + else: + fail("usage: tt sdk [version]") def resolve_target(): """Returns the SDK version and platforms of the app manifest""" manifest_path = find_manifest() if manifest_path is None: + # A project always decides the SDK. Without one, a chosen version beats the device. + stored_version = read_stored_sdk_version() + if stored_version is not None: + print(f"tt: using SDK {stored_version} from {SDK_VERSION_PATH}", file=sys.stderr) + return stored_version, [] + device_version = resolve_device_target() + if device_version is not None: + return device_version, [] print("tt: no manifest.properties found in . or Apps/*/ or apps/*/", file=sys.stderr) - manifest_path = offer_new_project() - if manifest_path is None: - # Commands like "tt shell" don't need an app, only the tools of an SDK version + # Commands like "tt shell" and "tt app create" don't need an app, only the tools of an SDK version version = ask("SDK version to use without a project (leave empty to abort)", None, is_valid_version, "invalid SDK version") if version is None: fail("no manifest.properties found in . or Apps/*/ or apps/*/") + write_stored(SDK_VERSION_PATH, version) return version, [] - manifest = read_properties(manifest_path) - version = manifest.get("target.sdk") - if not version: - fail(f"target.sdk not found in {manifest_path}") - if not is_valid_version(version): - fail(f"invalid target.sdk in {manifest_path}: {version}") + manifest, version = read_project_properties(manifest_path) platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()] return version, platforms @@ -357,6 +421,10 @@ def print_help(version, tools_directory): """Without an app manifest, the version and its commands are unknown""" print("Usage: tt [arguments]") print("") + print("Built-in commands:") + print(" host") + print(" sdk") + 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: @@ -392,6 +460,12 @@ def run_tool(tool_path, arguments, environment): def main(arguments): command = arguments[0] if arguments else None + if command == "host": + host_command(arguments[1:]) + return + if command == "sdk": + sdk_command(arguments[1:]) + return 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}")