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/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/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/APPLaunch/share/audio/applaunch-arcade-refund.mp3 b/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-refund.mp3 new file mode 100644 index 00000000..ddc4d25c Binary files /dev/null and b/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-refund.mp3 differ diff --git a/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-warning.mp3 b/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-warning.mp3 new file mode 100644 index 00000000..e0cbe11f Binary files /dev/null and b/projects/APPLaunch/APPLaunch/share/audio/applaunch-arcade-warning.mp3 differ 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/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/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/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); diff --git a/projects/APPLaunch/tests/run_tests.sh b/projects/APPLaunch/tests/run_tests.sh index de948073..b98b75fc 100755 --- a/projects/APPLaunch/tests/run_tests.sh +++ b/projects/APPLaunch/tests/run_tests.sh @@ -12,8 +12,10 @@ 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_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" @@ -34,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_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/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/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" + ) 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}")