From cc81253e8b9a72d5134f349532151eb2cecbe358 Mon Sep 17 00:00:00 2001 From: dianjixz <18637716021@163.com> Date: Thu, 12 Nov 2026 14:20:06 +0800 Subject: [PATCH 1/3] audio: use libpulse in-process instead of spawning pactl Replace the pactl popen/system calls with a libpulse.so.0 client loaded through dlopen, so volume and mute control no longer spawn processes and the build needs no PulseAudio development package. Calls fail cleanly when the library or server is missing. - Add cp0_pulse_client with sink lookup, volume/mute read-write and the PulseAudio volume-scale conversions. - Add the Preload audio API command and warm the mixer up during the splash screen so the first volume action does not pay loader cost. - Seed the Settings volume page from the persisted config before the list is built, and keep that value when the mixer read fails, instead of showing the 100% default. - Deduplicate the static_lib download logic in project SConstructs via SDK's ensure_static_lib_version. - Update docs, tests and submodules. --- SDK | 2 +- docs/OPEN_SOURCE_COMPONENTS.md | 3 +- docs/cp0_lvgl.en.md | 2 +- docs/cp0_lvgl.ja.md | 2 +- docs/cp0_lvgl.md | 1 + .../src/cp0/cp0_audio_mixer_control.cpp | 90 +--- .../src/cp0/cp0_audio_mixer_control.hpp | 21 +- .../cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp | 8 + .../cp0_lvgl/src/cp0/cp0_pulse_client.cpp | 430 ++++++++++++++++++ .../cp0_lvgl/src/cp0/cp0_pulse_client.hpp | 39 ++ .../cp0_lvgl/src/cp0_audio_api_contract.cpp | 1 + .../cp0_lvgl/src/cp0_audio_api_contract.hpp | 2 + .../cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp | 9 + ext_components/cp0_lvgl/tests/run_tests.sh | 3 +- .../tests/test_audio_api_contract.cpp | 3 + .../tests/test_audio_mixer_control.cpp | 34 +- projects/APPLaunch/SConstruct | 34 +- .../main/ui/settings/settings_volume_page.cpp | 34 +- projects/APPLaunch/main/ui/ui_launch_page.h | 1 + .../main/ui/ui_launch_page_startup.cpp | 14 + projects/APPLaunch/tests/run_tests.sh | 1 + .../test_settings_volume_initial_selection.py | 73 +++ projects/AppStore | 2 +- projects/CameraApp/SConstruct | 34 +- projects/HelloWorld/SConstruct | 34 +- projects/LaunchWizard/SConstruct | 35 +- projects/UserDemo/SConstruct | 35 +- projects/ZClaw/SConstruct | 34 +- 28 files changed, 703 insertions(+), 278 deletions(-) create mode 100644 ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.cpp create mode 100644 ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.hpp create mode 100644 projects/APPLaunch/tests/test_settings_volume_initial_selection.py diff --git a/SDK b/SDK index 0412b648..1846503d 160000 --- a/SDK +++ b/SDK @@ -1 +1 @@ -Subproject commit 0412b6481a0608214891b359de2af38d0429390c +Subproject commit 1846503d46fa74685b6df4735aad6391d1624a96 diff --git a/docs/OPEN_SOURCE_COMPONENTS.md b/docs/OPEN_SOURCE_COMPONENTS.md index b8bd5e97..b676478f 100644 --- a/docs/OPEN_SOURCE_COMPONENTS.md +++ b/docs/OPEN_SOURCE_COMPONENTS.md @@ -22,7 +22,7 @@ Paths in the evidence column are relative to the repository root. | smooth_ui_toolkit | `ext_components/SmoothUI/SConstruct`, pinned optional UI helper | [MIT](../ext_components/SmoothUI/LICENSE) | | spdlog | `ext_components/Spdlog/SConstruct`, pinned optional logging component | [MIT](../ext_components/Spdlog/LICENSE); bundled fmt notice also retained | | eventpp | cp0_lvgl, APPLaunch, AppStore and ZClaw requirements; SDK eventpp component | [Apache-2.0](../LICENSES/eventpp-Apache-2.0.txt) | -| libhv | AppStore and ZClaw `main/SConstruct` requirement `hv` | [BSD-3-Clause](../LICENSES/libhv-BSD-3-Clause.txt) | +| libhv | ZClaw `main/SConstruct` requirement `hv` | [BSD-3-Clause](../LICENSES/libhv-BSD-3-Clause.txt) | | fmt | `SDK/components/utilities/SConstruct`, `party/fmt` | [MIT with optional compiled-object exception](../LICENSES/fmt-MIT.txt) | | nlohmann/json 3.11.3 | `projects/CardputerZero-Emulator/vendor/nlohmann/json.hpp` | [MIT](../LICENSES/nlohmann-json-MIT.txt), Niels Lohmann; original SPDX preserved | | tinyalsa | Calculator `main/SConstruct` requirement `tinyalsa_component` | [BSD-3-Clause](../LICENSES/tinyalsa-BSD-3-Clause.txt), copied from upstream `NOTICE` (`LICENSE` points there) | @@ -52,6 +52,7 @@ Component SConstruct files and application SConstruct/CMake files resolve these | SDL2, optional SDL2_mixer | Simulator/emulator display, input and audio | zlib; mixer codecs have separate terms | | GLib, GObject, GIO | Native service/event integration | LGPL-2.1-or-later | | libdbus; BlueZ daemon | Bluetooth IPC | D-Bus AFL/GPL dual terms; BlueZ GPL/LGPL per component | +| libpulse.so.0; pipewire-pulse daemon | Speaker volume/mute through the PulseAudio-compatible server (`dlopen`, no build-time dependency) | libpulse LGPL-2.1-or-later; PipeWire MIT (Expat) with LGPL-2.1+ parts | | libnm; NetworkManager daemon | Wi-Fi | libnm LGPL-2.1-or-later; NetworkManager GPL-2.0-or-later | | libinput, xkbcommon, udev | Native input/device discovery | MIT for libinput/xkbcommon; udev LGPL terms | | libcamera, libcamera-base | Camera | LGPL-2.1-or-later library; separate tool terms | diff --git a/docs/cp0_lvgl.en.md b/docs/cp0_lvgl.en.md index 51f9b3bb..b0fe79f7 100644 --- a/docs/cp0_lvgl.en.md +++ b/docs/cp0_lvgl.en.md @@ -106,7 +106,7 @@ All `args` below are `std::list` with the command at element zero. - `cp0_signal_audio_cap(bool enable)`: one-way start/stop capture. - `cp0_signal_system_play(std::string name)`: play a system sound name. Register extra names first with `RegisterSystemSounds`. - `cp0_signal_audio_setup`: `{"set_callback"}`, `{"set_waveform"}` or `{"set_waveform","0"}`, and `{"stop_play"}` set status callbacks, waveform reporting, and stop playback. -- `cp0_signal_audio_api`: `PlayFile path`, `Play asset`, `PlayPause`, `PlayContinue`, `PlayEnd`, `Cap`, `CapPause`, `CapContinue`, `CapEnd`, `CapFileSave path`, `SetCallback`, `VolumeRead`, `VolumeWrite 0..100`, `DefaultSink` (sink name, or code -1 with empty data when the backend has no real sink), `MuteRead`, `MuteToggle`, `SetSystemSoundNames [up to 3 names]`, `RegisterSystemSounds [1..32 names]`, `SystemSoundPlay 0..2`, `SystemSoundSuspend`, `SystemSoundPrepare`, and `SystemSoundEnable [0|1/on/off...]`. `Play` resolves a separator-free name as a resource; `PlayFile` uses the supplied path. Errors are negative codes with text. +- `cp0_signal_audio_api`: `PlayFile path`, `Play asset`, `PlayPause`, `PlayContinue`, `PlayEnd`, `Cap`, `CapPause`, `CapContinue`, `CapEnd`, `CapFileSave path`, `SetCallback`, `Preload`, `VolumeRead`, `VolumeWrite 0..100`, `DefaultSink` (sink name, or code -1 with empty data when the backend has no real sink), `MuteRead`, `MuteToggle`, `SetSystemSoundNames [up to 3 names]`, `RegisterSystemSounds [1..32 names]`, `SystemSoundPlay 0..2`, `SystemSoundSuspend`, `SystemSoundPrepare`, and `SystemSoundEnable [0|1/on/off...]`. `Preload` loads the mixer client ahead of the first volume action; `Play` resolves a separator-free name as a resource; `PlayFile` uses the supplied path. Errors are negative codes with text. ### PTY diff --git a/docs/cp0_lvgl.ja.md b/docs/cp0_lvgl.ja.md index 93968ad4..b597c9aa 100644 --- a/docs/cp0_lvgl.ja.md +++ b/docs/cp0_lvgl.ja.md @@ -87,7 +87,7 @@ int cp0_file_read_first_line(const char *path, char *out, int out_size); - `cp0_signal_audio_cap(bool enable)`: 録音の開始/停止。 - `cp0_signal_system_play(std::string name)`: システム音を名前で再生。追加名は `RegisterSystemSounds` で登録します。 - `cp0_signal_audio_setup`: `{"set_callback"}`、`{"set_waveform"}` または `{"set_waveform","0"}`、`{"stop_play"}`。 -- `cp0_signal_audio_api`: `PlayFile path`、`Play asset`、`PlayPause`、`PlayContinue`、`PlayEnd`、`Cap`、`CapPause`、`CapContinue`、`CapEnd`、`CapFileSave path`、`SetCallback`、`VolumeRead`、`VolumeWrite 0..100`、`DefaultSink`(sink 名。実 sink がない backend は code -1 と空 data)、`MuteRead`、`MuteToggle`、`SetSystemSoundNames [最大3名]`、`RegisterSystemSounds [1..32名]`、`SystemSoundPlay 0..2`、`SystemSoundSuspend`、`SystemSoundPrepare`、`SystemSoundEnable [0/1 または on/off]`。`Play` は区切り文字のない名前を resource として解決し、`PlayFile` は指定パスを使います。 +- `cp0_signal_audio_api`: `PlayFile path`、`Play asset`、`PlayPause`、`PlayContinue`、`PlayEnd`、`Cap`、`CapPause`、`CapContinue`、`CapEnd`、`CapFileSave path`、`SetCallback`、`Preload`、`VolumeRead`、`VolumeWrite 0..100`、`DefaultSink`(sink 名。実 sink がない backend は code -1 と空 data)、`MuteRead`、`MuteToggle`、`SetSystemSoundNames [最大3名]`、`RegisterSystemSounds [1..32名]`、`SystemSoundPlay 0..2`、`SystemSoundSuspend`、`SystemSoundPrepare`、`SystemSoundEnable [0/1 または on/off]`。`Preload` は最初の音量操作より前にミキサークライアントを読み込みます。`Play` は区切り文字のない名前を resource として解決し、`PlayFile` は指定パスを使います。 ### PTY diff --git a/docs/cp0_lvgl.md b/docs/cp0_lvgl.md index ce1fd0f8..6c3ef759 100644 --- a/docs/cp0_lvgl.md +++ b/docs/cp0_lvgl.md @@ -184,6 +184,7 @@ int cp0_time_ntp_set(int enable); | `Cap`, `CapPause`, `CapContinue`, `CapEnd` | 无 | 录音开始、暂停、继续、停止 | | `CapFileSave` | 输出文件 | 保存录音 | | `SetCallback` | 无 | 设置状态回调 | +| `Preload` | 无 | 预加载音频混音客户端(首次调音量前调用可避免加载延迟);data 为 ok | | `VolumeRead` | 无 | data 为 0..100 音量 | | `DefaultSink` | 无 | 成功时 data 为默认 sink 名称;SDL 后端没有真实 sink,返回 code=-1 且 data 为空 | | `VolumeWrite` | 0..100 | data 为实际值 | diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp index 707079c6..72d09a7e 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.cpp @@ -6,109 +6,35 @@ #include "cp0_audio_mixer_control.hpp" -#include -#include -#include -#include -#include -#include - -bool Cp0AudioMixerControl::parse_volume_line(const char *line, int &percent) -{ - if (!line) - return false; - const char *marker = std::strchr(line, '%'); - if (!marker) - return false; - const char *start = marker; - while (start > line && start[-1] >= '0' && start[-1] <= '9') - --start; - if (start == marker) - return false; - if (start > line && !std::isspace(static_cast(start[-1]))) - return false; - - int parsed = 0; - const auto result = std::from_chars(start, marker, parsed); - if (result.ec != std::errc{} || result.ptr != marker || parsed < 0 || parsed > 100) - return false; - percent = parsed; - return true; -} +#include "cp0_pulse_client.hpp" std::string Cp0AudioMixerControl::default_sink_name() const { - FILE *pipe = popen("pactl get-default-sink 2>/dev/null", "r"); - if (!pipe) - return {}; - - char buffer[256] = {}; std::string name; - if (fgets(buffer, sizeof(buffer), pipe)) { - name = buffer; - while (!name.empty() && - std::isspace(static_cast(name.back()))) - name.pop_back(); - } - pclose(pipe); - return name; + return cp0::pulse::default_sink_name(name) ? name : std::string(); } int Cp0AudioMixerControl::read_volume() const { - FILE *pipe = popen("pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null", "r"); - if (!pipe) - return -1; - - char buffer[256]; - int volume = -1; - while (fgets(buffer, sizeof(buffer), pipe)) { - if (parse_volume_line(buffer, volume)) - break; - } - pclose(pipe); - return volume; + return cp0::pulse::read_default_sink_volume(); } int Cp0AudioMixerControl::write_volume(int percent) const { - percent = clamp_percent(percent); - char command[128]; - std::snprintf(command, sizeof(command), - "pactl set-sink-volume @DEFAULT_SINK@ %d%%", percent); - return std::system(command) == 0 ? percent : -1; + return cp0::pulse::write_default_sink_volume(percent); } int Cp0AudioMixerControl::read_mute() const { - FILE *pipe = popen("pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null", "r"); - if (!pipe) - return -1; - - char buffer[128] = {}; - int muted = -1; - while (fgets(buffer, sizeof(buffer), pipe)) { - if (std::strstr(buffer, "yes")) { - muted = 1; - break; - } - if (std::strstr(buffer, "no")) { - muted = 0; - break; - } - } - pclose(pipe); - return muted; + return cp0::pulse::read_default_sink_mute(); } int Cp0AudioMixerControl::toggle_mute() const { - if (std::system("pactl set-sink-mute @DEFAULT_SINK@ toggle >/dev/null 2>&1") != 0) - return -1; - return read_mute(); + return cp0::pulse::toggle_default_sink_mute(); } -int Cp0AudioMixerControl::clamp_percent(int percent) +bool Cp0AudioMixerControl::preload() const { - return std::max(0, std::min(100, percent)); + return cp0::pulse::preload(); } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp index 61b7e0e8..6701c16e 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_audio_mixer_control.hpp @@ -13,14 +13,17 @@ class Cp0AudioMixerControl { public: - static bool parse_volume_line(const char *line, int &percent); + // The device implementation talks to the PulseAudio-compatible server + // (pipewire-pulse) in process through libpulse.so.0; see + // cp0_pulse_client.cpp. It never spawns a process, so a missing library or + // server simply makes every call return an error. // "auto_null" is PipeWire's placeholder sink. A volume written to it is // discarded when the real hardware sink appears, so callers that must // reach the speaker have to tell the two apart. While no sink exists at // all, pipewire-pulse reports the special name "@DEFAULT_SINK@" instead. // Defined inline because shared callers such as commount.cpp are also built - // by the emulator and AppStore targets, which do not compile the pactl - // implementation in cp0_audio_mixer_control.cpp. + // by the emulator and AppStore targets, which do not compile the + // libpulse-backed implementation in cp0_audio_mixer_control.cpp. static bool is_hardware_sink_name(const char *name) { if (!name || !name[0] || name[0] == '@') @@ -33,17 +36,17 @@ class Cp0AudioMixerControl int write_volume(int percent) const; int read_mute() const; int toggle_mute() const; - -private: - static int clamp_percent(int percent); + // Loads the PulseAudio client library ahead of the first use. Idempotent; + // returns false when the client library is unavailable. + bool preload() const; }; // Decides one pass of the startup volume restore. The loop itself lives in // the launcher, but the transitions are the part that usually breaks, so they -// stay pure: no clock, no pactl, no signals. +// stay pure: no clock, no audio server, no signals. // Defined inline for the same reason as is_hardware_sink_name: commount.cpp is -// built by the emulator and AppStore targets, which do not compile the pactl -// implementation in cp0_audio_mixer_control.cpp. +// built by the emulator and AppStore targets, which do not compile the +// libpulse-backed implementation in cp0_audio_mixer_control.cpp. struct Cp0VolumeRestoreStep { enum class Action diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp index e9067e2f..01cbab3f 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_audio.cpp @@ -491,6 +491,13 @@ class AudioSystem capture_.set_status_callback(callback); } + void Preload(arg_t arg, callback_t callback) + { + (void)arg; + const bool ready = mixer_.preload(); + report(callback, ready ? 0 : -1, ready ? "ok\n" : "pulse unavailable\n"); + } + void VolumeRead(arg_t arg, callback_t callback) { (void)arg; @@ -546,6 +553,7 @@ class AudioSystem map_fun(CapEnd), map_fun(CapFileSave), map_fun(SetCallback), + map_fun(Preload), map_fun(VolumeRead), map_fun(DefaultSink), map_fun(MuteRead), diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.cpp new file mode 100644 index 00000000..b5594725 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.cpp @@ -0,0 +1,430 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "cp0_pulse_client.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cp0::pulse { +namespace { + +using pa_volume_t = std::uint32_t; + +constexpr std::uint32_t kPaVolumeNorm = 0x10000U; +constexpr std::size_t kPaChannelsMax = 32; +constexpr int kPaContextReady = 4; +constexpr int kPaContextNoAutospawn = 0x0001; +constexpr int kPaOperationCancelled = 2; +constexpr auto kRequestTimeout = std::chrono::milliseconds(2000); + +struct pa_mainloop; +struct pa_context; +struct pa_operation; +struct pa_mainloop_api; + +// The declarations below mirror the stable public ABI of libpulse.so.0. Only +// the leading fields this file reads are declared; the server fills the full +// structures. The layout is checked at compile time below. +struct PaSampleSpec { + int format; + std::uint32_t rate; + std::uint8_t channels; +}; + +struct PaChannelMap { + std::uint8_t channels; + int map[kPaChannelsMax]; +}; + +struct PaCVolume { + std::uint8_t channels; + pa_volume_t values[kPaChannelsMax]; +}; + +struct PaSinkInfo { + const char *name; + std::uint32_t index; + const char *description; + PaSampleSpec sample_spec; + PaChannelMap channel_map; + std::uint32_t owner_module; + PaCVolume volume; + int mute; + // The remaining fields of the real pa_sink_info are not accessed. +}; + +struct PaServerInfo { + const char *user_name; + const char *host_name; + const char *server_version; + const char *server_name; + PaSampleSpec sample_spec; + const char *default_sink_name; + // The remaining fields of the real pa_server_info are not accessed. +}; + +static_assert(sizeof(void *) == 8, "the libpulse ABI declarations below assume a 64-bit target"); +static_assert(sizeof(PaSampleSpec) == 12, "unexpected pa_sample_spec layout"); +static_assert(sizeof(PaChannelMap) == 4 + kPaChannelsMax * sizeof(int), "unexpected pa_channel_map layout"); +static_assert(sizeof(PaCVolume) == 4 + kPaChannelsMax * sizeof(pa_volume_t), "unexpected pa_cvolume layout"); +static_assert(offsetof(PaSinkInfo, volume) == 172, "unexpected pa_sink_info layout"); +static_assert(offsetof(PaSinkInfo, mute) == 304, "unexpected pa_sink_info layout"); +static_assert(offsetof(PaServerInfo, default_sink_name) == 48, "unexpected pa_server_info layout"); + +using SinkInfoCallback = void (*)(pa_context *, const PaSinkInfo *, int, void *); +using ServerInfoCallback = void (*)(pa_context *, const PaServerInfo *, void *); +using SuccessCallback = void (*)(pa_context *, int, void *); + +struct PulseApi { + void *handle = nullptr; + pa_mainloop *(*mainloop_new)() = nullptr; + pa_mainloop_api *(*mainloop_get_api)(pa_mainloop *) = nullptr; + int (*mainloop_prepare)(pa_mainloop *, int) = nullptr; + int (*mainloop_poll)(pa_mainloop *) = nullptr; + int (*mainloop_dispatch)(pa_mainloop *) = nullptr; + void (*mainloop_free)(pa_mainloop *) = nullptr; + pa_context *(*context_new)(pa_mainloop_api *, const char *) = nullptr; + int (*context_connect)(pa_context *, const char *, int, const void *) = nullptr; + int (*context_get_state)(pa_context *) = nullptr; + void (*context_disconnect)(pa_context *) = nullptr; + void (*context_unref)(pa_context *) = nullptr; + pa_operation *(*get_server_info)(pa_context *, ServerInfoCallback, void *) = nullptr; + pa_operation *(*get_sink_info_by_name)(pa_context *, const char *, SinkInfoCallback, void *) = nullptr; + pa_operation *(*set_sink_volume_by_name)(pa_context *, const char *, const PaCVolume *, SuccessCallback, + void *) = nullptr; + pa_operation *(*set_sink_mute_by_name)(pa_context *, const char *, int, SuccessCallback, void *) = nullptr; + int (*operation_get_state)(pa_operation *) = nullptr; + void (*operation_unref)(pa_operation *) = nullptr; + + bool complete() const + { + return handle && mainloop_new && mainloop_get_api && mainloop_prepare && mainloop_poll && + mainloop_dispatch && mainloop_free && context_new && context_connect && context_get_state && + context_disconnect && context_unref && get_server_info && get_sink_info_by_name && + set_sink_volume_by_name && set_sink_mute_by_name && operation_get_state && operation_unref; + } +}; + +template +Function resolve_symbol(void *handle, const char *name) +{ + Function function = nullptr; + void *symbol = dlsym(handle, name); + static_assert(sizeof(function) == sizeof(symbol), "function pointer size mismatch"); + std::memcpy(&function, &symbol, sizeof(function)); + return function; +} + +const PulseApi *pulse_api() +{ + static const PulseApi api = [] { + PulseApi loaded; + loaded.handle = dlopen("libpulse.so.0", RTLD_NOW | RTLD_LOCAL); + if (!loaded.handle) return loaded; + + loaded.mainloop_new = resolve_symbol(loaded.handle, "pa_mainloop_new"); + loaded.mainloop_get_api = + resolve_symbol(loaded.handle, "pa_mainloop_get_api"); + loaded.mainloop_prepare = + resolve_symbol(loaded.handle, "pa_mainloop_prepare"); + loaded.mainloop_poll = resolve_symbol(loaded.handle, "pa_mainloop_poll"); + loaded.mainloop_dispatch = + resolve_symbol(loaded.handle, "pa_mainloop_dispatch"); + loaded.mainloop_free = resolve_symbol(loaded.handle, "pa_mainloop_free"); + loaded.context_new = resolve_symbol(loaded.handle, "pa_context_new"); + loaded.context_connect = + resolve_symbol(loaded.handle, "pa_context_connect"); + loaded.context_get_state = + resolve_symbol(loaded.handle, "pa_context_get_state"); + loaded.context_disconnect = + resolve_symbol(loaded.handle, "pa_context_disconnect"); + loaded.context_unref = resolve_symbol(loaded.handle, "pa_context_unref"); + loaded.get_server_info = + resolve_symbol(loaded.handle, "pa_context_get_server_info"); + loaded.get_sink_info_by_name = resolve_symbol( + loaded.handle, "pa_context_get_sink_info_by_name"); + loaded.set_sink_volume_by_name = resolve_symbol( + loaded.handle, "pa_context_set_sink_volume_by_name"); + loaded.set_sink_mute_by_name = resolve_symbol( + loaded.handle, "pa_context_set_sink_mute_by_name"); + loaded.operation_get_state = + resolve_symbol(loaded.handle, "pa_operation_get_state"); + loaded.operation_unref = + resolve_symbol(loaded.handle, "pa_operation_unref"); + + if (!loaded.complete()) { + (void)dlclose(loaded.handle); + loaded = PulseApi{}; + } + return loaded; + }(); + return api.handle ? &api : nullptr; +} + +struct NameState { + bool done = false; + bool ok = false; + std::string name; +}; + +struct SinkState { + bool done = false; + bool ok = false; + std::uint8_t channels = 0; + std::uint32_t volume = 0; + int mute = 0; +}; + +struct SuccessState { + bool done = false; + bool ok = false; +}; + +void server_info_cb(pa_context *, const PaServerInfo *info, void *userdata) +{ + auto *state = static_cast(userdata); + if (info && info->default_sink_name && info->default_sink_name[0]) { + state->name = info->default_sink_name; + state->ok = true; + } + state->done = true; +} + +void sink_info_cb(pa_context *, const PaSinkInfo *info, int eol, void *userdata) +{ + auto *state = static_cast(userdata); + if (eol < 0) { + state->done = true; + state->ok = false; + return; + } + if (info) { + const auto channels = std::min(info->volume.channels, kPaChannelsMax); + state->channels = static_cast(channels); + state->volume = info->volume.values[0]; + state->mute = info->mute; + state->ok = state->channels > 0; + } + if (eol > 0) state->done = true; +} + +void success_cb(pa_context *, int success, void *userdata) +{ + auto *state = static_cast(userdata); + state->done = true; + state->ok = success != 0; +} + +class Session { +public: + Session() = default; + ~Session() { close(); } + + Session(const Session &) = delete; + Session &operator=(const Session &) = delete; + + bool open() + { + api_ = pulse_api(); + if (!api_) return false; + + mainloop_ = api_->mainloop_new(); + if (!mainloop_) return false; + + pa_mainloop_api *mainloop_api = api_->mainloop_get_api(mainloop_); + if (!mainloop_api) return false; + + context_ = api_->context_new(mainloop_api, "APPLaunch"); + if (!context_) return false; + + deadline_ = std::chrono::steady_clock::now() + kRequestTimeout; + if (api_->context_connect(context_, nullptr, kPaContextNoAutospawn, nullptr) < 0) return false; + + for (;;) { + const int state = api_->context_get_state(context_); + if (state == kPaContextReady) return true; + if (state < 0 || state > kPaContextReady) return false; + if (!pump()) return false; + } + } + + bool default_sink(std::string &name) + { + NameState state; + if (!run(api_->get_server_info(context_, &server_info_cb, &state), state)) return false; + name = std::move(state.name); + return true; + } + + bool sink(const std::string &name) + { + SinkState state; + if (!run(api_->get_sink_info_by_name(context_, name.c_str(), &sink_info_cb, &state), state)) return false; + sink_channels_ = state.channels; + sink_volume_ = state.volume; + sink_mute_ = state.mute; + return true; + } + + bool set_volume(const std::string &name, int percent) + { + if (sink_channels_ == 0) return false; + + PaCVolume volume{}; + volume.channels = sink_channels_; + const pa_volume_t target = volume_scale_from_percent(percent); + for (std::uint8_t channel = 0; channel < sink_channels_; ++channel) volume.values[channel] = target; + + SuccessState state; + return run(api_->set_sink_volume_by_name(context_, name.c_str(), &volume, &success_cb, &state), state); + } + + bool set_mute(const std::string &name, bool muted) + { + SuccessState state; + return run(api_->set_sink_mute_by_name(context_, name.c_str(), muted ? 1 : 0, &success_cb, &state), state); + } + + int volume_percent() const { return percent_from_volume_scale(sink_volume_); } + bool muted() const { return sink_mute_ != 0; } + +private: + bool pump() + { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline_) return false; + const auto remaining_us = + std::chrono::duration_cast(deadline_ - now).count(); + int slice_us = static_cast(std::min(remaining_us, 100000)); + if (slice_us < 1000) slice_us = 1000; + + if (api_->mainloop_prepare(mainloop_, slice_us) < 0) return false; + if (api_->mainloop_poll(mainloop_) < 0) return false; + if (api_->mainloop_dispatch(mainloop_) < 0) return false; + return true; + } + + template + bool run(pa_operation *operation, State &state) + { + if (!operation) return false; + while (!state.done) { + if (api_->operation_get_state(operation) == kPaOperationCancelled) break; + if (!pump()) break; + } + api_->operation_unref(operation); + return state.done && state.ok; + } + + void close() + { + if (!api_) return; + if (context_) { + api_->context_disconnect(context_); + api_->context_unref(context_); + context_ = nullptr; + } + if (mainloop_) { + api_->mainloop_free(mainloop_); + mainloop_ = nullptr; + } + } + + const PulseApi *api_ = nullptr; + pa_mainloop *mainloop_ = nullptr; + pa_context *context_ = nullptr; + std::chrono::steady_clock::time_point deadline_{}; + std::uint8_t sink_channels_ = 0; + std::uint32_t sink_volume_ = 0; + int sink_mute_ = 0; +}; + +} // namespace + +int percent_from_volume_scale(std::uint32_t volume) +{ + const std::uint64_t percent = + (static_cast(volume) * 100 + kPaVolumeNorm / 2) / kPaVolumeNorm; + return static_cast(std::min(percent, std::numeric_limits::max())); +} + +std::uint32_t volume_scale_from_percent(int percent) +{ + const int clamped = std::clamp(percent, 0, 100); + return static_cast(static_cast(clamped) * kPaVolumeNorm / 100); +} + +bool preload() +{ + return pulse_api() != nullptr; +} + +bool default_sink_name(std::string &name) +{ + Session session; + if (!session.open()) return false; + return session.default_sink(name); +} + +int read_default_sink_volume() +{ + Session session; + if (!session.open()) return -1; + + std::string name; + if (!session.default_sink(name)) return -1; + if (!session.sink(name)) return -1; + return session.volume_percent(); +} + +int write_default_sink_volume(int percent) +{ + const int clamped = std::clamp(percent, 0, 100); + + Session session; + if (!session.open()) return -1; + + std::string name; + if (!session.default_sink(name)) return -1; + if (!session.sink(name)) return -1; + return session.set_volume(name, clamped) ? clamped : -1; +} + +int read_default_sink_mute() +{ + Session session; + if (!session.open()) return -1; + + std::string name; + if (!session.default_sink(name)) return -1; + if (!session.sink(name)) return -1; + return session.muted() ? 1 : 0; +} + +int toggle_default_sink_mute() +{ + Session session; + if (!session.open()) return -1; + + std::string name; + if (!session.default_sink(name)) return -1; + if (!session.sink(name)) return -1; + + const bool target = !session.muted(); + if (!session.set_mute(name, target)) return -1; + return target ? 1 : 0; +} + +} // namespace cp0::pulse diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.hpp b/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.hpp new file mode 100644 index 00000000..e6d68c73 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0/cp0_pulse_client.hpp @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include + +namespace cp0::pulse { + +// The launcher talks to the PulseAudio-compatible server (pipewire-pulse) in +// process through libpulse.so.0. The library is loaded with dlopen so the +// build does not need the PulseAudio development package; when the library or +// the server is unavailable every call fails and the caller falls back to the +// persisted configuration. No call spawns a process. + +// Loads libpulse.so.0 and resolves the client symbols. Call this during +// startup (for example while the splash animation is on screen) so the first +// volume action does not pay the loader cost. Returns false when libpulse is +// missing. The call is idempotent and thread-safe; later calls only return the +// cached result. +bool preload(); + +bool default_sink_name(std::string &name); +int read_default_sink_volume(); +int write_default_sink_volume(int percent); +int read_default_sink_mute(); +int toggle_default_sink_mute(); + +// PulseAudio stores volumes on a linear scale where PA_VOLUME_NORM (0x10000) +// is 100%. The percentage is rounded like pa_cvolume_snprint(), so a value +// written by volume_scale_from_percent() reads back unchanged. +int percent_from_volume_scale(std::uint32_t volume); +std::uint32_t volume_scale_from_percent(int percent); + +} // namespace cp0::pulse diff --git a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp index 97778787..03c42e6c 100644 --- a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp +++ b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.cpp @@ -49,6 +49,7 @@ bool is_no_argument_command(const std::string &command, ApiCommand &parsed) else if (command == "CapContinue") parsed = ApiCommand::CapContinue; else if (command == "CapEnd") parsed = ApiCommand::CapEnd; else if (command == "SetCallback") parsed = ApiCommand::SetCallback; + else if (command == "Preload") parsed = ApiCommand::Preload; else if (command == "VolumeRead") parsed = ApiCommand::VolumeRead; else if (command == "DefaultSink") parsed = ApiCommand::DefaultSink; else if (command == "MuteRead") parsed = ApiCommand::MuteRead; diff --git a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp index 870bb738..87bf5bcc 100644 --- a/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp +++ b/ext_components/cp0_lvgl/src/cp0_audio_api_contract.hpp @@ -26,6 +26,8 @@ enum class ApiCommand { CapEnd, CapFileSave, SetCallback, + /* Load the audio mixer client ahead of the first volume action. */ + Preload, VolumeRead, VolumeWrite, DefaultSink, diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp index 79fd740f..13ae7496 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_audio.cpp @@ -90,6 +90,7 @@ class AudioSystem map_fun(CapEnd), map_fun(CapFileSave), map_fun(SetCallback), + map_fun(Preload), map_fun(VolumeRead), map_fun(DefaultSink), map_fun(MuteRead), @@ -317,6 +318,14 @@ class AudioSystem status_callback_ = std::move(callback); } + void Preload(arg_t arg, callback_t callback) + { + (void)arg; + // The SDL backend keeps the volume in memory; there is no client + // library to load, so the request is always satisfied. + report(callback, 0, "ok\n"); + } + void VolumeRead(arg_t arg, callback_t callback) { (void)arg; diff --git a/ext_components/cp0_lvgl/tests/run_tests.sh b/ext_components/cp0_lvgl/tests/run_tests.sh index d6b30b7b..8d428cea 100755 --- a/ext_components/cp0_lvgl/tests/run_tests.sh +++ b/ext_components/cp0_lvgl/tests/run_tests.sh @@ -59,7 +59,8 @@ trap 'rm -f "$binary" "$init_plan_object" "$esc_state_object"' EXIT HUP INT TERM "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ -I"$root/src/cp0" \ "$root/src/cp0/cp0_audio_mixer_control.cpp" \ - "$root/tests/test_audio_mixer_control.cpp" -o "$binary" + "$root/src/cp0/cp0_pulse_client.cpp" \ + "$root/tests/test_audio_mixer_control.cpp" -ldl -o "$binary" "$binary" "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ diff --git a/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp b/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp index beafe6c3..22912c66 100644 --- a/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp +++ b/ext_components/cp0_lvgl/tests/test_audio_api_contract.cpp @@ -35,6 +35,9 @@ void test_path_and_no_argument_commands() assert(cp0::audio::parse_api_request({"PlayPause"}, request)); assert(request.command == ApiCommand::PlayPause); assert(!cp0::audio::parse_api_request({"PlayPause", "junk"}, request)); + assert(cp0::audio::parse_api_request({"Preload"}, request)); + assert(request.command == ApiCommand::Preload); + assert(!cp0::audio::parse_api_request({"Preload", "junk"}, request)); assert(cp0::audio::parse_api_request({"CapEnd"}, request)); assert(request.command == ApiCommand::CapEnd); assert(cp0::audio::parse_api_request({"MuteToggle"}, request)); diff --git a/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp b/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp index dfa80cad..0ef99393 100644 --- a/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp +++ b/ext_components/cp0_lvgl/tests/test_audio_mixer_control.cpp @@ -5,25 +5,33 @@ */ #include "cp0_audio_mixer_control.hpp" +#include "cp0_pulse_client.hpp" #include #include +#include namespace { -void test_volume_lines() +void test_volume_scale_conversions() { - int value = 77; - assert(!Cp0AudioMixerControl::parse_volume_line(nullptr, value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("", value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("Volume: junk%", value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("Volume: -1%", value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("Volume: junk42%", value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("Volume: 101%", value) && value == 77); - assert(!Cp0AudioMixerControl::parse_volume_line("Volume: 999999999999999999999%", value) && value == 77); - assert(Cp0AudioMixerControl::parse_volume_line("Volume: 0%", value) && value == 0); - assert(Cp0AudioMixerControl::parse_volume_line("Volume: 100%", value) && value == 100); - assert(Cp0AudioMixerControl::parse_volume_line("Volume: 42% / 42%", value) && value == 42); + // PulseAudio rounds the percentage like pa_cvolume_snprint(); a value + // written by the launcher must read back unchanged. + assert(cp0::pulse::percent_from_volume_scale(0) == 0); + assert(cp0::pulse::percent_from_volume_scale(1) == 0); + assert(cp0::pulse::percent_from_volume_scale(32767) == 50); + assert(cp0::pulse::percent_from_volume_scale(32768) == 50); + assert(cp0::pulse::percent_from_volume_scale(26214) == 40); + assert(cp0::pulse::percent_from_volume_scale(65535) == 100); + assert(cp0::pulse::percent_from_volume_scale(65536) == 100); + assert(cp0::pulse::percent_from_volume_scale(131072) == 200); + + for (int percent = 0; percent <= 100; ++percent) { + const std::uint32_t scale = cp0::pulse::volume_scale_from_percent(percent); + assert(cp0::pulse::percent_from_volume_scale(scale) == percent); + } + assert(cp0::pulse::volume_scale_from_percent(-20) == 0); + assert(cp0::pulse::volume_scale_from_percent(140) == cp0::pulse::volume_scale_from_percent(100)); } void test_sink_names() @@ -98,7 +106,7 @@ void test_volume_restore_steps() int main() { - test_volume_lines(); + test_volume_scale_conversions(); test_sink_names(); test_volume_restore_steps(); } diff --git a/projects/APPLaunch/SConstruct b/projects/APPLaunch/SConstruct index 65104a57..48441f91 100644 --- a/projects/APPLaunch/SConstruct +++ b/projects/APPLaunch/SConstruct @@ -57,30 +57,10 @@ env = SConscript( # static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/M5CardputerZero-UserDemo/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) diff --git a/projects/APPLaunch/main/ui/settings/settings_volume_page.cpp b/projects/APPLaunch/main/ui/settings/settings_volume_page.cpp index 4d2cab32..58934cc0 100644 --- a/projects/APPLaunch/main/ui/settings/settings_volume_page.cpp +++ b/projects/APPLaunch/main/ui/settings/settings_volume_page.cpp @@ -158,6 +158,16 @@ class settings_audio_com { return saved.code == 0 ? saved : backend; } + // The persisted key is what the startup restore writes to the mixer, so it + // is the best value the page can show before the asynchronous mixer read + // returns. A missing or invalid entry keeps the supplied fallback. + static int read_saved_volume(int fallback) + { + const auto saved = decode_volume(invoke_config_default( + {"GetInt", "volume", std::to_string(fallback)}, std::chrono::milliseconds(3000))); + return saved.code == 0 ? saved.value : fallback; + } + static bool save_volume_config(int value) { const auto previous = decode_volume( @@ -256,7 +266,9 @@ const std::string &LvSettingVolumePage3::last_error() const int LvSettingVolumePage3::initial_selection() const { - return 0; + // The persisted volume is seeded before the value list is built, so the + // first frame already shows the real level instead of the 100% default. + return selection_for_volume(backend_volume_); } SettingApiResult LvSettingVolumePage3::activate_selected() @@ -298,6 +310,16 @@ void LvSettingVolumePage3::initialize_page(lv_obj_t *parent, std::function Speaker does not block + // on the loader. The request is a no-op on backends without a client + // library (SDL), and idempotent after the startup volume restore already + // loaded it. + cp0_signal_audio_api({"Preload"}, nullptr); } void UILaunchPage::handle_startup_gif_event(lv_event_t *event) diff --git a/projects/APPLaunch/tests/run_tests.sh b/projects/APPLaunch/tests/run_tests.sh index de948073..150fa84f 100755 --- a/projects/APPLaunch/tests/run_tests.sh +++ b/projects/APPLaunch/tests/run_tests.sh @@ -12,6 +12,7 @@ python3 "$test_root/test_settings_menu_order.py" python3 "$test_root/test_settings_wifi_enable_copy_contract.py" python3 "$test_root/test_settings_ethernet_layout_contract.py" python3 "$test_root/test_settings_rtc_layout_contract.py" +python3 "$test_root/test_settings_volume_initial_selection.py" python3 "$test_root/test_preinstalled_app_packaging.py" python3 "$test_root/test_settings_storage_build_contract.py" python3 "$test_root/test_static_resource_staging.py" diff --git a/projects/APPLaunch/tests/test_settings_volume_initial_selection.py b/projects/APPLaunch/tests/test_settings_volume_initial_selection.py new file mode 100644 index 00000000..e1bcc728 --- /dev/null +++ b/projects/APPLaunch/tests/test_settings_volume_initial_selection.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +# SPDX-License-Identifier: MIT + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = (ROOT / "main/ui/settings/settings_volume_page.cpp").read_text( + encoding="utf-8" +) + + +def function_body(signature: str) -> str: + start = SOURCE.index(signature) + opening_brace = SOURCE.index("{", start) + depth = 0 + for index in range(opening_brace, len(SOURCE)): + if SOURCE[index] == "{": + depth += 1 + elif SOURCE[index] == "}": + depth -= 1 + if depth == 0: + return SOURCE[opening_brace + 1 : index] + raise AssertionError(f"unterminated function: {signature}") + + +def normalized(source: str) -> str: + return re.sub(r"\s+", " ", source).strip() + + +def test_page_seeds_the_persisted_volume_before_the_list_is_built(): + body = normalized(function_body("void LvSettingVolumePage3::initialize_page")) + seed = body.index("read_saved_volume") + built = body.index("initialize(parent)") + + assert seed < built, "the saved volume must be read before the value list is built" + for member in ( + "original_volume_", + "backend_volume_", + "last_preview_requested_", + "last_preview_actual_", + "locked_selection_", + ): + assert member in body[seed:built], f"{member} is not seeded before create_ui" + + +def test_seed_uses_the_persisted_config_not_the_mixer(): + body = normalized(function_body("static int read_saved_volume(int fallback)")) + + assert '{"GetInt", "volume"' in body + assert "VolumeRead" not in body + + +def test_initial_selection_uses_the_seeded_volume(): + body = normalized(function_body("int LvSettingVolumePage3::initial_selection() const")) + + assert "return selection_for_volume(backend_volume_);" in body + assert "return 0;" not in body + + +def test_failed_read_does_not_restore_the_100_percent_default(): + body = function_body("void LvSettingVolumePage3::handle_volume_result(") + read_branch = body[ + body.index("if (request.kind == RequestKind::Read)") : body.index( + "if (request.kind == RequestKind::RestoreWrite)" + ) + ] + read_branch = normalized(read_branch) + + assert "metric(LayoutMetric::MaxVolume)" not in read_branch + assert "select_volume(0);" not in read_branch + assert "select_volume(selection_for_volume(backend_volume_));" in read_branch diff --git a/projects/AppStore b/projects/AppStore index e13d32c1..18dd9768 160000 --- a/projects/AppStore +++ b/projects/AppStore @@ -1 +1 @@ -Subproject commit e13d32c1c6e79e2025788fe0b86a9573b008b654 +Subproject commit 18dd9768f21239b2bfc91cc7a7fcf0f0085f1627 diff --git a/projects/CameraApp/SConstruct b/projects/CameraApp/SConstruct index 188e9e48..e9b70a68 100644 --- a/projects/CameraApp/SConstruct +++ b/projects/CameraApp/SConstruct @@ -53,30 +53,10 @@ env = SConscript( # static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/launcher/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) diff --git a/projects/HelloWorld/SConstruct b/projects/HelloWorld/SConstruct index 571ff38c..4c4e3929 100644 --- a/projects/HelloWorld/SConstruct +++ b/projects/HelloWorld/SConstruct @@ -71,30 +71,10 @@ env = SConscript( # static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/launcher/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) diff --git a/projects/LaunchWizard/SConstruct b/projects/LaunchWizard/SConstruct index 5977bb57..6139ab40 100644 --- a/projects/LaunchWizard/SConstruct +++ b/projects/LaunchWizard/SConstruct @@ -79,31 +79,12 @@ for src in list(lvgl_component["SRCS"]): lvgl_component["SRCS"].remove(src) +# static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/launcher/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) diff --git a/projects/UserDemo/SConstruct b/projects/UserDemo/SConstruct index a6d32a09..fc5ade6c 100644 --- a/projects/UserDemo/SConstruct +++ b/projects/UserDemo/SConstruct @@ -57,31 +57,12 @@ env = SConscript( duplicate=0, ) +# static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/launcher/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) diff --git a/projects/ZClaw/SConstruct b/projects/ZClaw/SConstruct index 982607ec..50b511fc 100644 --- a/projects/ZClaw/SConstruct +++ b/projects/ZClaw/SConstruct @@ -71,30 +71,10 @@ env = SConscript( # static_lib is only needed for cross-compilation, skip in SDL mode. if cross_package_enabled: - update = False - if not static_lib_path.exists(): - update = True - else: - try: - with open(str(static_lib_path / "version"), "r") as f: - if version != f.read().strip(): - update = True - except Exception: - update = True - - if update: - with open(env["PROJECT_TOOL_S"]) as f: - exec(f.read()) - try: - import tarfile - - tarfile.TarFile.extraction_filter = staticmethod( - tarfile.fully_trusted_filter - ) - except AttributeError: - pass - down_url = ( - "https://github.com/CardputerZero/M5CardputerZero-UserDemo/" - "releases/download/{}/sdk_bsp.tar.gz" - ).format(version) - check_wget_down(down_url, "static_lib_{}.tar.gz".format(version)) + tools = {} + exec(Path(env["PROJECT_TOOL_S"]).read_text(), tools) + tools["ensure_static_lib_version"]( + f"https://github.com/CardputerZero/launcher/releases/download/{version}/sdk_bsp.tar.gz", + version, + static_lib_path / "version" + ) From 0ce4d2350c4fe6cae778a4f0b623db2798b7b953 Mon Sep 17 00:00:00 2001 From: dianjixz <18637716021@163.com> Date: Thu, 12 Nov 2026 15:33:57 +0800 Subject: [PATCH 2/3] feat(low-battery): add sound cues for low battery warnings Play a reminder cue at 20% and a critical cue at 4%, registered once with the platform system-sound list and replayed by name through cp0_signal_system_play. - Latch each cue per warning phase so the 4% -> 3% promotion does not repeat the warning - Re-arm latches on recovery and on init/shutdown so the cues fire once per low-battery cycle - Register lazily and latch on failure to avoid retrying every frame - Add assets applaunch-arcade-refund.mp3 / applaunch-arcade-warning.mp3 - Cover the cue behaviour in test_low_battery_ui and document it in AGENTS.md --- AGENTS.md | 2 +- .../share/audio/applaunch-arcade-refund.mp3 | Bin 0 -> 8507 bytes .../share/audio/applaunch-arcade-warning.mp3 | Bin 0 -> 5267 bytes projects/APPLaunch/main/ui/ui_low_battery.cpp | 59 ++++++++ .../low_battery_ui/test_low_battery_ui.cpp | 135 ++++++++++++++++++ 5 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-refund.mp3 create mode 100644 projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-warning.mp3 diff --git a/AGENTS.md b/AGENTS.md index cfaadf18..3450a6fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,7 +259,7 @@ Screen geometry: **320×170**, a 20px top bar plus a 320×150 content area. | Toast styling | `main/ui/launcher_toast.cpp` | `kShowDurationMs` | | Loading overlay | `main/ui/ui_loading.cpp` | `show` / `hide` | | Lock screen (timeout/unlock/wallpaper) | `main/ui/ui_screensaver.cpp` + `model/lockscreen_state_model.hpp`, `model/screensaver_model.hpp` | constrained by `tests/test_lockscreen_contract.py` | -| Low-battery policy / appearance | `main/ui/low_battery_flow.hpp` / `ui_low_battery.cpp` | the policy is pure logic and unit-tested | +| Low-battery policy / appearance / sound cues | `main/ui/low_battery_flow.hpp` / `ui_low_battery.cpp` | the policy is pure logic and unit-tested; cues register with `RegisterSystemSounds` and play via `cp0_signal_system_play` | | Settings menu structure | `main/ui/settings/settings_page.cpp` | `create_page_detail` (node order = menu order), `UISettingTreePage` | | Settings entry policy/gating | `main/ui/settings/settings_tree_types.hpp` | `SettingEntry`, `SettingActivationPolicy`, `activation_gate` | | Add a settings sub-page | new `*_page.{hpp,cpp}` in `main/ui/settings/` + a factory and tree node in `settings_page.cpp` | three-level value pages derive from `LvSettingValuePage3Base` | diff --git a/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-refund.mp3 b/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-refund.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ddc4d25ca1d733f89b0e2f74cd736e4503f9a501 GIT binary patch literal 8507 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 E2LG1?VgLXD literal 0 HcmV?d00001 diff --git a/projects/APPLaunch/main/ui/ui_low_battery.cpp b/projects/APPLaunch/main/ui/ui_low_battery.cpp index a2863cfb..07fbf481 100644 --- a/projects/APPLaunch/main/ui/ui_low_battery.cpp +++ b/projects/APPLaunch/main/ui/ui_low_battery.cpp @@ -10,6 +10,7 @@ #include "low_battery_flow.hpp" #include "keyboard_input.h" #include "input_keys.h" +#include "hal_lvgl_bsp.h" #include "lvgl/lvgl.h" #include @@ -39,6 +40,37 @@ std::set swallowed_keys; void render(uint32_t now, bool force = false); +/* ---- low-battery sounds ------------------------------------------------ + * The assets are appended to the platform's system-sound list on first play so + * the device decodes them once and keeps a warm engine; the by-name request + * then reuses that player. Registration appends after the platform's indexed + * sounds, so the launcher's startup/switch/enter slots are untouched. */ +constexpr const char *kReminderSound = "applaunch-arcade-refund.mp3"; +constexpr const char *kCriticalSound = "applaunch-arcade-warning.mp3"; +bool low_battery_sounds_registered = false; +/* One latch per cue. Low and ShutdownCountdown share the critical latch so the + * 4% -> 3% promotion does not repeat the warning. */ +bool reminder_sound_played = false; +bool critical_sound_played = false; + +void register_low_battery_sounds() +{ + if (low_battery_sounds_registered) return; + /* Latch first: a failing backend must not retry on every 250 ms frame. */ + low_battery_sounds_registered = true; + cp0_signal_audio_api({"RegisterSystemSounds", kReminderSound, kCriticalSound}, nullptr); +} + +void play_low_battery_sound(const char *asset) noexcept +{ + if (!asset) return; + try { + register_low_battery_sounds(); + cp0_signal_system_play(asset); + } catch (...) { + } +} + int reminder_key_filter(const struct key_item *item) noexcept { try { @@ -217,6 +249,13 @@ void render(uint32_t now, bool force) { const LowBatteryWarning warning = flow.warning(); if (ui_screensaver_is_active()) { + /* Recovery has no deferred cue to protect, so re-arm right away even + * though the render is postponed; a phase entered while locked keeps its + * latch instead and is played once when the overlay is visible again. */ + if (warning == LowBatteryWarning::None || warning == LowBatteryWarning::Undefined) { + reminder_sound_played = false; + critical_sound_played = false; + } if (overlay) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); rendered_warning = LowBatteryWarning::None; @@ -224,6 +263,9 @@ void render(uint32_t now, bool force) return; } if (warning == LowBatteryWarning::None || warning == LowBatteryWarning::Undefined) { + /* Recovery re-arms both cues for the next low-battery cycle. */ + reminder_sound_played = false; + critical_sound_played = false; if (overlay) lv_obj_add_flag(overlay, LV_OBJ_FLAG_HIDDEN); rendered_warning = warning; @@ -237,6 +279,19 @@ void render(uint32_t now, bool force) lv_obj_move_foreground(overlay); lv_obj_clear_flag(overlay, LV_OBJ_FLAG_HIDDEN); + /* Trigger from the phase, not warning_changed: the render bookkeeping is + * reset when the overlay is rebuilt, and a replay there would be audible. + * The latches keep the cues one-shot for as long as the phase lasts. */ + if (warning == LowBatteryWarning::Reminder) { + if (!reminder_sound_played) { + reminder_sound_played = true; + play_low_battery_sound(kReminderSound); + } + } else if (!critical_sound_played) { + critical_sound_played = true; + play_low_battery_sound(kCriticalSound); + } + const bool warning_changed = warning != rendered_warning; if (warning_changed) { const bool reminder = warning == LowBatteryWarning::Reminder; @@ -304,6 +359,8 @@ void init_warning() try { if (warning_timer) return; + reminder_sound_played = false; + critical_sound_played = false; flow.reset(); battery_refresh_tick = lv_tick_get(); warning_timer = lv_timer_create(timer_cb, 250, nullptr); @@ -361,6 +418,8 @@ void shutdown_warning() countdown_label = nullptr; message_label = nullptr; flow.reset(); + reminder_sound_played = false; + critical_sound_played = false; rendered_warning = LowBatteryWarning::None; rendered_seconds = 0; battery_refresh_tick = 0; diff --git a/projects/APPLaunch/tests/low_battery_ui/test_low_battery_ui.cpp b/projects/APPLaunch/tests/low_battery_ui/test_low_battery_ui.cpp index f2154df9..902a9d17 100644 --- a/projects/APPLaunch/tests/low_battery_ui/test_low_battery_ui.cpp +++ b/projects/APPLaunch/tests/low_battery_ui/test_low_battery_ui.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "../../main/ui/ui_low_battery.cpp" #include "../../main/ui/ui_screensaver.cpp" #include "../../../../ext_components/cp0_lvgl/src/cp0_keyboard_queue.h" @@ -60,6 +61,18 @@ static void assert_sound(const char *expected) assert(std::strcmp(s_last_sound_asset, expected) == 0); } +/* Every asset handed to the platform player, in request order. The lock-screen + * tests keep using s_last_sound_asset; this records the low-battery cues. */ +static std::vector played_sounds; + +static int sound_plays(const char *asset) +{ + int plays = 0; + for (const std::string &played : played_sounds) + if (played == asset) ++plays; + return plays; +} + extern "C" cp0_battery_info_t cp0_battery_read() { ++battery_reads; return battery; } extern "C" void cp0_system_shutdown() { ++shutdown_calls; } @@ -562,6 +575,113 @@ static void test_screensaver_battery_interaction() shutdown_warning(); } +/* The cues defer while the lock screen owns the panel: a warning that happens + * behind the lock must stay silent and then sound exactly once when the overlay + * is visible again. */ +static void test_low_battery_sounds_respect_lockscreen() +{ + using namespace launcher_battery_ui; + shutdown_warning(); + set_battery(30); + init_warning(); + assert(!visible()); + + const int reminder_before = sound_plays("applaunch-arcade-refund.mp3"); + const int critical_before = sound_plays("applaunch-arcade-warning.mp3"); + + enter_lockscreen(); + assert(ui_screensaver_is_active()); + set_battery(20); + advance(3000); + assert(flow.warning() == LowBatteryWarning::Reminder && !visible()); + advance(3000); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before); + + unlock_screensaver(); + assert(visible()); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before + 1); + advance(3000); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before + 1); + + // Jumping straight into the shutdown countdown is still a single cue. + enter_lockscreen(); + set_battery(2); + advance(3000); + assert(flow.warning() == LowBatteryWarning::ShutdownCountdown && !visible()); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before); + advance(1000); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before); + + unlock_screensaver(); + assert(visible()); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before + 1); + advance(3000); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before + 1); + + stop_screensaver(); + shutdown_warning(); +} + +/* Recovery while locked must re-arm the cue even though the render is postponed: + * a new low-battery cycle that starts behind the lock still announces itself + * once the overlay returns. */ +static void test_low_battery_sounds_rearm_while_locked() +{ + using namespace launcher_battery_ui; + shutdown_warning(); + set_battery(15); + init_warning(); + assert(visible()); + + const int reminder_before = sound_plays("applaunch-arcade-refund.mp3"); + + // Charge behind the lock, then unplug: the new Reminder is still deferred. + enter_lockscreen(); + assert(ui_screensaver_is_active()); + set_battery(15, true); + advance(3000); + assert(flow.warning() == LowBatteryWarning::None); + set_battery(15); + advance(3000); + assert(flow.warning() == LowBatteryWarning::Reminder && !visible()); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before); + + unlock_screensaver(); + assert(visible()); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before + 1); + advance(3000); + assert(sound_plays("applaunch-arcade-refund.mp3") == reminder_before + 1); + + // The critical cue re-arms the same way after charging behind the lock. + set_battery(25); + advance(3000); + assert(!visible()); + set_battery(4); + advance(3000); + assert(visible()); + const int critical_before = sound_plays("applaunch-arcade-warning.mp3"); + + enter_lockscreen(); + set_battery(4, true); + advance(3000); + assert(flow.warning() == LowBatteryWarning::None); + set_battery(4); + advance(3000); + assert(flow.warning() == LowBatteryWarning::Low && !visible()); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before); + + unlock_screensaver(); + assert(visible()); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before + 1); + set_battery(3); + advance(3000); + assert(flow.warning() == LowBatteryWarning::ShutdownCountdown); + assert(sound_plays("applaunch-arcade-warning.mp3") == critical_before + 1); + + stop_screensaver(); + shutdown_warning(); +} + int main() { using namespace launcher_battery_ui; @@ -579,6 +699,7 @@ int main() cp0_signal_audio_api.append([](std::list, std::function reply) { if (reply) reply(0, ""); }); + cp0_signal_system_play.append([](std::string asset) { played_sounds.push_back(asset); }); cp0_signal_filesystem_api.append([](std::list arguments, std::function reply) { if (reply) reply(0, arguments.back() == "lockscreen.png" ? TEST_LOCKSCREEN_BACKGROUND : "/missing-screensaver-test-image.png"); @@ -613,13 +734,17 @@ int main() init_warning(); assert(visible()); assert(std::strstr(lv_label_get_text(message_label), "Press Enter to close")); + // One refund cue per low-battery cycle, even though the timer re-renders. + assert(sound_plays("applaunch-arcade-refund.mp3") == 1); snapshot_if_requested(); advance(60000); assert(visible()); + assert(sound_plays("applaunch-arcade-refund.mp3") == 1); set_battery(19); advance(3000); assert(std::strcmp(lv_label_get_text(countdown_label), "Battery: 19%") == 0); assert(lv_obj_get_style_bg_opa(tint, LV_PART_MAIN) == LV_OPA_TRANSP); + assert(sound_plays("applaunch-arcade-refund.mp3") == 1); key(KEY_ESC, KBD_KEY_PRESSED, true); key(KEY_ESC, KBD_KEY_RELEASED, true); assert(visible()); @@ -652,6 +777,8 @@ int main() set_battery(15); advance(3000); assert(visible()); + // Recovery re-arms the reminder for the next low-battery cycle. + assert(sound_plays("applaunch-arcade-refund.mp3") == 2); key(KEY_KPENTER, KBD_KEY_PRESSED, true); assert(!visible()); key(KEY_KPENTER, KBD_KEY_RELEASED, true); @@ -675,21 +802,27 @@ int main() advance(3000); assert(visible()); assert(lv_obj_get_style_bg_opa(tint, LV_PART_MAIN) == LV_OPA_20); + assert(sound_plays("applaunch-arcade-warning.mp3") == 1); advance(500); assert(lv_obj_get_style_bg_opa(tint, LV_PART_MAIN) == LV_OPA_50); key(KEY_ENTER, KBD_KEY_PRESSED, false); key(KEY_ENTER, KBD_KEY_RELEASED, false); assert(visible()); + advance(3000); + assert(sound_plays("applaunch-arcade-warning.mp3") == 1); set_battery(3); advance(3000); assert(std::strcmp(lv_label_get_text(countdown_label), "Power off in 15s") == 0); + // Low -> ShutdownCountdown promotes the same critical phase, not a new cue. + assert(sound_plays("applaunch-arcade-warning.mp3") == 1); advance(12000); set_battery(3, true); advance(3000); assert(shutdown_calls == 0 && !visible()); set_battery(3); advance(3000); + assert(sound_plays("applaunch-arcade-warning.mp3") == 2); advance(15000); assert(shutdown_calls == 1); advance(3000); @@ -708,6 +841,8 @@ int main() assert(!overlay && !warning_timer); assert(cp0_keyboard_get_key_filter() == previous_filter); test_screensaver_battery_interaction(); + test_low_battery_sounds_respect_lockscreen(); + test_low_battery_sounds_rearm_while_locked(); ui_screensaver_deinit(); cp0_keyboard_set_key_filter(nullptr); lv_display_delete(display); From bf3465a321237de5dcccf707bad38c0eaa7623eb Mon Sep 17 00:00:00 2001 From: dianjixz <18637716021@163.com> Date: Thu, 12 Nov 2026 19:19:26 +0800 Subject: [PATCH 3/3] feat(settings): add software update page for apps and launcher - Add Settings -> System -> Software listing installed application packages and the launcher, with GitHub release version checks and update prompts. - Add settings_software_model to parse dpkg status, release metadata and updater status payloads. - Add settings_software_updater plus a libcurl multi worker to fetch release metadata concurrently, falling back to curl subprocesses when libcurl is not compiled in. - Add applaunch-apps-updater service/script to the Debian packaging so the launcher can trigger allowlisted app updates via polkit. - Add unit and contract tests, wire them into run_tests.sh, and document the libcurl build dependency in the READMEs. --- README.md | 1 + README_JA.md | 1 + README_ZH.md | 1 + projects/APPLaunch/main/SConstruct | 4 + .../main/ui/settings/settings_http_worker.cpp | 385 +++++ .../main/ui/settings/settings_http_worker.hpp | 68 + .../ui/settings/settings_software_model.cpp | 365 +++++ .../ui/settings/settings_software_model.hpp | 125 ++ .../ui/settings/settings_software_updater.hpp | 468 ++++++ .../main/ui/settings/settings_system_page.cpp | 1362 ++++++++++++++--- .../main/ui/settings/settings_system_page.hpp | 23 +- projects/APPLaunch/tests/run_tests.sh | 10 + .../tests/test_settings_software_model.cpp | 186 +++ .../test_settings_software_page_contract.py | 101 ++ .../tests/test_settings_software_updater.cpp | 196 +++ .../APPLaunch/tests/test_updater_packaging.py | 147 ++ scripts/debian_packager.py | 232 ++- 17 files changed, 3464 insertions(+), 211 deletions(-) create mode 100644 projects/APPLaunch/main/ui/settings/settings_http_worker.cpp create mode 100644 projects/APPLaunch/main/ui/settings/settings_http_worker.hpp create mode 100644 projects/APPLaunch/main/ui/settings/settings_software_model.cpp create mode 100644 projects/APPLaunch/main/ui/settings/settings_software_model.hpp create mode 100644 projects/APPLaunch/main/ui/settings/settings_software_updater.hpp create mode 100644 projects/APPLaunch/tests/test_settings_software_model.cpp create mode 100644 projects/APPLaunch/tests/test_settings_software_page_contract.py create mode 100644 projects/APPLaunch/tests/test_settings_software_updater.cpp diff --git a/README.md b/README.md index d8a91574..0af84fb7 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ The project is mainly developed in a Linux environment and is also compatible wi ```bash sudo apt update sudo apt install python3 python3-pip build-essential pkg-config libffi-dev libsdl2-dev libfreetype6-dev +sudo apt install libcurl4-openssl-dev pip3 install parse scons requests tqdm pip3 install setuptools-rust paramiko scp diff --git a/README_JA.md b/README_JA.md index cbb7d0b6..73674109 100644 --- a/README_JA.md +++ b/README_JA.md @@ -111,6 +111,7 @@ launcher/ ```bash sudo apt update sudo apt install python3 python3-pip build-essential pkg-config libffi-dev libsdl2-dev libfreetype6-dev +sudo apt install libcurl4-openssl-dev pip3 install parse scons requests tqdm pip3 install setuptools-rust paramiko scp diff --git a/README_ZH.md b/README_ZH.md index 18189f0f..06ead272 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -119,6 +119,7 @@ launcher/ ```bash sudo apt update sudo apt install python3 python3-pip build-essential pkg-config libffi-dev libsdl2-dev libfreetype6-dev +sudo apt install libcurl4-openssl-dev pip3 install parse scons requests tqdm pip3 install setuptools-rust paramiko scp diff --git a/projects/APPLaunch/main/SConstruct b/projects/APPLaunch/main/SConstruct index 83819967..a220c766 100644 --- a/projects/APPLaunch/main/SConstruct +++ b/projects/APPLaunch/main/SConstruct @@ -33,6 +33,9 @@ LDFLAGS = [] LINK_SEARCH_PATH = [] STATIC_FILES = [] +REQUIREMENTS += [':libcurl.so.4'] +DEFINITIONS += ['-DAPPLAUNCH_USE_LIBCURL=1'] + # Generate page_app includes into build output so builds do not rewrite source files. def run_python_script(script_rel_path, args=None): script_path = script_rel_path.get_abspath() @@ -132,6 +135,7 @@ lvgl_component['PRIVATE_INCLUDE'] = [ ] + lvgl_component['PRIVATE_INCLUDE'] + if 'CONFIG_APPLAUNCH_CROSS' in os.environ: lvgl_component['DEFINITIONS'] += ['-D_REENTRANT'] lvgl_component['INCLUDE'] += [rootfs_path/"usr"/"include"/"freetype2", diff --git a/projects/APPLaunch/main/ui/settings/settings_http_worker.cpp b/projects/APPLaunch/main/ui/settings/settings_http_worker.cpp new file mode 100644 index 00000000..900693ff --- /dev/null +++ b/projects/APPLaunch/main/ui/settings/settings_http_worker.cpp @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "settings_http_worker.hpp" + +#include "cp0_lvgl_app.h" +#include "hal_lvgl_bsp.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef APPLAUNCH_USE_LIBCURL +#include +#endif + +namespace settings_http { +namespace { + +constexpr std::size_t kMaxBodyBytes = 512 * 1024; + +#ifdef APPLAUNCH_USE_LIBCURL +struct Transfer { + CURL *easy = nullptr; + std::size_t index = 0; + std::vector body; + bool overflow = false; + const CancelFlag *cancel = nullptr; + curl_slist *headers = nullptr; +}; + +struct Job { + std::vector requests; + const CancelFlag *cancel = nullptr; + std::vector responses; + int max_concurrency = 6; + bool done = false; + std::mutex mutex; + std::condition_variable condition; +}; + +class Worker { +public: + Worker() + { + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return; + multi_ = curl_multi_init(); + if (!multi_) return; + thread_ = std::thread([this]() { run(); }); + } + + ~Worker() + { + { + std::lock_guard lock(mutex_); + stop_ = true; + } + condition_.notify_all(); + if (thread_.joinable()) thread_.join(); + if (multi_) curl_multi_cleanup(multi_); + curl_global_cleanup(); + } + + bool available() const { return multi_ != nullptr && thread_.joinable(); } + + void wake() + { +#if defined(LIBCURL_VERSION_NUM) && LIBCURL_VERSION_NUM >= 0x074400 + if (multi_) curl_multi_wakeup(multi_); +#endif + } + + std::vector submit(std::vector requests, + const CancelFlag *cancel, + int max_concurrency) + { + auto job = std::make_shared(); + job->requests = std::move(requests); + job->cancel = cancel; + job->responses.resize(job->requests.size()); + job->max_concurrency = std::max(1, std::min(8, max_concurrency)); + if (!available()) return {}; + { + std::lock_guard lock(mutex_); + queue_.push_back(job); + } + condition_.notify_one(); + std::unique_lock lock(job->mutex); + job->condition.wait(lock, [&job]() { return job->done; }); + return job->responses; + } + +private: + void run() + { + for (;;) { + std::shared_ptr job; + { + std::unique_lock lock(mutex_); + condition_.wait(lock, [this]() { return stop_ || !queue_.empty(); }); + if (stop_) return; + job = queue_.front(); + queue_.pop_front(); + } + process(*job); + { + std::lock_guard lock(job->mutex); + job->done = true; + } + job->condition.notify_all(); + } + } + + static std::size_t write_callback(char *data, std::size_t size, + std::size_t count, void *userdata) + { + auto *transfer = static_cast(userdata); + if (!transfer) return 0; + const std::size_t bytes = size * count; + if (transfer->body.size() + bytes > kMaxBodyBytes) { + transfer->overflow = true; + return 0; + } + transfer->body.insert(transfer->body.end(), data, data + bytes); + return bytes; + } + + static int progress_callback(void *userdata, curl_off_t, curl_off_t, curl_off_t, + curl_off_t) + { + auto *transfer = static_cast(userdata); + if (transfer && transfer->cancel && + transfer->cancel->load(std::memory_order_acquire)) + return 1; + return 0; + } + + Transfer *start_transfer(std::size_t index, const Request &request, + const CancelFlag *cancel) + { + auto *transfer = new Transfer; + transfer->index = index; + transfer->cancel = cancel; + transfer->easy = curl_easy_init(); + if (!transfer->easy) { + delete transfer; + return nullptr; + } + CURL *easy = transfer->easy; + curl_easy_setopt(easy, CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(easy, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(easy, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(easy, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT_MS, + static_cast(std::min(5000, request.timeout_ms))); + curl_easy_setopt(easy, CURLOPT_TIMEOUT_MS, + static_cast(request.timeout_ms)); + curl_easy_setopt(easy, CURLOPT_USERAGENT, request.user_agent.c_str()); + curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(easy, CURLOPT_WRITEDATA, transfer); + curl_easy_setopt(easy, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(easy, CURLOPT_XFERINFOFUNCTION, progress_callback); + curl_easy_setopt(easy, CURLOPT_XFERINFODATA, transfer); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(easy, CURLOPT_SSL_VERIFYHOST, 2L); + if (!request.accept_header.empty()) { + const std::string header = "Accept: " + request.accept_header; + transfer->headers = curl_slist_append(transfer->headers, header.c_str()); + if (transfer->headers) + curl_easy_setopt(easy, CURLOPT_HTTPHEADER, transfer->headers); + } + if (curl_multi_add_handle(multi_, easy) != CURLM_OK) { + if (transfer->headers) curl_slist_free_all(transfer->headers); + curl_easy_cleanup(easy); + delete transfer; + return nullptr; + } + return transfer; + } + + void finish_transfer(Transfer *transfer, CURLcode code) + { + Response &response = (*current_responses_)[transfer->index]; + long status = 0; + char *effective = nullptr; + curl_easy_getinfo(transfer->easy, CURLINFO_RESPONSE_CODE, &status); + curl_easy_getinfo(transfer->easy, CURLINFO_EFFECTIVE_URL, &effective); + response.status = status; + response.effective_url = effective ? effective : ""; + response.body.assign(transfer->body.begin(), transfer->body.end()); + response.code = static_cast(code); + response.ok = code == CURLE_OK && status >= 200 && status < 300; + if (code == CURLE_OK && status >= 400) response.code = 22; + if (!response.ok) response.error = curl_easy_strerror(code); + curl_multi_remove_handle(multi_, transfer->easy); + curl_easy_cleanup(transfer->easy); + if (transfer->headers) curl_slist_free_all(transfer->headers); + delete transfer; + } + + void process(Job &job) + { + current_responses_ = &job.responses; + std::vector active; + std::size_t next = 0; + const auto add_next = [&]() { + while (next < job.requests.size() && + static_cast(active.size()) < job.max_concurrency) { + Transfer *transfer = + start_transfer(next, job.requests[next], job.cancel); + job.responses[next].code = transfer ? -1 : -ENOMEM; + if (!transfer) { + ++next; + continue; + } + active.push_back(transfer); + ++next; + } + }; + + add_next(); + while (!active.empty()) { + if (job.cancel && job.cancel->load(std::memory_order_acquire)) { + for (Transfer *transfer : active) { + job.responses[transfer->index].code = -ECANCELED; + curl_multi_remove_handle(multi_, transfer->easy); + curl_easy_cleanup(transfer->easy); + if (transfer->headers) curl_slist_free_all(transfer->headers); + delete transfer; + } + for (std::size_t i = next; i < job.requests.size(); ++i) + job.responses[i].code = -ECANCELED; + current_responses_ = nullptr; + return; + } + + int running = 0; + curl_multi_perform(multi_, &running); + CURLMsg *message = nullptr; + int left = 0; + while ((message = curl_multi_info_read(multi_, &left)) != nullptr) { + if (message->msg != CURLMSG_DONE) continue; + CURL *easy = message->easy_handle; + auto it = std::find_if(active.begin(), active.end(), + [easy](Transfer *transfer) { + return transfer->easy == easy; + }); + if (it == active.end()) continue; + Transfer *transfer = *it; + active.erase(it); + finish_transfer(transfer, message->data.result); + } + add_next(); + if (!active.empty()) + curl_multi_poll(multi_, nullptr, 0, 50, nullptr); + } + for (std::size_t i = next; i < job.requests.size(); ++i) { + if (job.responses[i].code == -1) job.responses[i].code = -ENOENT; + } + current_responses_ = nullptr; + } + + CURLM *multi_ = nullptr; + std::thread thread_; + std::mutex mutex_; + std::condition_variable condition_; + std::deque> queue_; + bool stop_ = false; + std::vector *current_responses_ = nullptr; +}; + +Worker &worker() +{ + static Worker instance; + return instance; +} +#endif // APPLAUNCH_USE_LIBCURL + +Response capture_with_curl(const Request &request, const CancelFlag *cancel) +{ + Response response; + if (cancel && cancel->load(std::memory_order_acquire)) { + response.code = -ECANCELED; + return response; + } + const int seconds = std::max(1, (request.timeout_ms + 999) / 1000); + std::list arguments = { + "CaptureArgv", "timeout", std::to_string(seconds + 5), "curl", "-fsSL", + "--max-time", std::to_string(seconds), "--connect-timeout", "5", + "-A", request.user_agent}; + if (!request.accept_header.empty()) + arguments.insert(arguments.end(), {"-H", "Accept: " + request.accept_header}); + arguments.insert(arguments.end(), + {"-w", "\n%{url_effective}", request.url}); + + std::string output; + int code = -1; + try { + cp0_signal_process_api( + std::move(arguments), [&](int result, std::string data) { + code = result; + output = std::move(data); + }); + } catch (...) { + response.code = -1; + response.error = "process api unavailable"; + return response; + } + if (code != 0) { + response.code = code; + response.error = "curl subprocess failed"; + return response; + } + const std::size_t split = output.rfind('\n'); + if (split != std::string::npos) { + response.body = output.substr(0, split); + response.effective_url = output.substr(split + 1); + } else { + response.body = std::move(output); + } + response.status = 200; + response.ok = true; + response.code = 0; + return response; +} + +} // namespace + +bool libcurl_available() +{ +#ifdef APPLAUNCH_USE_LIBCURL + return worker().available(); +#else + return false; +#endif +} + +void wake() +{ +#ifdef APPLAUNCH_USE_LIBCURL + if (worker().available()) worker().wake(); +#endif +} + +std::vector download_batch(const std::vector &requests, + const CancelFlag *cancel, + int max_concurrency) +{ + (void)max_concurrency; + if (requests.empty()) return {}; +#ifdef APPLAUNCH_USE_LIBCURL + if (worker().available()) { + std::vector responses = + worker().submit(requests, cancel, max_concurrency); + if (responses.size() == requests.size()) return responses; + } +#endif + std::vector responses; + responses.reserve(requests.size()); + for (const Request &request : requests) { + if (cancel && cancel->load(std::memory_order_acquire)) { + Response response; + response.code = -ECANCELED; + responses.push_back(std::move(response)); + continue; + } + responses.push_back(capture_with_curl(request, cancel)); + } + return responses; +} + +} // namespace settings_http diff --git a/projects/APPLaunch/main/ui/settings/settings_http_worker.hpp b/projects/APPLaunch/main/ui/settings/settings_http_worker.hpp new file mode 100644 index 00000000..af5c5eb8 --- /dev/null +++ b/projects/APPLaunch/main/ui/settings/settings_http_worker.hpp @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include + +namespace settings_http { + +/** + * One HTTP GET executed by the APPLaunch-side worker. + * + * `want_effective_url` is used for GitHub's `/releases/latest` redirect: the + * body is still returned, and `Response::effective_url` contains the final URL + * (for example `.../releases/tag/v1.0.3`). + */ +struct Request { + std::string url; + std::string accept_header; + std::string user_agent = "APPLaunch-Software-Check"; + int timeout_ms = 15000; + bool want_effective_url = false; +}; + +struct Response { + bool ok = false; + int code = -1; + long status = 0; + std::string body; + std::string effective_url; + std::string error; +}; + +using CancelFlag = std::atomic; + +/** + * Download a batch of URLs. + * + * When APPLaunch is compiled with libcurl (cross/device builds), a persistent + * worker thread owns one CURLM handle and executes at most `max_concurrency` + * transfers in parallel. The calling thread must not be the LVGL thread. + * + * When libcurl is not compiled in, the function falls back to synchronous + * `curl` subprocesses on the calling thread, preserving the previous behavior. + * The fallback is bounded by `timeout_ms` (and the outer 30s timeout). + * + * `cancel` is checked by the libcurl progress callback and by the fallback + * between requests; a cancelled batch returns entries with code -ECANCELED. + */ +std::vector download_batch(const std::vector &requests, + const CancelFlag *cancel = nullptr, + int max_concurrency = 6); + +/** True when the libcurl multi worker is compiled in and running. */ +bool libcurl_available(); + +/** + * Wake the libcurl multi worker out of curl_multi_poll. Safe to call from the + * LVGL thread after setting the cancel flag; a no-op in the curl fallback. + */ +void wake(); + +} // namespace settings_http diff --git a/projects/APPLaunch/main/ui/settings/settings_software_model.cpp b/projects/APPLaunch/main/ui/settings/settings_software_model.cpp new file mode 100644 index 00000000..833bd87f --- /dev/null +++ b/projects/APPLaunch/main/ui/settings/settings_software_model.cpp @@ -0,0 +1,365 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "settings_software_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace settings_software { +namespace { + +constexpr const char *kNotInstalled = "Not installed"; +constexpr const char *kNoDescription = "No description available"; + +std::string trim(std::string_view value) +{ + const auto is_space = [](unsigned char character) { + return std::isspace(character) != 0; + }; + const auto begin = std::find_if_not(value.begin(), value.end(), is_space); + const auto end = std::find_if_not(value.rbegin(), value.rend(), is_space).base(); + return begin < end ? std::string(begin, end) : std::string{}; +} + +struct StatusEntry { + std::string package; + std::string status; + std::string version; + std::string description; + std::string long_description; +}; + +bool is_installed_status(const std::string &status) +{ + return status == "install ok installed" || status == "hold ok installed"; +} + +void flush_entry(std::map &entries, StatusEntry &entry) +{ + if (entry.package.empty()) return; + if (!is_installed_status(entry.status)) return; + entries[entry.package] = std::move(entry); +} + +std::string description_text(const StatusEntry &entry) +{ + if (entry.description.empty() && entry.long_description.empty()) + return kNoDescription; + if (entry.long_description.empty()) return entry.description; + if (entry.description.empty()) return entry.long_description; + return entry.description + "\n" + entry.long_description; +} + +bool parse_boolean(const std::string &value, bool &result) +{ + if (value == "0") { + result = false; + return true; + } + if (value == "1") { + result = true; + return true; + } + return false; +} + +bool parse_nonnegative_int(const std::string &value, int &result) +{ + if (value.empty()) return false; + errno = 0; + char *end = nullptr; + const long parsed = std::strtol(value.c_str(), &end, 10); + if (errno == ERANGE || end == value.c_str() || *end != '\0' || parsed < 0 || + parsed > 1000000) + return false; + result = static_cast(parsed); + return true; +} + +std::vector split(const std::string &value, char separator) +{ + std::vector fields; + std::size_t begin = 0; + while (begin <= value.size()) { + const std::size_t end = value.find(separator, begin); + fields.push_back(value.substr( + begin, end == std::string::npos ? std::string::npos : end - begin)); + if (end == std::string::npos) break; + begin = end + 1; + } + return fields; +} + +} // namespace + +const std::vector &app_definitions() +{ + static const std::vector definitions = { + {"recorder", "m5cardputerzero-recorder", "Recorder", + "m5cardputerzero-recorder_"}, + {"compass", "m5cardputerzero-compass", "Compass", + "m5cardputerzero-compass_"}, + {"camera", "camera", "CameraApp", "Camera_"}, + {"factorytest", "factorytest", "FactoryTest", "FactoryTest_"}, + {"files", "m5cardputerzero-files", "Files", "m5cardputerzero-files_"}, + {"music", "m5cardputerzero-music", "Music", "m5cardputerzero-music_"}, + {"ir-chat", "m5cardputerzero-ir-chat", "IR-Chat", + "m5cardputerzero-ir-chat_"}, + {"piano", "m5cardputerzero-piano", "Piano", + "m5cardputerzero-piano_"}, + {"ir-remote", "m5cardputerzero-ir-remote", "IR-Remote", + "m5cardputerzero-ir-remote_"}, + {"cap-cc1101-subg-chat", "m5cardputerzero-cap-cc1101-subg-chat", + "Cap-CC1101-SubG-Chat", + "m5cardputerzero-cap-cc1101-subg-chat_"}, + {"cap-cc1101-nfc", "m5cardputerzero-cap-cc1101-nfc", + "Cap-CC1101-NFC", "m5cardputerzero-cap-cc1101-nfc_"}, + {"cap-lora-1262", "m5cardputerzero-cap-lora-1262", "Cap-LoRa-1262", + "m5cardputerzero-cap-lora-1262_"}, + {"cap-lora-1262-gps", "m5cardputerzero-cap-lora-1262-gps", + "Cap-LoRa-1262-GPS", "m5cardputerzero-cap-lora-1262-gps_"}, + {"keyboard-guide", "m5cardputerzero-keyboard-guide", "Keyboard-Guide", + "m5cardputerzero-keyboard-guide_"}, + }; + return definitions; +} + +std::string short_name(const std::string &package) +{ + constexpr const char *prefix = "m5cardputerzero-"; + const std::string prefix_text(prefix); + if (package.rfind(prefix_text, 0) == 0 && package.size() > prefix_text.size()) + return package.substr(prefix_text.size()); + return package; +} + +std::vector read_installed_apps(const std::string &status_path) +{ + std::map entries; + std::ifstream file(status_path); + if (file) { + StatusEntry entry; + bool in_description = false; + std::string line; + while (std::getline(file, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) { + flush_entry(entries, entry); + entry = StatusEntry{}; + in_description = false; + continue; + } + if (!line.empty() && line.front() == ' ') { + if (!in_description) continue; + std::string continuation = line.substr(1); + if (continuation == ".") { + if (!entry.long_description.empty()) + entry.long_description.push_back('\n'); + continue; + } + if (!entry.long_description.empty()) + entry.long_description.push_back(' '); + entry.long_description += trim(continuation); + continue; + } + + const std::size_t separator = line.find(':'); + if (separator == std::string::npos) { + in_description = false; + continue; + } + const std::string field = line.substr(0, separator); + const std::string value = trim(line.substr(separator + 1)); + in_description = field == "Description"; + if (field == "Package") { + entry.package = value; + } else if (field == "Status") { + entry.status = value; + } else if (field == "Version") { + entry.version = value; + } else if (field == "Description") { + entry.description = value; + } + } + flush_entry(entries, entry); + } + + std::vector apps; + apps.reserve(app_definitions().size()); + for (const AppDefinition &definition : app_definitions()) { + InstalledApp app; + app.package = definition.package; + app.short_name = definition.short_name; + const auto found = entries.find(app.package); + if (found != entries.end()) { + app.installed = true; + app.version = found->second.version.empty() ? "--" : found->second.version; + app.description = description_text(found->second); + } else { + app.description = kNotInstalled; + } + apps.push_back(std::move(app)); + } + return apps; +} + +bool parse_check_result(const std::string &payload, CheckResult &result) +{ + constexpr const char *marker = "software|"; + const std::size_t marker_position = payload.find(marker); + if (marker_position == std::string::npos) return false; + + result = CheckResult{}; + const std::string body = payload.substr(marker_position + std::char_traits::length(marker)); + for (const std::string &token : split(body, '|')) { + const std::size_t separator = token.find('='); + if (separator == std::string::npos) continue; + const std::string key = token.substr(0, separator); + const std::string value = token.substr(separator + 1); + + if (key == "updates") { + for (const std::string &item : split(value, ';')) { + const std::size_t equals = item.find('='); + if (equals == std::string::npos || equals == 0) continue; + const std::string package = item.substr(0, equals); + const std::string versions = item.substr(equals + 1); + const std::size_t arrow = versions.find('>'); + if (arrow == std::string::npos) continue; + UpdateInfo update; + update.package = package; + update.installed = versions.substr(0, arrow); + update.latest = versions.substr(arrow + 1); + if (update.package.empty() || update.latest.empty()) continue; + result.updates.push_back(std::move(update)); + } + } else if (key == "checked") { + int parsed = 0; + if (parse_nonnegative_int(value, parsed)) result.checked = parsed; + } else if (key == "failed") { + int parsed = 0; + if (parse_nonnegative_int(value, parsed)) result.failed = parsed; + } else if (key == "skipped") { + int parsed = 0; + if (parse_nonnegative_int(value, parsed)) result.skipped = parsed; + } else if (key == "launcher_known") { + bool parsed = false; + if (parse_boolean(value, parsed)) result.launcher_known = parsed; + } else if (key == "launcher_available") { + bool parsed = false; + if (parse_boolean(value, parsed)) { + result.launcher_known = true; + result.launcher_available = parsed; + } + } else if (key == "launcher_failed") { + bool parsed = false; + if (parse_boolean(value, parsed)) result.launcher_failed = parsed; + } else if (key == "launcher_installed") { + result.launcher_installed = value; + } else if (key == "launcher_version") { + result.launcher_version = value; + } else if (key == "launcher_commit") { + result.launcher_commit = value; + } + } + return true; +} + +bool parse_app_update_status(const std::string &status, AppUpdateState &result) +{ + result = AppUpdateState{}; + if (status.empty()) return false; + + const auto starts_with = [&status](const char *prefix) { + return status.rfind(prefix, 0) == 0; + }; + const auto count_entries = [](const std::string &value) { + int count = 0; + for (const std::string &entry : split(value, ',')) + if (!entry.empty()) ++count; + return count; + }; + + if (starts_with("checking:") || starts_with("downloading:") || + starts_with("installing:")) { + const auto fields = split(status, ':'); + if (fields.size() < 4) return false; + result.phase = fields[0] == "checking" ? AppUpdateState::Phase::Checking + : fields[0] == "downloading" ? AppUpdateState::Phase::Downloading + : AppUpdateState::Phase::Installing; + int index = 0; + int total = 0; + if (!parse_nonnegative_int(fields[1], index) || + !parse_nonnegative_int(fields[2], total)) + return false; + result.index = index; + result.total = total; + result.package = fields[3]; + return true; + } + + if (starts_with("succeeded:")) { + result.phase = AppUpdateState::Phase::Succeeded; + const std::string body = status.substr(std::char_traits::length("succeeded:")); + for (const std::string &token : split(body, '|')) { + const std::size_t separator = token.find('='); + if (separator == std::string::npos) continue; + const std::string key = token.substr(0, separator); + const std::string value = token.substr(separator + 1); + if (key == "updated") + result.updated_count = count_entries(value); + else if (key == "failed") + result.failed_count = count_entries(value); + } + return true; + } + + if (starts_with("failed:")) { + result.phase = AppUpdateState::Phase::Failed; + result.detail = status.substr(std::char_traits::length("failed:")); + return true; + } + + if (status == "cancelled" || status == "canceled") { + result.phase = AppUpdateState::Phase::Cancelled; + return true; + } + + if (starts_with("cancelling:")) { + result.phase = AppUpdateState::Phase::Cancelling; + result.detail = status.substr(std::char_traits::length("cancelling:")); + return true; + } + + return false; +} + +bool update_changes_version(const UpdateInfo &update) +{ + return !update.installed.empty() && !update.latest.empty() && + update.installed != update.latest; +} + +std::string update_line(const UpdateInfo &update) +{ + return short_name(update.package) + " " + update.installed + " -> " + + update.latest; +} + +std::string launcher_update_line(const std::string &installed, + const std::string &latest) +{ + return "APPLaunch " + installed + " -> " + latest; +} + +} // namespace settings_software diff --git a/projects/APPLaunch/main/ui/settings/settings_software_model.hpp b/projects/APPLaunch/main/ui/settings/settings_software_model.hpp new file mode 100644 index 00000000..dcc109fa --- /dev/null +++ b/projects/APPLaunch/main/ui/settings/settings_software_model.hpp @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include + +namespace settings_software { + +/** + * The applications installed by the CardputerZero image stage + * (`pi-gen/stage2/05-cardputerzero/03-run.sh`). + * + * The order is the order used by that script and is also the order shown in + * Settings -> System -> Software. + */ +struct AppDefinition { + const char *short_name; + const char *package; + const char *repo; + const char *asset_prefix; +}; + +inline constexpr const char *kAssetSuffix = "_m5stack1_arm64.deb"; + +const std::vector &app_definitions(); + +/** `m5cardputerzero-recorder` -> `recorder`; other names are returned as-is. */ +std::string short_name(const std::string &package); + +struct InstalledApp { + std::string package; + std::string short_name; + std::string version = "--"; + std::string description; + bool installed = false; +}; + +/** + * Read the installed versions and descriptions of the known applications from + * a dpkg status database. Missing packages are reported as not installed + * instead of being dropped, so the Software page always shows the full list. + */ +std::vector read_installed_apps( + const std::string &status_path = "/var/lib/dpkg/status"); + +struct UpdateInfo { + std::string package; + std::string installed; + std::string latest; +}; + +struct CheckResult { + std::vector updates; + int checked = 0; + int failed = 0; + int skipped = 0; + bool launcher_known = false; + bool launcher_available = false; + bool launcher_failed = false; + std::string launcher_installed; + std::string launcher_version; + std::string launcher_commit; +}; + +/** + * Parse the payload produced by settings_software::check_software. + * + * The payload has the form + * `software|checked=N|failed=N|skipped=N|updates=pkg=installed>latest;...| + * launcher_known=0|1|launcher_available=0|1|launcher_installed=...| + * launcher_version=...|launcher_commit=...|launcher_failed=0|1`. + * A `succeeded:` prefix produced by `UpdateJobStatus` is accepted as well. + */ +bool parse_check_result(const std::string &payload, CheckResult &result); + +struct AppUpdateState { + enum class Phase { + Unknown, + Checking, + Downloading, + Installing, + Succeeded, + Failed, + Cancelled, + Cancelling, + }; + + Phase phase = Phase::Unknown; + int index = 0; + int total = 0; + std::string package; + int updated_count = 0; + int failed_count = 0; + std::string detail; +}; + +/** + * Parse one line from `/var/lib/applaunch-updater/apps-status`. + * + * Progress lines are `checking|downloading|installing:::`; + * terminal lines are `succeeded:updated=pkg:ver,...|failed=pkg:reason,...`, + * `failed:`, or `cancelled`. + */ +bool parse_app_update_status(const std::string &status, AppUpdateState &result); + +/** + * Defensive display guard: the backend only reports versions newer than the + * installed one, but never show an entry whose two version strings are equal. + */ +bool update_changes_version(const UpdateInfo &update); + +/** `recorder 0.1.8 -> 0.1.9` */ +std::string update_line(const UpdateInfo &update); + +/** `APPLaunch 0.6.100 -> 0.7.0` */ +std::string launcher_update_line(const std::string &installed, + const std::string &latest); + +} // namespace settings_software diff --git a/projects/APPLaunch/main/ui/settings/settings_software_updater.hpp b/projects/APPLaunch/main/ui/settings/settings_software_updater.hpp new file mode 100644 index 00000000..4e874cd5 --- /dev/null +++ b/projects/APPLaunch/main/ui/settings/settings_software_updater.hpp @@ -0,0 +1,468 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace settings_software { + +using Capture = std::function &, std::string &)>; + +struct Result { + int code = -1; + std::string stage; +}; + +inline std::string trim(std::string value) +{ + while (!value.empty() && std::isspace(static_cast(value.back()))) + value.pop_back(); + std::size_t first = 0; + while (first < value.size() && + std::isspace(static_cast(value[first]))) + ++first; + return value.substr(first); +} + +/** + * One application whose Debian package is published as a GitHub release asset. + * + * The asset name is expected to look like + * ``, for example + * `m5cardputerzero-recorder_0.1.8_m5stack1_arm64.deb`. The prefix and suffix + * are supplied by the caller so this module stays independent from the + * packaging script. + */ + +struct AppSpec { + std::string package; + std::string repo; + std::string asset_prefix; + std::string asset_suffix; +}; + +struct AppUpdate { + std::string package; + std::string installed; + std::string latest; +}; + +struct LauncherInfo { + bool known = false; + bool available = false; + bool failed = false; + std::string installed; + std::string version; + std::string commit; +}; + +struct LauncherUpdateInfo { + std::string version; + std::string package_version; + std::string commit; +}; + +inline bool safe_metadata_value(const std::string &value) +{ + if (value.empty() || value.size() > 128) return false; + for (const unsigned char character : value) { + if (character < 0x21 || character > 0x7e || character == '|' || + character == '=') + return false; + } + return true; +} + +inline bool launcher_update_info(const std::string &text, LauncherUpdateInfo &info) +{ + LauncherUpdateInfo parsed; + bool format_seen = false; + std::istringstream lines(text); + std::string line; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + const std::size_t separator = line.find('='); + if (separator == std::string::npos) return false; + const std::string key = line.substr(0, separator); + const std::string value = line.substr(separator + 1); + if (key == "format") { + if (format_seen || value != "1") return false; + format_seen = true; + } else if (key == "version") { + if (!parsed.version.empty()) return false; + parsed.version = value; + } else if (key == "package_version") { + if (!parsed.package_version.empty()) return false; + parsed.package_version = value; + } else if (key == "commit") { + if (!parsed.commit.empty()) return false; + parsed.commit = value; + } + } + if (!format_seen || !safe_metadata_value(parsed.version) || + !safe_metadata_value(parsed.commit)) + return false; + if (parsed.package_version.empty()) parsed.package_version = parsed.version; + if (!safe_metadata_value(parsed.package_version)) return false; + for (const unsigned char character : parsed.commit) + if (!std::isxdigit(character)) return false; + if (parsed.commit.size() < 7 || parsed.commit.size() > 64) return false; + info = std::move(parsed); + return true; +} + +inline void check_launcher(const Capture &capture, + const std::string &release_url, + LauncherInfo &info) +{ + std::string metadata; + if (capture({"curl", "-fsSL", "--max-time", "20", "--connect-timeout", "5", + release_url + "/applaunch_arm64.deb.update-info"}, + metadata) != 0) { + info.failed = true; + return; + } + LauncherUpdateInfo parsed; + if (!launcher_update_info(metadata, parsed)) { + info.failed = true; + return; + } + std::string installed; + if (capture({"dpkg-query", "-W", "-f=${Version}", "applaunch"}, installed) != 0) { + info.failed = true; + return; + } + installed = trim(std::move(installed)); + if (installed.empty()) { + info.failed = true; + return; + } + info.installed = installed; + std::string ignored; + const int comparison = capture( + {"dpkg", "--compare-versions", parsed.package_version, "gt", installed}, + ignored); + if (comparison != 0 && comparison != 1) { + info.failed = true; + return; + } + info.known = true; + info.available = comparison == 0; + info.version = parsed.version; + info.commit = parsed.commit; +} + + +struct Outcome { + int checked = 0; + int failed = 0; + int skipped = 0; + std::vector updates; + LauncherInfo launcher; +}; + +inline bool safe_field(const std::string &value) +{ + if (value.empty() || value.size() > 160) return false; + for (const unsigned char character : value) { + if (std::isalnum(character) || character == '.' || character == '_' || + character == '+' || character == '~' || character == '-') + continue; + return false; + } + return true; +} + +inline bool parse_app_spec(const std::string &text, AppSpec &spec) +{ + std::vector fields; + std::size_t start = 0; + for (;;) { + const std::size_t separator = text.find('|', start); + fields.push_back(text.substr( + start, separator == std::string::npos ? std::string::npos : separator - start)); + if (separator == std::string::npos) break; + start = separator + 1; + if (fields.size() > 4) return false; + } + if (fields.size() != 4) return false; + if (!safe_field(fields[0]) || !safe_field(fields[1]) || + !safe_field(fields[2]) || !safe_field(fields[3])) + return false; + if (fields[3].size() < 4 || + fields[3].compare(fields[3].size() - 4, 4, ".deb") != 0) + return false; + + spec.package = std::move(fields[0]); + spec.repo = std::move(fields[1]); + spec.asset_prefix = std::move(fields[2]); + spec.asset_suffix = std::move(fields[3]); + return true; +} + +/** + * Extract the version from the first asset in a GitHub release JSON payload + * whose name starts with `asset_prefix` and ends with `asset_suffix`. + * + * Only the JSON string value is inspected, so callers do not need a JSON + * dependency for this narrow, machine-generated payload. + */ +inline bool parse_asset_version(const std::string &json, const AppSpec &spec, + std::string &version) +{ + std::size_t position = 0; + while ((position = json.find("\"name\"", position)) != std::string::npos) { + const std::size_t colon = json.find(':', position + 6); + if (colon == std::string::npos) break; + const std::size_t quote = json.find('"', colon + 1); + if (quote == std::string::npos) break; + const std::size_t end_quote = json.find('"', quote + 1); + if (end_quote == std::string::npos) break; + + const std::string name = json.substr(quote + 1, end_quote - quote - 1); + if (name.size() > spec.asset_prefix.size() + spec.asset_suffix.size() && + name.rfind(spec.asset_prefix, 0) == 0 && + name.compare(name.size() - spec.asset_suffix.size(), + spec.asset_suffix.size(), spec.asset_suffix) == 0) { + version = name.substr( + spec.asset_prefix.size(), + name.size() - spec.asset_prefix.size() - spec.asset_suffix.size()); + return !version.empty(); + } + position = end_quote + 1; + } + return false; +} + +inline bool parse_asset_version_from_text(const std::string &text, + const AppSpec &spec, + std::string &version) +{ + std::size_t position = 0; + while ((position = text.find(spec.asset_prefix, position)) != + std::string::npos) { + const std::size_t version_begin = position + spec.asset_prefix.size(); + const std::size_t suffix = text.find(spec.asset_suffix, version_begin); + if (suffix != std::string::npos && suffix >= version_begin && + suffix - version_begin <= 128) { + version = text.substr(version_begin, suffix - version_begin); + if (!version.empty()) return true; + } + position += 1; + } + return false; +} + +inline bool safe_version(const std::string &version) +{ + if (version.empty() || version.size() > 128) return false; + for (const unsigned char character : version) { + if (character == '|' || character == ';' || character == '=' || + character == '>' || character == '\n' || character == '\r') + return false; + } + return true; +} + +struct ReleaseCacheEntry { + std::string version; + std::chrono::steady_clock::time_point fetched; +}; + +inline std::mutex &release_cache_mutex() +{ + static std::mutex mutex; + return mutex; +} + +inline std::map &release_cache() +{ + static std::map cache; + return cache; +} + +inline bool cached_latest_version(const std::string &repo, std::string &version) +{ + std::lock_guard lock(release_cache_mutex()); + const auto found = release_cache().find(repo); + if (found == release_cache().end()) return false; + if (std::chrono::steady_clock::now() - found->second.fetched > + std::chrono::minutes(5)) + return false; + version = found->second.version; + return !version.empty(); +} + +inline void store_latest_version(const std::string &repo, + const std::string &version) +{ + std::lock_guard lock(release_cache_mutex()); + release_cache()[repo] = {version, std::chrono::steady_clock::now()}; +} + +inline void clear_release_cache() +{ + std::lock_guard lock(release_cache_mutex()); + release_cache().clear(); +} + +inline bool installed_version(const Capture &capture, const std::string &package, + std::string &version) +{ + if (!capture || package.empty()) return false; + std::string output; + if (capture({"dpkg-query", "-W", "-f=${Version}", package}, output) != 0) + return false; + version = trim(std::move(output)); + return safe_version(version); +} + +inline bool latest_version_from_html(const Capture &capture, + const AppSpec &spec, + std::string &version) +{ + std::string effective_url; + if (capture({"curl", "-fsSL", "--max-time", "15", "--connect-timeout", "5", + "-o", "/dev/null", "-w", "%{url_effective}", + "https://github.com/CardputerZero/" + spec.repo + + "/releases/latest"}, + effective_url) != 0) + return false; + const std::size_t tag_position = effective_url.rfind("/tag/"); + if (tag_position == std::string::npos) return false; + std::string tag = trim(effective_url.substr(tag_position + 5)); + if (tag.empty()) return false; + + std::string html; + if (capture({"curl", "-fsSL", "--max-time", "15", "--connect-timeout", "5", + "https://github.com/CardputerZero/" + spec.repo + + "/releases/expanded_assets/" + tag}, + html) != 0) + return false; + return parse_asset_version_from_text(html, spec, version) && + safe_version(version); +} + +inline bool latest_version(const Capture &capture, const AppSpec &spec, + std::string &version) +{ + if (!capture) return false; + if (cached_latest_version(spec.repo, version)) return true; + std::string json; + const std::vector argv = { + "curl", "-fsSL", "--max-time", "15", "--connect-timeout", "5", + "-H", "Accept: application/vnd.github+json", + "-H", "User-Agent: APPLaunch-Software-Check", + "https://api.github.com/repos/CardputerZero/" + spec.repo + + "/releases/latest"}; + if (capture(argv, json) == 0 && + parse_asset_version(json, spec, version) && safe_version(version)) { + store_latest_version(spec.repo, version); + return true; + } + if (!latest_version_from_html(capture, spec, version)) return false; + store_latest_version(spec.repo, version); + return true; +} + +/** Returns 0 when `latest` is newer, 1 when it is not, other values on error. */ +inline int compare_versions(const Capture &capture, const std::string &latest, + const std::string &installed) +{ + if (!capture) return -1; + std::string ignored; + return capture({"dpkg", "--compare-versions", latest, "gt", installed}, + ignored); +} + +inline std::string encode_outcome(const Outcome &outcome) +{ + std::ostringstream encoded; + encoded << "software|checked=" << outcome.checked + << "|failed=" << outcome.failed + << "|skipped=" << outcome.skipped + << "|updates="; + bool first = true; + for (const AppUpdate &update : outcome.updates) { + if (!safe_field(update.package) || !safe_version(update.installed) || + !safe_version(update.latest)) + continue; + if (!first) encoded << ';'; + first = false; + encoded << update.package << '=' << update.installed << '>' + << update.latest; + } + encoded << "|launcher_known=" << (outcome.launcher.known ? 1 : 0) + << "|launcher_available=" << (outcome.launcher.available ? 1 : 0) + << "|launcher_installed=" << outcome.launcher.installed + << "|launcher_version=" << outcome.launcher.version + << "|launcher_commit=" << outcome.launcher.commit + << "|launcher_failed=" << (outcome.launcher.failed ? 1 : 0); + return encoded.str(); +} + +/** + * Check every supplied application plus APPLaunch itself. + * + * The operation is cooperative: `cancel` is observed between commands, and + * `progress` receives a 0..100 value. A failure for one package does not + * discard the results of the other packages. + */ +inline Result check_software(const std::vector &specs, + const Capture &capture, + const std::string &launcher_release_url, + const std::atomic &cancel, + const std::function &progress = {}) +{ + Outcome outcome; + outcome.checked = static_cast(specs.size()); + const int total = std::max(1, outcome.checked); + + for (std::size_t index = 0; index < specs.size(); ++index) { + if (cancel.load()) return {-ECANCELED, "cancelled"}; + if (progress) progress(static_cast(index * 80 / total)); + + std::string installed; + if (!installed_version(capture, specs[index].package, installed)) { + ++outcome.skipped; + continue; + } + std::string latest; + if (!latest_version(capture, specs[index], latest)) { + ++outcome.failed; + continue; + } + const int comparison = compare_versions(capture, latest, installed); + if (comparison == 0) { + outcome.updates.push_back({specs[index].package, installed, latest}); + } else if (comparison != 1) { + ++outcome.failed; + } + } + + if (progress) progress(85); + if (cancel.load()) return {-ECANCELED, "cancelled"}; + check_launcher(capture, launcher_release_url, outcome.launcher); + if (cancel.load()) return {-ECANCELED, "cancelled"}; + + if (progress) progress(100); + return {0, encode_outcome(outcome)}; +} + +} // namespace settings_software diff --git a/projects/APPLaunch/main/ui/settings/settings_system_page.cpp b/projects/APPLaunch/main/ui/settings/settings_system_page.cpp index 9880d02d..8fba95b2 100644 --- a/projects/APPLaunch/main/ui/settings/settings_system_page.cpp +++ b/projects/APPLaunch/main/ui/settings/settings_system_page.cpp @@ -9,18 +9,27 @@ #include "cp0_lvgl_app.h" #include "cp0_font_service.hpp" #include "hal_lvgl_bsp.h" +#include "input_keys.h" +#include "keyboard_input.h" #include "settings_about_info_model.hpp" +#include "settings_software_model.hpp" +#include "settings_http_worker.hpp" +#include "settings_software_updater.hpp" #include #include #include #include #include +#include #include +#include #include +#include #include #include #include +#include namespace { @@ -254,6 +263,204 @@ void request_osinfo(std::list arguments, } } +std::string launcher_release_url() +{ + const char *configured = std::getenv("APPLAUNCH_UPDATE_RELEASE_URL"); + return configured && configured[0] + ? configured + : "https://github.com/CardputerZero/launcher/releases/download/launcher-latest"; +} + +std::string app_update_status_path() +{ + const char *configured = std::getenv("APPLAUNCH_UPDATE_STATE_DIR"); + const std::string root = configured && configured[0] + ? configured : "/var/lib/applaunch-updater"; + return root + "/apps-status"; +} + +std::string read_app_update_status() +{ + std::ifstream input(app_update_status_path()); + std::string status; + if (!std::getline(input, status)) return {}; + return status; +} + +int capture_process(const std::vector &arguments, std::string &output) +{ + output.clear(); + std::list request{"CaptureArgv", "timeout", "30"}; + for (const std::string &argument : arguments) request.push_back(argument); + int code = -1; + try { + cp0_signal_process_api( + std::move(request), [&](int result, std::string data) { + code = result; + output = std::move(data); + }); + } catch (...) { + return -1; + } + return code; +} + +struct CachedCurl { + int code = -1; + std::string output; +}; + +using CurlCache = std::map; + +void cache_http_response(CurlCache &cache, const std::string &url, + const settings_http::Response &response, + bool output_effective_url) +{ + CachedCurl cached; + if (response.ok) { + cached.code = 0; + cached.output = output_effective_url + ? settings_software::trim(response.effective_url) + : response.body; + } else { + cached.code = response.code != 0 ? response.code : 22; + } + cache[url] = std::move(cached); +} + +void prefetch_software_http(const std::vector &specs, + const std::atomic &cancel, + const std::function &progress, + CurlCache &cache) +{ + if (cancel.load(std::memory_order_acquire)) return; + + std::vector api_requests; + api_requests.reserve(specs.size() + 1); + for (const auto &spec : specs) { + settings_http::Request request; + request.url = "https://api.github.com/repos/CardputerZero/" + spec.repo + + "/releases/latest"; + request.accept_header = "application/vnd.github+json"; + request.timeout_ms = 15000; + api_requests.push_back(std::move(request)); + } + settings_http::Request launcher_request; + launcher_request.url = launcher_release_url() + "/applaunch_arm64.deb.update-info"; + launcher_request.timeout_ms = 20000; + const std::string launcher_request_url = launcher_request.url; + api_requests.push_back(std::move(launcher_request)); + + if (progress) progress(10); + const std::vector api_responses = + settings_http::download_batch(api_requests, &cancel, 6); + if (cancel.load(std::memory_order_acquire)) return; + if (progress) progress(35); + + std::vector fallback_specs; + for (std::size_t i = 0; i < specs.size() && i < api_responses.size(); ++i) { + const auto &response = api_responses[i]; + cache_http_response(cache, api_requests[i].url, response, false); + std::string version; + if (!response.ok || + !settings_software::parse_asset_version(response.body, specs[i], version)) + fallback_specs.push_back(specs[i]); + } + if (!api_responses.empty()) + cache_http_response(cache, launcher_request_url, api_responses.back(), false); + + std::vector latest_requests; + for (const auto &spec : fallback_specs) { + settings_http::Request request; + request.url = "https://github.com/CardputerZero/" + spec.repo + + "/releases/latest"; + request.timeout_ms = 15000; + request.want_effective_url = true; + latest_requests.push_back(std::move(request)); + } + + if (cancel.load(std::memory_order_acquire)) return; + const std::vector latest_responses = + settings_http::download_batch(latest_requests, &cancel, 6); + if (cancel.load(std::memory_order_acquire)) return; + if (progress) progress(60); + + std::vector asset_pages; + std::vector asset_urls; + for (std::size_t i = 0; i < fallback_specs.size() && + i < latest_responses.size(); + ++i) { + const auto &response = latest_responses[i]; + cache_http_response(cache, latest_requests[i].url, response, true); + if (!response.ok) continue; + const std::string effective = settings_software::trim(response.effective_url); + const std::size_t tag_position = effective.rfind("/tag/"); + if (tag_position == std::string::npos) continue; + const std::string tag = effective.substr(tag_position + 5); + if (tag.empty()) continue; + settings_http::Request request; + request.url = "https://github.com/CardputerZero/" + fallback_specs[i].repo + + "/releases/expanded_assets/" + tag; + request.timeout_ms = 15000; + asset_urls.push_back(request.url); + asset_pages.push_back(std::move(request)); + } + + const std::vector asset_responses = + settings_http::download_batch(asset_pages, &cancel, 6); + if (cancel.load(std::memory_order_acquire)) return; + for (std::size_t i = 0; i < asset_urls.size() && i < asset_responses.size(); ++i) + cache_http_response(cache, asset_urls[i], asset_responses[i], false); + if (progress) progress(70); +} + +template +bool start_process_request(Owner *owner, + std::list arguments, + std::function handler, + std::function stale_handler = {}) +{ + if (!owner || !handler || !owner->ensure_async_dispatch()) return false; + const auto token = owner->async_token(); + if (!token.valid()) return false; + + auto callback = [token, + handler = std::move(handler), + stale_handler = std::move(stale_handler)](int code, + std::string data) mutable { + auto deliver_stale = [&stale_handler](int result_code, std::string result_data) { + if (!stale_handler) return; + try { + stale_handler(result_code, std::move(result_data)); + } catch (...) { + } + }; + if (!token.valid()) { + deliver_stale(code, std::move(data)); + return; + } + const std::string stale_data = data; + const bool queued = SettingsAsync::Dispatch::enqueue_from_callback( + token, + [handler = std::move(handler), code, data = std::move(data)]() mutable { + if (handler) handler(code, std::move(data)); + }); + if (!queued) deliver_stale(code, stale_data); + }; + + std::list request{"CaptureArgv", "timeout", "30"}; + request.splice(request.end(), arguments); + return owner->async_tasks().start( + [request = std::move(request), callback = std::move(callback)](auto stop) mutable { + if (stop && stop->load(std::memory_order_acquire)) return; + try { + cp0_signal_process_api(request, callback); + } catch (...) { + callback(-1, "process api unavailable"); + } + }); +} + template bool start_osinfo_request(Owner *owner, std::list arguments, @@ -765,6 +972,13 @@ void LvSettingSystemInfoPage3::create_ui(lv_obj_t *parent) refresh_system_info(this); } +enum class SoftwareDialogOption +{ + Apps, + Launcher, + NotNow, +}; + struct LvSettingUpdatePage3::State { settings_system::UpdateAction action = settings_system::UpdateAction::UpdateLauncher; @@ -772,34 +986,52 @@ struct LvSettingUpdatePage3::State bool request_pending = false; bool poll_pending = false; bool update_pending = false; - bool checking = false; + bool software_checking = false; + bool launcher_update = false; + bool apps_updating = false; bool cancelling = false; - bool confirming = false; - bool update_selected = true; bool leaving = false; + bool dialog_open = false; + int dialog_selection = 0; std::string job_id; std::string backend_state; - std::string candidate_version; - std::string candidate_commit; std::string status = "Press ENTER to check for updates"; int progress = static_cast(settings_system::UpdateStatusInfo::Progress::Unknown); settings_system::UpdatePhase phase = settings_system::UpdatePhase::Idle; std::chrono::steady_clock::time_point deadline; lv_timer_t *poll_timer = nullptr; lv_obj_t *title = nullptr; - lv_obj_t *device = nullptr; - lv_obj_t *lvgl = nullptr; - lv_obj_t *version = nullptr; - lv_obj_t *build = nullptr; - lv_obj_t *commit = nullptr; + lv_obj_t *summary = nullptr; + lv_obj_t *scroll_body = nullptr; lv_obj_t *status_label = nullptr; lv_obj_t *hint = nullptr; + + struct AppRow { + lv_obj_t *name = nullptr; + lv_obj_t *description = nullptr; + std::string package; + std::string short_name; + std::string version = "--"; + }; + std::vector app_rows; + settings_software::CheckResult check; + settings_software::AppUpdateState::Phase app_update_phase = + settings_software::AppUpdateState::Phase::Unknown; + std::chrono::steady_clock::time_point app_update_started; + bool scroll_animation_active = false; + lv_obj_t *keyboard_root = nullptr; + lv_event_dsc_t *keyboard_event_dsc = nullptr; + lv_obj_t *dialog = nullptr; + lv_obj_t *dialog_content = nullptr; lv_obj_t *dialog_message = nullptr; + lv_obj_t *apps_button = nullptr; lv_obj_t *update_button = nullptr; lv_obj_t *skip_button = nullptr; + lv_obj_t *ok_button = nullptr; lv_obj_t *progress_bar = nullptr; lv_obj_t *progress_label = nullptr; + std::vector dialog_options; }; namespace { @@ -854,11 +1086,21 @@ void clear_dialog_state(StateT *state) { if (!state) return; state->dialog = nullptr; + state->dialog_content = nullptr; state->dialog_message = nullptr; + state->apps_button = nullptr; state->update_button = nullptr; state->skip_button = nullptr; + state->ok_button = nullptr; state->progress_bar = nullptr; state->progress_label = nullptr; + state->dialog_options.clear(); +} + +void set_label_color(lv_obj_t *label, uint32_t color) +{ + if (!label) return; + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); } void keep_update_page_focus(LvSettingUpdatePage3 *page) @@ -880,13 +1122,16 @@ void remove_dialog_controls_from_group(lv_obj_t *dialog) } } -void close_update_dialog(LvSettingUpdatePage3 *page) +void close_dialog(LvSettingUpdatePage3 *page) { if (!page || !page->state_) return; - lv_obj_t *dialog = page->state_->dialog; + auto *state = page->state_.get(); + lv_obj_t *dialog = state->dialog; remove_dialog_controls_from_group(dialog); - clear_dialog_state(page->state_.get()); + clear_dialog_state(state); + state->dialog_open = false; if (dialog) lv_obj_delete(dialog); + keep_update_page_focus(page); } void style_dialog(lv_obj_t *dialog) @@ -974,72 +1219,244 @@ void style_dialog_layout(lv_obj_t *dialog) remove_dialog_controls_from_group(dialog); } -void configure_machine_label(lv_obj_t *label) -{ - if (!label) return; - lv_obj_set_style_text_font(label, settings_fonts::mono(11), LV_PART_MAIN); - lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR); -} - template void render_dialog_selection(StateT *state) { - if (!state || !state->update_button || !state->skip_button) return; + if (!state) return; const auto style_button = [](lv_obj_t *button, bool selected) { + if (!button) return; lv_obj_set_style_bg_color( button, lv_color_hex(selected ? 0x2878C8 : 0x333333), LV_PART_MAIN); lv_obj_set_style_border_width(button, selected ? 2 : 0, LV_PART_MAIN); lv_obj_set_style_border_color(button, lv_color_hex(0x8FCBFF), LV_PART_MAIN); }; - style_button(state->update_button, state->update_selected); - style_button(state->skip_button, !state->update_selected); + for (std::size_t index = 0; index < state->dialog_options.size(); ++index) { + lv_obj_t *button = nullptr; + switch (state->dialog_options[index]) { + case SoftwareDialogOption::Apps: + button = state->apps_button; + break; + case SoftwareDialogOption::Launcher: + button = state->update_button; + break; + case SoftwareDialogOption::NotNow: + button = state->skip_button; + break; + } + style_button(button, static_cast(index) == state->dialog_selection); + } +} + +void render_update_page(LvSettingUpdatePage3 *page); + +void scroll_body_by(LvSettingUpdatePage3 *page, int dy) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (!state->scroll_body || state->scroll_animation_active) return; + lv_obj_scroll_by_bounded(state->scroll_body, 0, dy, LV_ANIM_ON); +} + +void scroll_dialog_by(LvSettingUpdatePage3 *page, int dy) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (!state->dialog_content) return; + lv_obj_scroll_by_bounded(state->dialog_content, 0, dy, LV_ANIM_ON); +} + +void software_scroll_event_cb(lv_event_t *event) +{ + if (!event) return; + auto *page = static_cast(lv_event_get_user_data(event)); + if (!page || !page->state_) return; + const lv_event_code_t code = lv_event_get_code(event); + if (code == LV_EVENT_SCROLL_BEGIN) + page->state_->scroll_animation_active = true; + else if (code == LV_EVENT_SCROLL_END) + page->state_->scroll_animation_active = false; +} + +void schedule_job_cancel(std::string job_id) +{ + if (job_id.empty()) return; + request_osinfo( + {"UpdateJobCancel", std::move(job_id)}, [](int, std::string) {}); +} + +void schedule_app_update_cancel(LvSettingUpdatePage3 *page) +{ + start_process_request( + page, + {"systemctl", "stop", "--no-block", "applaunch-apps-updater.service"}, + [](int, std::string) {}, {}); +} + +template +void stop_update_poll(StateT *state) +{ + if (!state) return; + stop_timer(state->poll_timer); + state->poll_pending = false; } -void show_update_confirmation(LvSettingUpdatePage3 *page) +void apply_update_marks(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + for (auto &row : state->app_rows) { + const settings_software::UpdateInfo *found = nullptr; + for (const auto &update : state->check.updates) { + if (update.package == row.package) { + found = &update; + break; + } + } + if (found && settings_software::update_changes_version(*found)) { + const std::string installed = found->installed.empty() ? row.version : found->installed; + set_label(row.name, row.short_name + " " + installed + " -> " + found->latest); + set_label_color(row.name, 0xF0C850); + } else { + set_label(row.name, row.short_name + " " + row.version); + set_label_color(row.name, 0xF2F2F2); + } + } +} + +void reset_update_marks(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + page->state_->check = settings_software::CheckResult{}; + apply_update_marks(page); +} + +void render_update_page(LvSettingUpdatePage3 *page) +{ + if (!page || !page->Get() || !page->state_) return; + auto *state = page->state_.get(); + set_label(state->status_label, state->status); + if (state->progress_label) set_label(state->progress_label, state->status); + if (state->progress_bar) + lv_bar_set_value(state->progress_bar, std::max(0, state->progress), LV_ANIM_OFF); + + std::string summary = "APPLaunch " + std::string(launcher_version()); + uint32_t summary_color = 0xECECEC; + if (state->check.launcher_available) { + const std::string installed = state->check.launcher_installed.empty() + ? std::string(launcher_version()) : state->check.launcher_installed; + summary = "APPLaunch " + installed + " -> " + state->check.launcher_version; + summary_color = 0xF0C850; + } + set_label(state->summary, summary); + set_label_color(state->summary, summary_color); + + if (state->hint) { + const char *hint = "ENTER: check ESC: back"; + if (state->dialog_open) + hint = ""; + else if (state->request_pending || state->update_pending) + hint = "ESC: cancel"; + lv_label_set_text(state->hint, hint); + } +} + +void show_updates_dialog(LvSettingUpdatePage3 *page) { if (!page || !page->state_ || !page->Get()) return; auto *state = page->state_.get(); - close_update_dialog(page); - state->confirming = true; - state->update_selected = true; + std::vector lines; + if (state->check.launcher_available) { + const std::string installed = state->check.launcher_installed.empty() + ? std::string(launcher_version()) : state->check.launcher_installed; + lines.push_back( + settings_software::launcher_update_line(installed, state->check.launcher_version)); + } + for (const auto &update : state->check.updates) { + if (settings_software::update_changes_version(update)) + lines.push_back(settings_software::update_line(update)); + } + if (state->check.failed > 0) + lines.push_back(std::to_string(state->check.failed) + " package check(s) failed"); + if (state->check.launcher_failed) + lines.push_back("APPLaunch update check failed"); + if (lines.empty()) return; + + std::string message; + for (std::size_t index = 0; index < lines.size(); ++index) { + if (index != 0) message.push_back('\n'); + message += lines[index]; + } + + close_dialog(page); state->dialog = lv_msgbox_create(page->Get()); if (!state->dialog) { - state->confirming = false; - state->status = "Unable to show update confirmation"; + state->status = "Unable to show update results"; return; } style_dialog(state->dialog); - lv_obj_t *title = lv_msgbox_add_title(state->dialog, "New APPLaunch version"); + lv_obj_t *title = lv_msgbox_add_title(state->dialog, "Updates available"); if (title) lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); - const std::string message = "Version: " + - (state->candidate_version.empty() ? std::string("unknown") : state->candidate_version) + - "\nCommit: " + - (state->candidate_commit.empty() ? std::string("unknown") : state->candidate_commit); - state->dialog_message = lv_msgbox_add_text(state->dialog, message.c_str()); - if (state->dialog_message) { - lv_obj_set_style_text_color(state->dialog_message, lv_color_hex(0xECECEC), LV_PART_MAIN); - lv_label_set_long_mode(state->dialog_message, LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_set_style_text_font(state->dialog_message, settings_fonts::sans(12), LV_PART_MAIN); - } - state->update_button = lv_msgbox_add_footer_button(state->dialog, "Update"); + + lv_obj_t *content = lv_msgbox_get_content(state->dialog); + style_dialog_layout(state->dialog); + if (content) { + lv_obj_set_height( + content, LvSettingUpdatePage3::metric(LvSettingUpdatePage3::LayoutMetric::DialogContentH)); + lv_obj_set_width(content, lv_pct(100)); + lv_obj_add_flag(content, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(content, LV_DIR_VER); + lv_obj_set_scrollbar_mode(content, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(content, 2, LV_PART_MAIN); + state->dialog_content = content; + state->dialog_message = lv_label_create(content); + if (state->dialog_message) { + lv_obj_set_width(state->dialog_message, lv_pct(100)); + lv_label_set_long_mode(state->dialog_message, LV_LABEL_LONG_WRAP); + lv_label_set_text(state->dialog_message, message.c_str()); + lv_obj_set_style_text_color(state->dialog_message, lv_color_hex(0xECECEC), LV_PART_MAIN); + lv_obj_set_style_text_font( + state->dialog_message, settings_fonts::sans(12), LV_PART_MAIN); + } + } + + state->dialog_options.clear(); + if (!state->check.updates.empty()) { + state->apps_button = + lv_msgbox_add_footer_button(state->dialog, "Update apps"); + state->dialog_options.push_back(SoftwareDialogOption::Apps); + } + if (state->check.launcher_available) { + state->update_button = + lv_msgbox_add_footer_button(state->dialog, "Update APPLaunch"); + state->dialog_options.push_back(SoftwareDialogOption::Launcher); + } state->skip_button = lv_msgbox_add_footer_button(state->dialog, "Not now"); - if (state->update_button) lv_obj_set_flex_grow(state->update_button, 1); - if (state->skip_button) lv_obj_set_flex_grow(state->skip_button, 1); + state->dialog_options.push_back(SoftwareDialogOption::NotNow); + if (state->dialog_options.size() <= 2) { + if (state->apps_button) lv_obj_set_flex_grow(state->apps_button, 1); + if (state->update_button) lv_obj_set_flex_grow(state->update_button, 1); + if (state->skip_button) lv_obj_set_flex_grow(state->skip_button, 1); + } style_dialog_layout(state->dialog); lv_obj_update_layout(state->dialog); + state->dialog_open = true; + state->dialog_selection = 0; keep_update_page_focus(page); render_dialog_selection(state); + render_update_page(page); } -void show_update_progress(LvSettingUpdatePage3 *page) +void show_update_progress(LvSettingUpdatePage3 *page, + const char *title_text = "Updating APPLaunch") { if (!page || !page->state_ || !page->Get()) return; auto *state = page->state_.get(); - close_update_dialog(page); + close_dialog(page); state->dialog = lv_msgbox_create(page->Get()); if (!state->dialog) return; style_dialog(state->dialog); - lv_obj_t *title = lv_msgbox_add_title(state->dialog, "Updating APPLaunch"); + lv_obj_t *title = lv_msgbox_add_title(state->dialog, title_text); if (title) lv_obj_set_style_text_color(title, lv_color_hex(0x58A6FF), LV_PART_MAIN); lv_obj_t *content = lv_msgbox_get_content(state->dialog); if (!content) return; @@ -1065,7 +1482,9 @@ void show_update_progress(LvSettingUpdatePage3 *page) state->progress_bar, lv_color_hex(0x2878C8), LV_PART_INDICATOR); } lv_obj_update_layout(state->dialog); + state->dialog_open = true; keep_update_page_focus(page); + render_update_page(page); } std::string update_failure_status(settings_system::UpdateAction action, @@ -1077,46 +1496,6 @@ std::string update_failure_status(settings_system::UpdateAction action, return settings_system::update_job_label(action, code, state); } -void render_update_page(LvSettingUpdatePage3 *page) -{ - if (!page || !page->Get() || !page->state_) return; - auto *state = page->state_.get(); - set_label(state->title, "APPLaunch"); - set_label(state->device, "Device: M5CardputerZero"); - set_label(state->lvgl, "LVGL: " + lvgl_version()); - set_label(state->version, "APPLaunch: " + std::string(launcher_version())); - set_label(state->build, "Build date: " + std::string(launcher_build_date())); - set_label(state->commit, "Commit: " + std::string(launcher_commit())); - set_label(state->status_label, state->status); - if (state->progress_label) set_label(state->progress_label, state->status); - if (state->progress_bar) - lv_bar_set_value(state->progress_bar, std::max(0, state->progress), LV_ANIM_ON); - - const char *hint = "ENTER: check ESC: back"; - if (state->request_pending || state->update_pending) - hint = "ESC: cancel"; - else if (state->confirming) - hint = ""; - else if (state->phase != settings_system::UpdatePhase::Idle) - hint = "ENTER: check again ESC: back"; - if (state->hint) lv_label_set_text(state->hint, hint); -} - -void schedule_job_cancel(std::string job_id) -{ - if (job_id.empty()) return; - request_osinfo( - {"UpdateJobCancel", std::move(job_id)}, [](int, std::string) {}); -} - -template -void stop_update_poll(StateT *state) -{ - if (!state) return; - stop_timer(state->poll_timer); - state->poll_pending = false; -} - void finish_update(LvSettingUpdatePage3 *page, settings_system::UpdatePhase phase, int code, @@ -1125,20 +1504,27 @@ void finish_update(LvSettingUpdatePage3 *page, { if (!page || !page->state_) return; auto *state = page->state_.get(); + const bool was_launcher_update = state->launcher_update; const std::string job_id = std::move(state->job_id); state->job_id.clear(); stop_update_poll(state); state->request_pending = false; state->update_pending = false; - state->checking = false; + state->software_checking = false; + state->launcher_update = false; + state->apps_updating = false; state->cancelling = false; - state->confirming = false; ++state->generation; if (state->generation == 0) state->generation = 1; page->advance_async_generation(); state->backend_state = std::move(backend_state); state->phase = phase; if (phase == settings_system::UpdatePhase::Succeeded) { + state->progress = 100; + if (was_launcher_update) { + state->check.launcher_available = false; + state->check.launcher_failed = false; + } state->status = settings_system::update_job_label(state->action, code, state->backend_state); } else if (phase == settings_system::UpdatePhase::Failed) { state->status = update_failure_status(state->action, code, state->backend_state); @@ -1146,50 +1532,232 @@ void finish_update(LvSettingUpdatePage3 *page, state->status = settings_system::update_phase_label( state->action, phase, code, state->backend_state); } - if (phase != settings_system::UpdatePhase::Running) close_update_dialog(page); + if (phase != settings_system::UpdatePhase::Running) close_dialog(page); render_update_page(page); if (cancel_backend && !job_id.empty()) schedule_job_cancel(job_id); } -void poll_update(LvSettingUpdatePage3 *page); - -void finish_check(LvSettingUpdatePage3 *page, - int code, - std::string payload, - const settings_system::UpdateStatusInfo &info) +void finish_software_check(LvSettingUpdatePage3 *page, + int code, + std::string payload, + bool cancel_backend) { if (!page || !page->state_) return; - if (code != 0 || !info.terminal || !info.availability_known) { - finish_update(page, settings_system::UpdatePhase::Failed, - code == 0 ? -1 : code, std::move(payload), false); - if (page->state_) { - page->state_->status = "Unable to determine whether an update is available"; - render_update_page(page); + auto *state = page->state_.get(); + const std::string job_id = std::move(state->job_id); + state->job_id.clear(); + stop_update_poll(state); + state->request_pending = false; + state->update_pending = false; + state->software_checking = false; + state->launcher_update = false; + state->apps_updating = false; + state->cancelling = false; + ++state->generation; + if (state->generation == 0) state->generation = 1; + page->advance_async_generation(); + state->backend_state = payload; + + if (code == -ECANCELED || payload == "cancelled" || payload == "canceled") { + state->phase = settings_system::UpdatePhase::Cancelled; + state->status = "Update check cancelled"; + render_update_page(page); + return; + } + + settings_software::CheckResult result; + if (code != 0 || !settings_software::parse_check_result(payload, result)) { + state->phase = code == -ETIMEDOUT ? settings_system::UpdatePhase::TimedOut + : settings_system::UpdatePhase::Failed; + state->status = code == -ETIMEDOUT ? "Update check timed out" + : "Unable to check for updates"; + render_update_page(page); + if (cancel_backend && !job_id.empty()) schedule_job_cancel(job_id); + return; + } + + state->check = std::move(result); + state->check.updates.erase( + std::remove_if(state->check.updates.begin(), state->check.updates.end(), + [](const settings_software::UpdateInfo &update) { + return !settings_software::update_changes_version(update); + }), + state->check.updates.end()); + apply_update_marks(page); + state->phase = settings_system::UpdatePhase::Succeeded; + state->progress = 100; + const int update_count = static_cast(state->check.updates.size()) + + (state->check.launcher_available ? 1 : 0); + if (update_count == 0) { + if (state->check.launcher_failed && state->check.checked > 0 && + state->check.failed >= state->check.checked) { + state->status = "Unable to check for updates"; + } else if (state->check.failed > 0 || state->check.launcher_failed) { + state->status = "Some checks failed; no updates found"; + } else { + state->status = "All software is up to date"; } + render_update_page(page); + if (cancel_backend && !job_id.empty()) schedule_job_cancel(job_id); return; } - const std::string version = info.version; - const std::string commit = info.commit; - finish_update(page, settings_system::UpdatePhase::Succeeded, code, payload, false); - if (!page->state_) return; + state->status = std::to_string(update_count) + + (update_count == 1 ? " update available" : " updates available"); + render_update_page(page); + show_updates_dialog(page); + if (cancel_backend && !job_id.empty()) schedule_job_cancel(job_id); +} + +void refresh_installed_rows(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + const std::vector apps = + settings_software::read_installed_apps(); + for (auto &row : state->app_rows) { + const settings_software::InstalledApp *found = nullptr; + for (const auto &app : apps) { + if (app.package == row.package) { + found = &app; + break; + } + } + if (!found) continue; + row.version = found->version; + if (row.description) + lv_label_set_text(row.description, found->description.c_str()); + } + state->check.updates.clear(); + apply_update_marks(page); +} + +void finish_apps_update(LvSettingUpdatePage3 *page, + int code, + std::string payload, + bool cancel_backend) +{ + if (!page || !page->state_) return; auto *state = page->state_.get(); - state->candidate_version = version; - state->candidate_commit = commit; - if (!info.available) { - state->status = version.empty() - ? "APPLaunch is up to date" - : "APPLaunch " + version + " is up to date"; + stop_update_poll(state); + state->request_pending = false; + state->update_pending = false; + state->software_checking = false; + state->launcher_update = false; + state->apps_updating = false; + state->cancelling = false; + ++state->generation; + if (state->generation == 0) state->generation = 1; + page->advance_async_generation(); + close_dialog(page); + refresh_installed_rows(page); + + settings_software::AppUpdateState result; + const bool parsed = settings_software::parse_app_update_status(payload, result); + if (parsed) state->app_update_phase = result.phase; + if (code == -ETIMEDOUT) { + state->phase = settings_system::UpdatePhase::TimedOut; + state->status = "App update timed out"; + } else if (!parsed) { + state->phase = settings_system::UpdatePhase::Failed; + state->status = "Unable to update apps"; + } else if (result.phase == settings_software::AppUpdateState::Phase::Succeeded) { + state->phase = settings_system::UpdatePhase::Succeeded; + state->progress = 100; + if (result.updated_count == 0 && result.failed_count == 0) { + state->status = "Apps are up to date"; + } else if (result.failed_count > 0) { + state->status = std::to_string(result.updated_count) + + " app(s) updated, " + + std::to_string(result.failed_count) + " failed"; + } else { + state->status = std::to_string(result.updated_count) + " app(s) updated"; + } + } else if (result.phase == settings_software::AppUpdateState::Phase::Cancelled || + result.phase == settings_software::AppUpdateState::Phase::Cancelling) { + state->phase = settings_system::UpdatePhase::Cancelled; + state->status = "App updates cancelled"; + } else if (result.phase == settings_software::AppUpdateState::Phase::Failed) { + state->phase = settings_system::UpdatePhase::Failed; + state->status = result.detail.empty() + ? "App update failed" + : "App update failed: " + result.detail; + } else { + state->phase = settings_system::UpdatePhase::Failed; + state->status = "App update did not finish"; + } + if (cancel_backend) schedule_app_update_cancel(page); + render_update_page(page); +} + +void poll_apps_update(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (!state->request_pending && !state->update_pending) return; + if (std::chrono::steady_clock::now() >= state->deadline) { + finish_apps_update(page, -ETIMEDOUT, "timeout", true); + return; + } + if (!state->update_pending) return; + state->poll_pending = false; + + const std::string payload = read_app_update_status(); + if (payload.empty()) { + state->status = "Starting app updates..."; render_update_page(page); return; } - state->status = version.empty() - ? "A new APPLaunch version is available" - : "APPLaunch " + version + " is available"; - show_update_confirmation(page); - render_update_page(page); + + settings_software::AppUpdateState result; + if (!settings_software::parse_app_update_status(payload, result)) { + finish_apps_update(page, -1, payload, false); + return; + } + state->app_update_phase = result.phase; + if (result.phase == settings_software::AppUpdateState::Phase::Checking || + result.phase == settings_software::AppUpdateState::Phase::Downloading || + result.phase == settings_software::AppUpdateState::Phase::Installing || + result.phase == settings_software::AppUpdateState::Phase::Cancelling) { + const int total = std::max(1, result.total); + const int index = std::max(1, std::min(result.index, total)); + state->progress = (index - 1) * 100 / total; + if (result.phase == settings_software::AppUpdateState::Phase::Checking) { + state->status = "Checking app versions... (" + + std::to_string(index) + "/" + + std::to_string(total) + ")"; + } else if (result.phase == + settings_software::AppUpdateState::Phase::Downloading) { + state->status = "Downloading " + + settings_software::short_name(result.package) + + " (" + std::to_string(index) + "/" + + std::to_string(total) + ")..."; + } else if (result.phase == + settings_software::AppUpdateState::Phase::Installing) { + state->status = "Installing " + + settings_software::short_name(result.package) + + " (" + std::to_string(index) + "/" + + std::to_string(total) + ")..."; + } else { + state->status = "Cancelling app updates..."; + } + if (state->cancelling) + state->status = "Cancelling app updates..."; + render_update_page(page); + return; + } + + if (std::chrono::steady_clock::now() - state->app_update_started < + std::chrono::seconds(1)) { + state->status = "Starting app updates..."; + render_update_page(page); + return; + } + finish_apps_update(page, 0, payload, false); } +void poll_update(LvSettingUpdatePage3 *page); + void update_poll_timer_cb(lv_timer_t *timer) noexcept { auto *page = timer ? static_cast(lv_timer_get_user_data(timer)) : nullptr; @@ -1202,6 +1770,18 @@ void poll_update(LvSettingUpdatePage3 *page) if (!page || !page->state_) return; auto *state = page->state_.get(); if (!state->request_pending && !state->update_pending) return; + if (state->apps_updating) { + poll_apps_update(page); + return; + } + if (state->software_checking) { + if (std::chrono::steady_clock::now() >= state->deadline) { + page->async_tasks().cancel(); + settings_http::wake(); + finish_software_check(page, -ETIMEDOUT, "timeout", false); + } + return; + } if (std::chrono::steady_clock::now() >= state->deadline) { finish_update(page, settings_system::UpdatePhase::TimedOut, -ETIMEDOUT, "timeout", true); return; @@ -1223,21 +1803,31 @@ void poll_update(LvSettingUpdatePage3 *page) if (code == 0 && is_update_progress_state(payload)) { state->phase = settings_system::UpdatePhase::Running; state->progress = stage_progress(info); - state->status = state->cancelling - ? "Cancelling update..." - : progress_status(info); + if (state->software_checking) { + state->status = state->progress >= 85 + ? "Checking APPLaunch update..." + : "Checking for updates... " + + std::to_string(std::max(0, state->progress)) + "%"; + } else if (state->cancelling) { + state->status = "Cancelling update..."; + } else { + state->status = progress_status(info); + } render_update_page(page); return; } if (payload == "cancelled" || payload == "canceled" || payload.rfind("failed:cancelled:", 0) == 0 || payload.rfind("failed:canceled:", 0) == 0) { - finish_update(page, settings_system::UpdatePhase::Cancelled, code, - std::move(payload), false); + if (state->software_checking) + finish_software_check(page, code, std::move(payload), false); + else + finish_update(page, settings_system::UpdatePhase::Cancelled, code, + std::move(payload), false); return; } - if (state->checking) { - finish_check(page, code, std::move(payload), info); + if (state->software_checking) { + finish_software_check(page, code, std::move(payload), false); return; } if (code == 0 && payload.rfind("succeeded:", 0) == 0) { @@ -1250,12 +1840,124 @@ void poll_update(LvSettingUpdatePage3 *page) std::move(payload), false); })) { state->poll_pending = false; - finish_update(page, settings_system::UpdatePhase::Failed, -1, - "status request could not be scheduled", true); + if (state->software_checking) + finish_software_check(page, -1, "status request could not be scheduled", true); + else + finish_update(page, settings_system::UpdatePhase::Failed, -1, + "status request could not be scheduled", true); } } -void start_update(LvSettingUpdatePage3 *page, bool check_only = false) +void start_launcher_update(LvSettingUpdatePage3 *page); + +void start_software_check(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (state->request_pending || state->update_pending || state->leaving) return; + + close_dialog(page); + reset_update_marks(page); + page->advance_async_generation(); + ++state->generation; + if (state->generation == 0) state->generation = 1; + state->request_pending = true; + state->software_checking = true; + state->launcher_update = false; + state->apps_updating = false; + state->cancelling = false; + state->progress = 0; + state->phase = settings_system::UpdatePhase::Starting; + state->status = "Checking for updates..."; + state->deadline = std::chrono::steady_clock::now() + std::chrono::minutes(10); + stop_update_poll(state); + state->poll_timer = lv_timer_create( + update_poll_timer_cb, static_cast(TimerInterval::UpdatePollMs), page); + if (!state->poll_timer) { + state->request_pending = false; + state->software_checking = false; + finish_software_check(page, -1, "status timer unavailable", false); + return; + } + render_update_page(page); + + if (!page->ensure_async_dispatch()) { + state->request_pending = false; + finish_software_check(page, -1, "async dispatch unavailable", false); + return; + } + const auto token = page->async_token(); + if (!token.valid()) { + state->request_pending = false; + finish_software_check(page, -1, "async dispatch unavailable", false); + return; + } + + std::vector specs; + for (const auto &app : settings_software::app_definitions()) { + specs.push_back({app.package, app.repo, app.asset_prefix, + settings_software::kAssetSuffix}); + } + + if (!page->async_tasks().start( + [page, token, specs](auto stop) { + std::atomic fallback{false}; + std::atomic &cancel = stop ? *stop : fallback; + const auto report_progress = [page, token](int value) { + if (!token.valid()) return; + SettingsAsync::Dispatch::enqueue_from_callback( + token, [page, value]() { + if (!page->state_ || !page->state_->software_checking) + return; + page->state_->progress = value; + page->state_->status = value >= 85 + ? "Checking APPLaunch update..." + : "Checking for updates... " + + std::to_string(std::max(0, value)) + "%"; + render_update_page(page); + }); + }; + + CurlCache cache; + prefetch_software_http(specs, cancel, report_progress, cache); + if (cancel.load(std::memory_order_acquire)) return; + + auto capture = [&cancel, &cache]( + const std::vector &arguments, + std::string &output) { + if (cancel.load(std::memory_order_acquire)) return -ECANCELED; + if (!arguments.empty() && arguments.front() == "curl") { + const std::string &url = arguments.back(); + const auto found = cache.find(url); + if (found != cache.end()) { + output = found->second.output; + return found->second.code; + } + } + return capture_process(arguments, output); + }; + const settings_software::Result result = + settings_software::check_software( + specs, capture, launcher_release_url(), cancel, + [report_progress](int value) { + report_progress(70 + value * 30 / 100); + }); + if (!token.valid()) return; + if (result.code == -ECANCELED) return; + SettingsAsync::Dispatch::enqueue_from_callback( + token, [page, result]() { + if (!page->state_ || !page->state_->software_checking) + return; + finish_software_check(page, result.code, result.stage, + false); + }); + })) { + state->request_pending = false; + finish_software_check(page, -1, "check request could not be scheduled", false); + } +} + +void start_launcher_update(LvSettingUpdatePage3 *page) { if (!page || !page->state_) return; auto *state = page->state_.get(); @@ -1266,12 +1968,15 @@ void start_update(LvSettingUpdatePage3 *page, bool check_only = false) if (state->generation == 0) state->generation = 1; const uint64_t request_generation = state->generation; state->request_pending = true; - state->checking = check_only; - state->confirming = false; + state->software_checking = false; + state->launcher_update = true; + state->apps_updating = false; + state->cancelling = false; state->progress = 0; state->phase = settings_system::UpdatePhase::Starting; - state->status = check_only ? "Starting update check..." : "Starting APPLaunch update..."; + state->status = "Starting APPLaunch update..."; state->deadline = std::chrono::steady_clock::now() + kUpdateTimeout; + stop_update_poll(state); state->poll_timer = lv_timer_create( update_poll_timer_cb, static_cast(TimerInterval::UpdatePollMs), page); if (!state->poll_timer) { @@ -1280,12 +1985,10 @@ void start_update(LvSettingUpdatePage3 *page, bool check_only = false) "status timer unavailable", false); return; } - if (!check_only) show_update_progress(page); + show_update_progress(page); render_update_page(page); - const char *command = check_only - ? "UpdateLauncherCheckStart" - : settings_system::update_request(state->action); + const char *command = settings_system::update_request(state->action); if (!start_osinfo_request( page, {command}, @@ -1308,18 +2011,13 @@ void start_update(LvSettingUpdatePage3 *page, bool check_only = false) schedule_job_cancel(state->job_id); state->status = "Cancellation requested..."; } else { - state->status = state->checking - ? "Checking for updates..." - : "Updating APPLaunch..."; + state->status = "Updating APPLaunch..."; } render_update_page(page); lv_timer_ready(state->poll_timer); }, - [](int code, std::string payload) { - if (code != 0 || payload.empty()) return; - request_osinfo( - {"UpdateJobCancel", std::move(payload)}, - [](int, std::string) {}); + [](int, std::string payload) { + if (!payload.empty()) schedule_job_cancel(std::move(payload)); })) { state->request_pending = false; finish_update(page, settings_system::UpdatePhase::Failed, -1, @@ -1327,20 +2025,120 @@ void start_update(LvSettingUpdatePage3 *page, bool check_only = false) } } +void start_apps_update(LvSettingUpdatePage3 *page) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (state->request_pending || state->update_pending || state->leaving) return; + + close_dialog(page); + page->advance_async_generation(); + ++state->generation; + if (state->generation == 0) state->generation = 1; + const uint64_t request_generation = state->generation; + state->request_pending = true; + state->software_checking = false; + state->launcher_update = false; + state->apps_updating = true; + state->cancelling = false; + state->app_update_phase = settings_software::AppUpdateState::Phase::Unknown; + state->app_update_started = std::chrono::steady_clock::now(); + state->progress = 0; + state->phase = settings_system::UpdatePhase::Starting; + state->status = "Starting app updates..."; + state->deadline = std::chrono::steady_clock::now() + std::chrono::minutes(30); + stop_update_poll(state); + state->poll_timer = lv_timer_create( + update_poll_timer_cb, static_cast(TimerInterval::UpdatePollMs), page); + if (!state->poll_timer) { + state->request_pending = false; + finish_apps_update(page, -1, "status timer unavailable", false); + return; + } + show_update_progress(page, "Updating apps"); + render_update_page(page); + + if (!start_process_request( + page, + {"systemctl", "start", "--no-block", + "applaunch-apps-updater.service"}, + [page, request_generation](int code, std::string) { + auto *state = page ? page->state_.get() : nullptr; + if (!state || request_generation != state->generation || !page->Get()) return; + state->request_pending = false; + if (code != 0) { + finish_apps_update(page, code, "unable to start app updater", false); + return; + } + state->update_pending = true; + state->phase = settings_system::UpdatePhase::Running; + state->status = "Updating apps..."; + render_update_page(page); + lv_timer_ready(state->poll_timer); + }, + [](int, std::string) {})) { + state->request_pending = false; + finish_apps_update(page, -1, "update request could not be scheduled", false); + } +} + void cancel_update(LvSettingUpdatePage3 *page, bool leave_page) { if (!page || !page->state_ || page->state_->leaving) return; auto *state = page->state_.get(); + + if (state->apps_updating) { + if (leave_page) { + // The root updater service keeps running after the page closes. + state->leaving = true; + if (page->LeaveSelfPage) page->LeaveSelfPage(); + return; + } + if (state->app_update_phase == + settings_software::AppUpdateState::Phase::Installing) { + state->status = "Installing updates; please wait"; + render_update_page(page); + return; + } + if (!state->cancelling) { + state->cancelling = true; + schedule_app_update_cancel(page); + state->status = "Cancelling app updates..."; + render_update_page(page); + } + return; + } + + if (state->software_checking) { + page->async_tasks().cancel(); + settings_http::wake(); + stop_update_poll(state); + state->request_pending = false; + state->update_pending = false; + state->software_checking = false; + state->cancelling = false; + ++state->generation; + if (state->generation == 0) state->generation = 1; + page->advance_async_generation(); + state->phase = settings_system::UpdatePhase::Cancelled; + state->status = "Update check cancelled"; + render_update_page(page); + if (leave_page) { + state->leaving = true; + if (page->LeaveSelfPage) page->LeaveSelfPage(); + } + return; + } + const bool active = state->request_pending || state->update_pending; if (active) { if (leave_page) { if (!state->job_id.empty()) schedule_job_cancel(state->job_id); } else if (!state->cancelling) { + state->cancelling = true; if (state->job_id.empty()) { - state->cancelling = true; state->status = "Waiting for update job before cancelling..."; } else { - state->cancelling = true; state->status = "Cancelling update..."; const uint64_t request_generation = state->generation; const std::string job_id = state->job_id; @@ -1377,6 +2175,91 @@ void cancel_update(LvSettingUpdatePage3 *page, bool leave_page) } } +void handle_dialog_key(LvSettingUpdatePage3 *page, uint32_t key) +{ + if (!page || !page->state_) return; + auto *state = page->state_.get(); + if (key == LV_KEY_UP || key == LV_KEY_PREV) { + scroll_dialog_by(page, LvSettingUpdatePage3::metric( + LvSettingUpdatePage3::LayoutMetric::ScrollStep)); + return; + } + if (key == LV_KEY_DOWN || key == LV_KEY_NEXT) { + scroll_dialog_by(page, -LvSettingUpdatePage3::metric( + LvSettingUpdatePage3::LayoutMetric::ScrollStep)); + return; + } + + const std::size_t option_count = state->dialog_options.size(); + if (option_count > 1 && (key == LV_KEY_LEFT || key == LV_KEY_RIGHT)) { + const int delta = key == LV_KEY_LEFT ? -1 : 1; + state->dialog_selection = + (state->dialog_selection + delta + static_cast(option_count)) % + static_cast(option_count); + render_dialog_selection(state); + return; + } + if (key == LV_KEY_ENTER) { + const SoftwareDialogOption option = option_count == 0 + ? SoftwareDialogOption::NotNow + : state->dialog_options[static_cast(state->dialog_selection)]; + close_dialog(page); + switch (option) { + case SoftwareDialogOption::Apps: + state->status = "Starting app updates..."; + render_update_page(page); + start_apps_update(page); + break; + case SoftwareDialogOption::Launcher: + state->status = "Starting APPLaunch update..."; + render_update_page(page); + start_launcher_update(page); + break; + case SoftwareDialogOption::NotNow: + state->status = "Update skipped"; + render_update_page(page); + break; + } + return; + } + if (key == LV_KEY_ESC) { + close_dialog(page); + render_update_page(page); + } +} + +void software_keyboard_event_cb(lv_event_t *event) +{ + if (!event) return; + auto *page = static_cast(lv_event_get_user_data(event)); + auto *item = static_cast(lv_event_get_param(event)); + if (!page || !page->state_ || !item || item->key_state != KBD_KEY_REPEATED) return; + + uint32_t key = 0; + switch (item->key_code) { + case KEY_F: + case KEY_UP: + key = KEY_UP; + break; + case KEY_X: + case KEY_DOWN: + key = KEY_DOWN; + break; + default: + return; + } + + const int step = LvSettingUpdatePage3::metric( + LvSettingUpdatePage3::LayoutMetric::ScrollStep); + if (page->state_->dialog_open) { + if (!page->state_->progress_bar && page->state_->dialog_content) + scroll_dialog_by(page, key == KEY_UP ? step : -step); + } else { + scroll_body_by(page, key == KEY_UP ? step : -step); + } + lv_event_stop_processing(event); +} + void update_key_event(LvSettingUpdatePage3 *page, lv_event_t *event) { if (!page || !event || lv_event_get_code(event) != LV_EVENT_KEY) return; @@ -1384,31 +2267,32 @@ void update_key_event(LvSettingUpdatePage3 *page, lv_event_t *event) auto *state = page->state_.get(); if (!state) return; - if (state->confirming) { - if (key == LV_KEY_LEFT || key == LV_KEY_RIGHT) { - state->update_selected = key == LV_KEY_LEFT; - render_dialog_selection(state); - } else if (key == LV_KEY_ENTER) { - const bool update = state->update_selected; - state->confirming = false; - close_update_dialog(page); - if (update) start_update(page, false); - else { - state->status = "Update skipped"; - render_update_page(page); - } - } else if (key == LV_KEY_ESC) { - state->confirming = false; - close_update_dialog(page); - state->status = "Update skipped"; - render_update_page(page); + if (state->dialog_open) { + if (state->progress_bar || state->apps_updating || state->launcher_update) { + if (key == LV_KEY_ESC) cancel_update(page, false); + } else { + handle_dialog_key(page, key); } - } else if (state->request_pending || state->update_pending) { + lv_event_stop_processing(event); + return; + } + + if (state->request_pending || state->update_pending) { if (key == LV_KEY_ESC) cancel_update(page, false); - } else if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - if (page->LeaveSelfPage) page->LeaveSelfPage(); + lv_event_stop_processing(event); + return; + } + + if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { + cancel_update(page, true); + } else if (key == LV_KEY_UP) { + scroll_body_by(page, LvSettingUpdatePage3::metric( + LvSettingUpdatePage3::LayoutMetric::ScrollStep)); + } else if (key == LV_KEY_DOWN) { + scroll_body_by(page, -LvSettingUpdatePage3::metric( + LvSettingUpdatePage3::LayoutMetric::ScrollStep)); } else if (key == LV_KEY_ENTER || key == LV_KEY_RIGHT) { - start_update(page, true); + start_software_check(page); } lv_event_stop_processing(event); } @@ -1440,6 +2324,10 @@ LvSettingUpdatePage3::~LvSettingUpdatePage3() state_->request_pending = false; state_->update_pending = false; if (!job_id.empty()) schedule_job_cancel(job_id); + if (state_->keyboard_root && state_->keyboard_event_dsc) { + lv_obj_remove_event_dsc(state_->keyboard_root, state_->keyboard_event_dsc); + state_->keyboard_event_dsc = nullptr; + } } cancel_async_tasks(); if (ComponensObj) { @@ -1475,8 +2363,8 @@ void LvSettingUpdatePage3::create_ui(lv_obj_t *parent) ComponensObj = lv_obj_create(parent); if (!ComponensObj) return; configure_page(ComponensObj, - LvSettingUpdatePage3::metric(LvSettingUpdatePage3::LayoutMetric::ScreenW), - LvSettingUpdatePage3::metric(LvSettingUpdatePage3::LayoutMetric::ScreenH)); + LvSettingUpdatePage3::metric(LayoutMetric::ScreenW), + LvSettingUpdatePage3::metric(LayoutMetric::ScreenH)); DComponens::lvgl_bind_event( ComponensObj, LV_EVENT_KEY, @@ -1487,48 +2375,128 @@ void LvSettingUpdatePage3::create_ui(lv_obj_t *parent) LvSettingUpdatePage3::metric(LayoutMetric::TextX), LvSettingUpdatePage3::metric(LayoutMetric::TitleY), LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "APPLaunch", 0x58A6FF, title_font()); - state_->device = create_label(ComponensObj, - LvSettingUpdatePage3::metric(LayoutMetric::TextX), - LvSettingUpdatePage3::metric(LayoutMetric::DeviceY), - LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "", 0xECECEC, body_font()); - state_->lvgl = create_label(ComponensObj, - LvSettingUpdatePage3::metric(LayoutMetric::TextX), - LvSettingUpdatePage3::metric(LayoutMetric::LvglY), - LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "", 0xDADADA, body_font()); - state_->version = create_label(ComponensObj, + "Software", 0x58A6FF, title_font()); + state_->summary = create_label(ComponensObj, LvSettingUpdatePage3::metric(LayoutMetric::TextX), - LvSettingUpdatePage3::metric(LayoutMetric::VersionY), + LvSettingUpdatePage3::metric(LayoutMetric::SummaryY), LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "", 0xCCCCCC, body_font()); - state_->build = create_label(ComponensObj, - LvSettingUpdatePage3::metric(LayoutMetric::TextX), - LvSettingUpdatePage3::metric(LayoutMetric::BuildY), - LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "", 0xBBBBBB, body_font()); - state_->commit = create_label(ComponensObj, - LvSettingUpdatePage3::metric(LayoutMetric::TextX), - LvSettingUpdatePage3::metric(LayoutMetric::CommitY), - LvSettingUpdatePage3::metric(LayoutMetric::TextW), - "", 0xAAAAAA, body_font()); - for (lv_obj_t *label : {state_->device, state_->lvgl, state_->version, - state_->build, state_->commit}) - configure_machine_label(label); + "", 0xECECEC, settings_fonts::mono(12)); + if (state_->summary) + lv_label_set_long_mode(state_->summary, LV_LABEL_LONG_SCROLL_CIRCULAR); + + state_->scroll_body = lv_obj_create(ComponensObj); + if (state_->scroll_body) { + lv_obj_set_pos(state_->scroll_body, + LvSettingUpdatePage3::metric(LayoutMetric::TextX), + LvSettingUpdatePage3::metric(LayoutMetric::ScrollY)); + lv_obj_set_size(state_->scroll_body, + LvSettingUpdatePage3::metric(LayoutMetric::TextW), + LvSettingUpdatePage3::metric(LayoutMetric::ScrollH)); + lv_obj_set_style_bg_opa(state_->scroll_body, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(state_->scroll_body, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(state_->scroll_body, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(state_->scroll_body, 3, LV_PART_MAIN); + lv_obj_add_flag(state_->scroll_body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(state_->scroll_body, LV_DIR_VER); + lv_obj_set_scrollbar_mode(state_->scroll_body, LV_SCROLLBAR_MODE_OFF); + lv_obj_remove_flag(state_->scroll_body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_flex_flow(state_->scroll_body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(state_->scroll_body, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_add_event_cb(state_->scroll_body, &software_scroll_event_cb, + LV_EVENT_SCROLL_BEGIN, this); + lv_obj_add_event_cb(state_->scroll_body, &software_scroll_event_cb, + LV_EVENT_SCROLL_END, this); + + const auto add_text = [this](const std::string &text, const lv_font_t *font, + uint32_t color, bool wrap) -> lv_obj_t * { + lv_obj_t *label = lv_label_create(state_->scroll_body); + if (!label) return nullptr; + lv_obj_set_width(label, LvSettingUpdatePage3::metric(LayoutMetric::TextW)); + lv_label_set_long_mode(label, wrap ? LV_LABEL_LONG_WRAP + : LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(label, text.c_str()); + lv_obj_set_style_text_font(label, font, LV_PART_MAIN); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + return label; + }; + + const std::string build_line = + "Build " + std::string(launcher_build_date()) + " " + + launcher_channel() + " (" + launcher_commit() + ")"; + add_text(build_line, settings_fonts::mono(12), 0x9A9A9A, false); + add_text("Device: M5CardputerZero LVGL: " + lvgl_version(), + settings_fonts::mono(12), 0x9A9A9A, false); + + lv_obj_t *separator = lv_obj_create(state_->scroll_body); + if (separator) { + lv_obj_set_width(separator, lv_pct(100)); + lv_obj_set_height(separator, 1); + lv_obj_set_style_bg_color(separator, lv_color_hex(0x383838), LV_PART_MAIN); + lv_obj_set_style_bg_opa(separator, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(separator, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(separator, 0, LV_PART_MAIN); + lv_obj_remove_flag(separator, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(separator, LV_OBJ_FLAG_SCROLLABLE); + } + + add_text("Installed software", settings_fonts::sans(12, LV_FREETYPE_FONT_STYLE_BOLD), + 0x58A6FF, false); + + const std::vector apps = + settings_software::read_installed_apps(); + for (const settings_software::InstalledApp &app : apps) { + State::AppRow row; + row.package = app.package; + row.short_name = app.short_name; + row.version = app.version; + row.name = lv_label_create(state_->scroll_body); + if (row.name) { + lv_obj_set_width(row.name, LvSettingUpdatePage3::metric(LayoutMetric::TextW)); + lv_label_set_long_mode(row.name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(row.name, (app.short_name + " " + app.version).c_str()); + lv_obj_set_style_text_font( + row.name, settings_fonts::sans(13, LV_FREETYPE_FONT_STYLE_BOLD), + LV_PART_MAIN); + lv_obj_set_style_text_color(row.name, lv_color_hex(0xF2F2F2), LV_PART_MAIN); + lv_obj_set_style_pad_top(row.name, 3, LV_PART_MAIN); + } + row.description = lv_label_create(state_->scroll_body); + if (row.description) { + lv_obj_set_width(row.description, + LvSettingUpdatePage3::metric(LayoutMetric::TextW)); + lv_label_set_long_mode(row.description, LV_LABEL_LONG_WRAP); + lv_label_set_text(row.description, app.description.c_str()); + lv_obj_set_style_text_font(row.description, settings_fonts::sans(12), + LV_PART_MAIN); + lv_obj_set_style_text_color(row.description, lv_color_hex(0xAAAAAA), + LV_PART_MAIN); + } + state_->app_rows.push_back(std::move(row)); + } + lv_obj_update_layout(state_->scroll_body); + } + state_->status_label = create_label(ComponensObj, LvSettingUpdatePage3::metric(LayoutMetric::TextX), LvSettingUpdatePage3::metric(LayoutMetric::StatusY), LvSettingUpdatePage3::metric(LayoutMetric::TextW), "", 0xF0C850, body_font()); - // Status text must stay on one line so it cannot cover the footer hint; - // scroll long backend errors instead of clipping them silently. - lv_label_set_long_mode(state_->status_label, LV_LABEL_LONG_SCROLL_CIRCULAR); + if (state_->status_label) + lv_label_set_long_mode(state_->status_label, LV_LABEL_LONG_SCROLL_CIRCULAR); state_->hint = create_label(ComponensObj, LvSettingUpdatePage3::metric(LayoutMetric::TextX), LvSettingUpdatePage3::metric(LayoutMetric::HintY), LvSettingUpdatePage3::metric(LayoutMetric::TextW), "", 0x46DC87, hint_font()); + + state_->keyboard_root = lv_screen_active(); + if (state_->keyboard_root && LV_EVENT_KEYBOARD != 0) { + state_->keyboard_event_dsc = lv_obj_add_event_cb( + state_->keyboard_root, &software_keyboard_event_cb, + static_cast(LV_EVENT_KEYBOARD), this); + } + render_update_page(this); } diff --git a/projects/APPLaunch/main/ui/settings/settings_system_page.hpp b/projects/APPLaunch/main/ui/settings/settings_system_page.hpp index 96d1a7e4..e5114dba 100644 --- a/projects/APPLaunch/main/ui/settings/settings_system_page.hpp +++ b/projects/APPLaunch/main/ui/settings/settings_system_page.hpp @@ -90,21 +90,20 @@ class LvSettingUpdatePage3 : public DComponens::LvglComponensBase { ScreenH = 150, TextX = 8, TextW = 304, - TitleY = 2, - DeviceY = 22, - LvglY = 39, - VersionY = 56, - BuildY = 73, - CommitY = 90, - StatusY = 108, - // Leave a couple of pixels for FreeType descenders in the final row. - HintY = 131, - DialogW = 296, - DialogH = 132, + TitleY = 1, + SummaryY = 21, + ScrollY = 38, + ScrollH = 76, + StatusY = 116, + HintY = 134, + DialogW = 300, + DialogH = 140, DialogRadius = 4, DialogBorderWidth = 1, ProgressBarH = 10, - ContentPadRow = 8, + ContentPadRow = 6, + DialogContentH = 64, + ScrollStep = 48, }; static constexpr int metric(LayoutMetric value) diff --git a/projects/APPLaunch/tests/run_tests.sh b/projects/APPLaunch/tests/run_tests.sh index 150fa84f..b98b75fc 100755 --- a/projects/APPLaunch/tests/run_tests.sh +++ b/projects/APPLaunch/tests/run_tests.sh @@ -15,6 +15,7 @@ python3 "$test_root/test_settings_rtc_layout_contract.py" python3 "$test_root/test_settings_volume_initial_selection.py" python3 "$test_root/test_preinstalled_app_packaging.py" python3 "$test_root/test_settings_storage_build_contract.py" +python3 "$test_root/test_settings_software_page_contract.py" python3 "$test_root/test_static_resource_staging.py" python3 "$test_root/test_lockscreen_contract.py" python3 "$test_root/test_home_icon_fallback_contract.py" @@ -35,6 +36,15 @@ ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror -pthread \ "$test_root/test_page_timer_lifecycle.cpp" \ -o "$build_dir/test_page_timer_lifecycle" "$build_dir/test_page_timer_lifecycle" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ + "$test_root/test_settings_software_model.cpp" \ + "$settings_ui_root/settings_software_model.cpp" \ + -o "$build_dir/test_settings_software_model" +${CXX:-g++} -std=c++17 -Wall -Wextra -Werror -pthread \ + "$test_root/test_settings_software_updater.cpp" \ + -o "$build_dir/test_settings_software_updater" +"$build_dir/test_settings_software_updater" +"$build_dir/test_settings_software_model" python3 "$test_root/test_updater_packaging.py" python3 "$test_root/test_appstore_packaging.py" ${CXX:-g++} -std=c++17 -Wall -Wextra -Werror \ diff --git a/projects/APPLaunch/tests/test_settings_software_model.cpp b/projects/APPLaunch/tests/test_settings_software_model.cpp new file mode 100644 index 00000000..cc93e51c --- /dev/null +++ b/projects/APPLaunch/tests/test_settings_software_model.cpp @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "../main/ui/settings/settings_software_model.hpp" + +#include +#include +#include +#include +#include + +namespace { + +std::string write_status_file() +{ + char path[] = "/tmp/settings-software-status-XXXXXX"; + const int descriptor = mkstemp(path); + assert(descriptor >= 0); + close(descriptor); + + std::ofstream file(path); + file << "Package: m5cardputerzero-recorder\n" + "Status: install ok installed\n" + "Version: 0.1.8\n" + "Description: Recorder application for M5CardputerZero APPLaunch\n" + " Recorder application, launcher entry, and runtime icon.\n" + "\n" + "Package: m5cardputerzero-compass\n" + "Status: install ok installed\n" + "Version: 0.2.4\n" + "Description: Compass application for M5CardputerZero APPLaunch\n" + " Compass application, launcher entry, and runtime icon.\n" + "\n" + "Package: camera\n" + "Status: install ok installed\n" + "Version: 1.0.1-m5stack1\n" + "Description: Camera application for M5CardputerZero APPLaunch\n" + "\n" + "Package: obsolete-package\n" + "Status: deinstall ok config-files\n" + "Version: 9.9.9\n" + "Description: Removed package\n" + "\n"; + file.close(); + return path; +} + +} // namespace + +int main() +{ + using namespace settings_software; + + assert(app_definitions().size() == 14); + assert(std::string(app_definitions().front().package) == + "m5cardputerzero-recorder"); + assert(std::string(app_definitions().back().package) == + "m5cardputerzero-keyboard-guide"); + assert(std::string(kAssetSuffix) == "_m5stack1_arm64.deb"); + + assert(short_name("m5cardputerzero-recorder") == "recorder"); + assert(short_name("camera") == "camera"); + assert(short_name("m5cardputerzero-") == "m5cardputerzero-"); + + const std::vector missing = read_installed_apps( + "/nonexistent/settings-software-status"); + assert(missing.size() == app_definitions().size()); + for (const InstalledApp &app : missing) { + assert(!app.installed); + assert(app.version == "--"); + assert(app.description == "Not installed"); + } + + const std::string path = write_status_file(); + const std::vector apps = read_installed_apps(path); + std::remove(path.c_str()); + assert(apps.size() == app_definitions().size()); + + const auto app_for = [&apps](const std::string &package) -> const InstalledApp & { + for (const InstalledApp &app : apps) + if (app.package == package) return app; + assert(false); + return apps.front(); + }; + + const InstalledApp &recorder = app_for("m5cardputerzero-recorder"); + assert(recorder.installed); + assert(recorder.short_name == "recorder"); + assert(recorder.version == "0.1.8"); + assert(recorder.description == + "Recorder application for M5CardputerZero APPLaunch\n" + "Recorder application, launcher entry, and runtime icon."); + + const InstalledApp &compass = app_for("m5cardputerzero-compass"); + assert(compass.installed); + assert(compass.version == "0.2.4"); + + const InstalledApp &camera = app_for("camera"); + assert(camera.installed); + assert(camera.version == "1.0.1-m5stack1"); + assert(camera.description == "Camera application for M5CardputerZero APPLaunch"); + + const InstalledApp &files = app_for("m5cardputerzero-files"); + assert(!files.installed); + assert(files.version == "--"); + assert(files.description == "Not installed"); + + CheckResult result; + assert(parse_check_result( + "succeeded:software|checked=14|failed=1|skipped=0|" + "updates=m5cardputerzero-recorder=0.1.8>0.1.9;" + "m5cardputerzero-files=1.0.0>1.0.2|" + "launcher_known=1|launcher_available=1|" + "launcher_installed=0.6.100|launcher_version=0.7.0|" + "launcher_commit=abcdef1|launcher_failed=0", + result)); + assert(result.checked == 14); + assert(result.failed == 1); + assert(result.skipped == 0); + assert(result.updates.size() == 2); + assert(result.updates[0].package == "m5cardputerzero-recorder"); + assert(result.updates[0].installed == "0.1.8"); + assert(result.updates[0].latest == "0.1.9"); + assert(result.updates[1].latest == "1.0.2"); + assert(result.launcher_known); + assert(result.launcher_available); + assert(result.launcher_installed == "0.6.100"); + assert(result.launcher_version == "0.7.0"); + assert(result.launcher_commit == "abcdef1"); + assert(!result.launcher_failed); + + CheckResult current; + assert(parse_check_result( + "succeeded:software|checked=14|failed=0|skipped=0|updates=|" + "launcher_known=1|launcher_available=0|launcher_installed=0.6.100|" + "launcher_version=0.6.100|launcher_commit=abcdef1|launcher_failed=0", + current)); + assert(current.updates.empty()); + assert(!current.launcher_available); + assert(!current.launcher_failed); + + CheckResult malformed; + assert(!parse_check_result("succeeded:up-to-date|version=0.6.100", malformed)); + assert(!parse_check_result("failed:software-check:1", malformed)); + + AppUpdateState app_state; + assert(parse_app_update_status("checking:2:7:m5cardputerzero-recorder", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Checking); + assert(app_state.index == 2); + assert(app_state.total == 7); + assert(app_state.package == "m5cardputerzero-recorder"); + assert(parse_app_update_status("downloading:3:7:camera", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Downloading); + assert(parse_app_update_status("installing:4:7:factorytest", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Installing); + assert(parse_app_update_status( + "succeeded:updated=m5cardputerzero-recorder:0.1.9," + "m5cardputerzero-files:1.0.2|failed=camera:install,", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Succeeded); + assert(app_state.updated_count == 2); + assert(app_state.failed_count == 1); + assert(parse_app_update_status("succeeded:updated=|failed=", app_state)); + assert(app_state.updated_count == 0); + assert(app_state.failed_count == 0); + assert(parse_app_update_status("failed:unexpected", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Failed); + assert(app_state.detail == "unexpected"); + assert(parse_app_update_status("cancelled", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Cancelled); + assert(parse_app_update_status("cancelling:installing", app_state)); + assert(app_state.phase == AppUpdateState::Phase::Cancelling); + assert(!parse_app_update_status("garbage", app_state)); + + UpdateInfo update{"m5cardputerzero-recorder", "0.1.8", "0.1.9"}; + assert(update_changes_version(update)); + assert(!update_changes_version(UpdateInfo{"pkg", "1.0.0", "1.0.0"})); + assert(!update_changes_version(UpdateInfo{"pkg", "", "1.0.0"})); + assert(update_line(update) == "recorder 0.1.8 -> 0.1.9"); + assert(launcher_update_line("0.6.100", "0.7.0") == + "APPLaunch 0.6.100 -> 0.7.0"); + + return 0; +} diff --git a/projects/APPLaunch/tests/test_settings_software_page_contract.py b/projects/APPLaunch/tests/test_settings_software_page_contract.py new file mode 100644 index 00000000..43e71741 --- /dev/null +++ b/projects/APPLaunch/tests/test_settings_software_page_contract.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +# +# SPDX-License-Identifier: MIT + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODEL = (ROOT / "main/ui/settings/settings_software_model.cpp").read_text( + encoding="utf-8" +) +PAGE = (ROOT / "main/ui/settings/settings_system_page.cpp").read_text( + encoding="utf-8" +) + +# Packages installed by pi-gen/stage2/05-cardputerzero/03-run.sh. +PACKAGES = [ + "m5cardputerzero-recorder", + "m5cardputerzero-compass", + "camera", + "factorytest", + "m5cardputerzero-files", + "m5cardputerzero-music", + "m5cardputerzero-ir-chat", + "m5cardputerzero-piano", + "m5cardputerzero-ir-remote", + "m5cardputerzero-cap-cc1101-subg-chat", + "m5cardputerzero-cap-cc1101-nfc", + "m5cardputerzero-cap-lora-1262", + "m5cardputerzero-cap-lora-1262-gps", + "m5cardputerzero-keyboard-guide", +] + +for package in PACKAGES: + assert package in MODEL, f"missing software package: {package}" + +assert "read_installed_apps" in MODEL +assert "parse_check_result" in MODEL +assert 'constexpr const char *prefix = "m5cardputerzero-";' in MODEL + +assert '#include "settings_software_updater.hpp"' in PAGE +assert '#include "settings_http_worker.hpp"' in PAGE +assert "settings_software::check_software" in PAGE +assert "settings_http::download_batch" in PAGE +assert "prefetch_software_http" in PAGE +assert (ROOT / "main/ui/settings/settings_http_worker.cpp").exists() +assert (ROOT / "main/ui/settings/settings_http_worker.hpp").exists() +sconstruct = (ROOT / "main/SConstruct").read_text(encoding="utf-8") +assert "APPLAUNCH_USE_LIBCURL" in sconstruct +assert ":libcurl.so.4" in sconstruct +assert "CONFIG_APPLAUNCH_LINUX_X86_CROSS_CP0" in sconstruct +assert "settings_software::read_installed_apps()" in PAGE +assert 'lv_msgbox_add_title(state->dialog, "Updates available")' in PAGE +assert "settings_software::update_line(update)" in PAGE +assert 'lv_msgbox_add_footer_button(state->dialog, "Update apps")' in PAGE +assert 'lv_msgbox_add_footer_button(state->dialog, "Update APPLaunch")' in PAGE +assert 'lv_msgbox_add_footer_button(state->dialog, "Not now")' in PAGE +assert "SoftwareDialogOption::Apps" in PAGE +assert 'start_process_request' in PAGE +assert 'applaunch-apps-updater.service' in PAGE + +# The platform layer must not receive APPLaunch-specific updater commands. +for platform_source in ( + ROOT.parents[1] / "ext_components/cp0_lvgl/src/cp0/cp0_lvgl_osinfo.cpp", + ROOT.parents[1] / "ext_components/cp0_lvgl/src/sdl/sdl_lvgl_osinfo.cpp", +): + text = platform_source.read_text(encoding="utf-8") + assert "SoftwareCheckStart" not in text + assert "AppUpdateStart" not in text + assert "AppUpdateStatus" not in text + assert "AppUpdateCancel" not in text +assert 'state->status = "All software is up to date"' in PAGE +assert 'state->status = "Update check cancelled"' in PAGE + +# The privileged updater keeps the same package allowlist as the page. A +# package added to one list but not the other would silently stop updating. +PACKAGER = (ROOT.parents[1] / "scripts/debian_packager.py").read_text( + encoding="utf-8" +) +for package in PACKAGES: + assert package in PACKAGER, f"missing updater package: {package}" +assert "APP_UPDATE_SPECS" in PACKAGER + +import re + +model_specs = { + (package, repo, prefix) + for _short, package, repo, prefix in re.findall( + r'\{"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\}', + MODEL, + ) +} +spec_block = PACKAGER.split("APP_UPDATE_SPECS = (", 1)[1].split("\n)", 1)[0] +packager_specs = { + (package, repo, prefix) + for package, repo, prefix in re.findall( + r'\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\)', + spec_block, + ) +} +assert model_specs == packager_specs, (model_specs, packager_specs) diff --git a/projects/APPLaunch/tests/test_settings_software_updater.cpp b/projects/APPLaunch/tests/test_settings_software_updater.cpp new file mode 100644 index 00000000..ecf0b196 --- /dev/null +++ b/projects/APPLaunch/tests/test_settings_software_updater.cpp @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#include "../main/ui/settings/settings_software_updater.hpp" + +#include +#include +#include +#include +#include + +namespace { + +struct Fixture { + std::map installed = { + {"applaunch", "0.6.100\n"}, + {"m5cardputerzero-recorder", "0.1.8\n"}, + {"m5cardputerzero-compass", "0.2.4\n"}, + {"m5cardputerzero-files", "1.0.0\n"}, + }; + std::map releases = { + {"Recorder", + R"({"tag_name":"v1.0.0","assets":[{"name":"M5CardputerZero-Recorder","url":"x"},{"name":"m5cardputerzero-recorder_0.1.9_m5stack1_arm64.deb","url":"y"}]})"}, + {"Compass", + R"({"tag_name":"v1.0.0","assets":[{"name":"m5cardputerzero-compass_0.2.4_m5stack1_arm64.deb","url":"y"}]})"}, + {"Files", + R"({"tag_name":"v1.0.2","assets":[{"name":"m5cardputerzero-files_1.0.2_m5stack1_arm64.deb","url":"y"}]})"}, + }; + std::string launcher_metadata = + "format=1\nversion=0.7.0\npackage_version=0.7.0-1\ncommit=abcdef1\n"; + std::string launcher_url = "https://example.invalid/launcher"; + std::vector newer = {"0.1.9", "1.0.2", "0.7.0-1"}; + + int capture(const std::vector &arguments, std::string &output) + { + if (arguments.empty()) return -1; + if (arguments[0] == "dpkg-query") { + const auto found = installed.find(arguments.back()); + if (found == installed.end()) return 1; + output = found->second; + return 0; + } + if (arguments[0] == "curl") { + const std::string &url = arguments.back(); + if (url.find(".update-info") != std::string::npos) { + output = launcher_metadata; + return 0; + } + for (const auto &entry : releases) { + if (url.find("/" + entry.first + "/") == std::string::npos) continue; + output = entry.second; + return 0; + } + return 22; + } + if (arguments[0] == "dpkg" && arguments.size() >= 5 && + arguments[1] == "--compare-versions") { + const std::string &candidate = arguments[2]; + const std::string ¤t = arguments[4]; + for (const std::string &value : newer) { + if (value == candidate && value != current) return 0; + } + return 1; + } + return -1; + } +}; + +settings_software::AppSpec spec(const std::string &package, const std::string &repo, + const std::string &prefix) +{ + return {package, repo, prefix, "_m5stack1_arm64.deb"}; +} + +} // namespace + +int main() +{ + using namespace settings_software; + + AppSpec parsed; + assert(parse_app_spec("m5cardputerzero-recorder|Recorder|m5cardputerzero-recorder_|_m5stack1_arm64.deb", parsed)); + assert(parsed.package == "m5cardputerzero-recorder"); + assert(parsed.repo == "Recorder"); + assert(!parse_app_spec("bad", parsed)); + assert(!parse_app_spec("pkg|repo|prefix|suffix", parsed)); + assert(!parse_app_spec("pkg|repo|prefix|suffix.deb|extra", parsed)); + + const AppSpec recorder = spec("m5cardputerzero-recorder", "Recorder", + "m5cardputerzero-recorder_"); + Fixture fixture; + std::string version; + assert(parse_asset_version(fixture.releases["Recorder"], recorder, version)); + assert(version == "0.1.9"); + assert(!parse_asset_version("{}", recorder, version)); + assert(safe_version("0.1.9")); + assert(!safe_version("0.1.9|bad")); + assert(!safe_version("0.1.9;bad")); + + LauncherUpdateInfo launcher_info; + assert(launcher_update_info( + "format=1\nversion=0.7.0\npackage_version=0.7.0-1\ncommit=abcdef1\n", + launcher_info)); + assert(launcher_info.version == "0.7.0"); + assert(launcher_info.package_version == "0.7.0-1"); + assert(launcher_info.commit == "abcdef1"); + assert(!launcher_update_info("version=0.7.0\ncommit=abcdef1\n", launcher_info)); + + const std::string html = + "download"; + assert(parse_asset_version_from_text(html, spec("m5cardputerzero-piano", "Piano", + "m5cardputerzero-piano_"), version)); + assert(version == "0.1.1"); + + clear_release_cache(); + const AppSpec piano = spec("m5cardputerzero-piano", "Piano", + "m5cardputerzero-piano_"); + const auto html_capture = [&html](const std::vector &arguments, + std::string &output) { + const std::string &last = arguments.back(); + if (last.find("api.github.com") != std::string::npos) return 22; + if (last.find("/releases/latest") != std::string::npos) { + output = "https://github.com/CardputerZero/Piano/releases/tag/v1.0.0\n"; + return 0; + } + if (last.find("expanded_assets") != std::string::npos) { + output = html; + return 0; + } + return -1; + }; + std::string html_version; + assert(latest_version(html_capture, piano, html_version)); + assert(html_version == "0.1.1"); + + const std::vector specs = { + recorder, + spec("m5cardputerzero-compass", "Compass", "m5cardputerzero-compass_"), + spec("m5cardputerzero-files", "Files", "m5cardputerzero-files_"), + spec("m5cardputerzero-piano", "Piano", "m5cardputerzero-piano_"), + }; + std::atomic cancel{false}; + int last_progress = -1; + clear_release_cache(); + const settings_software::Result result = check_software( + specs, + [&fixture](const std::vector &arguments, std::string &output) { + return fixture.capture(arguments, output); + }, + fixture.launcher_url, cancel, + [&last_progress](int value) { last_progress = value; }); + + assert(result.code == 0); + assert(last_progress == 100); + const std::string &payload = result.stage; + assert(payload.find("software|") == 0); + assert(payload.find("checked=4") != std::string::npos); + assert(payload.find("failed=0") != std::string::npos); + assert(payload.find("skipped=1") != std::string::npos); + assert(payload.find("updates=m5cardputerzero-recorder=0.1.8>0.1.9;" + "m5cardputerzero-files=1.0.0>1.0.2") != std::string::npos); + assert(payload.find("launcher_known=1") != std::string::npos); + assert(payload.find("launcher_available=1") != std::string::npos); + assert(payload.find("launcher_installed=0.6.100") != std::string::npos); + assert(payload.find("launcher_version=0.7.0") != std::string::npos); + assert(payload.find("launcher_commit=abcdef1") != std::string::npos); + + fixture.releases.erase("Files"); + clear_release_cache(); + const settings_software::Result partial = check_software( + specs, + [&fixture](const std::vector &arguments, std::string &output) { + return fixture.capture(arguments, output); + }, + fixture.launcher_url, cancel); + assert(partial.code == 0); + assert(partial.stage.find("failed=1") != std::string::npos); + assert(partial.stage.find("m5cardputerzero-recorder=0.1.8>0.1.9") != std::string::npos); + assert(partial.stage.find("m5cardputerzero-files") == std::string::npos); + + cancel.store(true); + clear_release_cache(); + const settings_software::Result cancelled = check_software( + specs, + [&fixture](const std::vector &arguments, std::string &output) { + return fixture.capture(arguments, output); + }, + fixture.launcher_url, cancel); + assert(cancelled.code == -ECANCELED); + + return 0; +} diff --git a/projects/APPLaunch/tests/test_updater_packaging.py b/projects/APPLaunch/tests/test_updater_packaging.py index 8c31bfd5..41be183f 100755 --- a/projects/APPLaunch/tests/test_updater_packaging.py +++ b/projects/APPLaunch/tests/test_updater_packaging.py @@ -285,6 +285,18 @@ def run_update(fail_install: bool = False, assert "status downloading:5" in packager._updater_script_text() assert "status installing:85" in packager._updater_script_text() assert "status restarting:95" in packager._updater_script_text() +assert "applaunch-apps-updater.service" in packager._updater_polkit_text() +assert "TimeoutStartSec=30min" in packager._apps_updater_service_text() +assert "TimeoutStopSec=5min" in packager._apps_updater_service_text() +assert "apt-get install" in packager._apps_updater_script_text() +assert "m5cardputerzero-recorder" in packager._apps_updater_script_text() +assert "m5cardputerzero-keyboard-guide" in packager._apps_updater_script_text() +assert "/usr/libexec/applaunch-apps-updater" in packager._apps_updater_service_text() +assert subprocess.run( + ["sh", "-n"], input=packager._apps_updater_script_text(), text=True, + capture_output=True, check=False, +).returncode == 0 +assert len(packager.APP_UPDATE_SPECS) == 14 workflow = (repo / ".github" / "workflows" / "launcher-build.yml").read_text( encoding="utf-8" @@ -348,3 +360,138 @@ def run_update(fail_install: bool = False, assert "applaunch_arm64.deb.update-info" in release_builder assert "printf 'format=1\\n'" in release_builder assert "printf 'commit=%s\\n'" in release_builder + + +def run_apps_update(fail_install_for: str = "", + install_latest: bool = False) -> tuple[subprocess.CompletedProcess[str], str]: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + fake_bin = root / "bin" + releases = root / "releases" + json_dir = root / "json" + installed = root / "installed" + state = root / "state" + cache = root / "cache" + for directory in (fake_bin, releases, json_dir, installed): + directory.mkdir() + + installed_versions = { + "m5cardputerzero-recorder": "0.1.9" if install_latest else "0.1.8", + "m5cardputerzero-compass": "0.2.4", + "m5cardputerzero-files": "1.0.2" if install_latest else "1.0.0", + } + for package, version in installed_versions.items(): + (installed / package).write_text(version + "\n", encoding="ascii") + + assets = { + "m5cardputerzero-recorder": ( + "m5cardputerzero-recorder", "0.1.9", "Recorder"), + "m5cardputerzero-compass": ( + "m5cardputerzero-compass", "0.2.4", "Compass"), + "m5cardputerzero-files": ( + "m5cardputerzero-files", "1.0.2", "Files"), + } + for package, (prefix, version, repo) in assets.items(): + asset = f"{prefix}_{version}_m5stack1_arm64.deb" + (releases / asset).write_text( + f"Package={package}\nArchitecture=arm64\nVersion={version}\n", + encoding="ascii", + ) + (json_dir / f"{repo}.json").write_text( + '{"tag_name":"v1.0.0","assets":[{"name":"' + + asset + '"}]}', + encoding="ascii", + ) + + log = root / "commands.log" + executable( + fake_bin / "curl", + 'url=""; dest=""; prev=""\n' + 'for arg in "$@"; do\n' + ' [ "$prev" = "-o" ] && dest=$arg\n' + ' case "$arg" in http*) url=$arg;; esac\n' + ' prev=$arg\n' + 'done\n' + 'repo=$(printf "%s" "$url" | sed "s#.*/CardputerZero/##; s#/releases/latest##")\n' + 'cp "$TEST_JSON_DIR/$repo.json" "$dest"\n', + ) + executable( + fake_bin / "wget", + 'url=""; dest=""; prev=""\n' + 'for arg in "$@"; do\n' + ' [ "$prev" = "-O" ] && dest=$arg\n' + ' case "$arg" in http*) url=$arg;; esac\n' + ' prev=$arg\n' + 'done\n' + 'cp "$TEST_RELEASES/${url##*/}" "$dest"\n', + ) + executable( + fake_bin / "dpkg-query", + 'package=$3\n' + 'if [ -f "$TEST_INSTALLED/$package" ]; then cat "$TEST_INSTALLED/$package"; ' + 'else exit 1; fi\n', + ) + executable( + fake_bin / "dpkg", + 'if [ "$1" = "--compare-versions" ]; then\n' + ' higher=$(printf "%s\\n%s\\n" "$2" "$4" | sort -V | tail -1)\n' + ' [ "$higher" = "$2" ] && [ "$2" != "$4" ]\n' + ' exit $?\n' + 'fi\n' + 'exit 0\n', + ) + executable( + fake_bin / "dpkg-deb", + 'field=$3\n' + 'sed -n "s/^${field}=//p" "$2"\n', + ) + executable( + fake_bin / "apt-get", + 'case "$1" in update) exit 0;; esac\n' + 'deb=""; for arg in "$@"; do deb=$arg; done\n' + 'package=$(sed -n "s/^Package=//p" "$deb")\n' + 'version=$(sed -n "s/^Version=//p" "$deb")\n' + 'echo "apt-get install $package $version" >> "$TEST_LOG"\n' + 'if [ -n "$TEST_FAIL_INSTALL" ] && [ "$package" = "$TEST_FAIL_INSTALL" ]; then exit 1; fi\n' + 'printf "%s\\n" "$version" > "$TEST_INSTALLED/$package"\n', + ) + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "APPLAUNCH_UPDATE_STATE_DIR": str(state), + "APPLAUNCH_APPS_UPDATE_CACHE_DIR": str(cache), + "APPLAUNCH_APPS_UPDATE_API_ROOT": "https://example.invalid/CardputerZero", + "APPLAUNCH_APPS_UPDATE_DOWNLOAD_ROOT": "https://example.invalid/dl", + "TEST_JSON_DIR": str(json_dir), + "TEST_RELEASES": str(releases), + "TEST_INSTALLED": str(installed), + "TEST_LOG": str(log), + "TEST_FAIL_INSTALL": fail_install_for, + } + ) + result = subprocess.run( + ["sh"], input=packager._apps_updater_script_text(), text=True, + env=env, capture_output=True, check=False, + ) + status = (state / "apps-status").read_text(encoding="utf-8").strip() + return result, status + + +success, status = run_apps_update() +assert success.returncode == 0, (success.stderr, success.stdout, status) +assert status == ( + "succeeded:updated=m5cardputerzero-recorder:0.1.9," + "m5cardputerzero-files:1.0.2|failed=" +), status + +current, status = run_apps_update(install_latest=True) +assert current.returncode == 0 +assert status == "succeeded:updated=|failed=", status + +partial, status = run_apps_update(fail_install_for="m5cardputerzero-files") +assert partial.returncode == 0 +assert status == ( + "succeeded:updated=m5cardputerzero-recorder:0.1.9|" + "failed=m5cardputerzero-files:install" +), status diff --git a/scripts/debian_packager.py b/scripts/debian_packager.py index 4040c839..8d856962 100755 --- a/scripts/debian_packager.py +++ b/scripts/debian_packager.py @@ -48,6 +48,33 @@ ) +# Application packages installed by pi-gen/stage2/05-cardputerzero/03-run.sh. +# (Debian package, GitHub repository, release asset prefix) +APP_UPDATE_SPECS = ( + ("m5cardputerzero-recorder", "Recorder", "m5cardputerzero-recorder_"), + ("m5cardputerzero-compass", "Compass", "m5cardputerzero-compass_"), + ("camera", "CameraApp", "Camera_"), + ("factorytest", "FactoryTest", "FactoryTest_"), + ("m5cardputerzero-files", "Files", "m5cardputerzero-files_"), + ("m5cardputerzero-music", "Music", "m5cardputerzero-music_"), + ("m5cardputerzero-ir-chat", "IR-Chat", "m5cardputerzero-ir-chat_"), + ("m5cardputerzero-piano", "Piano", "m5cardputerzero-piano_"), + ("m5cardputerzero-ir-remote", "IR-Remote", "m5cardputerzero-ir-remote_"), + ("m5cardputerzero-cap-cc1101-subg-chat", "Cap-CC1101-SubG-Chat", + "m5cardputerzero-cap-cc1101-subg-chat_"), + ("m5cardputerzero-cap-cc1101-nfc", "Cap-CC1101-NFC", + "m5cardputerzero-cap-cc1101-nfc_"), + ("m5cardputerzero-cap-lora-1262", "Cap-LoRa-1262", + "m5cardputerzero-cap-lora-1262_"), + ("m5cardputerzero-cap-lora-1262-gps", "Cap-LoRa-1262-GPS", + "m5cardputerzero-cap-lora-1262-gps_"), + ("m5cardputerzero-keyboard-guide", "Keyboard-Guide", + "m5cardputerzero-keyboard-guide_"), +) + +APP_UPDATE_ASSET_SUFFIX = "_m5stack1_arm64.deb" + + class PackError(RuntimeError): """Raised when the package cannot be assembled.""" @@ -406,11 +433,13 @@ def _updater_polkit_text() -> str: polkit.addRule(function(action, subject) { if (action.id == "org.freedesktop.systemd1.manage-units" && (action.lookup("unit") == "applaunch-updater.service" || - action.lookup("unit") == "applaunch-apt-update.service") && + action.lookup("unit") == "applaunch-apt-update.service" || + action.lookup("unit") == "applaunch-apps-updater.service") && (action.lookup("verb") == "start" || (action.lookup("verb") == "stop" && (action.lookup("unit") == "applaunch-updater.service" || - action.lookup("unit") == "applaunch-apt-update.service"))) && + action.lookup("unit") == "applaunch-apt-update.service" || + action.lookup("unit") == "applaunch-apps-updater.service"))) && subject.isInGroup("sudo")) { return polkit.Result.YES; } @@ -641,6 +670,195 @@ def _updater_script_text() -> str: """ +def _apps_updater_service_text() -> str: + return """[Unit] +Description=APPLaunch application package updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/libexec/applaunch-apps-updater +TimeoutStartSec=30min +TimeoutStopSec=5min +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +""" + + +def _apps_updater_script_text() -> str: + apps = "\n".join("|".join(spec) for spec in APP_UPDATE_SPECS) + script = r"""#!/bin/sh +set -eu + +# Update the application packages installed by the CardputerZero image stage. +# The package/repository/asset allowlist below is fixed so the unprivileged +# launcher can only trigger installations from trusted CardputerZero releases. +STATE_DIR=${APPLAUNCH_UPDATE_STATE_DIR:-/var/lib/applaunch-updater} +STATUS_FILE=$STATE_DIR/apps-status +CACHE_DIR=${APPLAUNCH_APPS_UPDATE_CACHE_DIR:-/var/cache/APPLaunch/app-updates} +LOG_FILE=$STATE_DIR/apps-update.log +ARCHITECTURE=arm64 +ASSET_SUFFIX=__ASSET_SUFFIX__ +API_ROOT=${APPLAUNCH_APPS_UPDATE_API_ROOT:-https://api.github.com/repos/CardputerZero} +DOWNLOAD_ROOT=${APPLAUNCH_APPS_UPDATE_DOWNLOAD_ROOT:-https://github.com/CardputerZero} + +status() { + printf '%s\n' "$1" >"$STATUS_FILE.tmp" + chmod 0644 "$STATUS_FILE.tmp" + mv -f "$STATUS_FILE.tmp" "$STATUS_FILE" +} +fail() { + finished=1 + status "failed:$1" + exit 1 +} + +mkdir -p "$STATE_DIR" "$CACHE_DIR" +chmod 0755 "$STATE_DIR" +chmod 0700 "$CACHE_DIR" +: >"$LOG_FILE" +chmod 0644 "$LOG_FILE" +exec 9>"$STATE_DIR/apps.lock" +flock -n 9 || exit 0 + +tmp_dir=$(mktemp -d "$STATE_DIR/apps-run.XXXXXX") +finished=0 +cleanup() { + if [ "$finished" -eq 0 ]; then + status "failed:unexpected" + fi + rm -rf "$tmp_dir" +} +cancelled() { + finished=1 + status cancelled + exit 0 +} +trap cleanup EXIT +trap cancelled HUP INT TERM + +APPS='__APP_UPDATE_SPECS__' +count=$(printf '%s\n' "$APPS" | sed '/^$/d' | wc -l) +index=0 +updated= +failed= +dpkg --configure -a >>"$LOG_FILE" 2>&1 || true +# App dependencies (for example factorytest -> libcamera) can be newer than +# the image's package indexes. Refresh them once before installing. +timeout 300 env DEBIAN_FRONTEND=noninteractive apt-get update >>"$LOG_FILE" 2>&1 || true + +while IFS='|' read -r package repo prefix; do + [ -n "$package" ] || continue + index=$((index + 1)) + status "checking:$index:$count:" + installed=$(dpkg-query -W -f='${Version}' "$package" 2>/dev/null || true) + [ -n "$installed" ] || continue + + cache_file=$CACHE_DIR/$repo.json + tag= + asset= + refresh=1 + if [ -f "$cache_file" ] && [ -z "$(find "$cache_file" -mmin +5 2>/dev/null)" ]; then + refresh=0 + fi + if [ "$refresh" -eq 1 ]; then + if curl -fsSL --max-time 15 --connect-timeout 5 \ + -H 'Accept: application/vnd.github+json' \ + -H 'User-Agent: APPLaunch-App-Updater' \ + "$API_ROOT/$repo/releases/latest" -o "$cache_file.tmp" \ + >>"$LOG_FILE" 2>&1; then + mv -f "$cache_file.tmp" "$cache_file" + elif [ ! -f "$cache_file" ]; then + # The API is rate limited for the unauthenticated device. Fall + # back to the public release page, which is not API-rate-limited. + effective_url=$(curl -fsSL --max-time 15 --connect-timeout 5 \ + -o /dev/null -w '%{url_effective}' \ + "https://github.com/CardputerZero/$repo/releases/latest" \ + 2>>"$LOG_FILE" || true) + tag=${effective_url##*/tag/} + if [ -n "$tag" ]; then + html=$(curl -fsSL --max-time 15 --connect-timeout 5 \ + "https://github.com/CardputerZero/$repo/releases/expanded_assets/$tag" \ + 2>>"$LOG_FILE" || true) + asset=$(printf '%s' "$html" | \ + grep -o "${prefix}[^\"]*${ASSET_SUFFIX}" | head -n 1) + fi + if [ -z "$tag" ] || [ -z "$asset" ]; then + failed="$failed$package:release," + continue + fi + fi + fi + + if [ -z "$tag" ] || [ -z "$asset" ]; then + tag=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cache_file" | head -n 1) + asset=$(grep -o "\"name\"[[:space:]]*:[[:space:]]*\"${prefix}[^\"]*${ASSET_SUFFIX}\"" "$cache_file" | head -n 1 | sed 's/.*"\([^"]*\)"$/\1/') + fi + if [ -z "$tag" ] || [ -z "$asset" ]; then + failed="$failed$package:metadata," + continue + fi + version=${asset#"$prefix"} + version=${version%"$ASSET_SUFFIX"} + if [ -z "$version" ]; then + failed="$failed$package:version," + continue + fi + if ! dpkg --compare-versions "$version" gt "$installed" 2>/dev/null; then + continue + fi + + status "downloading:$index:$count:$package" + deb=$tmp_dir/$package.deb + if ! timeout 120 wget -q --https-only --timeout=30 --tries=3 \ + "$DOWNLOAD_ROOT/$repo/releases/download/$tag/$asset" -O "$deb" \ + >>"$LOG_FILE" 2>&1; then + failed="$failed$package:download," + rm -f "$deb" + continue + fi + deb_package=$(dpkg-deb -f "$deb" Package 2>/dev/null || true) + deb_arch=$(dpkg-deb -f "$deb" Architecture 2>/dev/null || true) + deb_version=$(dpkg-deb -f "$deb" Version 2>/dev/null || true) + if [ "$deb_package" != "$package" ] || + [ "$deb_arch" != "$ARCHITECTURE" ] || + [ -z "$deb_version" ] || + ! dpkg --compare-versions "$deb_version" gt "$installed" 2>/dev/null; then + failed="$failed$package:invalid-package," + rm -f "$deb" + continue + fi + + status "installing:$index:$count:$package" + if ! timeout 300 env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends "$deb" >>"$LOG_FILE" 2>&1; then + failed="$failed$package:install," + rm -f "$deb" + continue + fi + active=$(dpkg-query -W -f='${Version}' "$package" 2>/dev/null || true) + if [ "$active" != "$deb_version" ]; then + failed="$failed$package:verify," + rm -f "$deb" + continue + fi + updated="$updated$package:$deb_version," + rm -f "$deb" +done < str: if config.service_scope == "system": return f"""[Unit] @@ -774,6 +992,16 @@ def prepare_package_tree( paths.package_root / Path(*LIBEXEC_PATH.parts) / "applaunch-updater", _updater_script_text(), mode=0o755, ) + _write_text( + paths.package_root / Path(*SYSTEM_SERVICE_PATH.parts) / + "applaunch-apps-updater.service", + _apps_updater_service_text(), + ) + _write_text( + paths.package_root / Path(*LIBEXEC_PATH.parts) / + "applaunch-apps-updater", + _apps_updater_script_text(), mode=0o755, + ) print(f"Staged package tree: {paths.package_root}") print(f" binary: {binary}")