This commit is contained in:
Ken Van Hoeylandt 2026-10-16 08:40:04 +02:00
parent 80a0906867
commit 6baf92581b
12 changed files with 213 additions and 56 deletions

View File

@ -52,11 +52,19 @@ 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"""
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)
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:
@ -66,22 +74,37 @@ def extract(archive_data, target_directory):
fail(f"invalid entry in the archive: {member.filename}")
archive.extractall(root)
except zipfile.BadZipFile:
shutil.rmtree(staging_directory, ignore_errors=True)
fail("the downloaded archive is invalid")
shutil.rmtree(target_directory, ignore_errors=True)
def discard(self):
for _, staging_directory, _, _ in self.staged:
shutil.rmtree(staging_directory, ignore_errors=True)
self.staged = []
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)
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)
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
update = Update()
try:
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}")
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")
install_tools(version_directory, archive_data, tools["sha256"])
updated = True
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"Updating SDK {version} for {platform}")
install_platform(platform_directory, fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}"), commit)
updated = True
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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
#include <http/private/http_parsing.h>
#include <cctype>
#include <climits>
#include <cstdlib>
#include <string_view>
@ -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) {

View File

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

View File

@ -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<Timer> filterTimer;
std::unique_ptr<Thread> detectThread;
std::atomic<bool> detecting = false;
// Set when the view is destroyed, so a running detection ends without using it
std::atomic<bool> cancelled = false;
// Valid while the widgets exist, guarded by the LVGL lock
lv_obj_t* textarea = nullptr;

View File

@ -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<int32_t>(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_obj_t*>(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);

View File

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

38
tt.py
View File

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