From d34af346fab3b4787521dd72e09bbd9ae74bd869 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 01:01:06 -0400 Subject: [PATCH 1/2] feat(network): render bounded Wi-Fi scans --- build/bloom-typography.json | 4 +- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 8 + docs/DEVELOPMENT.md | 6 + docs/SECURITY.md | 6 + docs/program/BloomOS_Work_Program_v3.json | 9 +- src/bloomShell/bloom_shell_wifi_ui.c | 6 + src/bloomShell/main.c | 257 +++++++++++++++++++--- test/shell/bloom_shell_settings.bats | 7 + test/test_bloom_shell_wifi_ui.cpp | 36 +++ 10 files changed, 304 insertions(+), 37 deletions(-) diff --git a/build/bloom-typography.json b/build/bloom-typography.json index 5711fd63c..2f53820f8 100644 --- a/build/bloom-typography.json +++ b/build/bloom-typography.json @@ -96,7 +96,7 @@ "unclassified_data": "unchanged", "fragment_kinds": ["copy", "data"], "maximum_fragments": 8, - "classified_copy_call_sites": 81, - "classified_mixed_call_sites": 13 + "classified_copy_call_sites": 82, + "classified_mixed_call_sites": 14 } } diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index cb9f53671..e3e4d1587 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -50,7 +50,7 @@ shoulder button. | Network services | Desired/runtime separation; carrier without IPv4 remains acquiring/not-ready; canonical DHCP restart and bounded post-address listener reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | | Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement and `bloomctl network profile upsert ID`; requested/document ID binding; explicit `network forget ID`; inactive-ID deletion with active-profile refusal; active forget only after a separately confirmed fallback connection; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; explicit local credential-free UI list with one active marker; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | | Wi-Fi scan API | Asynchronous start; redacted status; explicit ID-scoped local results; cancel and terminal discard; one active worker; five-second rate limit; 15-second runtime; 64-KiB raw ceiling; 32 unique printable SSIDs; eight-operation history; open/WPA2-PSK/unsupported classification; duplicate signal reconciliation; backend failure, timeout, oversize, unsafe-path, stale/live-lock, and unsupported-hardware handling | -| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; controller-owned start/status/results/discard and cancel-during-start sequencing; 250-ms status opportunities; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; terminal history discarded; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | +| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; selectable Scan/Cancel row and read-only discovered networks; active-only 100-ms SDL wake; deferred GameSwitcher until cancellation discard; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; controller-owned start/status/results/discard and cancel-during-start sequencing; 250-ms status opportunities; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; terminal history discarded; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f44512853..b685d1254 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -566,6 +566,14 @@ remembered and issued immediately afterward. Successful networks and terminal errors remain in bounded memory for presentation while the service-side operation ID is cleared, preventing scan-history accumulation. +Bloom Shell drives the controller from a 100-ms SDL wake only while work is +active. The Wi-Fi panel exposes one Scan/Cancel row and renders either saved +profiles or successful read-only scan results through shared list components. +B closes the panel only after initiating bounded cancellation; MENU defers the +GameSwitcher handoff until cancel, terminal status, and discard clear the exact +operation. No scan command is invoked from `draw()` and no result can initiate +association in this slice. + Profile association reuses the one guarded network-transaction authority rather than adding a second recovery mechanism. `begin` copies the exact current supplicant file into a mode-0700 JFFS2-private transaction root, records only diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d46021e9f..ecc09ad33 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -388,6 +388,12 @@ the next wake. Keep the controller alive until cancel/status/discard completes, including when cancel is requested while start is still returning its operation ID. +The SDL owner uses `WIFI_EVENT_TYPE` only while the controller is active. +Changing surfaces may hide the panel, but must not destroy an in-flight +controller: B requests cancellation in the background, and GameSwitcher waits +until the exact operation reaches discard. Scan result rows are deliberately +read-only until the separate guarded association and credential slices land. + The NET-04 scan slice is asynchronous and keeps result data only under `/tmp`: ```sh diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3f05433b6..84b8cc7cd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -149,4 +149,10 @@ operation ID: cancellation requested during start remains pending until the validated start response supplies the ID. This bounds scan history and prevents a UI close or rapid cancel from racing an unrelated service operation. +The rendered surface does not turn a discovered SSID into an action. Scan rows +are classified runtime data and remain read-only; only the Bloom-owned Scan row +accepts A. A GameSwitcher request during a scan is held until the controller has +confirmed and discarded cancellation, so a child UI cannot strand the Shell's +operation or inherit its command pipe. + Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 9f46fb423..56860ac87 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -883,12 +883,13 @@ "github_prs": [ 545, 546, - 547 + 547, + 548 ], "risk_tier": 1, "deployment_tier": "hot", "device_gate_required": true, - "last_audited_main": "765f2038b7d63e61d6db8bab1a70ce750a07d088", + "last_audited_main": "9b3a847d99daf8baf44d953670d0ff4a18562a60", "evidence": [ "issue #544 tracks NET-05 on current main after merged NET-04A-D and UI-05; the audit found that Bloom Shell still treats Wi-Fi as an inline toggle whose A and left/right handlers synchronously call bloom-network enable or disable instead of opening the bounded profile and scan UI", "the first host-safe slice adds an explicit local credential-free projection of at most 16 validated profile IDs, printable SSIDs, security classes, hidden flags, and one active marker; the private service never returns a PSK and the Shell model rejects unknown or credential-bearing fields", @@ -898,7 +899,9 @@ "GitHub PR #546 passed format in 14 seconds, the affected ARM build in 1 minute 17 seconds, native tests and the deterministic UI gallery in 1 minute 29 seconds, AddressSanitizer in 1 minute 56 seconds, static analysis in 2 minutes 17 seconds, and the 737-test shell gate in 2 minutes 19 seconds; it merged by squash as 7dee3fc27b5b0cd5b743af99f8e66b631c514c12", "the next host-only slice adds no runtime consumer or device action; it provides a nonblocking fixed-argv client for scan start, status, results, cancel, and discard with strict operation IDs, a 16-KiB output ceiling, a five-second command deadline, process-group kill and reap, no-follow executable checks, exact response parsing, and deterministic teardown so the later event-loop UI cannot improvise subprocess behavior", "GitHub PR #547 used risk-scoped CI: format passed in 23 seconds, 58 focused shell tests in 43 seconds, native tests in 1 minute 8 seconds, AddressSanitizer in 1 minute 12 seconds, the affected Bloom Shell ARM build in 1 minute 21 seconds, and scoped static analysis in 2 minutes 8 seconds; it merged by squash as 765f2038b7d63e61d6db8bab1a70ce750a07d088", - "the following host-only lifecycle controller sequences start, bounded status polling, results, and terminal discard through the single-flight request client; it preserves terminal results in memory while clearing the service operation, and a cancel received during start is deferred until the exact operation ID arrives before status confirmation and discard" + "the following host-only lifecycle controller sequences start, bounded status polling, results, and terminal discard through the single-flight request client; it preserves terminal results in memory while clearing the service operation, and a cancel received during start is deferred until the exact operation ID arrives before status confirmation and discard", + "GitHub PR #548 passed format in 18 seconds, 58 focused shell tests in 50 seconds, the affected Bloom Shell ARM build in 1 minute 11 seconds, native tests in 1 minute 20 seconds, AddressSanitizer in 1 minute 26 seconds, and scoped static analysis in 2 minutes 8 seconds; it merged by squash as 9b3a847d99daf8baf44d953670d0ff4a18562a60", + "the rendered scan slice integrates the controller through a 100-ms SDL wake without invoking commands from draw or blocking input, adds a selectable Scan action and read-only bounded results, cancels in the background when leaving the panel, and defers GameSwitcher until the exact scan operation is cancelled, confirmed terminal, and discarded; association, credential entry, forget, and radio power remain absent" ] }, { diff --git a/src/bloomShell/bloom_shell_wifi_ui.c b/src/bloomShell/bloom_shell_wifi_ui.c index d8a339c1a..e5ad4f5dd 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.c +++ b/src/bloomShell/bloom_shell_wifi_ui.c @@ -752,8 +752,14 @@ int bloom_shell_wifi_controller_cancel(BloomShellWifiController *controller, } if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING) return 0; + if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING) + return 0; if (wifi->scan_id[0] == '\0') return -1; + if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_FETCHING) { + bloom_shell_wifi_request_destroy(&controller->request); + return controller_discard(controller, bloomctl_path, wifi, now_ms); + } if (bloom_shell_wifi_request_active(&controller->request)) bloom_shell_wifi_request_destroy(&controller->request); controller->cancel_pending = 0; diff --git a/src/bloomShell/main.c b/src/bloomShell/main.c index d62ddd0ac..90d78158e 100644 --- a/src/bloomShell/main.c +++ b/src/bloomShell/main.c @@ -84,6 +84,7 @@ #define THEME_MEDIA_EVENT_TYPE (SDL_USEREVENT + 5) #define GAME_SWITCHER_WAKE_EVENT_TYPE (SDL_USEREVENT + 6) #define OPERATIONS_EVENT_TYPE (SDL_USEREVENT + 7) +#define WIFI_EVENT_TYPE (SDL_USEREVENT + 8) #define MIXED_LABEL_MAX_BYTES 640 static volatile sig_atomic_t game_switcher_requested = 0; @@ -170,6 +171,25 @@ static Uint32 operations_tick(Uint32 interval, void *context) return interval; } +static Uint32 wifi_tick(Uint32 interval, void *context) +{ + (void)context; + SDL_Event event; + memset(&event, 0, sizeof(event)); + event.type = WIFI_EVENT_TYPE; + SDL_PushEvent(&event); + return interval; +} + +static int wifi_timer_start(SDL_TimerID *timer) +{ + if (timer == NULL) + return -1; + if (*timer == NULL) + *timer = SDL_AddTimer(100, wifi_tick, NULL); + return *timer == NULL ? -1 : 0; +} + static sqlite3_int64 wall_time_seconds(void) { time_t now = time(NULL); @@ -1984,11 +2004,46 @@ static void draw_remote_keys(SDL_Surface *screen, const BloomUiLayout *layout, T layout->content.width - 40, result > 0 ? cream : sand); } +static size_t wifi_detail_item_count(const BloomShellWifiUi *wifi, + const BloomShellWifiController *controller) +{ + size_t items = controller->state == BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE + ? wifi->network_count + : wifi->profile_count; + return 1 + (items > 0 ? items : 1); +} + +static const char *wifi_scan_action_label(const BloomShellWifiController *controller) +{ + switch (controller->state) { + case BLOOM_SHELL_WIFI_CONTROLLER_STARTING: + return "Starting scan... A Cancel"; + case BLOOM_SHELL_WIFI_CONTROLLER_POLLING: + return "Scanning... A Cancel"; + case BLOOM_SHELL_WIFI_CONTROLLER_FETCHING: + return "Loading networks..."; + case BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING: + return "Cancelling scan..."; + case BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING: + return "Finishing scan..."; + case BLOOM_SHELL_WIFI_CONTROLLER_FAILED: + return "Scan failed A Retry"; + case BLOOM_SHELL_WIFI_CONTROLLER_CANCELLED: + return "Scan cancelled A Retry"; + case BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE: + return "Scan again"; + default: + return "Scan for networks"; + } +} + static void draw_wifi_details(SDL_Surface *screen, const BloomUiLayout *layout, TTF_Font *font, TTF_Font *compact_font, const BloomUiTheme *theme, const BloomShellQuickValues *quick_values, - const BloomShellWifiUi *wifi, const BloomUiFocus *focus) + const BloomShellWifiUi *wifi, + const BloomShellWifiController *controller, + const BloomUiFocus *focus) { SDL_Color cream = {243, 226, 189, 0}; SDL_Color sand = {214, 187, 137, 0}; @@ -2007,7 +2062,10 @@ static void draw_wifi_details(SDL_Surface *screen, const BloomUiLayout *layout, render_copy_label(screen, compact_font, connection, layout->content.x + 20, layout->content.y + layout->row_height, layout->content.width - 40, sand); - render_copy_label(screen, compact_font, "Saved networks", + render_copy_label(screen, compact_font, + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE + ? "Available networks" + : "Saved networks", layout->content.x + 20, layout->content.y + layout->row_height * 2, layout->content.width - 40, sand); @@ -2031,8 +2089,35 @@ static void draw_wifi_details(SDL_Surface *screen, const BloomUiLayout *layout, selected && (theme == NULL || theme->focus == BLOOM_UI_THEME_FOCUS_INVERT) ? canvas : cream; - if (wifi->ready && wifi->profile_count > 0 && item < wifi->profile_count) { - const BloomShellWifiProfile *profile = &wifi->profiles[item]; + if (item == 0) + render_copy_label(screen, compact_font, + wifi_scan_action_label(controller), slots.content.x, + y + layout->row_height / 3, slots.content.width, + row_color); + else if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE && + wifi->network_count > 0 && item - 1 < wifi->network_count) { + const BloomShellWifiNetwork *network = &wifi->networks[item - 1]; + char signal[24]; + snprintf(signal, sizeof(signal), " %d dBm", network->signal_dbm); + const char *security = + network->security == BLOOM_SHELL_WIFI_SECURITY_OPEN + ? " Open" + : network->security == BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK + ? " WPA2" + : " Unsupported"; + const BloomUiTextFragment fragments[] = { + {BLOOM_UI_TEXT_FRAGMENT_DATA, network->ssid}, + {BLOOM_UI_TEXT_FRAGMENT_COPY, security}, + {BLOOM_UI_TEXT_FRAGMENT_DATA, signal}, + }; + render_fragment_label(screen, compact_font, fragments, 3, + slots.content.x, y + layout->row_height / 3, + slots.content.width, row_color); + } + else if (controller->state != BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE && + wifi->ready && wifi->profile_count > 0 && + item - 1 < wifi->profile_count) { + const BloomShellWifiProfile *profile = &wifi->profiles[item - 1]; const BloomUiTextFragment fragments[] = { {BLOOM_UI_TEXT_FRAGMENT_DATA, profile->ssid}, {BLOOM_UI_TEXT_FRAGMENT_COPY, profile->active ? " Connected" : " Saved"}, @@ -2043,7 +2128,10 @@ static void draw_wifi_details(SDL_Surface *screen, const BloomUiLayout *layout, } else render_copy_label(screen, compact_font, - wifi->ready ? "No saved networks" : "Profile list unavailable", + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE + ? "No networks found" + : wifi->ready ? "No saved networks" + : "Profile list unavailable", slots.content.x, y + layout->row_height / 3, slots.content.width, row_color); } @@ -2645,7 +2733,8 @@ static void draw(SDL_Surface *screen, SDL_Surface *video, const BloomUiLayout *l const BloomShellCapabilities *capabilities, const BloomShellQuickValues *quick_values, const BloomShellNetworkCredential *network_credential, - const BloomShellWifiUi *wifi, int wifi_open, + const BloomShellWifiUi *wifi, + const BloomShellWifiController *wifi_controller, int wifi_open, const BloomUiFocus *wifi_focus, const BloomShellRemoteKeys *remote_keys, int remote_keys_open, const BloomUiFocus *remote_keys_focus, int remote_keys_result, @@ -3026,7 +3115,11 @@ static void draw(SDL_Surface *screen, SDL_Surface *video, const BloomUiLayout *l const char *footer = quick_settings ? "Left/Right Change A Toggle/Open B/START Close" : wifi_open - ? "Up/Down Browse L/R Page B Settings" + ? bloom_shell_wifi_controller_active(wifi_controller) + ? "A Cancel B Settings START Quick" + : wifi_focus->selected == 0 + ? "A Scan B Settings START Quick" + : "Up/Down Browse L/R Page B Settings" : remote_keys_open ? remote_keys_focus->selected < remote_keys->key_count ? "X Remove L/R Page B Settings" @@ -3060,8 +3153,8 @@ static void draw(SDL_Surface *screen, SDL_Surface *video, const BloomUiLayout *l draw_remote_keys(screen, layout, font, compact_font, remote_keys, remote_keys_focus, remote_keys_result); if (wifi_open && !quick_settings) - draw_wifi_details(screen, layout, font, compact_font, theme, quick_values, wifi, - wifi_focus); + draw_wifi_details(screen, layout, font, compact_font, theme, quick_values, + wifi, wifi_controller, wifi_focus); if (ra_form_open) draw_ra_form(screen, layout, font, ra_form); if (search->open) @@ -3399,14 +3492,15 @@ int main(int argc, char **argv) BloomShellNetworkCredential network_credential = {0}; BloomShellWifiUi wifi_ui; bloom_shell_wifi_ui_init(&wifi_ui); + BloomShellWifiController wifi_controller; + bloom_shell_wifi_controller_init(&wifi_controller); + int wifi_game_switcher_pending = 0; int wifi_open = wifi_requested && !safe_mode && capabilities.wifi; if (wifi_open) bloom_shell_wifi_profiles_load(BLOOMCTL_BINARY, &wifi_ui); BloomUiFocus wifi_focus; bloom_ui_focus_init(&wifi_focus, - wifi_ui.ready && wifi_ui.profile_count > 0 - ? wifi_ui.profile_count - : 1); + wifi_detail_item_count(&wifi_ui, &wifi_controller)); int remote_keys_open = remote_keys_requested && !safe_mode && capabilities.wifi; int remote_keys_result = 0; BloomUiFocus remote_keys_focus; @@ -3450,6 +3544,7 @@ int main(int argc, char **argv) bloom_shell_library_menu_init(&library_menu); SDL_TimerID refresh_timer = NULL; SDL_TimerID operations_timer = NULL; + SDL_TimerID wifi_timer = NULL; SDL_TimerID game_switcher_wake_timer = SDL_AddTimer(25, game_switcher_wake_tick, NULL); BloomShellFrontendState refresh_restore_state; bloom_shell_state_defaults(&refresh_restore_state); @@ -3481,7 +3576,8 @@ int main(int argc, char **argv) draw(screen, video, &layout, &immutable_layout, &theme, font, compact_font, immutable_font, immutable_compact_font, destination, &root, &games_browser, &favorites_focus, &recent_focus, &all_games_focus, &collections, games, &achievement_index, favorites, recents, has_recent, &search, browse, game_list_view, &library_menu, &filter_picker, &details, jump_open, jump_letter, &settings_focus, &apps_focus, apps, &status, - &capabilities, &quick_values, &network_credential, &wifi_ui, wifi_open, &wifi_focus, + &capabilities, &quick_values, &network_credential, &wifi_ui, &wifi_controller, + wifi_open, &wifi_focus, &remote_keys, remote_keys_open, &remote_keys_focus, remote_keys_result, remote_key_remove_confirm_open, &remote_key_remove_dialog, &ra_values, support_export_result, @@ -3504,6 +3600,20 @@ int main(int argc, char **argv) while (running) { if (game_switcher_requested) { game_switcher_requested = 0; + if (bloom_shell_wifi_controller_active(&wifi_controller) || + wifi_ui.scan_id[0] != '\0') { + wifi_game_switcher_pending = 1; + wifi_open = 0; + quick_settings = 0; + if (bloom_shell_wifi_controller_cancel( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()) != 0 || + wifi_timer_start(&wifi_timer) != 0) { + wifi_game_switcher_pending = 0; + wifi_open = 1; + } + continue; + } int child_result = run_game_switcher_child(destination == BLOOM_UI_DESTINATION_ROOT); if (child_result > 0) { @@ -3570,6 +3680,37 @@ int main(int argc, char **argv) &library_menu.operations, DATABASE_PATH, ROM_ROOT, wall_time_seconds()); } + else if (event.type == WIFI_EVENT_TYPE) { + bloom_shell_wifi_controller_poll(&wifi_controller, BLOOMCTL_BINARY, + &wifi_ui, SDL_GetTicks()); + bloom_ui_focus_set_count( + &wifi_focus, + wifi_detail_item_count(&wifi_ui, &wifi_controller), + layout.visible_rows > 3 ? layout.visible_rows - 3 : 1); + if (!bloom_shell_wifi_controller_active(&wifi_controller)) { + if (wifi_timer != NULL) { + SDL_RemoveTimer(wifi_timer); + wifi_timer = NULL; + } + if (wifi_game_switcher_pending) { + wifi_game_switcher_pending = 0; + if (wifi_ui.scan_id[0] == '\0') { + if (run_game_switcher_child( + destination == BLOOM_UI_DESTINATION_ROOT) > 0) { + exit_code = LAUNCH_READY_EXIT; + running = 0; + } + } + else { + wifi_open = 1; + focus_select(&wifi_focus, 0, + layout.visible_rows > 3 + ? layout.visible_rows - 3 + : 1); + } + } + } + } else if (event.type == GAME_SWITCHER_WAKE_EVENT_TYPE) continue; else if (event.type == SDL_KEYDOWN) @@ -3637,7 +3778,19 @@ int main(int argc, char **argv) continue; else if (quick_settings && action == BLOOM_UI_ACTION_GAME_SWITCHER) { quick_settings = 0; - if (run_game_switcher_child(destination == BLOOM_UI_DESTINATION_ROOT) > 0) { + if (bloom_shell_wifi_controller_active(&wifi_controller) || + wifi_ui.scan_id[0] != '\0') { + wifi_game_switcher_pending = 1; + if (bloom_shell_wifi_controller_cancel( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()) != 0 || + wifi_timer_start(&wifi_timer) != 0) { + wifi_game_switcher_pending = 0; + wifi_open = 1; + } + } + else if (run_game_switcher_child( + destination == BLOOM_UI_DESTINATION_ROOT) > 0) { exit_code = LAUNCH_READY_EXIT; running = 0; } @@ -3673,10 +3826,9 @@ int main(int argc, char **argv) } } bloom_shell_wifi_profiles_load(BLOOMCTL_BINARY, &wifi_ui); - bloom_ui_focus_init(&wifi_focus, - wifi_ui.ready && wifi_ui.profile_count > 0 - ? wifi_ui.profile_count - : 1); + bloom_ui_focus_init( + &wifi_focus, + wifi_detail_item_count(&wifi_ui, &wifi_controller)); wifi_open = 1; } } @@ -3684,10 +3836,24 @@ int main(int argc, char **argv) else if (quick_settings) continue; else if (wifi_open && action == BLOOM_UI_ACTION_GAME_SWITCHER) { - wifi_open = 0; - if (run_game_switcher_child(destination == BLOOM_UI_DESTINATION_ROOT) > 0) { - exit_code = LAUNCH_READY_EXIT; - running = 0; + if (bloom_shell_wifi_controller_active(&wifi_controller) || + wifi_ui.scan_id[0] != '\0') { + wifi_game_switcher_pending = 1; + if (bloom_shell_wifi_controller_cancel( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()) == 0 && + wifi_timer_start(&wifi_timer) == 0) + wifi_open = 0; + else + wifi_game_switcher_pending = 0; + } + else { + wifi_open = 0; + if (run_game_switcher_child( + destination == BLOOM_UI_DESTINATION_ROOT) > 0) { + exit_code = LAUNCH_READY_EXIT; + running = 0; + } } } else if (wifi_open && action == BLOOM_UI_ACTION_QUICK_SETTINGS) @@ -3704,8 +3870,35 @@ int main(int argc, char **argv) bloom_ui_focus_page(&wifi_focus, action == BLOOM_UI_ACTION_PAGE_BACK ? -1 : 1, layout.visible_rows > 3 ? layout.visible_rows - 3 : 1); - else if (wifi_open && action == BLOOM_UI_ACTION_BACK) - wifi_open = 0; + else if (wifi_open && action == BLOOM_UI_ACTION_CONFIRM && + wifi_focus.selected == 0) { + int result = bloom_shell_wifi_controller_active(&wifi_controller) || + wifi_ui.scan_id[0] != '\0' + ? bloom_shell_wifi_controller_cancel( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()) + : bloom_shell_wifi_controller_begin( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()); + if (result == 0 && wifi_timer_start(&wifi_timer) != 0) + bloom_shell_wifi_controller_destroy(&wifi_controller); + bloom_ui_focus_set_count( + &wifi_focus, + wifi_detail_item_count(&wifi_ui, &wifi_controller), + layout.visible_rows > 3 ? layout.visible_rows - 3 : 1); + } + else if (wifi_open && action == BLOOM_UI_ACTION_BACK) { + if (bloom_shell_wifi_controller_active(&wifi_controller) || + wifi_ui.scan_id[0] != '\0') { + if (bloom_shell_wifi_controller_cancel( + &wifi_controller, BLOOMCTL_BINARY, &wifi_ui, + SDL_GetTicks()) == 0 && + wifi_timer_start(&wifi_timer) == 0) + wifi_open = 0; + } + else + wifi_open = 0; + } else if (wifi_open) continue; else if (remote_key_remove_confirm_open && @@ -4852,10 +5045,9 @@ int main(int argc, char **argv) destination == BLOOM_UI_DESTINATION_SETTINGS && selected_settings_valid && selected_settings.id == BLOOM_SHELL_SETTINGS_WIFI) { bloom_shell_wifi_profiles_load(BLOOMCTL_BINARY, &wifi_ui); - bloom_ui_focus_init(&wifi_focus, - wifi_ui.ready && wifi_ui.profile_count > 0 - ? wifi_ui.profile_count - : 1); + bloom_ui_focus_init( + &wifi_focus, + wifi_detail_item_count(&wifi_ui, &wifi_controller)); wifi_open = 1; } else if (action == BLOOM_UI_ACTION_CONFIRM && @@ -5001,7 +5193,7 @@ int main(int argc, char **argv) immutable_font, immutable_compact_font, destination, &root, &games_browser, &favorites_focus, &recent_focus, &all_games_focus, &collections, games, &achievement_index, favorites, recents, has_recent, &search, browse, game_list_view, &library_menu, &filter_picker, &details, jump_open, jump_letter, &settings_focus, &apps_focus, apps, &status, &capabilities, &quick_values, &network_credential, - &wifi_ui, wifi_open, &wifi_focus, &remote_keys, + &wifi_ui, &wifi_controller, wifi_open, &wifi_focus, &remote_keys, remote_keys_open, &remote_keys_focus, remote_keys_result, remote_key_remove_confirm_open, &remote_key_remove_dialog, &ra_values, support_export_result, @@ -5028,7 +5220,7 @@ int main(int argc, char **argv) &games_browser, &favorites_focus, &recent_focus, &all_games_focus, &collections, games, &achievement_index, favorites, recents, has_recent, &search, browse, game_list_view, &library_menu, &filter_picker, &details, jump_open, jump_letter, &settings_focus, &apps_focus, apps, &status, &capabilities, &quick_values, &network_credential, - &wifi_ui, wifi_open, &wifi_focus, &remote_keys, + &wifi_ui, &wifi_controller, wifi_open, &wifi_focus, &remote_keys, remote_keys_open, &remote_keys_focus, remote_keys_result, remote_key_remove_confirm_open, &remote_key_remove_dialog, &ra_values, support_export_result, @@ -5053,7 +5245,7 @@ int main(int argc, char **argv) &games_browser, &favorites_focus, &recent_focus, &all_games_focus, &collections, games, &achievement_index, favorites, recents, has_recent, &search, browse, game_list_view, &library_menu, &filter_picker, &details, jump_open, jump_letter, &settings_focus, &apps_focus, apps, &status, &capabilities, &quick_values, &network_credential, - &wifi_ui, wifi_open, &wifi_focus, &remote_keys, + &wifi_ui, &wifi_controller, wifi_open, &wifi_focus, &remote_keys, remote_keys_open, &remote_keys_focus, remote_keys_result, remote_key_remove_confirm_open, &remote_key_remove_dialog, &ra_values, support_export_result, @@ -5082,9 +5274,12 @@ int main(int argc, char **argv) SDL_RemoveTimer(refresh_timer); if (operations_timer != NULL) SDL_RemoveTimer(operations_timer); + if (wifi_timer != NULL) + SDL_RemoveTimer(wifi_timer); if (game_switcher_wake_timer != NULL) SDL_RemoveTimer(game_switcher_wake_timer); bloom_shell_library_refresh_destroy(&library_menu.refresh); + bloom_shell_wifi_controller_destroy(&wifi_controller); if (media_ready) bloom_shell_media_runtime_destroy(&media_runtime); if (theme_media_ready) diff --git a/test/shell/bloom_shell_settings.bats b/test/shell/bloom_shell_settings.bats index 36fa9016b..1fa901ea6 100644 --- a/test/shell/bloom_shell_settings.bats +++ b/test/shell/bloom_shell_settings.bats @@ -92,6 +92,13 @@ grep -F 'wifi_open && action == BLOOM_UI_ACTION_BACK' "$shell" grep -F 'draw_wifi_details(' "$shell" grep -F 'execl(bloomctl_path, bloomctl_path, "network", "profile", "list"' "$wifi" + grep -F 'bloom_shell_wifi_controller_begin(' "$shell" + grep -F 'bloom_shell_wifi_controller_cancel(' "$shell" + grep -F 'bloom_shell_wifi_controller_poll(' "$shell" + grep -F 'wifi_game_switcher_pending = 1;' "$shell" + grep -F 'WIFI_EVENT_TYPE' "$shell" + grep -F 'wifi_focus.selected == 0' "$shell" + grep -F 'BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE' "$shell" grep -F 'if (quick_capabilities.wifi && row == 2)' "$switcher" grep -F 'appState.exit_to_wifi = true;' "$switcher" ! grep -F 'run_network_request' "$model" diff --git a/test/test_bloom_shell_wifi_ui.cpp b/test/test_bloom_shell_wifi_ui.cpp index b147d7fd2..b86cfd33d 100644 --- a/test/test_bloom_shell_wifi_ui.cpp +++ b/test/test_bloom_shell_wifi_ui.cpp @@ -380,3 +380,39 @@ TEST(BloomShellWifiUi, ControllerHonorsCancelWhileStartIsInFlight) bloom_shell_wifi_controller_destroy(&controller); std::filesystem::remove_all(directory); } + +TEST(BloomShellWifiUi, ControllerClosesResultFetchThroughDiscardNotCancel) +{ + auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-fetch-close-" + std::to_string(getpid())); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + auto script = directory / "bloomctl"; + { + std::ofstream output(script); + output << "#!/bin/sh\ncase \"$*\" in\n" + " 'network scan results 1234567890-abcdef0123456789') sleep 10 ;;\n" + " 'network scan discard 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"discard\",\"discarded\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " *) exit 2 ;;\n" + "esac\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + BloomShellWifiUi wifi{}; + snprintf(wifi.scan_id, sizeof(wifi.scan_id), "%s", + "1234567890-abcdef0123456789"); + wifi.scan_phase = BLOOM_SHELL_WIFI_SCAN_SUCCEEDED; + BloomShellWifiController controller; + bloom_shell_wifi_controller_init(&controller); + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &controller.request, script.c_str(), + BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS, wifi.scan_id, 3000)); + controller.state = BLOOM_SHELL_WIFI_CONTROLLER_FETCHING; + ASSERT_EQ(0, bloom_shell_wifi_controller_cancel( + &controller, script.c_str(), &wifi, 3001)); + EXPECT_EQ(BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING, controller.state); + ASSERT_EQ(1, drive_wifi_controller(&controller, script, &wifi, 3001)); + EXPECT_EQ(BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE, controller.state); + EXPECT_STREQ("", wifi.scan_id); + bloom_shell_wifi_controller_destroy(&controller); + std::filesystem::remove_all(directory); +} From 3f3f87fe28cdcc4b957253a550fb32cf5d1db3a8 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 01:02:01 -0400 Subject: [PATCH 2/2] docs(program): record NET-05 scan UI PR --- docs/program/BloomOS_Work_Program_v3.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 56860ac87..4a0995372 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -884,7 +884,8 @@ 545, 546, 547, - 548 + 548, + 549 ], "risk_tier": 1, "deployment_tier": "hot", @@ -901,7 +902,7 @@ "GitHub PR #547 used risk-scoped CI: format passed in 23 seconds, 58 focused shell tests in 43 seconds, native tests in 1 minute 8 seconds, AddressSanitizer in 1 minute 12 seconds, the affected Bloom Shell ARM build in 1 minute 21 seconds, and scoped static analysis in 2 minutes 8 seconds; it merged by squash as 765f2038b7d63e61d6db8bab1a70ce750a07d088", "the following host-only lifecycle controller sequences start, bounded status polling, results, and terminal discard through the single-flight request client; it preserves terminal results in memory while clearing the service operation, and a cancel received during start is deferred until the exact operation ID arrives before status confirmation and discard", "GitHub PR #548 passed format in 18 seconds, 58 focused shell tests in 50 seconds, the affected Bloom Shell ARM build in 1 minute 11 seconds, native tests in 1 minute 20 seconds, AddressSanitizer in 1 minute 26 seconds, and scoped static analysis in 2 minutes 8 seconds; it merged by squash as 9b3a847d99daf8baf44d953670d0ff4a18562a60", - "the rendered scan slice integrates the controller through a 100-ms SDL wake without invoking commands from draw or blocking input, adds a selectable Scan action and read-only bounded results, cancels in the background when leaving the panel, and defers GameSwitcher until the exact scan operation is cancelled, confirmed terminal, and discarded; association, credential entry, forget, and radio power remain absent" + "GitHub PR #549 renders the bounded scan controller through a 100-ms SDL wake without invoking commands from draw or blocking input, adds a selectable Scan action and read-only bounded results, cancels in the background when leaving the panel, and defers GameSwitcher until the exact scan operation is cancelled, confirmed terminal, and discarded; association, credential entry, forget, and radio power remain absent" ] }, {