From 27a8af7baf6a79dbb9de1bb8da873ec1d0904080 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:03:54 +0300 Subject: [PATCH 1/3] feat(input): B leaves the app where the launcher hands the key over --- src/app/mod.rs | 20 +++++++++++++++----- src/ui/history.rs | 3 +++ src/ui/home.rs | 20 ++++++++++---------- src/ui/receive.rs | 16 ++++++++-------- src/ui/settings.rs | 32 ++++++++++++++------------------ 5 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index b3f55ae..77c9c31 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,7 +20,7 @@ use crate::transfer::outbound::{self, OutboundSession}; use crate::ui::AppUi; use std::path::PathBuf; use std::sync::atomic::Ordering; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; /// Rows a shoulder-button page jump moves in a list. const PAGE_JUMP: i32 = 8; @@ -45,6 +45,17 @@ pub struct App { running: bool, } +/// Whether Back on the home screen leaves the app. Android's Back is a system +/// button that has to lead somewhere; on a handheld only a launcher keeping no +/// kill helper of its own hands the key over. +pub fn back_quits() -> bool { + static BACK_QUITS: OnceLock = OnceLock::new(); + *BACK_QUITS.get_or_init(|| { + cfg!(target_os = "android") + || std::env::var_os("RETSEND_BACK_QUIT").is_some_and(|v| v != "0") + }) +} + struct SendTarget { alias: String, /// `http://ip:port` @@ -329,11 +340,10 @@ impl App { (Focus::Tabs, AppCommand::Alt) if self.ui.tabs.active() == Tab::History => { self.delete_history_entry(); } - // Nothing left to leave. Android's Back is a system button that has - // to lead somewhere, so there it quits; on the handhelds the - // launcher owns quitting and this stays inert. + // Nothing left to leave: where the launcher owns quitting this + // stays inert, and where it does not, this is the way out. (Focus::Tabs, AppCommand::Back) => { - if cfg!(target_os = "android") { + if back_quits() { self.running = false; } } diff --git a/src/ui/history.rs b/src/ui/history.rs index b8ea935..56ffef4 100644 --- a/src/ui/history.rs +++ b/src/ui/history.rs @@ -67,6 +67,9 @@ pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); - hint_bar( - ui, - &[ - ("← →", "Tabs", None), - ("Select", "Refresh", Some(AppCommand::ReAnnounce)), - ("X", "Add IP", Some(AppCommand::Alt)), - ("A", "Choose files", Some(AppCommand::Confirm)), - ], - taps, - ); + let mut hints: Vec = vec![ + ("← →", "Tabs", None), + ("Select", "Refresh", Some(AppCommand::ReAnnounce)), + ("X", "Add IP", Some(AppCommand::Alt)), + ("A", "Choose files", Some(AppCommand::Confirm)), + ]; + if crate::app::back_quits() { + hints.push(("B", "Quit", Some(AppCommand::Back))); + } + hint_bar(ui, &hints, taps); ui.add_space(4.0); }); diff --git a/src/ui/receive.rs b/src/ui/receive.rs index 5a3644b..b2fe935 100644 --- a/src/ui/receive.rs +++ b/src/ui/receive.rs @@ -28,14 +28,14 @@ pub fn render(root: &mut egui::Ui, data: &ReceiveData, taps: &mut Vec = vec![ + ("← →", "Tabs", None), + ("Select", "Announce", Some(AppCommand::ReAnnounce)), + ]; + if crate::app::back_quits() { + hints.push(("B", "Quit", Some(AppCommand::Back))); + } + super::home::hint_bar(ui, &hints, taps); ui.add_space(4.0); }); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index f7347ef..3de7eb6 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -83,27 +83,23 @@ pub fn render( egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); // The row's action, where every other screen puts its buttons. - match rows[state.cursor.min(rows.len() - 1)].2 { - Some(action) => super::home::hint_bar( - ui, - &[ - ("← →", "Tabs", None), - ("A", action, Some(AppCommand::Confirm)), - ], - taps, - ), + let mut hints: Vec = match rows[state.cursor.min(rows.len() - 1)].2 { + Some(action) => vec![ + ("← →", "Tabs", None), + ("A", action, Some(AppCommand::Confirm)), + ], // A stepper row spends ◂ ▸ on its own value, so it names them — // and naming them is what lets a screen with no pad reach them. - None => super::home::hint_bar( - ui, - &[ - ("◂", "Smaller", Some(AppCommand::Nav(Direction::Left))), - ("▸", "Bigger", Some(AppCommand::Nav(Direction::Right))), - ("L1 R1", "Tabs", None), - ], - taps, - ), + None => vec![ + ("◂", "Smaller", Some(AppCommand::Nav(Direction::Left))), + ("▸", "Bigger", Some(AppCommand::Nav(Direction::Right))), + ("L1 R1", "Tabs", None), + ], + }; + if crate::app::back_quits() { + hints.push(("B", "Quit", Some(AppCommand::Back))); } + super::home::hint_bar(ui, &hints, taps); ui.add_space(4.0); }); From ab6d3731cb6f8a326ff4f4f00f795d3d45d5f2bb Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:04:01 +0300 Subject: [PATCH 2/3] feat(muos): an application package for muOS --- .github/workflows/build-linux-arm.yml | 51 +++++++++++++++++ CHANGELOG.md | 11 ++++ muos/README.md | 76 ++++++++++++++++++++++++++ muos/glyph/retsend.png | Bin 0 -> 1030 bytes muos/mux_launch.sh | 68 +++++++++++++++++++++++ resources/retsend-glyph.svg | 6 ++ 6 files changed, 212 insertions(+) create mode 100644 muos/README.md create mode 100644 muos/glyph/retsend.png create mode 100755 muos/mux_launch.sh create mode 100644 resources/retsend-glyph.svg diff --git a/.github/workflows/build-linux-arm.yml b/.github/workflows/build-linux-arm.yml index 071731d..dd0b274 100644 --- a/.github/workflows/build-linux-arm.yml +++ b/.github/workflows/build-linux-arm.yml @@ -328,3 +328,54 @@ jobs: files: | retsend-allium.zip retsend-allium.zip.sha256 + + muos: + needs: build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: retsend-linux-aarch64 + path: bin + + - name: Assemble the application folder + run: | + set -eux + pkg=muos-dist + app=$pkg/Retsend + rm -rf "$pkg" + mkdir -p "$app" + # Archive Manager unpacks a .muxapp into MUOS/application/, so the + # folder at the archive root is the installed app. + cp muos/mux_launch.sh "$app/" + cp muos/README.md "$app/" + # The glyph the app list falls back to, named after the ICON header. + # 24x24, the size themes cut theirs to: muOS draws it at native size. + cp -r muos/glyph "$app/" + install -m 755 bin/retsend "$app/retsend" + chmod 755 "$app/mux_launch.sh" + ls -laR "$pkg" + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: retsend-muos + path: muos-dist + + - name: Pack for release + if: github.event_name == 'release' + run: | + set -eux + # .muxapp is a zip; the extension is what Archive Manager dispatches on. + (cd muos-dist && zip -r ../retsend-muos.muxapp Retsend) + sha256sum retsend-muos.muxapp > retsend-muos.muxapp.sha256 + + - name: Upload to GitHub Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: | + retsend-muos.muxapp + retsend-muos.muxapp.sha256 diff --git a/CHANGELOG.md b/CHANGELOG.md index e22db89..db2c719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A muOS package**, `retsend-muos.muxapp`, installed from Archive Manager and + listed under Applications rather than under Ports as the PortMaster build is. + The launcher hands muOS's own `SETUP_APP` the pad and the governor, holds the + device awake for the length of a transfer, and points saves at `ROMS/` on the + card that booted. **B on the home screen quits**, which muOS needs: its only + exit is a `kill -9` that would cut a transfer where it stands, so the app + answers the key itself and closes the sockets on the way out. Other ports leave + B inert — there the launcher owns quitting — and the launcher opts in with + `RETSEND_BACK_QUIT`. The home tabs name the key in their hints only where it is + on, so nowhere else grows a hint for a button that does nothing. + - **A "UI scale" setting**, 60% to 160% of what the screen itself asks for, stepped with ◂ ▸ on its row (L1/R1 still switch tabs, and the hints name both arrows so a screen with no pad can reach them). Persisted as diff --git a/muos/README.md b/muos/README.md new file mode 100644 index 0000000..e6d3c35 --- /dev/null +++ b/muos/README.md @@ -0,0 +1,76 @@ +# retsend for muOS + +Transfer files between a muOS handheld and your phone or PC over wifi, using +[LocalSend](https://localsend.org). + +Every device muOS runs on is aarch64 and carries its own SDL2, so this package is +the binary and a launcher — no bundled libraries. muOS also ships PortMaster, and +`retsend-portmaster.zip` installs the same app under Ports; this one puts it in +the **Applications** menu instead, where it belongs on a device with no ROM to +launch. + +## Install + +Copy `retsend-muos.muxapp` to `ARCHIVE/` on the SD card, then run +**Applications → Archive Manager** and pick it. It unpacks into +`MUOS/application/Retsend/` and appears under **Applications**. + +## Setup + +1. Install LocalSend on your phone or PC (localsend.org). +2. Connect both to the same network — wifi in muOS's own settings. +3. Launch Retsend — nearby devices appear on the radar. + +Received files land in `ROMS/` on the card muOS boots from; change the folder in +Settings. The config lives in `MUOS/application/Retsend/data/config.toml`, and +the last run's log in `MUOS/application/Retsend/log.txt`. + +## Controls + +| Button | Action | +|---------|---------------------------------------------------------| +| D-pad | Navigate | +| A | Send to device / select file / accept / type (keyboard) | +| B | Back / decline / cancel / erase (keyboard) · **quit** | +| Start | Settings · confirm send · OK (keyboard) | +| Select | Refresh radar · switch roots · layer (keyboard) | +| L1/R1 | Page through lists | + +## Notes for this device + +**B on the home screen is the way out**, and here it has to be: muOS expects an +app to end itself, and the only exit it offers is `R2+SELECT+B`, which `kill -9`s +the foreground process — mid-transfer that leaves a peer holding a connection +that stopped answering. Leaving through B stops announcing and closes the sockets +first, so the peer sees a transfer end. The launcher sets `RETSEND_BACK_QUIT`; +every other port leaves the key inert, because there the launcher owns quitting. + +Transfers are minutes with no button pressed, which is exactly what the device +reads as idle. The launcher holds `CAFFEINE` on for the run so it does not sleep +part-way through a file. + +`SETUP_APP` from muOS's own `func.sh` does the work a launcher here would +otherwise repeat — the action file, the CPU governor, and the SDL controller +database that makes the pad arrive as a gamepad rather than as keys. It also +points `HOME` at the board's, which is read-only rootfs, so the launcher moves it +back to the app folder afterwards; the config, the log and the panic file all sit +on the card. Older muOS has no `SETUP_APP`, and the launcher spells the same +steps out when it is missing. + +The app glyph is the package's own, at `glyph/retsend.png`. muOS looks in the +active theme first and falls back to this, so a theme carrying a `retsend` glyph +wins and one without still shows an icon. + +It is 24×24 because that is the size themes cut their own glyphs to, and at the +usual "native" glyph setting muOS draws a raster at whatever pixels it has — +there is no fitting to the row. A vector would be fitted, but the `.svg` the +frontend reaches for did not render on device, so the package ships the raster +that does. `resources/retsend-glyph.svg` is what it is cut from. + +## Credits + +- Developed and ported by [mxmgorin](https://github.com/mxmgorin/) +- Implements the [LocalSend](https://localsend.org) protocol +- [muOS](https://muos.dev) is the MustardOS team's; this package only follows its + application layout +- Source and issues: https://github.com/mxmgorin/retsend diff --git a/muos/glyph/retsend.png b/muos/glyph/retsend.png new file mode 100644 index 0000000000000000000000000000000000000000..88ab5ea7f4765c2659899a0e22926a44cb35b9a4 GIT binary patch literal 1030 zcmV+h1o``kP)4!M|sFK+rp*FT-;SUQ$g+*IvLIn~LEGQ5bwSw)3<2%)T zj-B@&wh3qV&Yd}TX5P%bmrw^?p^&SiEjP@P`$6ai@Pe_xcu2%^1e(dT>(TVvpA>3o zP&IN}e6oRc8UyepfR1`W0Qf!W+tX8P(eZ?Mbn-3M=@$U6KSj6%#?OXdWNJrI;`W8@ zqkq-Rz%v~jkc6`UnpY(bEG!oE&L)?0EwT6$fD53VN#AI^Y#_{$*Abs(;1q!lfbcmO z#{t}AqEqSg-8@Mx2;g}DO#q*Je|qXoO9sm|zfz?vH$l8VIlLzZBJK@^o>SySzdu*< zi#u4K4*>Ly)s6mXl>cp-9vRb(45Tg&+Qk_h$xFxPq&G05#1-dk$sKXEWe=Ba5+2Wz zQ+pf&P$|i>_r|PfRhl5xitVl0+W^*^EUxFJqvz7du1uLwX$#`Z%7-Prn;IFtQYmjd z)XaMJ4*;*4H2z)4GgO7D?}@w?JaO0J@3UBa3v>Y_p_k6E#}5Q7dTQg4sMv z^VT5HQJI5k=nDsT6UbZBfVkfs3GS*R!NMJ$3OiZelfX45iaRtOE6)bN9|1@|QQHy} zmuBGOlC6OaR(UKqm@rGg@Ct`*D(wVO9)M4A?#Sd2n4-4mKQm^e;>d)^)QH7|a8+o4 z`uk>dxuZWA!p{PCG^o1qH7m<8q0VzP6t>ik56(=qtyWwO8YHPXG2`xoOw)N`To%m7 zY&&l(6xW>T`R%9Hr-`dTSkImVVWnkDIPtgN|Jb+>r~xNgM*zSZCWD~{v6Oa4CifTF zabF{O#_TO6-BX8ML7(@lBy8eyjWY9boUO6RYAzfnG4D)l4^&PA%b=ya zd(TUztwzMFLwmDh&Aya(?;RrA%|(9CEUr-d($C`nh`z9&w01D~Qpy+DQfs~H24!r% zJmGA9*f2=U16(>3%P))v0MCL%Y;kQqR`1#JKbrV^ZTIT{NdN!<07*qoM6N<$f-OGX Av;Y7A literal 0 HcmV?d00001 diff --git a/muos/mux_launch.sh b/muos/mux_launch.sh new file mode 100755 index 0000000..406d52d --- /dev/null +++ b/muos/mux_launch.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# HELP: Send and receive files over wifi with LocalSend - no cable, no SSH +# ICON: retsend +# GRID: Retsend + +. /opt/muos/script/var/func.sh + +APP_BIN="retsend" +# frontend.sh passes the app folder, which is SD1 or SD2 depending on where the +# archive was installed. The fallback is for a run by hand over SSH. +APP_DIR="${1:-$(cd "$(dirname "$0")" && pwd)}" +LOG_FILE="$APP_DIR/log.txt" + +# Jacaranda and newer: one call does the action file, the governor, HOME and the +# SDL controller database. Older muOS spells the same out. +if command -v SETUP_APP >/dev/null 2>&1; then + SETUP_STAGE_OVERLAY + SETUP_APP "$APP_BIN" "" +else + echo app >/tmp/act_go + SETUP_SDL_ENVIRONMENT + SET_VAR "system" "foreground_process" "$APP_BIN" +fi + +# RK3576 (Vita Pro and kin) ships an SDL that finds no EGL driver on its own. +if grep -q "rk3576" /proc/device-tree/compatible 2>/dev/null; then + for MALI_LIBRARY in /usr/lib/libmali.so /usr/lib/aarch64-linux-gnu/libmali.so; do + if [ -e "$MALI_LIBRARY" ]; then + export SDL_VIDEO_EGL_DRIVER="$MALI_LIBRARY" + break + fi + done + export SDL_OPENGL_ES_DRIVER=1 +fi + +cd "$APP_DIR" || exit 1 + +# SETUP_APP points HOME at the board's, which is rootfs; keep writable paths on +# the card next to the app. +export HOME="$APP_DIR" +export XDG_DATA_HOME="$APP_DIR" +export RETSEND_DATA_DIR="$APP_DIR/data" +export RETSEND_PANIC_FILE="$APP_DIR/retsend-panic.log" +ROM_MOUNT="$(GET_VAR "device" "storage/rom/mount")" +export RETSEND_SAVE_DIR="$ROM_MOUNT/ROMS" +# muOS expects an app to end itself — its own way out is the R2+SELECT+B kill — +# so B on the home screen is ours, and the sockets close on the way. +export RETSEND_BACK_QUIT=1 +#export RETSEND_LOG_LEVEL=debug + +: >"$LOG_FILE" + +# An empty radar reads as a broken app rather than an answer, so say which it is. +NET_STATE="$(GET_VAR "device" "network/state")" +if [ -r "$NET_STATE" ] && [ "$(cat "$NET_STATE")" != "up" ]; then + echo "wifi is down; connect it in muOS settings or nothing will be found" >>"$LOG_FILE" +fi + +# A transfer is minutes with no button pressed, which is the device's cue to +# sleep. Absent before Jacaranda, hence the guard. +HAS_CAFFEINE=0 +command -v CAFFEINE >/dev/null 2>&1 && HAS_CAFFEINE=1 + +[ "$HAS_CAFFEINE" -eq 1 ] && CAFFEINE on +./"$APP_BIN" >>"$LOG_FILE" 2>&1 +[ "$HAS_CAFFEINE" -eq 1 ] && CAFFEINE off + +exit 0 diff --git a/resources/retsend-glyph.svg b/resources/retsend-glyph.svg new file mode 100644 index 0000000..0387ae2 --- /dev/null +++ b/resources/retsend-glyph.svg @@ -0,0 +1,6 @@ + + + + + + From fac771d91cfce0569f96956a6a673694e0ffa3ec Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:04:07 +0300 Subject: [PATCH 3/3] docs: muOS among the supported systems --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4cf2a5..1c90fa7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ client for retro handhelds: send and receive files with your phone or PC over Wi-Fi, no cable or SSH. Compatible with the official LocalSend apps. -It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running [OnionOS](https://github.com/OnionUI/Onion), [spruceOS](https://github.com/spruceUI/spruceOS) or [Allium](https://github.com/goweiwen/Allium), all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android. +It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds, the handhelds running [muOS](https://muos.dev), and the Miyoo Mini Plus and Flip running [OnionOS](https://github.com/OnionUI/Onion), [spruceOS](https://github.com/spruceUI/spruceOS) or [Allium](https://github.com/goweiwen/Allium), all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android.

Three devices on a couch: a handheld showing the radar of nearby devices, a clamshell handheld waiting to receive, and a phone running the official LocalSend app @@ -57,6 +57,14 @@ Grab `retsend-portmaster.zip` from [Releases](https://github.com/mxmgorin/retsend/releases) and unpack it into your ports folder (e.g. `/roms/ports/`). +## Install (muOS) + +Copy `retsend-muos.muxapp` from +[Releases](https://github.com/mxmgorin/retsend/releases) to `ARCHIVE/` on the SD +card and install it from **Applications → Archive Manager**. It then sits under +**Applications**, and **B on the home screen quits** — muOS keeps no way out of +its own. See [muos/README.md](muos/README.md). + ## Install (Miyoo Mini Plus / Flip) Download the appropriate zip from