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