diff --git a/resources/images/icons/Download.svg b/resources/images/icons/Download.svg new file mode 100644 index 00000000..7def8c02 --- /dev/null +++ b/resources/images/icons/Download.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/rust/launcher/build.rs b/rust/launcher/build.rs index caf650d2..f4d6dd9f 100644 --- a/rust/launcher/build.rs +++ b/rust/launcher/build.rs @@ -45,6 +45,7 @@ fn main() { "src/models/runtime.rs", "src/models/settings.rs", "src/models/system_status.rs", + "src/models/update_all_runner.rs", ]); // SAFETY: cc_builder is unsafe in 0.8 because cxx-qt makes no stability diff --git a/rust/launcher/src/models/mod.rs b/rust/launcher/src/models/mod.rs index a05921ac..bcaa6ad7 100644 --- a/rust/launcher/src/models/mod.rs +++ b/rust/launcher/src/models/mod.rs @@ -44,6 +44,7 @@ pub mod settings; pub mod system_status; pub mod systems; pub mod systems_state; +pub mod update_all_runner; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; diff --git a/rust/launcher/src/models/update_all_runner.rs b/rust/launcher/src/models/update_all_runner.rs new file mode 100644 index 00000000..77eca69d --- /dev/null +++ b/rust/launcher/src/models/update_all_runner.rs @@ -0,0 +1,421 @@ +// Zaparoo Launcher +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 +// +// `Browse.UpdateAllRunner` — prototype PTY bridge for MiSTer's +// update_all script. QML owns the modal and forwards D-pad/keyboard +// input; this singleton owns the child process, terminal output, and +// final status. + +use cxx_qt::{CxxQtType, Threading}; +use cxx_qt_lib::QString; +use std::ffi::CString; +use std::path::Path; +use std::pin::Pin; +use std::thread; +use tracing::{error, info, warn}; + +const SCRIPT_PATH: &str = "/media/fat/Scripts/update_all.sh"; +const STATE_IDLE: i32 = 0; +const STATE_RUNNING: i32 = 1; +const STATE_SUCCESS: i32 = 2; +const STATE_ERROR: i32 = 3; +const OUTPUT_CAP_BYTES: usize = 32 * 1024; + +pub struct UpdateAllRunnerRust { + state: i32, + output_text: QString, + error_message: QString, + exit_code: i32, + input_fd: i32, + child_pid: i32, +} + +impl Default for UpdateAllRunnerRust { + fn default() -> Self { + Self { + state: STATE_IDLE, + output_text: QString::default(), + error_message: QString::default(), + exit_code: -1, + input_fd: -1, + child_pid: -1, + } + } +} + +#[cxx_qt::bridge] +pub mod ffi { + unsafe extern "C++" { + include!("model_includes.h"); + + type QString = cxx_qt_lib::QString; + } + + unsafe extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + #[qproperty(i32, state)] + #[qproperty(QString, output_text)] + #[qproperty(QString, error_message)] + #[qproperty(i32, exit_code)] + type UpdateAllRunner = super::UpdateAllRunnerRust; + + #[qinvokable] + fn run(self: Pin<&mut UpdateAllRunner>); + + #[qinvokable] + fn reset(self: Pin<&mut UpdateAllRunner>); + + #[qinvokable] + fn send_input(self: Pin<&mut UpdateAllRunner>, input: QString); + } + + impl cxx_qt::Threading for UpdateAllRunner {} +} + +impl ffi::UpdateAllRunner { + fn run(mut self: Pin<&mut Self>) { + if self.state == STATE_RUNNING { + return; + } + self.as_mut().set_state(STATE_RUNNING); + self.as_mut().set_output_text(QString::from(format!("$ {SCRIPT_PATH}\n").as_str())); + self.as_mut().set_error_message(QString::default()); + self.as_mut().set_exit_code(-1); + self.as_mut().rust_mut().input_fd = -1; + self.as_mut().rust_mut().child_pid = -1; + + let qt_thread = self.qt_thread(); + match spawn_update_all_pty() { + Ok(child) => { + self.as_mut().rust_mut().input_fd = child.input_fd; + self.as_mut().rust_mut().child_pid = child.pid; + if let Err(e) = thread::Builder::new() + .name("zaparoo-update-all-pty".into()) + .spawn(move || { + let mut terminal = TerminalBuffer::new(format!("$ {SCRIPT_PATH}\n")); + let mut buf = [0_u8; 4096]; + loop { + // SAFETY: `child.master_fd` is owned by + // this thread until the loop exits. `buf` + // is valid writable memory for its length. + let n = unsafe { + libc::read( + child.master_fd, + buf.as_mut_ptr().cast(), + buf.len(), + ) + }; + if n <= 0 { + break; + } + terminal.push_bytes(&buf[..n as usize]); + let snapshot = terminal.snapshot(); + let _ = qt_thread.queue(move |mut model| { + model.as_mut().set_output_text(QString::from(snapshot.as_str())); + }); + } + + close_fd(child.master_fd); + let code = wait_for_child(child.pid); + let final_output = terminal.snapshot(); + let _ = qt_thread.queue(move |mut model| { + close_fd(model.as_mut().rust_mut().input_fd); + model.as_mut().rust_mut().input_fd = -1; + model.as_mut().rust_mut().child_pid = -1; + model + .as_mut() + .set_output_text(QString::from(final_output.as_str())); + model.as_mut().set_exit_code(code); + if code == 0 { + info!("update_all exited successfully"); + model.as_mut().set_state(STATE_SUCCESS); + } else { + warn!("update_all exited with code {code}"); + model.as_mut().set_error_message(QString::from( + format!("Exited with code {code}.").as_str(), + )); + model.as_mut().set_state(STATE_ERROR); + } + }); + }) + { + error!("failed to spawn update_all reader thread: {e}"); + close_fd(self.as_mut().rust_mut().input_fd); + self.as_mut().rust_mut().input_fd = -1; + self.as_mut() + .set_error_message(QString::from("Could not start reader thread.")); + self.as_mut().set_state(STATE_ERROR); + } + } + Err(e) => { + warn!("failed to start update_all: {e}"); + self.as_mut() + .set_error_message(QString::from(e.as_str())); + self.as_mut().set_state(STATE_ERROR); + } + } + } + + fn reset(mut self: Pin<&mut Self>) { + if self.state == STATE_RUNNING { + return; + } + self.as_mut().set_state(STATE_IDLE); + self.as_mut().set_output_text(QString::default()); + self.as_mut().set_error_message(QString::default()); + self.as_mut().set_exit_code(-1); + self.as_mut().rust_mut().input_fd = -1; + self.as_mut().rust_mut().child_pid = -1; + } + + fn send_input(self: Pin<&mut Self>, input: QString) { + if self.state != STATE_RUNNING { + return; + } + let fd = self.input_fd; + if fd < 0 { + return; + } + let bytes = input.to_string().into_bytes(); + if bytes.is_empty() { + return; + } + // SAFETY: `fd` is a live PTY master duplicate while state is + // RUNNING. `bytes.as_ptr()` is valid for `bytes.len()` during + // this call. Short writes are acceptable for tiny input tokens + // (arrow escapes / enter / escape) and the next user press can + // retry if the child is already gone. + let written = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) }; + if written < 0 { + warn!("failed to write input to update_all PTY"); + } + } +} + +struct PtyChild { + pid: i32, + master_fd: i32, + input_fd: i32, +} + +fn spawn_update_all_pty() -> Result { + if !Path::new(SCRIPT_PATH).exists() { + return Err(format!("Script not found: {SCRIPT_PATH}")); + } + + let mut master_fd: libc::c_int = -1; + // SAFETY: `forkpty` initializes `master_fd` in the parent and + // returns twice. Null termios/winsize asks the platform defaults. + // Child immediately replaces its image with `/bin/sh`. + let pid = unsafe { + libc::forkpty( + &mut master_fd, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }; + if pid < 0 { + return Err("forkpty failed.".into()); + } + if pid == 0 { + exec_update_all_child(); + } + + // SAFETY: `master_fd` is a valid fd in the parent after successful + // `forkpty`. A duplicate lets QML write input while the reader + // thread blocks on the original fd. + let input_fd = unsafe { libc::dup(master_fd) }; + if input_fd < 0 { + close_fd(master_fd); + return Err("dup failed for update_all PTY.".into()); + } + + Ok(PtyChild { + pid, + master_fd, + input_fd, + }) +} + +fn exec_update_all_child() -> ! { + if let Some(parent) = Path::new(SCRIPT_PATH).parent() { + if let Some(parent_str) = parent.to_str() { + if let Ok(cwd) = CString::new(parent_str) { + // SAFETY: `cwd` is a NUL-free C string created above. + unsafe { + libc::chdir(cwd.as_ptr()); + } + } + } + } + + let script = c"/media/fat/Scripts/update_all.sh"; + let bash = c"/bin/bash"; + let bash_arg0 = c"bash"; + let sh = c"/bin/sh"; + let sh_arg0 = c"sh"; + // SAFETY: all argv pointers are valid NUL-terminated strings. Each + // call either replaces the child process or returns with errno set. + // Try direct exec first so the script's shebang wins, then fall + // back through common shells for non-executable script files. + unsafe { + libc::execl( + script.as_ptr(), + script.as_ptr(), + std::ptr::null::(), + ); + libc::execl( + bash.as_ptr(), + bash_arg0.as_ptr(), + script.as_ptr(), + std::ptr::null::(), + ); + libc::execl( + sh.as_ptr(), + sh_arg0.as_ptr(), + script.as_ptr(), + std::ptr::null::(), + ); + libc::_exit(127); + } +} + +fn wait_for_child(pid: i32) -> i32 { + let mut status: libc::c_int = 0; + // SAFETY: `pid` is returned by `forkpty`; `status` points to valid + // writable memory for wait status. + let waited = unsafe { libc::waitpid(pid, &mut status, 0) }; + if waited < 0 { + return -1; + } + if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + -libc::WTERMSIG(status) + } else { + -1 + } +} + +fn close_fd(fd: i32) { + if fd < 0 { + return; + } + // SAFETY: closing an fd is safe here; callers only pass fds owned + // by the runner. Errors are not actionable during teardown. + unsafe { + libc::close(fd); + } +} + +#[derive(Debug)] +struct TerminalBuffer { + text: String, + esc_state: EscapeState, + csi: String, + pending_cr: bool, +} + +#[derive(Debug, Default)] +enum EscapeState { + #[default] + None, + Escape, + Csi, +} + +impl TerminalBuffer { + fn new(seed: String) -> Self { + Self { + text: seed, + esc_state: EscapeState::None, + csi: String::new(), + pending_cr: false, + } + } + + fn snapshot(&self) -> String { + self.text.clone() + } + + fn push_bytes(&mut self, bytes: &[u8]) { + let chunk = String::from_utf8_lossy(bytes); + for ch in chunk.chars() { + self.push_char(ch); + } + self.cap(); + } + + fn push_char(&mut self, ch: char) { + match self.esc_state { + EscapeState::None => self.push_plain(ch), + EscapeState::Escape => { + if ch == '[' { + self.csi.clear(); + self.esc_state = EscapeState::Csi; + } else { + self.esc_state = EscapeState::None; + } + } + EscapeState::Csi => { + self.csi.push(ch); + if ('@'..='~').contains(&ch) { + self.apply_csi(); + self.esc_state = EscapeState::None; + self.csi.clear(); + } + } + } + } + + fn push_plain(&mut self, ch: char) { + if self.pending_cr { + self.pending_cr = false; + if ch == '\n' { + self.text.push('\n'); + return; + } + self.truncate_current_line(); + } + + match ch { + '\x1b' => self.esc_state = EscapeState::Escape, + '\r' => self.pending_cr = true, + '\n' => self.text.push('\n'), + '\t' => self.text.push_str(" "), + ch if ch >= ' ' => self.text.push(ch), + _ => {} + } + } + + fn apply_csi(&mut self) { + if self.csi.ends_with('J') || self.csi.ends_with('H') || self.csi.ends_with('f') { + self.text.clear(); + } else if self.csi.ends_with('K') { + self.truncate_current_line(); + } + } + + fn truncate_current_line(&mut self) { + if let Some(pos) = self.text.rfind('\n') { + self.text.truncate(pos + 1); + } else { + self.text.clear(); + } + } + + fn cap(&mut self) { + if self.text.len() <= OUTPUT_CAP_BYTES { + return; + } + let keep_from = self.text.len() - OUTPUT_CAP_BYTES; + let keep_from = self.text[keep_from..] + .find('\n') + .map_or(keep_from, |offset| keep_from + offset + 1); + self.text = self.text[keep_from..].to_string(); + } +} diff --git a/src/ui/app/Main.qml b/src/ui/app/Main.qml index f203370c..c4bff182 100644 --- a/src/ui/app/Main.qml +++ b/src/ui/app/Main.qml @@ -33,6 +33,7 @@ MainLayout { readonly property string modalQrCode: "qr_code" readonly property string modalFirstRunIndex: "first_run_index" readonly property string modalLogUpload: "log_upload" + readonly property string modalUpdateAll: "update_all" // One-shot session flag: the first-run modal is shown at most // once per launcher process, even if the WS link drops and the // mediadb-empty condition would otherwise be satisfied again. @@ -482,6 +483,7 @@ MainLayout { function onRequestFavoritesScreen(): void { root._navigateToFavorites() } function onRequestRecentsScreen(): void { root._navigateToRecents() } function onRequestSettingsScreen(): void { root._navigateToSettings() } + function onRequestUpdateAll(): void { root.openUpdateAllModal() } } Connections { target: root.favoritesScreen @@ -772,6 +774,21 @@ MainLayout { onCloseLogUploadRequested: root.closeLogUploadModal() + function openUpdateAllModal(): void { + Browse.UpdateAllRunner.reset() + root.updateAllModalVisible = true + if (ScreenManager.topModal !== root.modalUpdateAll) + ScreenManager.pushModal(root.modalUpdateAll) + } + + function closeUpdateAllModal(): void { + root.updateAllModalVisible = false + if (ScreenManager.topModal === root.modalUpdateAll) + ScreenManager.popModal() + } + + onCloseUpdateAllRequested: root.closeUpdateAllModal() + Connections { target: Browse.AppStatus function onConnection_stateChanged(): void { @@ -886,6 +903,8 @@ MainLayout { root.firstRunIndexModal.handleAction(action) } else if (ScreenManager.topModal === root.modalLogUpload) { root.logUploadModal.handleAction(action) + } else if (ScreenManager.topModal === root.modalUpdateAll) { + root.updateAllModal.handleAction(action) } // While a modal owns input, swallow everything not handled // above rather than leak it to the root screen. @@ -1021,6 +1040,16 @@ MainLayout { Keys.onPressed: event => { if (event.isAutoRepeat) return + if (ScreenManager.topModal === root.modalUpdateAll) { + const action = Browse.Input.action_for_key(event.key) + if (action !== "") { + root.handleAction(action) + root._armRepeat(action, event.key) + } else { + root.updateAllModal.handleText(event.text) + } + return + } root.handleKey(event.key) } Keys.onReleased: event => { diff --git a/src/ui/app/MainLayout.qml b/src/ui/app/MainLayout.qml index 8ba086e0..6266d8df 100644 --- a/src/ui/app/MainLayout.qml +++ b/src/ui/app/MainLayout.qml @@ -68,12 +68,14 @@ ApplicationWindow { property alias contextMenu: contextMenu property alias firstRunIndexModal: firstRunIndexModal property alias logUploadModal: logUploadModal + property alias updateAllModal: updateAllModal property bool cardWriteModalVisible: false property bool cardWriteFailed: false property bool qrCodeModalVisible: false property bool firstRunIndexModalVisible: false property bool logUploadModalVisible: false + property bool updateAllModalVisible: false property bool contextMenuVisible: false property rect contextMenuAnchor: Qt.rect(0, 0, 0, 0) // Owner-aware. Written by Main.qml at openContextMenu time; each entry @@ -140,6 +142,7 @@ ApplicationWindow { signal closeQrCodeRequested() signal closeFirstRunIndexRequested() signal closeLogUploadRequested() + signal closeUpdateAllRequested() // Two-way sync between root.activeScreen and ScreenManager.activeScreen. // Binding-breaking assignments (tests setting root.activeScreen = "games") @@ -350,6 +353,17 @@ ApplicationWindow { onCloseRequested: root.closeLogUploadRequested() } + // update_all terminal modal. Opened from the Hub utility icon. + // It blocks dismissal while the PTY child is running; after exit + // Accept/Escape closes it through Main.qml. + UpdateAllModal { + id: updateAllModal + + anchors.fill: parent + open: root.updateAllModalVisible + onCloseRequested: root.closeUpdateAllRequested() + } + // ── Top-right HUD ───────────────────────────────────────────────────────── // // Host status icons plus clock. The Row is right-anchored so icons @@ -515,6 +529,19 @@ ApplicationWindow { // Idle / uploading: only Cancel. return [{ button: "ButtonB", label: qsTr("Cancel") }]; } + if (root.updateAllModalVisible) { + const phase = root.updateAllModal.phase; + if (phase === root.updateAllModal._stateRunning) + return [ + { button: "Dpad", label: qsTr("Control") }, + { button: "ButtonA", label: qsTr("Enter") }, + { button: "ButtonB", label: qsTr("Esc") } + ]; + return [ + { button: "ButtonA", label: qsTr("Close") }, + { button: "ButtonB", label: qsTr("Close") } + ]; + } if (!root.bootComplete) return []; if (root.firstRunIndexModalVisible) { diff --git a/src/ui/components/CMakeLists.txt b/src/ui/components/CMakeLists.txt index 782d5c7d..b772ca16 100644 --- a/src/ui/components/CMakeLists.txt +++ b/src/ui/components/CMakeLists.txt @@ -25,6 +25,7 @@ qt_add_qml_module( Tile.qml TileLoader.qml TopStatusStrip.qml + UpdateAllModal.qml ) target_link_libraries( diff --git a/src/ui/components/UpdateAllModal.qml b/src/ui/components/UpdateAllModal.qml new file mode 100644 index 00000000..bd2fe7d2 --- /dev/null +++ b/src/ui/components/UpdateAllModal.qml @@ -0,0 +1,172 @@ +// Zaparoo Launcher +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 + +import QtQuick +import Zaparoo.Theme +import Zaparoo.Browse as Browse + +// qmllint disable compiler + +Item { + id: modal + + property bool open: false + + signal closeRequested() + + readonly property int _stateIdle: 0 + readonly property int _stateRunning: 1 + readonly property int _stateSuccess: 2 + readonly property int _stateError: 3 + readonly property int phase: Browse.UpdateAllRunner.state + + visible: modal.open + anchors.fill: parent + z: 320 + + onOpenChanged: { + if (!modal.open) + return + if (modal.phase === modal._stateIdle) + Browse.UpdateAllRunner.run() + } + + function _send(action: string): void { + if (action === "up") + Browse.UpdateAllRunner.send_input("\u001b[A") + else if (action === "down") + Browse.UpdateAllRunner.send_input("\u001b[B") + else if (action === "right") + Browse.UpdateAllRunner.send_input("\u001b[C") + else if (action === "left") + Browse.UpdateAllRunner.send_input("\u001b[D") + else if (action === "accept") + Browse.UpdateAllRunner.send_input("\r") + else if (action === "cancel") + Browse.UpdateAllRunner.send_input("\u001b") + } + + function handleAction(action: string): void { + if (modal.phase === modal._stateRunning) { + modal._send(action) + return + } + if (action === "accept" || action === "cancel") + modal.closeRequested() + } + + function handleText(text: string): void { + if (modal.phase === modal._stateRunning && text !== "") + Browse.UpdateAllRunner.send_input(text) + } + + Rectangle { + anchors.fill: parent + color: "#cc000000" + + MouseArea { + anchors.fill: parent + } + } + + Rectangle { + id: panel + + anchors.centerIn: parent + width: Math.min(parent.width * 0.88, Sizing.pctH(150)) + height: Math.min(parent.height * 0.78, Sizing.pctH(88)) + color: Theme.bgPanel + border.width: 2 + border.color: Theme.textPrimary + radius: Sizing.cornerRadius + + Text { + id: title + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: Sizing.pctH(3) + anchors.leftMargin: Sizing.pctW(4) + anchors.rightMargin: Sizing.pctW(4) + text: qsTr("MiSTer update_all") + font.family: Theme.fontUi + font.pixelSize: Sizing.fontSize(3.0) + color: Theme.textPrimary + horizontalAlignment: Text.AlignHCenter + renderType: Text.NativeRendering + } + + Rectangle { + id: terminalFrame + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: title.bottom + anchors.bottom: footer.top + anchors.margins: Sizing.pctH(3) + color: Theme.bgBar + border.width: 1 + border.color: Theme.borderMid + radius: Math.max(1, Math.round(Sizing.cornerRadius * 0.45)) + clip: true + + Flickable { + id: terminalView + + anchors.fill: parent + anchors.margins: Sizing.pctH(2) + contentWidth: width + contentHeight: terminalText.height + boundsBehavior: Flickable.StopAtBounds + clip: true + + Text { + id: terminalText + + width: terminalView.width + text: Browse.UpdateAllRunner.output_text + font.family: Theme.fontMono + font.pixelSize: Sizing.fontSize(2.0) + color: Theme.textPrimary + wrapMode: Text.WrapAnywhere + renderType: Text.NativeRendering + + onTextChanged: Qt.callLater(function() { + terminalView.contentY = Math.max(0, + terminalView.contentHeight - terminalView.height) + }) + } + } + } + + Text { + id: footer + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: Sizing.pctH(3) + anchors.leftMargin: Sizing.pctW(4) + anchors.rightMargin: Sizing.pctW(4) + height: Sizing.pctH(6) + text: { + if (modal.phase === modal._stateRunning) + return qsTr("D-pad controls update_all. This window stays open while it runs.") + if (modal.phase === modal._stateSuccess) + return qsTr("Done.") + if (Browse.UpdateAllRunner.error_message !== "") + return qsTr("Failed: %1").arg(Browse.UpdateAllRunner.error_message) + return qsTr("Failed.") + } + font.family: Theme.fontUi + font.pixelSize: Sizing.fontSize(2.2) + color: modal.phase === modal._stateError ? Theme.accent : Theme.textPrimary + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + renderType: Text.NativeRendering + } + } +} diff --git a/src/ui/screens/HubScreen.qml b/src/ui/screens/HubScreen.qml index ffbc0e68..d0354f03 100644 --- a/src/ui/screens/HubScreen.qml +++ b/src/ui/screens/HubScreen.qml @@ -38,7 +38,7 @@ import Zaparoo.Browse as Browse // // Pure input dispatcher: emits one of `requestAccept(category)`, // `requestFavoritesScreen`, `requestRecentsScreen`, -// `requestSettingsScreen`, or `requestQuit`. +// `requestSettingsScreen`, `requestUpdateAll`, or `requestQuit`. // // All cross-screen orchestration (model fills, deferred set_category, // cover prefetch, transition overlay, screen flip) lives in Main.qml. @@ -48,7 +48,7 @@ Item { id: hub property bool transitioning: false - // 0 = categories row, 1 = actions row. + // 0 = categories row, 1 = actions row, 2 = bottom-right utility. property int currentRow: 0 // Index within the active row. property int currentIndex: 0 @@ -58,6 +58,7 @@ Item { signal requestFavoritesScreen() signal requestRecentsScreen() signal requestSettingsScreen() + signal requestUpdateAll() // Vertically center the (categories row + actions row + activeLabel) // block in the band between the logo bottom (pctH(9)) and the help @@ -135,7 +136,10 @@ Item { // Settings — the only meaningful action ("Run Update media // database from Settings") the empty-hub message points at. const savedRow = Browse.HubState.selected_row - if (savedRow === 1) { + if (savedRow === 2 && Browse.HubState.selected_action === "update_all") { + hub.currentRow = 2 + hub.currentIndex = 0 + } else if (savedRow === 1) { hub.currentRow = 1 hub.currentIndex = hub._actionIndexForId(Browse.HubState.selected_action) } else if (Browse.CategoriesModel.count === 0) { @@ -157,11 +161,26 @@ Item { // against. Both rows wrap modulo their count so a single Left/Right // press at either end whips around to the far side. function _navigate(delta: int): bool { + if (hub.currentRow === 2) { + if (delta < 0) { + hub.currentRow = 1 + hub.currentIndex = hub._actionIndexForId("settings") + return true + } + return false + } const count = hub.currentRow === 0 ? Browse.CategoriesModel.count : hub.actionEntries.length if (count <= 0) return false + if (hub.currentRow === 1 + && delta > 0 + && hub.currentIndex === count - 1) { + hub.currentRow = 2 + hub.currentIndex = 0 + return true + } const next = ((hub.currentIndex + delta) % count + count) % count if (next === hub.currentIndex) return false @@ -192,6 +211,11 @@ Item { function _crossRow(): bool { const topCount = Browse.CategoriesModel.count const bottomCount = hub.actionEntries.length + if (hub.currentRow === 2) { + hub.currentRow = 1 + hub.currentIndex = hub._actionIndexForId("settings") + return true + } if (hub.currentRow === 0) { if (bottomCount <= 0) return false @@ -225,9 +249,16 @@ Item { hub.actionEntries[hub.currentIndex].id } + function _commitUtilitySelection(): void { + Browse.HubState.selected_row = 2 + Browse.HubState.selected_action = "update_all" + } + function _commitCurrent(): void { if (hub.currentRow === 0) hub._commitCategorySelection() + else if (hub.currentRow === 2) + hub._commitUtilitySelection() else hub._commitActionSelection() } @@ -248,6 +279,12 @@ Item { hub._commitActionSelection() } + function _focusUtility(): void { + hub.currentRow = 2 + hub.currentIndex = 0 + hub._commitUtilitySelection() + } + function _activateCurrent(): void { if (hub.currentRow === 0) { // Empty row sends "" — router treats that as the committed @@ -258,6 +295,10 @@ Item { hub.requestAccept(chosen) return } + if (hub.currentRow === 2) { + hub.requestUpdateAll() + return + } const id = hub.actionEntries[hub.currentIndex].id if (id === "favorites") @@ -487,6 +528,8 @@ Item { const entry = hub.actionEntries[hub.currentIndex] return entry ? entry.text : "" } + if (hub.currentRow === 2) + return qsTr("Update all") if (Browse.CategoriesModel.count > 0) return Browse.CategoriesModel.category_at(hub.currentIndex) return "" @@ -507,4 +550,46 @@ Item { count: Browse.CategoriesModel.count emptyText: qsTr("No systems available. Run Update media database from Settings.") } + + Rectangle { + id: updateAllButton + + readonly property bool isSelected: hub.currentRow === 2 + readonly property int buttonSize: Sizing.pctH(8) + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: Sizing.pctW(3) + anchors.bottomMargin: Sizing.pctH(8) + width: buttonSize + height: buttonSize + visible: !hub.transitioning + color: isSelected ? Theme.accent : Theme.surfaceCard + border.width: isSelected ? 3 : 1 + border.color: isSelected ? Theme.textPrimary : Theme.borderMid + radius: Sizing.cornerRadius + + Image { + anchors.centerIn: parent + width: parent.width * 0.58 + height: width + fillMode: Image.PreserveAspectFit + source: Resources.iconUrl("Download") + sourceSize.width: width + sourceSize.height: height + smooth: true + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + + onEntered: hub._focusUtility() + onClicked: { + hub._focusUtility() + hub._activateCurrent() + } + } + } } diff --git a/src/ui/translations/launcher_en.ts b/src/ui/translations/launcher_en.ts index 947c7d10..d7b63287 100644 --- a/src/ui/translations/launcher_en.ts +++ b/src/ui/translations/launcher_en.ts @@ -209,6 +209,11 @@ No systems available. Run Update media database from Settings. + + + Update all + Update all + LoadingIndicator @@ -383,6 +388,49 @@ Toggle Toggle + + + Control + Control + + + + Enter + Enter + + + + Esc + Esc + + + + UpdateAllModal + + + MiSTer update_all + MiSTer update_all + + + + D-pad controls update_all. This window stays open while it runs. + D-pad controls update_all. This window stays open while it runs. + + + + Done. + Done. + + + + Failed: %1 + Failed: %1 + + + + Failed. + Failed. + Modal diff --git a/src/ui/translations/launcher_it.ts b/src/ui/translations/launcher_it.ts index 01c5a122..326d6a2a 100644 --- a/src/ui/translations/launcher_it.ts +++ b/src/ui/translations/launcher_it.ts @@ -209,6 +209,11 @@ No systems available. Run Update media database from Settings. + + + Update all + Aggiorna tutto + LoadingIndicator @@ -383,6 +388,49 @@ Toggle Alterna + + + Control + Controlla + + + + Enter + Invio + + + + Esc + Esc + + + + UpdateAllModal + + + MiSTer update_all + MiSTer update_all + + + + D-pad controls update_all. This window stays open while it runs. + Il D-pad controlla update_all. Questa finestra resta aperta durante l'esecuzione. + + + + Done. + Fatto. + + + + Failed: %1 + Non riuscito: %1 + + + + Failed. + Non riuscito. + Modal