floppy-utils/src/lib/common.sh
Greg Gauthier 34aeb7bc0d feat(floppy-utils): add bash toolkit for retro floppy disk operations
Introduce floppy-utils: a CLI for creating, editing, archiving, and
restoring floppy disk images on Linux with USB floppy drive support.
Includes main floppy command, legacy wrappers, dependency checker,
install script, and comprehensive documentation.
2026-06-01 20:54:30 +01:00

1019 lines
33 KiB
Bash

# Shared helpers for floppy-utils. Source from src/floppy only.
[[ -n "${_FLOPPY_COMMON_LOADED:-}" ]] && return 0
_FLOPPY_COMMON_LOADED=1
FLOPPY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FLOPPY_SCRIPT_DIR="$(cd "$FLOPPY_LIB_DIR/.." && pwd)"
floppy_load_config() {
local cfg="${XDG_CONFIG_HOME:-$HOME/.config}/floppy-utils/config"
if [[ -f "$cfg" ]]; then
# shellcheck source=/dev/null
source "$cfg"
fi
: "${FLOPPY_DISKDIR:=$HOME/Retro/BLANKS}"
: "${FLOPPY_MEDIADIR:=/media/${USER:-root}}"
: "${FLOPPY_DEFAULT_SIZE_KB:=1440}"
# USB floppy drives (e.g. Mitsumi UFDD) often appear as /dev/sda with 0B until media is inserted.
: "${FLOPPY_DEVICE_MATCH:=mitsumi|ufdd|fdd|floppy}"
}
floppy_die() {
echo "floppy: $*" >&2
exit 1
}
floppy_info() {
# Must use stderr: this is called from functions whose stdout is captured (e.g. pick_device).
echo ">>> $*" >&2
}
floppy_warn() {
echo "floppy: warning: $*" >&2
}
floppy_require_cmd() {
local cmd
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || floppy_die "required command not found: $cmd"
done
}
floppy_require_root() {
[[ "$(id -u)" -eq 0 ]] && return 0
command -v sudo >/dev/null 2>&1 || floppy_die "root privileges required (install sudo or run as root)"
}
floppy_run_root() {
floppy_require_root
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
else
sudo -n "$@" 2>/dev/null || sudo "$@"
fi
}
floppy_ensure_diskdir() {
if [[ ! -d "$FLOPPY_DISKDIR" ]]; then
mkdir -p "$FLOPPY_DISKDIR" || floppy_die "cannot create disk directory: $FLOPPY_DISKDIR"
fi
}
floppy_validate_size_kb() {
local size="$1"
case "$size" in
360|720|1440) return 0 ;;
*) floppy_die "size must be one of: 360, 720, 1440 (got: $size)" ;;
esac
}
floppy_random_name() {
local rando
rando="$(od -An -N4 -tu4 < /dev/urandom | tr -d ' ')"
echo "floppy${rando}"
}
# Sanitize volume label / user text for use as a filename.
floppy_sanitize_name() {
local raw="$1"
local clean
clean="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '_' | sed 's/^_//;s/_$//')"
[[ -n "$clean" ]] || clean="floppy"
echo "${clean:0:32}"
}
# Resolve argument to an absolute .img path.
# Like floppy_resolve_image but returns 1 instead of exiting.
floppy_try_resolve_image() {
local arg="${1:-}"
local path base
[[ -n "$arg" ]] || return 1
if [[ -f "$arg" ]]; then
printf '%s\n' "$(readlink -f "$arg")"
return 0
fi
base="${arg%.img}"
path="$FLOPPY_DISKDIR/${base}.img"
if [[ -f "$path" ]]; then
printf '%s\n' "$(readlink -f "$path")"
return 0
fi
return 1
}
floppy_resolve_image() {
local arg="${1:-}"
local path base
[[ -n "$arg" ]] || floppy_die "image name or path required"
if [[ -f "$arg" ]]; then
printf '%s\n' "$(readlink -f "$arg")"
return 0
fi
base="${arg%.img}"
path="$FLOPPY_DISKDIR/${base}.img"
if [[ -f "$path" ]]; then
printf '%s\n' "$(readlink -f "$path")"
return 0
fi
floppy_die "image not found: $arg (also tried $path)"
}
floppy_image_basename() {
local image="$1"
basename "${image%.img}"
}
floppy_image_path_in_diskdir() {
local name="${1%.img}"
printf '%s/%s.img\n' "$FLOPPY_DISKDIR" "$name"
}
floppy_create_blank() {
local name="$1"
local size_kb="$2"
local image
floppy_validate_size_kb "$size_kb"
floppy_ensure_diskdir
image="$(floppy_image_path_in_diskdir "$name")"
if [[ -f "$image" ]]; then
floppy_warn "image already exists: $image"
printf '%s\n' "$image"
return 0
fi
floppy_info "creating ${size_kb}K image: $image"
dd if=/dev/zero of="$image" bs=1K count="$size_kb" status=progress 2>/dev/null \
|| dd if=/dev/zero of="$image" bs=1K count="$size_kb"
printf '%s\n' "$(readlink -f "$image")"
}
# Per-device lsblk field (avoids broken parsing when LABEL is empty).
floppy_device_field() {
local dev="$1"
local field="$2"
lsblk -dno "$field" "$dev" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
# Volume label, or empty if unknown / invalid.
floppy_device_label() {
local dev="$1"
local label
label="$(floppy_device_field "$dev" LABEL)"
if [[ -z "$label" || "$label" == "/"* ]]; then
label="$(blkid -s LABEL -o value "$dev" 2>/dev/null | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
fi
if [[ -n "$label" && "$label" != "/"* ]]; then
printf '%s\n' "$label"
return 0
fi
return 1
}
# Score how likely a block device is a USB floppy drive (higher = better match).
floppy_device_score() {
local dev="$1"
local model size tran label
local score=0
model="$(floppy_device_field "$dev" MODEL | tr '[:upper:]' '[:lower:]')"
size="$(floppy_device_field "$dev" SIZE)"
tran="$(floppy_device_field "$dev" TRAN | tr '[:lower:]' '[:upper:]')"
label="$(floppy_device_label "$dev" 2>/dev/null || true)"
if [[ "$model" =~ ${FLOPPY_DEVICE_MATCH} ]]; then
score=$((score + 100))
fi
if [[ "$tran" == "USB" ]]; then
score=$((score + 40))
fi
case "$size" in
0|0B) score=$((score + 5)) ;;
360K|720K|1.0M|1.2M|1.4M|1.44M) score=$((score + 30)) ;;
esac
[[ -n "$label" ]] && score=$((score + 10))
printf '%s\n' "$score"
}
# List whole-disk block devices likely to be USB/removable drives (floppy, USB FDD, etc.)
floppy_list_devices() {
local verbose="${1:-0}"
local name rm type size tran model label fstype mnt
floppy_require_cmd lsblk
while IFS= read -r name; do
[[ -z "$name" ]] && continue
rm="$(floppy_device_field "$name" RM)"
type="$(floppy_device_field "$name" TYPE)"
[[ "$type" == "disk" && "$rm" == "1" ]] || continue
[[ "$name" =~ ^/dev/(sd|fd) ]] || continue
size="$(floppy_device_field "$name" SIZE)"
tran="$(floppy_device_field "$name" TRAN)"
model="$(floppy_device_field "$name" MODEL)"
label="$(floppy_device_label "$name" 2>/dev/null || echo "")"
fstype="$(floppy_device_field "$name" FSTYPE)"
mnt="$(floppy_device_field "$name" MOUNTPOINT)"
if [[ "$verbose" -eq 1 ]]; then
printf '%s rm=%s size=%s tran=%s model=%s label=%s fstype=%s mount=%s score=%s\n' \
"$name" "$rm" "$size" "$tran" "$model" "${label:-}" "${fstype:-}" "${mnt:-}" \
"$(floppy_device_score "$name")"
else
local extra=""
[[ -n "$label" ]] && extra=" label=$label"
[[ -n "$mnt" ]] && extra="$extra mount=$mnt"
printf '%s %s %s %s%s\n' "$name" "$size" "${tran:-?}" "${model:-unknown}" "$extra"
fi
done < <(lsblk -dpno NAME,RM,TYPE 2>/dev/null | awk '$2==1 && $3=="disk" && $1 ~ /^\/dev\/(sd|fd)/ {print $1}')
}
floppy_device_has_media() {
local dev="$1"
local size
size="$(floppy_device_field "$dev" SIZE)"
[[ -n "$size" && "$size" != "0" && "$size" != "0B" ]]
}
floppy_device_show() {
local dev="$1"
lsblk -d -o NAME,SIZE,MODEL,TRAN,LABEL,FSTYPE,MOUNTPOINT "$dev" 2>/dev/null || true
}
floppy_device_sectors() {
local dev="$1"
local sectors
floppy_require_cmd blockdev
sectors="$(floppy_run_root blockdev --getsz "$dev" 2>/dev/null)" || floppy_die "cannot read size of $dev"
if [[ -z "$sectors" || "$sectors" -eq 0 ]]; then
if ! floppy_device_has_media "$dev"; then
floppy_die "no media detected in $dev (insert a floppy and retry)"
fi
floppy_die "device $dev reports 0 sectors"
fi
printf '%s\n' "$sectors"
}
floppy_device_default_name() {
local dev="$1"
local label mnt base
if label="$(floppy_device_label "$dev" 2>/dev/null)"; then
floppy_sanitize_name "$label"
return 0
fi
# Unlabeled FAT volumes: udisks often mounts as $FLOPPY_MEDIADIR/<name>
mnt="$(floppy_device_field "$dev" MOUNTPOINT)"
if [[ -n "$mnt" ]]; then
base="$(basename "$mnt")"
if [[ -n "$base" && "$base" != "/"* ]]; then
floppy_sanitize_name "$base"
return 0
fi
fi
floppy_random_name
}
floppy_unmount_device() {
local dev="$1"
local mp unmounted=0
if command -v udisksctl >/dev/null 2>&1 && floppy_device_mounted "$dev"; then
if udisksctl unmount -b "$dev" 2>/dev/null; then
floppy_info "unmounted $dev via udisks"
return 0
fi
fi
while read -r mp; do
[[ -n "$mp" ]] || continue
floppy_info "unmounting $mp"
floppy_run_root umount "$mp" && unmounted=1
done < <(lsblk -rn -o MOUNTPOINT "$dev" 2>/dev/null | sed '/^$/d')
[[ "$unmounted" -eq 1 ]] || floppy_warn "$dev does not appear to be mounted"
}
# After swapping floppies in the same USB drive: drop stale mounts and re-probe media.
floppy_cmd_refresh() {
local device="${1:-}"
local do_eject="${2:-0}"
local do_mount="${3:-0}"
local wait_secs="${4:-2}"
floppy_require_cmd lsblk blockdev
device="$(floppy_resolve_burn_device "$device")"
floppy_info "refreshing $device (unmount stale state, re-probe media)"
echo "Before:"
floppy_device_show "$device"
echo ""
if floppy_device_mounted "$device"; then
floppy_unmount_device "$device"
fi
floppy_run_root blockdev --flushbufs "$device" 2>/dev/null || true
if [[ "$do_eject" -eq 1 ]]; then
if command -v eject >/dev/null 2>&1; then
floppy_info "ejecting $device (helps some USB FDD firmware notice media change)"
floppy_run_root eject "$device" 2>/dev/null \
|| floppy_warn "eject failed — swap the disk and continue anyway"
else
floppy_warn "eject command not found; install eject(1) or omit --eject"
fi
fi
if command -v udevadm >/dev/null 2>&1; then
udevadm settle --timeout=5 2>/dev/null || true
fi
if [[ "$wait_secs" -gt 0 ]]; then
floppy_info "waiting ${wait_secs}s for drive/kernel to settle"
sleep "$wait_secs"
fi
# Reading size nudges usb-storage to re-check capacity on many drives.
floppy_run_root blockdev --getsz "$device" >/dev/null 2>&1 || true
echo "After:"
floppy_device_show "$device"
echo ""
if floppy_device_has_media "$device"; then
local label mnt
label="$(floppy_device_label "$device" 2>/dev/null || true)"
mnt="$(floppy_device_field "$device" MOUNTPOINT)"
floppy_info "media detected${label:+ (label: $label)}"
if [[ "$do_mount" -eq 1 ]] && [[ -z "$mnt" ]]; then
if command -v udisksctl >/dev/null 2>&1; then
if udisksctl mount -b "$device" 2>/dev/null; then
mnt="$(lsblk -dno MOUNTPOINT "$device" 2>/dev/null)"
floppy_info "mounted at ${mnt:-?}"
else
floppy_warn "udisks mount failed — run: udisksctl mount -b $device"
fi
else
floppy_warn "udisksctl not found; mount manually or use a file manager"
fi
elif [[ -n "$mnt" ]]; then
floppy_info "already mounted at $mnt"
fi
else
floppy_warn "no media detected (size 0B) — insert a disk and run: floppy refresh"
fi
}
# Unmount, flush, and power off USB floppy — safe to unplug the cable afterward.
floppy_cmd_disconnect() {
local device="${1:-}"
local powered_off=0
floppy_require_cmd blockdev
device="$(floppy_resolve_burn_device "$device")"
floppy_info "preparing $device for safe USB removal"
floppy_device_show "$device"
echo ""
if floppy_device_mounted "$device"; then
floppy_unmount_device "$device"
else
floppy_info "no filesystem mounted on $device"
fi
floppy_run_root blockdev --flushbufs "$device" 2>/dev/null || true
sync
if command -v udisksctl >/dev/null 2>&1; then
if udisksctl power-off -b "$device" 2>/dev/null; then
powered_off=1
floppy_info "powered off $device via udisks"
else
floppy_warn "udisks power-off failed for $device"
fi
else
floppy_warn "udisksctl not found — install udisks2 for USB power-off"
fi
if [[ "$powered_off" -eq 0 ]] && command -v eject >/dev/null 2>&1; then
if floppy_run_root eject "$device" 2>/dev/null; then
powered_off=1
floppy_info "ejected $device (fallback)"
fi
fi
if command -v udevadm >/dev/null 2>&1; then
udevadm settle --timeout=5 2>/dev/null || true
fi
echo ""
if [[ "$powered_off" -eq 1 ]]; then
echo "Safe to unplug the USB floppy drive now."
else
echo "Unmount and cache flush completed."
echo "Wait a few seconds, then unplug the USB floppy drive."
echo "If the device is busy, close file managers and retry: floppy disconnect"
fi
if [[ -b "$device" ]]; then
floppy_info "($device still visible until the cable is removed)"
fi
}
floppy_pick_device() {
local -a entries=()
local line dev score best_dev="" best_score=-1
while IFS= read -r line; do
[[ -n "$line" ]] || continue
dev="${line%% *}"
score="$(floppy_device_score "$dev")"
entries+=("$score $dev")
if (( score > best_score )); then
best_score=$score
best_dev="$dev"
fi
done < <(floppy_list_devices 0)
if [[ ${#entries[@]} -eq 0 ]]; then
floppy_die "no removable block devices found (is the USB floppy connected?)"
fi
# Auto-select a clear floppy match (e.g. Mitsumi UFDD at /dev/sda).
if [[ ${#entries[@]} -eq 1 ]]; then
floppy_info "using removable device: ${entries[0]#* }"
printf '%s\n' "${entries[0]#* }"
return 0
fi
if (( best_score >= 100 )); then
local floppy_like=0 d s
for entry in "${entries[@]}"; do
s="${entry%% *}"
if (( s >= 100 )); then
floppy_like=$((floppy_like + 1))
best_dev="${entry#* }"
fi
done
if (( floppy_like == 1 )); then
floppy_info "using USB floppy drive: $best_dev"
printf '%s\n' "$best_dev"
return 0
fi
fi
echo "Multiple removable devices found:"
local i=1 entry s d
for entry in "${entries[@]}"; do
s="${entry%% *}"
d="${entry#* }"
echo " $i) $d $(lsblk -dno SIZE,MODEL,LABEL,TRAN "$d" 2>/dev/null | tr -s ' ') (score=$s)"
((i++)) || true
done
echo -n "Select device number [1-${#entries[@]}]: "
read -r choice
if [[ ! "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#entries[@]} )); then
floppy_die "invalid selection"
fi
entry="${entries[$((choice - 1))]}"
printf '%s\n' "${entry#* }"
}
floppy_device_mounted() {
local dev="$1"
lsblk -n -o MOUNTPOINT "$dev" 2>/dev/null | grep -q .
}
floppy_resolve_burn_device() {
local device="${1:-}"
if [[ -n "$device" ]]; then
[[ -b "$device" ]] || floppy_die "not a block device: $device"
printf '%s\n' "$device"
return 0
fi
if [[ -n "${FLOPPY_DEVICE:-}" ]]; then
[[ -b "$FLOPPY_DEVICE" ]] || floppy_die "FLOPPY_DEVICE is not a block device: $FLOPPY_DEVICE"
printf '%s\n' "$FLOPPY_DEVICE"
return 0
fi
floppy_pick_device
}
floppy_confirm_burn() {
local image="$1"
local device="$2"
local yes="${3:-0}"
echo ""
echo " image: $image ($(du -h "$image" | cut -f1))"
echo " target: $device"
lsblk -d -o NAME,SIZE,MODEL,TRAN,MOUNTPOINT "$device" 2>/dev/null || true
echo ""
if [[ "$yes" -eq 1 ]]; then
return 0
fi
if floppy_device_mounted "$device"; then
floppy_warn "$device is mounted — it will be unmounted before writing"
fi
echo -n "Write image to $device? This will ERASE the floppy. [y/N] "
read -r ans
[[ "$ans" =~ ^[Yy]$ ]] || floppy_die "aborted"
}
# Find loop device backing an image, if any.
floppy_loop_for_image() {
local image="$1"
losetup -j "$image" 2>/dev/null | awk -F: '{print $1; exit}'
}
floppy_mountpoint_for_loop() {
local loop="$1"
lsblk -n -o MOUNTPOINT "$loop" 2>/dev/null | grep -m1 .
}
floppy_cmd_attach() {
local image_arg="${1:-}"
local size_kb="${2:-$FLOPPY_DEFAULT_SIZE_KB}"
local do_format="${3:-auto}"
local image name mountpoint loop need_format
floppy_require_cmd losetup mount blkid
floppy_ensure_diskdir
if [[ -z "$image_arg" ]]; then
name="$(floppy_random_name)"
image="$(floppy_create_blank "$name" "$size_kb")"
need_format=1
else
if [[ -f "$image_arg" ]]; then
image="$(readlink -f "$image_arg")"
name="$(floppy_image_basename "$image")"
elif [[ -f "$(floppy_image_path_in_diskdir "$image_arg")" ]]; then
image="$(floppy_resolve_image "$image_arg")"
name="$(floppy_image_basename "$image")"
else
name="${image_arg%.img}"
image="$(floppy_create_blank "$name" "$size_kb")"
need_format=1
fi
fi
if [[ "$do_format" == "yes" ]]; then
need_format=1
elif [[ "$do_format" == "no" ]]; then
need_format=0
fi
loop="$(floppy_loop_for_image "$image")"
if [[ -n "$loop" ]]; then
floppy_warn "already attached on $loop"
else
floppy_info "attaching loop device for $image"
loop="$(floppy_run_root losetup --find --show "$image")" || floppy_die "losetup failed"
floppy_info "loop device: $loop"
fi
if [[ "${need_format:-0}" -eq 1 ]] || ! floppy_run_root blkid "$loop" &>/dev/null; then
if [[ "$do_format" == "no" ]]; then
floppy_warn "no filesystem on $loop; mount may fail (use --format to create vfat)"
else
floppy_info "formatting $loop as vfat"
floppy_run_root mkfs -t vfat -n "${name:0:11}" "$loop" || floppy_die "mkfs failed"
need_format=1
fi
fi
mountpoint="$FLOPPY_MEDIADIR/$name"
if [[ ! -d "$mountpoint" ]]; then
floppy_info "creating mount point: $mountpoint"
floppy_run_root mkdir -p "$mountpoint"
fi
if mountpoint -q "$mountpoint" 2>/dev/null; then
floppy_info "already mounted at $mountpoint"
else
floppy_info "mounting $loop at $mountpoint"
floppy_run_root mount "$loop" "$mountpoint" || floppy_die "mount failed"
fi
if [[ "${need_format:-0}" -eq 1 ]]; then
local info_file="$mountpoint/${name}.txt"
floppy_info "saving partition info to $info_file"
floppy_run_root fdisk -l "$loop" >"${info_file}.tmp" 2>/dev/null \
&& floppy_run_root chown "${USER:-root}:${USER:-root}" "${info_file}.tmp" 2>/dev/null \
&& mv "${info_file}.tmp" "$info_file" \
|| rm -f "${info_file}.tmp"
fi
echo ""
echo " image: $image"
echo " loop: $loop"
echo " mountpoint: $mountpoint"
}
floppy_cmd_detach() {
local target="${1:-}"
local image loop mountpoint
[[ -n "$target" ]] || floppy_die "specify image name, mount point, or loop device"
if [[ -b "$target" && "$target" =~ ^/dev/loop ]]; then
loop="$target"
mountpoint="$(floppy_mountpoint_for_loop "$loop")"
elif mountpoint -q "$target" 2>/dev/null; then
mountpoint="$target"
loop="$(findmnt -n -o SOURCE "$mountpoint" 2>/dev/null)"
elif [[ -f "$target" ]] || [[ -f "$(floppy_image_path_in_diskdir "$target")" ]]; then
image="$(floppy_resolve_image "$target")"
loop="$(floppy_loop_for_image "$image")"
[[ -n "$loop" ]] || floppy_die "image is not attached: $image"
mountpoint="$(floppy_mountpoint_for_loop "$loop")"
else
# Physical floppy: udisks often mounts at $FLOPPY_MEDIADIR/<LABEL> (e.g. BOOTDISK)
if mountpoint -q "$FLOPPY_MEDIADIR/${target}" 2>/dev/null; then
mountpoint="$FLOPPY_MEDIADIR/${target}"
loop="$(findmnt -n -o SOURCE "$mountpoint" 2>/dev/null)"
elif mountpoint -q "$FLOPPY_MEDIADIR/${target%.img}" 2>/dev/null; then
mountpoint="$FLOPPY_MEDIADIR/${target%.img}"
loop="$(findmnt -n -o SOURCE "$mountpoint" 2>/dev/null)"
elif image="$(floppy_try_resolve_image "$target")"; then
loop="$(floppy_loop_for_image "$image")"
[[ -n "$loop" ]] || floppy_die "image is not attached: $image"
mountpoint="$(floppy_mountpoint_for_loop "$loop")"
else
floppy_die "cannot resolve: $target"
fi
fi
if [[ -n "${mountpoint:-}" ]] && mountpoint -q "$mountpoint" 2>/dev/null; then
floppy_info "unmounting $mountpoint"
floppy_run_root umount "$mountpoint" || floppy_die "umount failed"
fi
if [[ -n "${loop:-}" && "$loop" =~ ^/dev/loop ]]; then
floppy_info "detaching $loop"
floppy_run_root losetup -d "$loop" || floppy_die "losetup -d failed"
elif [[ -n "${loop:-}" && "$loop" =~ ^/dev/sd ]]; then
floppy_info "physical drive $loop left attached (no loop device)"
fi
floppy_info "done"
}
floppy_copy_to_image() {
local dev="$1"
local image="$2"
local sectors="$3"
local strict="${4:-0}"
local conv="noerror,sync,fsync"
local dd_status=0 expected_bytes=$((sectors * 512)) actual_bytes=0
[[ "$strict" -eq 1 ]] && conv="fsync"
floppy_info "reading $sectors sectors from $dev into $image (conv=$conv)"
set +e
floppy_run_root dd if="$dev" of="$image" bs=512 count="$sectors" conv="$conv" status=progress 2>/dev/null
dd_status=$?
if [[ "$dd_status" -ne 0 ]]; then
floppy_run_root dd if="$dev" of="$image" bs=512 count="$sectors" conv="$conv"
dd_status=$?
fi
set -e
sync
if [[ -f "$image" ]]; then
actual_bytes="$(wc -c < "$image" | tr -d ' ')"
fi
if [[ "$dd_status" -ne 0 ]]; then
floppy_warn "dd exited with status $dd_status ($actual_bytes / $expected_bytes bytes)"
if [[ "$strict" -eq 1 ]]; then
floppy_die "read failed (use default noerror,sync mode, or retry after: floppy refresh)"
fi
floppy_warn "image may be incomplete — bad sectors are common on aging floppies"
elif [[ "$actual_bytes" -lt "$expected_bytes" ]]; then
floppy_warn "short read: $actual_bytes / $expected_bytes bytes — disk may have bad sectors"
fi
}
floppy_copy_to_device() {
local image="$1"
local dev="$2"
floppy_info "writing $image to $dev"
floppy_run_root dd if="$image" of="$dev" bs=512 conv=fsync status=progress 2>/dev/null \
|| floppy_run_root dd if="$image" of="$dev" bs=512 conv=fsync
sync
}
floppy_confirm_read() {
local dev="$1"
local image="$2"
local yes="${3:-0}"
echo ""
echo " source: $dev"
floppy_device_show "$dev"
echo " output: $image"
if [[ -f "$image" ]]; then
echo " note: output file exists and will be overwritten"
fi
echo ""
[[ "$yes" -eq 1 ]] && return 0
echo -n "Archive floppy to $image? [y/N] "
read -r ans
[[ "$ans" =~ ^[Yy]$ ]] || floppy_die "aborted"
}
floppy_cmd_read() {
local name="${1:-}"
local device="${2:-}"
local yes="${3:-0}"
local force="${4:-0}"
local strict="${5:-0}"
local image sectors
floppy_require_cmd dd lsblk blockdev
floppy_ensure_diskdir
device="$(floppy_resolve_burn_device "$device")"
if ! floppy_device_has_media "$device"; then
floppy_die "no media in $device — insert a floppy (size should show as 360K/720K/1.4M, not 0B)"
fi
if [[ -z "$name" ]]; then
name="$(floppy_device_default_name "$device")"
floppy_info "using output name: $name (from volume label or mount name)"
fi
name="${name%.img}"
image="$(floppy_image_path_in_diskdir "$name")"
if [[ -f "$image" && "$force" -ne 1 ]]; then
floppy_die "output exists: $image (use --force to overwrite)"
fi
sectors="$(floppy_device_sectors "$device")"
floppy_confirm_read "$device" "$image" "$yes"
if floppy_device_mounted "$device"; then
floppy_unmount_device "$device"
sleep 1
fi
floppy_copy_to_image "$device" "$image" "$sectors" "$strict"
floppy_info "read complete: $image ($(du -h "$image" | cut -f1), target ${sectors} sectors)"
}
floppy_cmd_dump() {
local output="${1:-}"
local device="${2:-}"
local yes="${3:-0}"
local force="${4:-0}"
local strict="${5:-0}"
local sectors
[[ -n "$output" ]] || floppy_die "usage: floppy dump -o FILE [-d DEVICE] [-y] [--force]"
floppy_require_cmd dd blockdev
device="$(floppy_resolve_burn_device "$device")"
if ! floppy_device_has_media "$device"; then
floppy_die "no media in $device — insert a floppy"
fi
if [[ "$output" != "-" ]]; then
output="$(readlink -f "$output" 2>/dev/null || echo "$output")"
if [[ -e "$output" && "$force" -ne 1 ]]; then
floppy_die "output exists: $output (use --force)"
fi
mkdir -p "$(dirname "$output")" 2>/dev/null || true
fi
sectors="$(floppy_device_sectors "$device")"
floppy_confirm_read "$device" "$output" "$yes"
if floppy_device_mounted "$device"; then
floppy_unmount_device "$device"
sleep 1
fi
if [[ "$output" == "-" ]]; then
floppy_info "dumping $sectors sectors from $device to stdout"
floppy_run_root dd if="$device" bs=512 count="$sectors" conv=noerror,sync 2>/dev/null
else
floppy_copy_to_image "$device" "$output" "$sectors" "$strict"
floppy_info "dump complete: $output"
fi
}
floppy_cmd_burn() {
local image="$1"
local device="${2:-}"
local yes="${3:-0}"
floppy_require_cmd dd lsblk
image="$(floppy_resolve_image "$image")"
device="$(floppy_resolve_burn_device "$device")"
floppy_confirm_burn "$image" "$device" "$yes"
if floppy_device_mounted "$device"; then
floppy_unmount_device "$device"
fi
floppy_copy_to_device "$image" "$device"
floppy_info "burn complete"
}
# Parse losetup -a lines into "loop|backing_file".
floppy_foreach_loop_backing() {
local line loop backing
while IFS= read -r line; do
[[ "$line" =~ ^/dev/loop ]] || continue
loop="${line%%:*}"
if [[ "$line" =~ \((.+)\)$ ]]; then
backing="${BASH_REMATCH[1]}"
printf '%s|%s\n' "$loop" "$backing"
fi
done < <(losetup -a 2>/dev/null)
}
floppy_cmd_list() {
local verbose="${1:-0}"
local loop backing mp name label size fstype dev
local had_loop=0 had_phys=0
local -A attached_images=()
floppy_require_cmd losetup lsblk findmnt
floppy_ensure_diskdir 2>/dev/null || true
while IFS='|' read -r loop backing; do
[[ -n "$backing" ]] && attached_images["$backing"]=1
done < <(floppy_foreach_loop_backing)
echo "Loop-attached disk images:"
while IFS='|' read -r loop backing; do
[[ -n "$loop" && -n "$backing" ]] || continue
[[ "$backing" == *.img ]] || continue
had_loop=1
name="$(floppy_image_basename "$backing")"
mp="$(floppy_mountpoint_for_loop "$loop" 2>/dev/null || true)"
if [[ -n "$mp" ]]; then
printf ' %-16s %-10s → %s\n' "$name" "$loop" "$mp"
else
printf ' %-16s %-10s (not mounted)\n' "$name" "$loop"
fi
if [[ "$verbose" -eq 1 ]]; then
printf ' %s\n' "$backing"
fi
done < <(floppy_foreach_loop_backing)
[[ "$had_loop" -eq 1 ]] || echo " (none)"
echo ""
echo "Physical floppy media:"
while IFS= read -r dev; do
[[ -n "$dev" ]] || continue
if ! floppy_device_has_media "$dev" && ! floppy_device_mounted "$dev"; then
continue
fi
had_phys=1
size="$(floppy_device_field "$dev" SIZE)"
label="$(floppy_device_label "$dev" 2>/dev/null || echo "—")"
mp="$(floppy_device_field "$dev" MOUNTPOINT)"
if [[ -n "$mp" ]]; then
printf ' %-16s %-6s label=%s → %s\n' "$dev" "$size" "$label" "$mp"
else
printf ' %-16s %-6s label=%s (not mounted)\n' "$dev" "$size" "$label"
fi
if [[ "$verbose" -eq 1 ]]; then
printf ' model=%s fstype=%s\n' \
"$(floppy_device_field "$dev" MODEL)" "$(floppy_device_field "$dev" FSTYPE)"
fi
done < <(lsblk -dpno NAME,RM,TYPE 2>/dev/null | awk '$2==1 && $3=="disk" && $1 ~ /^\/dev\/(sd|fd)/ {print $1}')
[[ "$had_phys" -eq 1 ]] || echo " (none)"
if [[ "$verbose" -eq 1 && -d "$FLOPPY_DISKDIR" ]]; then
echo ""
echo "Disk images in $FLOPPY_DISKDIR:"
local img
for img in "$FLOPPY_DISKDIR"/*.img; do
[[ -f "$img" ]] || continue
name="$(floppy_image_basename "$img")"
if [[ -n "${attached_images[$img]:-}" ]]; then
printf ' %-16s %s [loop-attached]\n' "$name" "$(du -h "$img" | cut -f1)"
else
printf ' %-16s %s\n' "$name" "$(du -h "$img" | cut -f1)"
fi
done
fi
}
floppy_cmd_status() {
floppy_require_cmd losetup lsblk findmnt
local dev
echo "Disk directory: $FLOPPY_DISKDIR"
echo "Mount base: $FLOPPY_MEDIADIR"
if [[ -n "${FLOPPY_DEVICE:-}" ]]; then
echo "FLOPPY_DEVICE: $FLOPPY_DEVICE"
fi
echo ""
echo "Attached images (loop):"
if losetup -a 2>/dev/null | grep -q '\.img'; then
losetup -a | grep '\.img' || true
else
echo " (none)"
fi
echo ""
echo "Mounts under $FLOPPY_MEDIADIR:"
if [[ -d "$FLOPPY_MEDIADIR" ]]; then
local m found=0
for m in "$FLOPPY_MEDIADIR"/*; do
[[ -e "$m" ]] || continue
found=1
findmnt -no TARGET,SOURCE,FSTYPE,OPTIONS "$m" 2>/dev/null \
|| echo " $m (present, not mounted)"
done
[[ "$found" -eq 1 ]] || echo " (empty)"
else
echo " (directory does not exist)"
fi
echo ""
echo "Removable drives:"
floppy_list_devices 1 || echo " (none)"
}
floppy_usage() {
cat <<'EOF'
floppy — create, mount, and burn floppy disk images (USB floppy drives)
Usage:
floppy make [name] [-s SIZE] Create blank .img (default 1440K)
floppy attach [name|path] [-s SIZE] Create/open image, loop-mount for editing
floppy detach <name|mount|loop> Unmount and detach loop device
floppy refresh [-d DEV] [--mount] Re-probe drive after swapping floppies
floppy disconnect [-d DEV] Unmount, power off USB — safe to unplug
floppy read [name] [-d DEV] [-y] Archive physical floppy to FLOPPY_DISKDIR
floppy dump -o FILE [-d DEV] [-y] Raw sector dump to any path (or - for stdout)
floppy burn <image> [-d DEV] [-y] Write image to removable drive
floppy devices [-v] List candidate USB/removable block devices
floppy list [-v] List attached images and physical media
floppy status Full status (config, loops, mounts, drives)
floppy help [command] Show this help
Options:
-s, --size SIZE Image size in KB: 360, 720, or 1440 (default: 1440)
-d, --device DEV Target block device for burn (e.g. /dev/sdb)
-y, --yes Skip confirmation prompt
--force Overwrite existing output file (read/dump)
--strict Abort read/dump on first I/O error (default: noerror,sync)
-o, --output FILE Output path (dump only; use - for stdout)
--mount Mount new media via udisks after refresh
--eject Run eject(1) before re-probe (some USB FDD drives)
-w, --wait SEC Seconds to wait for media settle (default: 2)
--format Force vfat format on attach (new images only by default)
--no-format Do not format even if image is blank
Environment:
FLOPPY_DISKDIR Directory for .img files (default: ~/Retro/BLANKS)
FLOPPY_MEDIADIR Base mount directory (default: /media/$USER)
FLOPPY_DEVICE Default physical drive (e.g. /dev/sda for Mitsumi UFDD)
FLOPPY_DEVICE_MATCH Regex (extended) to prefer USB floppy models in device selection
FLOPPY_DEFAULT_SIZE_KB Default image size (default: 1440)
Config file (optional):
~/.config/floppy-utils/config Shell snippet, e.g. FLOPPY_DISKDIR=/path/to/BLANKS
Legacy wrappers (same commands):
floppy-make, floppy-attach, floppy-burn
Examples:
floppy make mydisk -s 720
floppy attach mydisk # mount ~/Retro/BLANKS/mydisk.img
floppy attach # new random 1.44M image, formatted & mounted
floppy detach mydisk
floppy devices -v
floppy refresh --mount # after swapping disks in the USB drive
floppy read # archive to ~/Retro/BLANKS/<volume-label>.img
floppy read bootdisk -d /dev/sda
floppy dump -o /tmp/bootdisk.img
floppy burn mydisk.img -d /dev/sda
FLOPPY_DEVICE=/dev/sda floppy read bootdisk -y
EOF
}