From 52c21e24675284350a1031f9067c6013aea9c2b6 Mon Sep 17 00:00:00 2001 From: 28allday Date: Fri, 3 Jul 2026 19:35:43 +0100 Subject: [PATCH] Add gum TUI (default mode) + Walker floating launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context-aware menu: Connect phone (USB wait + Trust walkthrough), Switch internet → iPhone / back, live status header. install now writes a TUI.float .desktop entry and restarts elephant. Co-Authored-By: Claude Fable 5 --- README.md | 51 +++++++-- omatether.sh | 288 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 303 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index bca0d54..9f87447 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,34 @@ # omatether -iPhone USB tethering for **Omarchy / Arch** (systemd-networkd + iwd stack). -Plug an iPhone in over USB and use its internet connection — the phone's -Wi-Fi if it's on one, cellular otherwise. Fully headless, no GUI required. +iPhone USB tethering for **Omarchy / Arch** (systemd-networkd + iwd stack), +with a gum TUI. Plug an iPhone in over USB and use its internet connection — +the phone's Wi-Fi if it's on one, cellular otherwise. Works headless too. + +## TUI + +Run `omatether.sh` with no arguments (or launch **iPhone Tether** from Walker — +it opens as a floating window). The dashboard shows phone / tether / route +state live and offers only the actions that make sense right now: + +``` +╭───────────────────────╮ +│ OMATETHER │ +│ iPhone USB internet │ +╰───────────────────────╯ + Cowboy Bebop — paired +󰛳 Tether up: eth0 172.20.10.11/28 +󰈀 Internet via enp6s0 (phone is fallback) + +> Switch internet → iPhone + Status (full check) + Refresh + Quit +``` + +- **Connect phone** — waits for USB, walks you through unlock + Trust, brings + the connection up (shown only when not yet connected) +- **Switch internet → iPhone** — routes all traffic via the phone +- **Switch back → ethernet/wifi** — back to normal (tether stays as fallback) ## How it works @@ -10,28 +36,31 @@ Wi-Fi if it's on one, cellular otherwise. Fully headless, no GUI required. - the kernel's `ipheth` driver exposes the phone as a normal network interface - a `systemd-networkd` profile (`10-iphone-tether.network`) matches by `Driver=ipheth` and DHCPs it, at **fallback priority** (route metric 750) - so it never hijacks ethernet (100) or Wi-Fi (600) + so it never hijacks ethernet (100) or Wi-Fi (600); "Switch → iPhone" + rewrites the metric to 50 and reloads networkd ## Install ```bash -./omatether.sh install # packages + networkd profile -./omatether.sh pair # unlock phone, tap Trust +./omatether.sh install # packages + networkd profile + Walker launcher +./omatether.sh # TUI: pick "Connect phone", unlock, tap Trust ``` Then on the phone: **Settings → Personal Hotspot → Allow Others to Join: ON**. -The connection comes up automatically whenever the phone is plugged in. +After that it's zero-touch — plug in and the connection comes up by itself. -## Commands +## CLI + +Everything is scriptable without the TUI: | Command | What it does | |---|---| -| `install` | Install usbmuxd/libimobiledevice, write networkd profile | +| `install` | Install packages, networkd profile, Walker launcher | | `pair` / `unpair` | Manage the Trust pairing | | `status` | Device, interface, IP, route, live connectivity test | | `priority high` | Route all traffic via the phone even when ethernet is up | | `priority low` | Back to fallback-only (default) | -| `uninstall` | Remove the networkd profile | +| `uninstall` | Remove the networkd profile + launcher | ## Notes @@ -39,3 +68,5 @@ The connection comes up automatically whenever the phone is plugged in. - The tether interface usually appears as `eth0`; the profile matches by driver, not name. - `RequiredForOnline=no` — an unplugged phone never blocks boot. - DNS follows the Omarchy convention (`UseDNS=no`; global resolvers via systemd-resolved). +- If the phone was plugged in before usbmuxd existed it can show as unresponsive — + the tool restarts usbmuxd automatically to rescan. diff --git a/omatether.sh b/omatether.sh index 6f9a2eb..a98177d 100755 --- a/omatether.sh +++ b/omatether.sh @@ -5,17 +5,19 @@ # Plug an iPhone in over USB and use its internet connection (the phone's # Wi-Fi, or cellular if it isn't on Wi-Fi). Works headless — no GUI needed. # -# Usage: omatether.sh +# Run with no arguments for the interactive TUI (requires gum), or: +# omatether.sh # set -euo pipefail NETFILE="/etc/systemd/network/10-iphone-tether.network" +DESKTOP_FILE="$HOME/.local/share/applications/omatether.desktop" # Route metrics: Omarchy ships ethernet=100, wifi=600, wwan=700. METRIC_FALLBACK=750 # tether used only when nothing better is up (default) METRIC_PREFERRED=50 # tether beats ethernet/wifi while plugged in -RED=$'\e[31m'; GRN=$'\e[32m'; YLW=$'\e[33m'; RST=$'\e[0m' +RED=$'\e[31m'; GRN=$'\e[32m'; YLW=$'\e[33m'; DIM=$'\e[2m'; RST=$'\e[0m' say() { printf '%s\n' "$*"; } ok() { printf '%s\n' "${GRN}✓${RST} $*"; } warn() { printf '%s\n' "${YLW}!${RST} $*"; } @@ -25,6 +27,8 @@ as_root() { if [[ $EUID -eq 0 ]]; then "$@"; else sudo "$@"; fi } +# ---------------------------------------------------------------- probes --- + # Find the network interface backed by the ipheth driver, if any. tether_iface() { local dev drv @@ -35,6 +39,41 @@ tether_iface() { return 1 } +phone_on_usb() { lsusb 2>/dev/null | grep -qi 'apple.*iphone'; } +phone_visible() { idevice_id -l 2>/dev/null | grep -q .; } +phone_paired() { idevicepair validate >/dev/null 2>&1; } + +phone_name() { ideviceinfo -k DeviceName 2>/dev/null || echo "iPhone"; } + +tether_ip() { + local iface=$1 + ip -4 -br addr show "$iface" 2>/dev/null | awk '{print $3}' +} + +# Interface currently carrying the default route. +active_uplink() { + ip route get 1.1.1.1 2>/dev/null \ + | awk '{for(i=1;i/dev/null +} + +net_test() { + local iface=$1 + curl -sf --interface "$iface" --max-time 5 -o /dev/null https://www.google.com +} + +# usbmuxd drops devices that were plugged in before it started +# ("device unconfigured") — a restart rescans the bus. +revive_usbmuxd() { + as_root systemctl restart usbmuxd + sleep 2 +} + +# ------------------------------------------------------------ networkd ----- + write_netfile() { local metric=$1 as_root tee "$NETFILE" >/dev/null < "$DESKTOP_FILE" </dev/null || true + ok "Walker launcher installed: 'iPhone Tether' (floating window)" + # Nudge udev in case the phone was already plugged in before install. as_root udevadm trigger -s usb --action=add 2>/dev/null || true say "" - say "Next: unlock the iPhone, then run: $0 pair" + say "Run '$0' (no arguments) for the TUI, or '$0 pair' to pair now." +} + +do_pair_once() { + # Returns 0 on success; prints the failure reason otherwise. + local out + if out=$(idevicepair pair 2>&1); then + ok "$out" + return 0 + fi + case $out in + *passcode*) warn "Phone is locked — unlock it." ;; + *user\ denied*|*denied\ the\ trust*) warn "Trust was declined on the phone." ;; + *Please\ accept*) warn "Trust dialog showing on the phone — tap Trust." ;; + *) warn "$out" ;; + esac + return 1 +} + +# Retry pairing until it succeeds or times out (arg: seconds, default 120). +pair_wait() { + local deadline=$(( SECONDS + ${1:-120} )) + while (( SECONDS < deadline )); do + idevicepair pair >/dev/null 2>&1 && return 0 + sleep 5 + done + return 1 } cmd_pair() { command -v idevicepair >/dev/null || die "libimobiledevice not installed — run: $0 install" - idevice_id -l 2>/dev/null | grep -q . || die "No iPhone detected over USB. Plug it in and try again." + if ! phone_visible; then + phone_on_usb && revive_usbmuxd + phone_visible || die "No iPhone detected over USB. Plug it in and try again." + fi say "Pairing — unlock the iPhone and tap ${GRN}Trust${RST} when prompted…" - local out - if out=$(idevicepair pair 2>&1); then - ok "$out" - else - case $out in - *passcode*) die "Phone is locked. Unlock it, then re-run: $0 pair" ;; - *user\ denied*|*denied\ the\ trust*) die "Trust was declined on the phone. Re-run and tap Trust." ;; - *Please\ accept*) warn "Trust dialog is showing on the phone — tap Trust, then re-run: $0 pair"; exit 1 ;; - *) die "$out" ;; - esac - fi + do_pair_once || exit 1 say "" say "Now enable the hotspot on the phone:" @@ -119,9 +196,9 @@ cmd_status() { udid=$(idevice_id -l 2>/dev/null | head -1 || true) if [[ -n $udid ]]; then - name=$(ideviceinfo -k DeviceName 2>/dev/null || echo "unknown") + name=$(phone_name) ok "iPhone connected over USB: $name" - if idevicepair validate >/dev/null 2>&1; then + if phone_paired; then ok "Paired (trusted)" else warn "Not paired — run: $0 pair" @@ -134,11 +211,11 @@ cmd_status() { local state addr state=$(networkctl status "$iface" 2>/dev/null | awk '/State:/{print $2; exit}' || true) ok "Tether interface: $iface ($state)" - addr=$(ip -4 -br addr show "$iface" | awk '{print $3}') + addr=$(tether_ip "$iface") if [[ -n $addr ]]; then ok "IPv4: $addr" ip route show default dev "$iface" | sed 's/^/ /' - if curl -sf --interface "$iface" --max-time 5 -o /dev/null https://www.google.com; then + if net_test "$iface"; then ok "Internet via $iface works" else warn "No internet through $iface yet (is Personal Hotspot on?)" @@ -165,9 +242,7 @@ cmd_priority() { ok "Tether now FALLBACK (metric $METRIC_FALLBACK) — ethernet/wifi win when available" ;; *) - local cur - cur=$(awk -F= '/^RouteMetric/{print $2; exit}' "$NETFILE") - say "Current metric: $cur (ethernet=100, wifi=600)" + say "Current metric: $(current_metric) (ethernet=100, wifi=600)" say "Usage: $0 priority " ;; esac @@ -175,11 +250,172 @@ cmd_priority() { cmd_uninstall() { as_root rm -f "$NETFILE" + rm -f "$DESKTOP_FILE" as_root networkctl reload - ok "Removed $NETFILE (usbmuxd/libimobiledevice left installed)" + systemctl --user restart elephant.service 2>/dev/null || true + ok "Removed $NETFILE and the Walker launcher (usbmuxd/libimobiledevice left installed)" } -case ${1:-help} in +# ----------------------------------------------------------------- TUI ----- + +# Foot spawns TUI.float windows at 80 cols and snaps to real geometry over +# ~40ms; render before that settles and gum boxes fragment. Poll until two +# consecutive width reads agree (max 250ms). +poll_terminal_size() { + local prev curr i + prev=$(tput cols 2>/dev/null || echo 80) + for i in 1 2 3 4 5 6 7 8 9 10; do + sleep 0.025 + curr=$(tput cols 2>/dev/null || echo 80) + [[ $curr == "$prev" && $i -ge 2 ]] && return 0 + prev=$curr + done +} + +tui_header() { + gum style --border rounded --padding "0 2" --margin "0 0 1 0" \ + --border-foreground 212 --bold " OMATETHER" "${DIM}iPhone USB internet${RST}" +} + +tui_connect() { + say "" + if ! phone_visible; then + phone_on_usb && revive_usbmuxd + fi + if ! phone_visible; then + # shellcheck disable=SC2016 # expansion happens in the child shell + gum spin --title "Plug the iPhone in via USB…" -- bash -c \ + 'for i in $(seq 1 24); do idevice_id -l 2>/dev/null | grep -q . && exit 0; sleep 5; done; exit 1' \ + || { warn "No iPhone appeared. Check the cable and try again."; return 1; } + fi + + if ! phone_paired; then + say "Unlock the phone and tap ${GRN}Trust${RST} when the dialog appears." + idevicepair pair >/dev/null 2>&1 || true # trigger the Trust dialog + # shellcheck disable=SC2016 # expansion happens in the child shell + gum spin --title "Waiting for Trust… (unlock the phone)" -- bash -c \ + 'for i in $(seq 1 24); do idevicepair pair >/dev/null 2>&1 && exit 0; sleep 5; done; exit 1' \ + || { warn "Pairing timed out. Unlock the phone and pick Connect again."; return 1; } + ok "Paired with $(phone_name)" + fi + + # Give networkd a moment to DHCP the freshly-authorised interface. + local iface="" + gum spin --title "Bringing the connection up…" -- sleep 4 || true + if iface=$(tether_iface) && [[ -n $(tether_ip "$iface") ]] && net_test "$iface"; then + ok "Connected — internet available through the phone ($iface)" + else + warn "Paired, but no connection yet. On the phone enable:" + say " Settings → Personal Hotspot → Allow Others to Join: ON" + fi + gum input --placeholder "Press Enter to continue…" >/dev/null || true +} + +tui_switch() { + local target=$1 iface + say "" + if [[ $target == phone ]]; then + write_netfile "$METRIC_PREFERRED" + else + write_netfile "$METRIC_FALLBACK" + fi + reload_networkd + gum spin --title "Applying routes…" -- sleep 3 || true + + local uplink + uplink=$(active_uplink) + iface=$(tether_iface || true) + if [[ $target == phone && $uplink == "$iface" ]]; then + ok "All traffic now routes via the iPhone ($iface)" + elif [[ $target == normal && $uplink != "$iface" ]]; then + ok "Back to normal — traffic routes via ${uplink:-your usual connection}" + else + warn "Route didn't settle as expected (uplink: ${uplink:-none}). Check Status." + fi + gum input --placeholder "Press Enter to continue…" >/dev/null || true +} + +cmd_tui() { + command -v gum >/dev/null || die "gum is required for the TUI — run: $0 install" + poll_terminal_size + + while true; do + clear + tui_header + + # --- gather state --- + local iface="" addr="" uplink="" metric mode phone_line tether_line uplink_line + local connected=false paired=false + metric=$(current_metric || true) + mode="fallback"; [[ $metric == "$METRIC_PREFERRED" ]] && mode="preferred" + iface=$(tether_iface || true) + [[ -n $iface ]] && addr=$(tether_ip "$iface") + uplink=$(active_uplink) + + if phone_visible; then + paired=false; phone_paired && paired=true + if $paired; then + phone_line="${GRN}${RST} $(phone_name) — paired" + else + phone_line="${YLW}${RST} iPhone detected — not trusted yet" + fi + elif phone_on_usb; then + phone_line="${YLW}${RST} iPhone on USB — not responding (will retry)" + else + phone_line="${DIM} No iPhone plugged in${RST}" + fi + + if [[ -n $iface && -n $addr ]]; then + connected=true + tether_line="${GRN}󰛳${RST} Tether up: $iface $addr" + elif [[ -n $iface ]]; then + tether_line="${YLW}󰛳${RST} Interface $iface up, no IP (hotspot off?)" + else + tether_line="${DIM}󰛳 No tether connection${RST}" + fi + + if [[ -n $uplink && $uplink == "$iface" ]]; then + uplink_line="${GRN}${RST} Internet via iPhone" + elif [[ -n $uplink ]]; then + uplink_line="󰈀 Internet via $uplink ${DIM}(phone is $mode)${RST}" + else + uplink_line="${RED}󰈂${RST} No internet route" + fi + + printf '%s\n%s\n%s\n\n' "$phone_line" "$tether_line" "$uplink_line" + + # --- context-aware menu --- + local -a menu=() + [[ -f $NETFILE ]] || menu+=("Install tethering support") + if ! $connected; then + menu+=("Connect phone") + fi + if $connected && [[ $uplink != "$iface" ]]; then + menu+=("Switch internet → iPhone") + fi + if [[ $mode == preferred ]]; then + menu+=("Switch back → ethernet/wifi") + fi + menu+=("Status (full check)" "Refresh" "Quit") + + local choice + choice=$(gum choose --header "What do you want to do?" "${menu[@]}") || break + case $choice in + "Install tethering support") cmd_install; gum input --placeholder "Press Enter…" >/dev/null || true ;; + "Connect phone") tui_connect ;; + "Switch internet → iPhone") tui_switch phone ;; + "Switch back → ethernet/wifi") tui_switch normal ;; + "Status (full check)") say ""; cmd_status || true; gum input --placeholder "Press Enter…" >/dev/null || true ;; + "Refresh") ;; + "Quit"|"") break ;; + esac + done +} + +# ---------------------------------------------------------------- main ----- + +case ${1:-tui} in + tui) cmd_tui ;; install) cmd_install ;; pair) cmd_pair ;; unpair) cmd_unpair ;; @@ -187,7 +423,7 @@ case ${1:-help} in priority) shift; cmd_priority "${1:-}" ;; uninstall) cmd_uninstall ;; help|-h|--help) - sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ;; *) die "Unknown command: $1 (try: $0 help)" ;; esac