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/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 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 0000000..88ab5ea Binary files /dev/null and b/muos/glyph/retsend.png differ 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 @@ + + + + + + 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); });