From 6baf92581bfa61c0d3a69cce7ad11cccba5cf3f5 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 16 Oct 2026 08:40:04 +0200 Subject: [PATCH] Fixes --- Buildscripts/TactilitySDK/Tools/update-sdk.py | 115 +++++++++++------- Buildscripts/check-tt-version.py | 6 +- Documentation/ideas.md | 1 - .../app-hub-module/source/app_hub_command.cpp | 4 +- .../private/http/private/http_parsing.h | 3 + Modules/http-module/source/download_posix.cpp | 9 +- Modules/http-module/source/http_parsing.cpp | 17 +++ .../tests/source/http_parsing_test.cpp | 15 +++ .../Tactility/app/timezone/TimeZoneView.h | 4 +- Tactility/Source/app/TileGrid.cpp | 43 ++++++- .../Source/app/timezone/TimeZoneView.cpp | 12 +- tt.py | 40 +++++- 12 files changed, 213 insertions(+), 56 deletions(-) diff --git a/Buildscripts/TactilitySDK/Tools/update-sdk.py b/Buildscripts/TactilitySDK/Tools/update-sdk.py index e32c1c868..b21830e94 100755 --- a/Buildscripts/TactilitySDK/Tools/update-sdk.py +++ b/Buildscripts/TactilitySDK/Tools/update-sdk.py @@ -52,36 +52,59 @@ def read_installed_tools_hash(version_directory): return None -def extract(archive_data, target_directory): - """Extracts next to target_directory and then replaces it, 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) - 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 archive: {member.filename}") - archive.extractall(root) - except zipfile.BadZipFile: +class Update: + """Stages every component next to its installed folder, so a failed download leaves the installation untouched""" + + def __init__(self): + # (target directory, staging directory, metadata path, metadata) + self.staged = [] + + def stage(self, archive_data, target_directory, metadata_path, metadata): + """Extracts into a staging folder, refusing entries outside of it""" + staging_directory = f"{target_directory}.new" shutil.rmtree(staging_directory, ignore_errors=True) - fail("the downloaded archive is invalid") - shutil.rmtree(target_directory, ignore_errors=True) - os.replace(staging_directory, target_directory) + os.makedirs(staging_directory) + self.staged.append((target_directory, staging_directory, metadata_path, metadata)) + 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 archive: {member.filename}") + archive.extractall(root) + except zipfile.BadZipFile: + fail("the downloaded archive is invalid") + def discard(self): + for _, staging_directory, _, _ in self.staged: + shutil.rmtree(staging_directory, ignore_errors=True) + self.staged = [] -def install_platform(platform_directory, archive_data, commit): - extract(archive_data, os.path.join(platform_directory, "TactilitySDK")) - with open(os.path.join(platform_directory, "commit"), "w") as file: - file.write(commit) - - -def install_tools(version_directory, archive_data, sha256): - extract(archive_data, os.path.join(version_directory, "tools")) - with open(os.path.join(version_directory, "tools.sha256"), "w") as file: - file.write(sha256) + def commit(self): + """Replaces the installed folders, and puts the previous ones back when that fails""" + replaced = [] + try: + for target_directory, staging_directory, _, _ in self.staged: + backup_directory = f"{target_directory}.old" + shutil.rmtree(backup_directory, ignore_errors=True) + if os.path.isdir(target_directory): + os.replace(target_directory, backup_directory) + replaced.append((target_directory, backup_directory)) + os.replace(staging_directory, target_directory) + except OSError as error: + for target_directory, backup_directory in reversed(replaced): + shutil.rmtree(target_directory, ignore_errors=True) + if os.path.isdir(backup_directory): + os.replace(backup_directory, target_directory) + self.discard() + fail(f"failed to install the update: {error}") + for _, _, metadata_path, metadata in self.staged: + with open(metadata_path, "w") as file: + file.write(metadata) + for _, backup_directory in replaced: + shutil.rmtree(backup_directory, ignore_errors=True) + self.staged = [] def main(): @@ -99,22 +122,28 @@ def main(): fail("the downloaded index.json is invalid") commit = index.get("gitCommit", "") available = index.get("platforms", {}) - updated = False - tools = index.get("tools") - if tools is not None and read_installed_tools_hash(version_directory) != tools.get("sha256"): - print(f"Updating the tools of SDK {version}") - archive_data = fetch(f"{CDN_URL}/sdk/{version}/{tools['file']}") - if hashlib.sha256(archive_data).hexdigest() != tools["sha256"]: - fail("the downloaded tools don't match their checksum") - install_tools(version_directory, archive_data, tools["sha256"]) - updated = True - 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 + update = Update() + try: + tools = index.get("tools") + if tools is not None and read_installed_tools_hash(version_directory) != tools.get("sha256"): + print(f"Downloading the tools of SDK {version}", flush=True) + archive_data = fetch(f"{CDN_URL}/sdk/{version}/{tools['file']}") + if hashlib.sha256(archive_data).hexdigest() != tools["sha256"]: + fail("the downloaded tools don't match their checksum") + update.stage(archive_data, os.path.join(version_directory, "tools"), os.path.join(version_directory, "tools.sha256"), tools["sha256"]) + 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"Downloading SDK {version} for {platform}", flush=True) + archive_data = fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}") + update.stage(archive_data, os.path.join(platform_directory, "TactilitySDK"), os.path.join(platform_directory, "commit"), commit) + except SystemExit: + update.discard() + print(f"SDK {version} was not changed", file=sys.stderr) + raise + updated = len(update.staged) > 0 + update.commit() 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") diff --git a/Buildscripts/check-tt-version.py b/Buildscripts/check-tt-version.py index 233d3385b..b30e4cf05 100755 --- a/Buildscripts/check-tt-version.py +++ b/Buildscripts/check-tt-version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fails when the files of tt changed since a base revision, but TT_VERSION in tt.py wasn't increased. +"""Fails when the files of tt or its packaging changed since a base revision, but TT_VERSION in tt.py wasn't increased. Usage: check-tt-version.py , e.g. origin/main""" import importlib.util @@ -10,6 +10,8 @@ import sys ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) VERSION_PATTERN = re.compile(r'^TT_VERSION = "([^"]+)"', re.MULTILINE) +# Changes to what tt.zip contains, e.g. an added or removed file +PACKAGING_FILE_NAME = "Buildscripts/release-tt.py" def load_file_names(): @@ -39,7 +41,7 @@ def main(): sys.exit(2) base = sys.argv[1] file_names = load_file_names() - diff = git("diff", "--name-only", f"{base}...HEAD", "--", *file_names) + diff = git("diff", "--name-only", f"{base}...HEAD", "--", *file_names, PACKAGING_FILE_NAME) if diff.returncode != 0: print(f"git diff failed: {diff.stderr.strip()}", file=sys.stderr) sys.exit(2) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index a3efcc896..ee5280a25 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,7 +7,6 @@ ## Higher Priority -- Remove screenshot app and replace with tt.py + screenshot.py: "tt screenshot [filepath]". if filepath isn't specified, save as screenshot-[yyyy-mm-dd].png - 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 diff --git a/Modules/app-hub-module/source/app_hub_command.cpp b/Modules/app-hub-module/source/app_hub_command.cpp index 6b516a87a..41470eb1b 100644 --- a/Modules/app-hub-module/source/app_hub_command.cpp +++ b/Modules/app-hub-module/source/app_hub_command.cpp @@ -40,6 +40,7 @@ static int report_error(const char* action, error_t error) { /** * Queues an operation and waits for its result. * The subscription is made before queueing, so the operation can't finish unnoticed. + * It times out after OPERATION_TIMEOUT without events of the operation, so a long update of many apps doesn't. * @param[in] finished_type the event that ends the operation and carries its result * @param[in] on_event called for the other events of the operation (optional) */ @@ -62,7 +63,7 @@ static error_t run_and_wait( error = operation(id, &operation_id); if (error == ERROR_NONE) { error = ERROR_TIMEOUT; - const TickType_t start = get_ticks(); + TickType_t start = get_ticks(); TickType_t elapsed = 0; bool finished = false; while (!finished && elapsed < OPERATION_TIMEOUT) { @@ -72,6 +73,7 @@ static error_t run_and_wait( if (event.operation_id != operation_id) { continue; } + start = get_ticks(); if (event.type == finished_type) { error = event.result; finished = true; diff --git a/Modules/http-module/private/http/private/http_parsing.h b/Modules/http-module/private/http/private/http_parsing.h index 5e279561f..c0165ce21 100644 --- a/Modules/http-module/private/http/private/http_parsing.h +++ b/Modules/http-module/private/http/private/http_parsing.h @@ -20,5 +20,8 @@ bool http_parse_status_line(const std::string& line, int& statusCode); /** Parses a header line such as "Content-Length: 42". The name is returned in lower case, the value without surrounding whitespace. */ bool http_parse_header_line(const std::string& line, std::string& name, std::string& value); +/** Parses a Content-Length value: decimal digits only, without sign or overflow */ +bool http_parse_content_length(const std::string& value, long& length); + /** Parses the size line of a chunk in a chunked body, e.g. "1a2b" or "1a2b;name=value" */ bool http_parse_chunk_size(const std::string& line, size_t& size); diff --git a/Modules/http-module/source/download_posix.cpp b/Modules/http-module/source/download_posix.cpp index aa4db8603..6f5654ff1 100644 --- a/Modules/http-module/source/download_posix.cpp +++ b/Modules/http-module/source/download_posix.cpp @@ -299,6 +299,8 @@ HttpDownloadEvent http_download_run(const std::string& url, const std::string& c // -1 means the length is unknown: the body is chunked or ends when the server closes the connection long contentLength = -1; + std::string contentLengthValue; + bool hasContentLength = false; bool chunked = false; while (true) { if (!connection.readLine(line)) { @@ -313,7 +315,8 @@ HttpDownloadEvent http_download_run(const std::string& url, const std::string& c continue; } if (name == "content-length") { - contentLength = strtol(value.c_str(), nullptr, 10); + contentLengthValue = value; + hasContentLength = true; } else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) { chunked = true; } @@ -322,6 +325,10 @@ HttpDownloadEvent http_download_run(const std::string& url, const std::string& c if (statusCode < 200 || statusCode >= 300) { return http_download_make_error_event("Server response is not OK", statusCode); } + // A chunked body ignores the Content-Length + if (hasContentLength && !chunked && !http_parse_content_length(contentLengthValue, contentLength)) { + return http_download_make_error_event("Invalid Content-Length", statusCode); + } if (http_download_is_cancelled(link)) { return http_download_make_cancelled_event(statusCode); } diff --git a/Modules/http-module/source/http_parsing.cpp b/Modules/http-module/source/http_parsing.cpp index 61d4730b6..f783b4bb1 100644 --- a/Modules/http-module/source/http_parsing.cpp +++ b/Modules/http-module/source/http_parsing.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -110,6 +111,22 @@ bool http_parse_header_line(const std::string& line, std::string& name, std::str return !name.empty(); } +bool http_parse_content_length(const std::string& value, long& length) { + if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos) { + return false; + } + long result = 0; + for (const char c : value) { + const long digit = c - '0'; + if (result > (LONG_MAX - digit) / 10) { + return false; + } + result = result * 10 + digit; + } + length = result; + return true; +} + bool http_parse_chunk_size(const std::string& line, size_t& size) { const std::string digits = trim(line.substr(0, line.find(';'))); if (digits.empty() || digits.size() > sizeof(size_t) * 2) { diff --git a/Modules/http-module/tests/source/http_parsing_test.cpp b/Modules/http-module/tests/source/http_parsing_test.cpp index de9ac5227..b04b282a6 100644 --- a/Modules/http-module/tests/source/http_parsing_test.cpp +++ b/Modules/http-module/tests/source/http_parsing_test.cpp @@ -46,6 +46,21 @@ TEST_CASE("http_parse_header_line lowercases the name and trims the value") { CHECK_FALSE(http_parse_header_line(": empty name", name, value)); } +TEST_CASE("http_parse_content_length accepts decimal digits only") { + long length = -1; + REQUIRE(http_parse_content_length("42", length)); + CHECK_EQ(length, 42); + REQUIRE(http_parse_content_length("0", length)); + CHECK_EQ(length, 0); + CHECK_FALSE(http_parse_content_length("", length)); + CHECK_FALSE(http_parse_content_length("-5", length)); + CHECK_FALSE(http_parse_content_length("+5", length)); + CHECK_FALSE(http_parse_content_length("12abc", length)); + CHECK_FALSE(http_parse_content_length("1 2", length)); + CHECK_FALSE(http_parse_content_length("99999999999999999999999", length)); + CHECK_EQ(length, 0); +} + TEST_CASE("http_parse_chunk_size reads hexadecimal sizes and ignores extensions") { size_t size = 0; REQUIRE(http_parse_chunk_size("1a2B", size)); diff --git a/Tactility/Private/Tactility/app/timezone/TimeZoneView.h b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h index 6ab14d764..ff664a134 100644 --- a/Tactility/Private/Tactility/app/timezone/TimeZoneView.h +++ b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h @@ -27,7 +27,7 @@ public: explicit TimeZoneView(OnSelected onSelected); - /** Waits for a running detection, which can take a few seconds. Destroy it without holding the LVGL lock. */ + /** Cancels a running detection and waits for its lookup to end, which can take a few seconds */ ~TimeZoneView(); TimeZoneView(const TimeZoneView&) = delete; @@ -59,6 +59,8 @@ private: std::unique_ptr filterTimer; std::unique_ptr detectThread; std::atomic detecting = false; + // Set when the view is destroyed, so a running detection ends without using it + std::atomic cancelled = false; // Valid while the widgets exist, guarded by the LVGL lock lv_obj_t* textarea = nullptr; diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 01c0496db..8ed110ddd 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -90,6 +90,40 @@ void updatePageIndicatorWidth(void* data) { } } +// A page indicator below the grid gets the width of its parent. +// It shows the page number when its dots are wider than its max width, which must be in pixels. +void updateCenteredPageIndicatorWidth(lv_obj_t* indicator) { + const int32_t margins = lv_obj_get_style_margin_left(indicator, LV_PART_MAIN) + lv_obj_get_style_margin_right(indicator, LV_PART_MAIN); + const int32_t max_width = std::max(0, lv_obj_get_content_width(lv_obj_get_parent(indicator)) - margins); + // Setting a style marks the layout as dirty, which would resize the parent and call this again + if (lv_obj_get_style_max_width(indicator, LV_PART_MAIN) != max_width) { + lv_obj_set_style_max_width(indicator, max_width, LV_STATE_DEFAULT); + } +} + +void onCenteredPageIndicatorParentSizeChanged(lv_event_t* event) { + updateCenteredPageIndicatorWidth(static_cast(lv_event_get_user_data(event))); +} + +void onCenteredPageIndicatorDeleted(lv_event_t* event) { + lv_obj_t* indicator = lv_event_get_target_obj(event); + lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator); +} + +/** Call after moving the page indicator below the grid */ +void centerPageIndicator(lv_obj_t* indicator) { + lv_obj_set_width(indicator, LV_PCT(100)); + lv_obj_add_event_cb(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, LV_EVENT_SIZE_CHANGED, indicator); + lv_obj_add_event_cb(indicator, onCenteredPageIndicatorDeleted, LV_EVENT_DELETE, nullptr); + updateCenteredPageIndicatorWidth(indicator); +} + +/** Call before moving the page indicator away from below the grid */ +void uncenterPageIndicator(lv_obj_t* indicator) { + lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator); + lv_obj_remove_event_cb(indicator, onCenteredPageIndicatorDeleted); +} + void onBottomBarSizeChanged(lv_event_t* event) { lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event)); @@ -448,7 +482,7 @@ void TileGrid::createWidgets(lv_obj_t* parent) { createGrid(parent); // Centered below the grid pageIndicator = lvgl_page_indicator_create(parent); - lv_obj_set_width(pageIndicator, LV_PCT(100)); + centerPageIndicator(pageIndicator); // Keeps the dots off the screen's bottom edge lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? LV_DPX(2) : LV_DPX(8), LV_STATE_DEFAULT); finishCreate(parent); @@ -531,8 +565,7 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) { // The page indicator is centered below the grid lv_obj_set_parent(pageIndicator, barParent); lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1); - lv_obj_set_width(pageIndicator, LV_PCT(100)); - lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MAX_WIDTH, LV_PART_MAIN); + centerPageIndicator(pageIndicator); // Keeps the dots off the screen's bottom edge lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT); } else { @@ -543,9 +576,13 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) { lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_BG_OPA, LV_PART_MAIN); lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_PAD_ROW, LV_PART_MAIN); lv_obj_set_hidden(barSpacer, false); + uncenterPageIndicator(pageIndicator); lv_obj_set_parent(pageIndicator, bottomBar); lv_obj_set_width(pageIndicator, LV_SIZE_CONTENT); lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN); + // The bar's size can stay the same, which wouldn't update the max width + lv_async_call_cancel(updatePageIndicatorWidth, bottomBar); + lv_async_call(updatePageIndicatorWidth, bottomBar); } // The keys move through the bar where it's shown: below the statusbar's icons in the side strip, or below the grid setGroupOrder(grid, bottomBar, sideContainer != nullptr); diff --git a/Tactility/Source/app/timezone/TimeZoneView.cpp b/Tactility/Source/app/timezone/TimeZoneView.cpp index dcd758e67..2ddc341a2 100644 --- a/Tactility/Source/app/timezone/TimeZoneView.cpp +++ b/Tactility/Source/app/timezone/TimeZoneView.cpp @@ -78,6 +78,7 @@ TimeZoneView::TimeZoneView(OnSelected onSelected) : onSelected(std::move(onSelec TimeZoneView::~TimeZoneView() { filterTimer->stop(); + cancelled = true; if (detectThread != nullptr) { detectThread->join(); } @@ -268,7 +269,16 @@ void TimeZoneView::detect() { failure = "The time zone couldn't be detected. Check the internet connection and try again."; } - lvgl_lock(); + // Waits for the LVGL lock without blocking the destructor, which can be called with the lock held + while (!lvgl_try_lock(pdMS_TO_TICKS(50))) { + if (cancelled) { + return; + } + } + if (cancelled) { + lvgl_unlock(); + return; + } detecting = false; mutex.lock(); message = failure; diff --git a/tt.py b/tt.py index f05ce0820..b15e27d45 100755 --- a/tt.py +++ b/tt.py @@ -20,7 +20,7 @@ import time import urllib.request import zipfile -TT_VERSION = "1.1.0" +TT_VERSION = "1.1.1" CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") INDEX_VALIDITY_SECONDS = 3600 @@ -173,15 +173,47 @@ def install_platforms(version, version_directory, index, platforms): file.write(index.get("gitCommit", "")) +def read_text(path): + try: + with open(path) as file: + return file.read().strip() + except OSError: + return None + + +def is_installed_build(version_directory, index): + """True when the installed tools and platforms are of the build that the index describes""" + installed_tools_hash = read_text(os.path.join(version_directory, "tools.sha256")) + if installed_tools_hash is not None and installed_tools_hash != index.get("tools", {}).get("sha256"): + return False + for name in os.listdir(version_directory): + commit = read_text(os.path.join(version_directory, name, "commit")) + if name.startswith("platform-") and commit is not None and commit != index.get("gitCommit", ""): + return False + return True + + def prepare_sdk(version, platforms): """Installs what's missing of the SDK version. Installed tools and platforms are only replaced by "tt update-sdk", so they stay from the same build.""" version_directory = version_path(HOME_DIRECTORY, version) + index = None + # "target.platforms=all" means every platform of the SDK + if platforms == ["all"]: + os.makedirs(version_directory, exist_ok=True) + index = load_index(version, version_directory) + platforms = sorted(index.get("platforms", {})) has_tools = os.path.isdir(os.path.join(version_directory, "tools")) missing_platforms = [platform for platform in platforms if not os.path.isdir(os.path.join(version_directory, f"platform-{platform}", "TactilitySDK"))] if has_tools and not missing_platforms: return version_directory os.makedirs(version_directory, exist_ok=True) - index = load_index(version, version_directory) + if index is None: + index = load_index(version, version_directory) + # The CDN only has the latest build of a version, so a newer build can't be mixed with the installed one + if not is_installed_build(version_directory, index): + if has_tools: + fail(f"SDK {version} was published again since it was installed: run 'tt update-sdk' first") + fail(f"SDK {version} was published again since it was installed: remove {version_directory} and try again") if not has_tools: install_tools(version, version_directory, index) install_platforms(version, version_directory, index, missing_platforms) @@ -255,7 +287,9 @@ def main(arguments): fail(f"tools not found: {tools_directory}") else: sdk_path = HOME_DIRECTORY - tools_directory = os.path.join(prepare_sdk(version, platforms), "tools") + # update-sdk replaces the installed build, so it only needs the tools + required_platforms = [] if command == "update-sdk" else platforms + tools_directory = os.path.join(prepare_sdk(version, required_platforms), "tools") environment = tool_environment(version, sdk_path, is_local) if is_help: print_help(version, tools_directory)