#!/usr/bin/env bash # # omatether — iPhone USB tethering for Omarchy / Arch (systemd-networkd + iwd) # # 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. # # 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'; DIM=$'\e[2m'; RST=$'\e[0m' say() { printf '%s\n' "$*"; } ok() { printf '%s\n' "${GRN}✓${RST} $*"; } warn() { printf '%s\n' "${YLW}!${RST} $*"; } die() { printf '%s\n' "${RED}✗${RST} $*" >&2; exit 1; } 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 for dev in /sys/class/net/*; do drv=$(readlink -f "$dev/device/driver" 2>/dev/null) || continue [[ ${drv##*/} == ipheth ]] && { basename "$dev"; return 0; } done 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 "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" 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…" do_pair_once || exit 1 say "" say "Now enable the hotspot on the phone:" say " Settings → Personal Hotspot → Allow Others to Join: ON" say "The connection comes up automatically. Check with: $0 status" } cmd_unpair() { idevicepair unpair && ok "Unpaired." } cmd_status() { local iface udid name udid=$(idevice_id -l 2>/dev/null | head -1 || true) if [[ -n $udid ]]; then name=$(phone_name) ok "iPhone connected over USB: $name" if phone_paired; then ok "Paired (trusted)" else warn "Not paired — run: $0 pair" fi else warn "No iPhone detected over USB" fi if iface=$(tether_iface); then local state addr state=$(networkctl status "$iface" 2>/dev/null | awk '/State:/{print $2; exit}' || true) ok "Tether interface: $iface ($state)" addr=$(tether_ip "$iface") if [[ -n $addr ]]; then ok "IPv4: $addr" ip route show default dev "$iface" | sed 's/^/ /' if net_test "$iface"; then ok "Internet via $iface works" else warn "No internet through $iface yet (is Personal Hotspot on?)" fi else warn "No IP yet — enable Personal Hotspot on the phone (Allow Others to Join)" fi else warn "No tether interface — phone unplugged, untrusted, or hotspot off" fi } cmd_priority() { [[ -f $NETFILE ]] || die "Not installed — run: $0 install" case ${1:-} in high) write_netfile "$METRIC_PREFERRED" reload_networkd ok "Tether now PREFERRED (metric $METRIC_PREFERRED) — traffic routes via the phone when plugged in" ;; low) write_netfile "$METRIC_FALLBACK" reload_networkd ok "Tether now FALLBACK (metric $METRIC_FALLBACK) — ethernet/wifi win when available" ;; *) say "Current metric: $(current_metric) (ethernet=100, wifi=600)" say "Usage: $0 priority " ;; esac } cmd_uninstall() { as_root rm -f "$NETFILE" rm -f "$DESKTOP_FILE" as_root networkctl reload systemctl --user restart elephant.service 2>/dev/null || true ok "Removed $NETFILE and the Walker launcher (usbmuxd/libimobiledevice left installed)" } # ----------------------------------------------------------------- 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 ;; status) cmd_status ;; priority) shift; cmd_priority "${1:-}" ;; uninstall) cmd_uninstall ;; help|-h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ;; *) die "Unknown command: $1 (try: $0 help)" ;; esac