Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/build-linux-arm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="resources/retsend-devices.jpg" alt="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" width="480">
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions muos/README.md
Original file line number Diff line number Diff line change
@@ -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
Binary file added muos/glyph/retsend.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions muos/mux_launch.sh
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions resources/retsend-glyph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 15 additions & 5 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<bool> = 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`
Expand Down Expand Up @@ -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;
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/ui/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec<AppCommand
if data.can_resend {
hints.push(("A", "Send again", Some(AppCommand::Confirm)));
}
if crate::app::back_quits() {
hints.push(("B", "Quit", Some(AppCommand::Back)));
}
super::home::hint_bar(ui, &hints, taps);
ui.add_space(4.0);
});
Expand Down
20 changes: 10 additions & 10 deletions src/ui/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ pub struct HomeData {
pub fn render(root: &mut egui::Ui, data: &HomeData, taps: &mut Vec<AppCommand>) {
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<Hint> = 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);
});

Expand Down
16 changes: 8 additions & 8 deletions src/ui/receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ pub fn render(root: &mut egui::Ui, data: &ReceiveData, taps: &mut Vec<AppCommand
egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| {
ui.add_space(4.0);
// "Refresh" is the radar's word; here the button re-announces us.
super::home::hint_bar(
ui,
&[
("← →", "Tabs", None),
("Select", "Announce", Some(AppCommand::ReAnnounce)),
],
taps,
);
let mut hints: Vec<super::home::Hint> = 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);
});

Expand Down
32 changes: 14 additions & 18 deletions src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<super::home::Hint> = 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);
});

Expand Down
Loading