diskOS installer: initial public beta

Flashes the diskOS custom UI onto the FiiO Snowsky Disc over Ingenic mask-ROM USB,
building the image from your own stock firmware. Runs from source via install.sh.
This commit is contained in:
b0hemia
2026-08-26 15:26:14 +10:00
commit e0bc4785e9
109 changed files with 12625 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
"""diskOS installer - cross-platform (Linux/macOS) installer for the FiiO Snowsky Disc.
The whole tool is Python; the two bricking-sensitive operations (the Ingenic
mask-ROM USB flash and squashfs packing) delegate to PROVEN native binaries that
ship alongside this package (see ``bundle.py``) rather than being reimplemented.
Run from source with your own Python: ``./install.sh`` creates a local virtualenv and
installs the two pip dependencies (pyusb, pycryptodome), then ``./diskos-installer``
runs it. The GUI additionally needs the system's Tk; the flash step needs libusb-1.0.
"""
__version__ = "1.0.0"
+220
View File
@@ -0,0 +1,220 @@
"""diskOS installer CLI.
Commands:
doctor Report host, bundled tools, device presence, and saved state.
install Build a diskOS image from YOUR stock firmware and flash it.
restore-stock Deactivate diskOS: reflash your saved stock-rootfs image (leaves /usr/data files).
remove Delete everything this tool created (its full uninstall footprint).
Design: the whole tool is Python; the bricking-sensitive steps delegate to bundled
proven native binaries. Nothing is installed system-wide on Linux/macOS.
"""
import argparse
import fcntl
import os
import sys
import tempfile
from diskos_installer import (__version__, bundle, errors, flasher, imagebuild,
platform_probe, service, state, ui)
from diskos_installer.reporter import CLIReporter
# --- single-run lock so two installers can't touch the ONE physical device at once. It must be
# SHARED across all users on the host (a GUI as your user and a CLI under sudo must not both flash),
# so it lives at a FIXED /tmp path - NOT per-user. Advisory flock (auto-released on death -> no
# stale-lock hazard). Opened READ-ONLY so any user can take the flock on a 0644 file, and O_NOFOLLOW
# so a planted symlink can't redirect the open (we bail instead of following it). ---
class _Lock:
def __init__(self):
# A FIXED absolute path, not tempfile.gettempdir(): $TMPDIR differs between a normal run and a
# sudo run (and per-user on macOS), which would hand them SEPARATE locks and defeat the whole
# point. /tmp is world-accessible on every supported host (Linux/macOS); Windows is unsupported.
self.path = "/tmp/diskos-installer.lock"
self.fd = None
def __enter__(self):
import stat as _stat
flags = os.O_CREAT | os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
try:
self.fd = os.open(self.path, flags, 0o644)
except OSError as e:
raise SystemExit(ui.red(
f"could not open the run-lock at {self.path} ({e}). If it exists as a symlink, "
"remove it and retry."))
# A planted symlink is refused by O_NOFOLLOW above; also refuse a non-regular file.
if not _stat.S_ISREG(os.fstat(self.fd).st_mode):
os.close(self.fd); self.fd = None
raise SystemExit(ui.red(f"run-lock {self.path} is not a regular file - refusing."))
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(self.fd)
self.fd = None
raise SystemExit(ui.red(
"another diskOS installer is already running. Close it and retry "
"(only one may touch the device at a time)."))
return self
def __exit__(self, *exc):
try:
if self.fd is not None:
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
except OSError:
pass
return False
def _set_phase(st, phase):
st["phase"] = phase
state.save(st)
def cmd_doctor(args):
ui.step("diskOS installer - doctor")
o, a = platform_probe.host()
ui.info(f"version : {__version__}")
ui.info(f"host : {o}-{a} ({'supported' if platform_probe.is_supported() else 'UNSUPPORTED'})")
ui.info(f"state dir : {state.state_dir()}")
ui.info("bundled tools:")
all_ok = True
for name in ("usbboot", "mksquashfs", "unsquashfs", "my_write5_dram.bin", "disc_spl_lpddr3.bin"):
p = bundle.native(name, required=False)
(ui.ok if p else ui.err)(f" {name}: {p or 'MISSING'}")
all_ok = all_ok and bool(p)
for name in ("mq_ui", "S97diskos_install", "S99usbserial", "diskos-debug.sh", "dropbearmulti"):
p = bundle.data(name, required=False)
(ui.ok if p else ui.err)(f" {name}: {p or 'MISSING'}")
all_ok = all_ok and bool(p) # payload files are required to build a flashable image
n = platform_probe.maskrom_count()
if n < 0:
ui.warn("device: cannot enumerate USB (pyusb/lsusb unavailable)")
elif n == 0:
ui.info("device: none in mask-ROM mode (normal unless you're about to flash)")
else:
(ui.ok if n == 1 else ui.warn)(f"device: {n} in mask-ROM mode")
st = state.load()
if st.get("installed"):
ui.info(f"diskOS installed via this tool: yes (variant={st.get('variant')}, at {st.get('installed_at')})")
else:
ui.info("diskOS installed via this tool: no record")
ui.info(f"saved bone-stock image (for restore): {'yes' if state.have_stock_image() else 'no'}")
return 0 if all_ok else 1
def _cli_confirm(yes):
"""Build a service `confirm` callback for the CLI (prints the summary, then
y/N - or auto-yes with --yes)."""
def confirm(summary):
ui.step("Ready - please confirm")
for k in ("action", "variant", "image", "duration", "consequence"):
if summary.get(k):
ui.info(f" {k}: {summary[k]}")
if yes:
return True
return ui.confirm("Proceed?", default=False)
return confirm
def cmd_install(args):
if not platform_probe.is_supported():
ui.err(f"[E101] unsupported host {platform_probe.host_tag()} - Linux/macOS only for now")
return 2
if not args.firmware and not args.stock:
ui.err("[E140] need --firmware <FiiO official update .zip> (or --stock <rootfs.squashfs>).")
return 2
params = {"firmware": args.firmware, "stock": args.stock,
"ui_binary": args.ui, "variant": args.variant}
r = service.do_install(params, CLIReporter(), _cli_confirm(args.yes))
if r.get("ok"):
ui.info("The UI is embedded in the flashed image - just reboot the device and it installs")
ui.info("diskOS automatically on first boot (no microSD step needed).")
ui.info("To deactivate diskOS later (reflash your saved stock rootfs): diskos-installer restore-stock")
return 0
return 3 if r.get("aborted") else 1
def cmd_restore_stock(args):
r = service.do_restore({"firmware": args.firmware}, CLIReporter(), _cli_confirm(args.yes))
return 0 if r.get("ok") else (3 if r.get("aborted") else 1)
def cmd_remove(args):
def confirm(summary):
ui.warn(summary.get("consequence", ""))
return args.yes or ui.confirm("Delete the installer's files anyway?", default=False)
r = service.do_remove({"force": args.force}, CLIReporter(), confirm)
if r.get("ok"):
if getattr(sys, "frozen", False):
ui.info(f"To finish: delete this executable ({sys.executable}).")
else:
ui.info(f"To finish: delete the installer folder ({bundle.resource_root()}).")
ui.info("Nothing was installed system-wide, so there is nothing else to clean up.")
return 0
if r.get("errors"):
return 1 # partial-removal errors already reported by the service
ui.info("aborted; nothing removed.")
return 3
def build_parser():
p = argparse.ArgumentParser(prog="diskos-installer",
description="Standalone diskOS installer for the FiiO Snowsky Disc.")
p.add_argument("--version", action="version", version=f"diskos-installer {__version__}")
# no subcommand -> GUI (running ./diskos-installer with no arguments); subcommands = CLI
sub = p.add_subparsers(dest="cmd", required=False)
sub.add_parser("gui", help="launch the graphical installer (also the default with no arguments)")
d = sub.add_parser("doctor", help="report host, bundled tools, device, and state")
d.set_defaults(func=cmd_doctor)
i = sub.add_parser("install", help="build from YOUR stock firmware and flash diskOS")
i.add_argument("--firmware", help="FiiO official update .zip (your stock firmware)")
i.add_argument("--stock", help="pre-extracted stock rootfs.squashfs (instead of --firmware)")
i.add_argument("--ui", help="diskOS UI binary (default: bundled mq_ui)")
i.add_argument("--variant", choices=["public", "dev"], default="public",
help="public (no always-on shell; Debug Mode enables SSH on demand) or dev (adds an ALWAYS-ON PASSWORDLESS ROOT SHELL over "
"USB - anyone with physical access gets root every boot; dev devices only)")
i.add_argument("-y", "--yes", action="store_true", help="don't prompt before flashing")
i.set_defaults(func=cmd_install)
r = sub.add_parser("restore-stock", help="deactivate diskOS (reflash your saved stock rootfs)")
r.add_argument("--firmware", help="FiiO update .zip (only needed if no stock image is saved)")
r.add_argument("-y", "--yes", action="store_true", help="don't prompt before flashing")
r.set_defaults(func=cmd_restore_stock)
rm = sub.add_parser("remove", help="delete the installer and everything it created")
rm.add_argument("-y", "--yes", action="store_true", help="don't prompt")
rm.add_argument("--force", action="store_true", help="remove even if diskOS is still on the device")
rm.set_defaults(func=cmd_remove)
return p
def main(argv=None):
args = build_parser().parse_args(argv)
# no subcommand, or 'gui' -> launch the graphical installer (under the lock)
if getattr(args, "cmd", None) in (None, "gui"):
from diskos_installer import gui
with _Lock():
return gui.main()
try:
if args.cmd == "doctor": # doctor is read-only; no lock needed
return args.func(args)
with _Lock():
return args.func(args)
except errors.DiskOSError as e: # any coded installer error (Build/Flash/Preflight)
ui.err(str(e))
return 1
except KeyboardInterrupt:
ui.err("interrupted.")
return 130
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
"""Locate the bundled native binaries and data files.
Layout (in the source tree AND inside the PyInstaller bundle):
<root>/vendor/<os>-<arch>/ usbboot, mksquashfs, unsquashfs,
my_write5_dram.bin, disc_spl_lpddr3.bin,
lib/ (bundled .so/.dylib for usbboot)
<root>/payload/ mq_ui, S97diskos_install, S99usbserial,
diskos_manifest templates, etc.
When frozen by PyInstaller, data is unpacked under sys._MEIPASS; in the source
tree it sits next to this package. `resource_root()` resolves both."""
import os
import stat
import sys
from . import platform_probe
def resource_root():
"""Directory that contains vendor/ and payload/.
- Frozen (PyInstaller): sys._MEIPASS.
- Source tree: the installer/ dir (parent of this package)."""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS
# this file is installer/diskos_installer/bundle.py -> installer/
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def vendor_dir():
return os.path.join(resource_root(), "vendor", platform_probe.host_tag())
def payload_dir():
return os.path.join(resource_root(), "payload")
def _ensure_exec(path):
try:
st = os.stat(path)
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
pass
return path
def native(name, required=True):
"""Absolute path to a bundled native binary for this host, made executable.
Raises FileNotFoundError if required and missing."""
p = os.path.join(vendor_dir(), name)
if os.path.exists(p):
return _ensure_exec(p)
if required:
from .errors import PreflightError
raise PreflightError(
f"bundled tool '{name}' not found for {platform_probe.host_tag()}",
code="E102",
action="this build may not include binaries for your platform - "
"re-download the correct build")
return None
def data(name, required=True):
"""Absolute path to a bundled data/payload file."""
p = os.path.join(payload_dir(), name)
if os.path.exists(p):
return p
if required:
from .errors import PreflightError
raise PreflightError(f"bundled payload '{name}' not found", code="E102",
action="the install is incomplete - re-download the installer")
return None
def native_env():
"""Environment for running a bundled native binary, with its private lib dir
on the loader path (so a bundled libusb is found without touching the system)."""
env = dict(os.environ)
libdir = os.path.join(vendor_dir(), "lib")
if os.path.isdir(libdir):
o, _ = platform_probe.host()
var = "DYLD_LIBRARY_PATH" if o == "macos" else "LD_LIBRARY_PATH"
env[var] = libdir + (os.pathsep + env[var] if env.get(var) else "")
return env
+63
View File
@@ -0,0 +1,63 @@
"""Stable, user-quotable error codes for the diskOS installer.
Every user-facing failure carries a short code a beta tester can quote from a
screenshot so we can triage it instantly. Namespaces:
E1xx environment / preflight (nothing written to the device yet)
E2xx firmware extract & image build
E3xx flash orchestration (host side)
F1xx device writer result (mirrors the on-device dbg[16] codes)
An exception's str() renders as: "[E301] <what happened> - <what to do>"
so existing `rep.error(str(e))` call sites show the code with no other change.
"""
class DiskOSError(Exception):
"""Base installer error carrying a stable `code` and an optional `action`
(a short, plain-language "what to do next")."""
code = "E000"
def __init__(self, message, code=None, action=None):
self.message = message
if code:
self.code = code
self.action = action
super().__init__(self.render())
def render(self):
s = f"[{self.code}] {self.message}"
if self.action:
s += f" - {self.action}"
return s
class PreflightError(DiskOSError):
"""E1xx - environment/preflight; the device has NOT been touched."""
code = "E100"
class BuildError(DiskOSError):
"""E2xx - firmware extraction / diskOS image build."""
code = "E200"
class FlashError(DiskOSError):
"""E3xx - flash orchestration (host side). F0xx = device-writer verdicts."""
code = "E300"
# Device-writer result codes (from my_write5.c dbg[16]) → stable F-codes for the user.
DEVICE_RESULT_CODES = {
0x600DF10C: ("F001", "SUCCESS"),
0xDEAD0001: ("F101", "ABORT init/ECC"),
0xDEAD0002: ("F102", "ABORT out-of-space"),
0xDEAD0003: ("F103", "ABORT persistent block-write fail"),
0xDEAD0004: ("F104", "ABORT too many bad blocks"),
0xDEAD0005: ("F105", "ABORT ECC re-enable failed"),
0xDEAD0006: ("F106", "ABORT bad-block marker unreadable"),
}
RECOVERABLE = ("The device is normally recoverable via mask-ROM (not guaranteed for every unit "
"or failure): power the device OFF, hold Volume-Down, plug in USB to return to "
"mask-ROM, and re-flash your saved stock image or diskOS.")
+273
View File
@@ -0,0 +1,273 @@
"""Mask-ROM USB flashing - Python wrapper around the PROVEN native binaries
(usbboot + the my_write5 DRAM NAND writer + the X2000 SPL). We do NOT reimplement
the flashing protocol; we orchestrate it, watch it, and interpret the result.
Faithful port of flash_diskos.sh, with the python helper snippets (poison blob,
debug-struct parse) folded in natively, plus honest progress and fail-closed
result interpretation.
"""
import os
import shutil
import signal
import struct
import subprocess
import tempfile
from . import bundle, platform_probe
from .reporter import CLIReporter
from .errors import FlashError, DEVICE_RESULT_CODES, RECOVERABLE
IMG_SIZE = 76021760
SQUASH_MAGIC = b"hsqs"
RESULT_NAMES = {code: name for code, (_fcode, name) in DEVICE_RESULT_CODES.items()}
FLASH_EXPECT_SECS = 75 * 60 # ~60-90 min; expected writer duration
# Hard ceiling for the whole usbboot invocation: the writer's own --wait is 5400s (90 min);
# give it that plus margin for the image download + result readback, then treat a still-running
# usbboot as a hung/reset device and terminate it (E303) rather than blocking forever.
FLASH_HARD_TIMEOUT_SECS = 5400 + 20 * 60 # 90 min writer + 20 min margin
def _probe_helpers():
"""Confirm every bundled native tool can actually execute (and load its libs)
BEFORE the destructive gate. Catches a noexec temp mount / missing dylibs while
the device is still untouched. Non-zero exit is fine; an OSError is not."""
probes = [(bundle.native("usbboot"), ["--help"]),
(bundle.native("mksquashfs"), ["-version"]),
(bundle.native("unsquashfs"), ["-version"])]
for path, args in probes:
try:
subprocess.run([path] + args, env=bundle.native_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=15)
except OSError as e:
raise FlashError(
f"bundled helper '{os.path.basename(path)}' cannot execute ({e})",
code="E103",
action="the tool's directory may be on a noexec mount, or a required library is "
"missing; move the installer to an exec-capable filesystem and retry")
except subprocess.TimeoutExpired:
pass # it started (that's all we needed to prove)
def preflight(image_path, rep=None):
"""Validate the image and that exactly one device is in mask-ROM. Fail closed."""
rep = rep or CLIReporter()
if not os.path.exists(image_path):
raise FlashError(f"image not found: {image_path}", code="E120")
sz = os.path.getsize(image_path)
if sz != IMG_SIZE:
raise FlashError(f"wrong image size {sz} (must be {IMG_SIZE})", code="E121",
action="use a diskOS image built by this installer")
with open(image_path, "rb") as f:
if f.read(4) != SQUASH_MAGIC:
raise FlashError("image is not a squashfs (bad magic) - not a diskOS/stock image", code="E122")
_probe_helpers() # every bundled tool must actually execute (catches noexec /tmp, missing libs)
n = platform_probe.maskrom_count()
if n == 0:
raise FlashError(
"no device in mask-ROM mode", code="E110",
action="power the device OFF, hold Volume-Down, plug in USB (screen stays "
"black), then retry")
if n > 1:
raise FlashError(f"{n} devices in mask-ROM mode - need exactly 1", code="E111",
action="unplug the other Ingenic devices")
if n < 0:
# FAIL CLOSED: we could not prove exactly one device (no libusb backend or a
# permission error). Never cross the destructive gate on an unproven count.
raise FlashError(
"cannot confirm exactly one device (USB enumeration failed - missing libusb "
"backend or insufficient USB permissions)", code="E112",
action="refusing to flash on an unproven device count; fix USB access and retry")
def _parse_debug(dbg_path):
"""Parse the 1KB little-endian debug readback (256 x uint32). Returns a dict.
Mirrors the field offsets in flash_diskos.sh."""
with open(dbg_path, "rb") as f:
raw = f.read(1024)
if len(raw) < 1024:
raise FlashError(f"short debug readback ({len(raw)} bytes) - flash result UNKNOWN", code="E302",
action=RECOVERABLE)
w = struct.unpack("<256I", raw)
nbad = w[20]
return {
"magic": w[0],
"done": w[9],
"skipped": w[10],
"result": w[16],
"retried": w[17],
"worst_retries": w[18],
"bad_found": nbad,
"bad_list": [w[40 + i] for i in range(min(nbad, 64))],
}
def flash(image_path, log_path=None, rep=None):
"""Flash `image_path` to the device via mask-ROM. Returns the parsed debug dict
on SUCCESS; raises FlashError otherwise (fail-closed)."""
rep = rep or CLIReporter()
preflight(image_path, rep)
image_path = os.path.abspath(image_path)
usbboot = bundle.native("usbboot")
writer = bundle.native("my_write5_dram.bin")
spl = bundle.native("disc_spl_lpddr3.bin")
tmp = tempfile.mkdtemp(prefix="diskos-flash-")
poison = os.path.join(tmp, "poison.bin")
dbg = os.path.join(tmp, "dbg.bin")
with open(poison, "wb") as f:
f.write(b"\xee" * 128)
cmd = [
usbboot, "-v", "--cpu", "x2000", "--stage1", spl, "--wait", "2",
"--addr", "0xa0c00000", "--download", writer,
"--addr", "0xa1000000", "--download", image_path,
"--addr", "0xa0a00000", "--download", poison,
"--start1", "0xa0c00030", "--wait", "5400",
"--addr", "0xa0a00000", "--length", "0x400", "--upload", dbg,
]
rep.phase("Flashing diskOS (mask-ROM) - ~60-90 minutes", destructive=True)
rep.warning("Do NOT disconnect the device or let the host sleep during the flash.")
logf = open(log_path, "w") if log_path else open(os.path.join(tmp, "flash.log"), "w")
rep.indeterminate(True, note="flashing (scan → erase → program → verify → retry)",
expect_secs=FLASH_EXPECT_SECS)
try:
try:
with _sleep_inhibited(rep):
# own session so a GUI/parent crash can't SIGPIPE the flasher; output goes
# to a file (never a pipe) so a closed reader can't deadlock or kill it.
proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT,
env=bundle.native_env(), start_new_session=True)
def _kill_flasher():
# The flasher runs in its OWN session (start_new_session) so a parent SIGPIPE
# can't kill it mid-write - but that also means it SURVIVES us. Kill the whole
# process group so NAND writes actually stop, then reap without blocking.
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
proc.kill()
try:
proc.wait(timeout=30) # reap; don't block if stuck in D-state
except subprocess.TimeoutExpired:
pass
try:
# A3: bound the wait. If the device resets/re-enumerates mid-flash,
# usbboot can block on a dead handle forever - never hang the app.
rc = proc.wait(timeout=FLASH_HARD_TIMEOUT_SECS)
except subprocess.TimeoutExpired:
_kill_flasher()
raise FlashError(
f"flash timed out - the device stopped responding after "
f"{FLASH_HARD_TIMEOUT_SECS // 60} min (it most likely reset "
"mid-flash)", code="E303", action=RECOVERABLE)
except BaseException:
# Ctrl-C, SIGTERM, a GUI/parent crash, or any error during the wait: WITHOUT
# this the flasher keeps writing NAND after we return, and a tester who believes
# flashing stopped may unplug mid-write and brick the device. Kill it, then
# propagate the original interruption/error.
_kill_flasher()
raise
finally:
rep.indeterminate(False)
logf.close()
rep.log(f"usbboot exit={rc}")
if not os.path.exists(dbg):
raise FlashError(
"no debug readback produced - flash result UNKNOWN; assume FAILED",
code="E301", action=RECOVERABLE)
d = _parse_debug(dbg)
ok = (d["magic"] == 0x4004E005 and d["done"] == 0x55555555
and d["result"] == 0x600DF10C)
rep.log(f"scan: bad-blocks-found={d['bad_found']} list={d['bad_list']}")
# B4: dbg[17]/[18] mean retried/worst only on SUCCESS; on the out-of-space /
# block-write-fail aborts they hold the last phys/logical block; on the other
# aborts they are 0 and must NOT be shown as "last block" (would be misleading).
if ok:
rep.log(f"write: skipped={d['skipped']} retried={d['retried']} "
f"worst={d['worst_retries']}")
elif d["result"] in (0xDEAD0002, 0xDEAD0003):
rep.log(f"write: skipped={d['skipped']} last-phys-block={d['retried']} "
f"last-logical-block={d['worst_retries']}")
else:
rep.log(f"write: skipped={d['skipped']}")
fcode, name = DEVICE_RESULT_CODES.get(d["result"], ("F000", "UNKNOWN"))
rep.log(f"result: 0x{d['result']:08X} [{fcode}] {name}")
if not ok:
raise FlashError(
f"flash FAILED (device result [{fcode}] {name}, "
f"magic=0x{d['magic']:08X} done=0x{d['done']:08X})",
code="E310", action=RECOVERABLE)
rep.ok("flash verified OK")
return d
finally:
# B3: never leak the per-flash tempdir (poison/dbg/flash.log), but keep the log
# debuggable: if no external log_path was given, preserve the internal log to a
# single stable file (overwritten each flash - bounded, not a growing leak).
internal_log = os.path.join(tmp, "flash.log")
if not log_path and os.path.exists(internal_log):
try:
# Unique, 0600, no symlink-follow: write THROUGH the mkstemp fd (never reopen the path,
# which could follow a swapped-in symlink and truncate an arbitrary target). Path reported.
fd, kept = tempfile.mkstemp(prefix="diskos-flash-", suffix=".log")
with os.fdopen(fd, "wb") as out, open(internal_log, "rb") as src:
shutil.copyfileobj(src, out)
rep.log(f"flash log saved to {kept}")
except OSError:
pass
shutil.rmtree(tmp, ignore_errors=True)
class _sleep_inhibited:
"""Best-effort host sleep inhibitor for the duration of the flash. No-op if the
platform tool isn't available; never blocks or fails the flash. If it CAN'T
inhibit sleep, it warns (B5) - a suspend mid-flash would abort it."""
def __init__(self, rep=None):
self.rep = rep
def __enter__(self):
self.proc = None
o, _ = platform_probe.host()
try:
if o == "macos":
self.proc = subprocess.Popen(["caffeinate", "-dimsu"])
elif o == "linux":
import shutil
if shutil.which("systemd-inhibit"):
# keep a long-lived inhibitor process alive; we kill it on exit
self.proc = subprocess.Popen(
["systemd-inhibit", "--what=sleep:idle",
"--why=diskOS flash in progress", "sleep", "infinity"])
except Exception:
self.proc = None
if self.proc is None and self.rep is not None:
self.rep.warning(
"could not auto-inhibit system sleep on this host - make sure your "
"computer will NOT sleep/suspend for the next ~90 minutes (a suspend "
"mid-flash aborts it; the device stays recoverable).")
return self
def __exit__(self, *exc):
if self.proc:
try:
self.proc.terminate()
self.proc.wait(timeout=5) # reap so no zombie / stray 'sleep infinity' lingers
except Exception:
try:
self.proc.kill()
except Exception:
pass
return False
+570
View File
@@ -0,0 +1,570 @@
"""Tkinter/ttk graphical installer.
Architecture:
* All Tk work on the main thread; a single worker thread runs the engine.
* The worker drives a QueueReporter; the GUI drains the queue via root.after.
* A blocking confirm() gate: the worker asks, the main thread shows a modal
acknowledgement dialog, the worker resumes on the user's decision.
* Explicit operation states; no Cancel once the destructive flash starts;
window-close is intercepted while flashing.
"""
import os
import queue
import sys
import threading
import time
from diskos_installer import __version__, bundle, platform_probe, service, state
from diskos_installer.reporter import QueueReporter
ACCENT = "#FF375F" # diskOS accent
BG = "#1c1c1e"
FG = "#f2f2f7"
SUBFG = "#9a9aa0"
PANEL = "#2c2c2e"
WARN = "#ffcc00"
OKC = "#34c759"
ERRC = "#ff453a"
# operation states
IDLE, RUNNING, CONFIRM, SUCCESS, FAILED = "idle", "running", "confirm", "success", "failed"
class App:
def __init__(self, root):
self.root = root
self.q = queue.Queue()
self.reporter = QueueReporter(self.q)
self.worker = None
self.state = IDLE
self.flashing = False # True only during the destructive phase
self._confirm_event = threading.Event()
self._confirm_result = False
self.start_time = None
root.title("diskOS Installer")
root.configure(bg=BG)
root.minsize(560, 640)
root.protocol("WM_DELETE_WINDOW", self._on_close)
self._build_style()
self._build_widgets()
self._refresh_state_labels()
self.root.after(80, self._drain)
self.root.after(1000, self._tick_elapsed)
self.root.after(4000, self._tick_device)
# ---- styling -----------------------------------------------------------
def _build_style(self):
import tkinter.font as tkfont
from tkinter import ttk
self.ttk = ttk
st = ttk.Style()
try:
st.theme_use("clam") # consistent across Linux/macOS
except Exception:
pass
# Derive from the platform's NAMED fonts (always present) rather than
# hard-coding families that may be absent (Helvetica/Menlo on Linux).
def _font(base, size, weight="normal"):
f = tkfont.nametofont(base).copy()
f.configure(size=size, weight=weight)
return f
self.f_title = _font("TkDefaultFont", 20, "bold")
self.f_h = _font("TkDefaultFont", 12, "bold")
self.f_b = _font("TkDefaultFont", 11)
self.f_mono = _font("TkFixedFont", 9)
st.configure("TFrame", background=BG)
st.configure("Panel.TFrame", background=PANEL)
st.configure("TLabel", background=BG, foreground=FG, font=self.f_b)
st.configure("Sub.TLabel", background=BG, foreground=SUBFG)
st.configure("Panel.TLabel", background=PANEL, foreground=FG)
st.configure("H.TLabel", background=BG, foreground=FG, font=self.f_h)
st.configure("TRadiobutton", background=BG, foreground=FG, font=self.f_b)
st.map("TRadiobutton", background=[("active", BG)])
st.configure("TCheckbutton", background=BG, foreground=FG, font=self.f_b)
st.map("TCheckbutton", background=[("active", BG)])
st.configure("Accent.TButton", font=self.f_h, padding=10)
st.configure("TButton", padding=6)
st.configure("diskos.Horizontal.TProgressbar", background=ACCENT, troughcolor=PANEL)
# ---- layout ------------------------------------------------------------
def _build_widgets(self):
ttk = self.ttk
pad = {"padx": 16}
head = ttk.Frame(self.root)
head.pack(fill="x", pady=(16, 4), **pad)
ttk.Label(head, text="diskOS Installer", font=self.f_title,
background=BG, foreground=FG).pack(anchor="w")
self.host_lbl = ttk.Label(head, text="", style="Sub.TLabel")
self.host_lbl.pack(anchor="w")
# mode
self.mode_frame = ttk.Frame(self.root)
self.mode_frame.pack(fill="x", pady=(10, 4), **pad)
self.mode = __import__("tkinter").StringVar(value="install")
for val, txt in (("install", "Install diskOS"),
("restore", "Remove diskOS (restore stock)"),
("remove", "Uninstall this tool")):
ttk.Radiobutton(self.mode_frame, text=txt, value=val, variable=self.mode,
command=self._on_mode).pack(anchor="w", pady=2)
# config panel (per-mode)
self.cfg = ttk.Frame(self.root, style="Panel.TFrame")
self.cfg.pack(fill="x", pady=10, **pad)
self._build_config()
# primary action
self.action_btn = ttk.Button(self.root, text="Install diskOS…",
style="Accent.TButton", command=self._on_action)
self.action_btn.pack(fill="x", pady=(4, 8), **pad)
# progress
prog = ttk.Frame(self.root)
prog.pack(fill="both", expand=True, **pad)
self.phase_lbl = ttk.Label(prog, text="Ready.", style="H.TLabel")
self.phase_lbl.pack(anchor="w")
self.status_lbl = ttk.Label(prog, text="", style="Sub.TLabel")
self.status_lbl.pack(anchor="w", pady=(0, 6))
self.bar = ttk.Progressbar(prog, style="diskos.Horizontal.TProgressbar",
mode="determinate", maximum=100)
self.bar.pack(fill="x")
self.elapsed_lbl = ttk.Label(prog, text="", style="Sub.TLabel")
self.elapsed_lbl.pack(anchor="w", pady=(2, 6))
self.warn_banner = __import__("tkinter").Label(
prog, text="", bg=WARN, fg="#111", font=self.f_h, anchor="center")
# packed only while flashing
import tkinter as tk
logwrap = ttk.Frame(prog)
logwrap.pack(fill="both", expand=True, pady=(4, 12))
self.log = tk.Text(logwrap, height=10, bg="#111", fg="#cfcfd4",
font=self.f_mono, wrap="word", relief="flat",
insertbackground=FG)
sb = ttk.Scrollbar(logwrap, command=self.log.yview)
self.log.configure(yscrollcommand=sb.set, state="disabled")
self.log.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
def _build_config(self):
for w in self.cfg.winfo_children():
w.destroy()
ttk = self.ttk
import tkinter as tk
m = self.mode.get()
inner = ttk.Frame(self.cfg, style="Panel.TFrame")
inner.pack(fill="x", padx=12, pady=12)
if m == "install":
ttk.Label(inner, text="Your FiiO firmware (.zip):", style="Panel.TLabel").grid(
row=0, column=0, sticky="w")
self.fw_var = tk.StringVar()
ttk.Entry(inner, textvariable=self.fw_var, width=44).grid(row=1, column=0, sticky="we", pady=(2, 8))
ttk.Button(inner, text="Browse…", command=self._pick_firmware).grid(row=1, column=1, padx=(8, 0))
ttk.Label(inner, text="Variant:", style="Panel.TLabel").grid(row=2, column=0, sticky="w")
self.variant = tk.StringVar(value="public")
vr = ttk.Frame(inner, style="Panel.TFrame")
vr.grid(row=3, column=0, sticky="w")
ttk.Radiobutton(vr, text="Public (recommended)", value="public",
variable=self.variant, command=self._variant_warn).pack(side="left")
ttk.Radiobutton(vr, text="Dev (root shell)", value="dev",
variable=self.variant, command=self._variant_warn).pack(side="left", padx=(12, 0))
self.variant_warn_lbl = tk.Label(
inner, text="", bg=PANEL, fg=WARN, font=self.f_b, justify="left", wraplength=460)
self.variant_warn_lbl.grid(row=4, column=0, sticky="w", pady=(4, 0))
inner.columnconfigure(0, weight=1)
elif m == "restore":
have = state.stock_image_exists() # fast; full verify happens at restore time
msg = ("A saved bone-stock image is available - restore is one click."
if have else
"No saved stock image. Pick your FiiO firmware .zip so I can rebuild it.")
ttk.Label(inner, text=msg, style="Panel.TLabel", wraplength=460).grid(
row=0, column=0, columnspan=2, sticky="w")
if not have:
self.fw_var = tk.StringVar()
ttk.Entry(inner, textvariable=self.fw_var, width=44).grid(row=1, column=0, sticky="we", pady=(8, 0))
ttk.Button(inner, text="Browse…", command=self._pick_firmware).grid(row=1, column=1, padx=(8, 0), pady=(8, 0))
inner.columnconfigure(0, weight=1)
else: # remove
ttk.Label(inner, wraplength=460, style="Panel.TLabel",
text=("Deletes this installer's saved files from THIS computer. "
"Nothing was installed system-wide. If diskOS is still on the "
"device, use ‘Remove diskOS’ first.")).pack(anchor="w")
# ---- helpers -----------------------------------------------------------
def _pick_firmware(self):
from tkinter import filedialog
p = filedialog.askopenfilename(title="Select FiiO firmware .zip",
filetypes=[("Firmware zip", "*.zip"), ("All", "*")])
if p:
self.fw_var.set(p)
def _variant_warn(self):
lbl = getattr(self, "variant_warn_lbl", None)
if lbl is None:
return
if self.variant.get() == "dev":
lbl.config(text="⚠ Dev installs an ALWAYS-ON PASSWORDLESS ROOT SHELL over USB - "
"anyone with physical access gets root on every boot. It bypasses "
"device security. Development devices only, never an everyday one.")
else:
lbl.config(text="")
def _refresh_state_labels(self):
o, a = platform_probe.host()
n = platform_probe.maskrom_count()
dev = {0: "no device in flash mode", -1: "USB not enumerable"}.get(n, f"{n} device(s) in flash mode")
self.host_lbl.config(text=f"{o}-{a} · v{__version__} · {dev}")
def _on_mode(self):
self._build_config()
self.action_btn.config(text={"install": "Install diskOS…",
"restore": "Remove diskOS (restore stock)…",
"remove": "Uninstall this tool"}[self.mode.get()])
def _log(self, line, color=None):
self.log.config(state="normal")
self.log.insert("end", line + "\n")
# cap the log so it can't grow unbounded
if int(self.log.index("end-1c").split(".")[0]) > 500:
self.log.delete("1.0", "100.0")
self.log.see("end")
self.log.config(state="disabled")
# ---- action / worker ---------------------------------------------------
def _on_action(self):
if self.state == RUNNING:
return
m = self.mode.get()
params = {}
if m == "install":
fw = getattr(self, "fw_var", None) and self.fw_var.get().strip()
if not fw:
self._log("Choose your FiiO firmware .zip first.")
return
params = {"firmware": fw, "variant": self.variant.get()}
fn = service.do_install
elif m == "restore":
fw = getattr(self, "fw_var", None)
params = {"firmware": (fw.get().strip() if fw else None)}
fn = service.do_restore
else:
params = {"force": False}
fn = service.do_remove
self.state = RUNNING
self.start_time = time.monotonic()
self._set_controls(False)
self.bar.config(mode="determinate", value=0)
self.status_lbl.config(text="")
self.elapsed_lbl.config(text="")
self.phase_lbl.config(text="Starting…")
self._clear_finish_extra()
def run():
try:
r = fn(params, self.reporter, self._worker_confirm)
self.q.put(("done", r))
except Exception as e: # BuildError/FlashError/etc.
self.q.put(("failed", {"error": str(e)}))
self.worker = threading.Thread(target=run, daemon=True)
self.worker.start()
def _set_controls(self, enabled):
"""Enable/disable all pre-flight controls (mode + config + action) so nothing
can be changed or re-triggered while a worker is running."""
st = "normal" if enabled else "disabled"
def walk(w):
for c in w.winfo_children():
cls = c.winfo_class()
if cls in ("TRadiobutton", "TButton", "TEntry", "TCheckbutton",
"Radiobutton", "Button", "Entry", "Checkbutton"):
try:
c.config(state=st)
except Exception:
pass
walk(c)
walk(self.mode_frame)
walk(self.cfg)
self.action_btn.config(state=st)
def _clear_finish_extra(self):
w = getattr(self, "_finish_extra", None)
if w is not None:
w.destroy()
self._finish_extra = None
def _worker_confirm(self, summary):
"""Called ON THE WORKER THREAD. Ask the UI (main thread) and block."""
self._confirm_result = False
self._confirm_event.clear()
self.q.put(("confirm", summary))
self._confirm_event.wait()
return self._confirm_result
def _show_confirm_dialog(self, summary):
import tkinter as tk
from tkinter import ttk
W = 480
dlg = tk.Toplevel(self.root)
dlg.title("Confirm")
dlg.configure(bg=BG)
dlg.transient(self.root)
dlg.resizable(False, False)
destructive = summary.get("action") in ("install", "restore-stock")
body = ttk.Frame(dlg)
body.pack(fill="both", expand=True, padx=18, pady=16)
ttk.Label(body, text=summary.get("action", "confirm").upper(),
style="H.TLabel").pack(anchor="w", pady=(0, 6))
for k in ("variant", "duration", "consequence"):
if summary.get(k):
ttk.Label(body, text=f"{k}: {summary[k]}", style="Sub.TLabel",
wraplength=W - 36, justify="left").pack(anchor="w", pady=1)
def _resolve(val):
# Mark the destructive boundary SYNCHRONOUSLY (main thread) the instant the
# user accepts - before the worker unblocks and spawns usbboot - so the
# window can't be closed in the gap before the 'phase' event is drained.
if val and destructive:
self.flashing = True
self._confirm_result = val
dlg.destroy()
self._confirm_event.set()
# button row FIRST (both buttons are direct children of it)
row = ttk.Frame(body)
row.pack(side="bottom", fill="x", pady=(14, 0))
ttk.Button(row, text="Cancel", command=lambda: _resolve(False)).pack(side="right", padx=(8, 0))
begin = ttk.Button(row, text=("Begin 60-90 minute flash" if destructive else "Proceed"),
style="Accent.TButton", command=lambda: _resolve(True))
begin.pack(side="right")
ack = tk.BooleanVar(value=not destructive)
if destructive:
# classic tk.Checkbutton (ttk.Checkbutton has no wraplength)
tk.Checkbutton(body, variable=ack, wraplength=W - 40, justify="left",
text="I understand this rewrites my device and must not be interrupted.",
bg=BG, fg=FG, selectcolor=PANEL, activebackground=BG,
activeforeground=FG, highlightthickness=0, font=self.f_b,
command=lambda: begin.config(state=("normal" if ack.get() else "disabled"))
).pack(anchor="w", pady=(12, 8))
begin.config(state="disabled")
# size to content, then centre over the main window
dlg.update_idletasks()
h = dlg.winfo_reqheight()
self.root.update_idletasks()
px, py = self.root.winfo_rootx(), self.root.winfo_rooty()
pw = self.root.winfo_width()
dlg.geometry(f"{W}x{h}+{px + max(0, (pw - W) // 2)}+{py + 110}")
dlg.grab_set()
dlg.protocol("WM_DELETE_WINDOW", lambda: _resolve(False))
dlg.bind("<Escape>", lambda e: _resolve(False))
dlg.bind("<Return>", lambda e: _resolve(True) if str(begin["state"]) == "normal" else None)
(begin if not destructive else dlg).focus_set()
# ---- queue drain (main thread) ----------------------------------------
def _drain(self):
budget = 200 # bounded per tick so a flood of events can't starve Tk
try:
while budget > 0:
kind, payload = self.q.get_nowait()
budget -= 1
try:
self._handle(kind, payload)
except Exception as e: # one malformed event must not stop the drain
self._log(f"[ui error handling {kind}: {e}]")
except queue.Empty:
pass
self.root.after(80, self._drain)
def _handle(self, kind, p):
if kind == "phase":
self.phase_lbl.config(text=p["name"])
if p.get("destructive"):
self.flashing = True
self.bar.config(mode="indeterminate")
self.bar.start(12)
self.warn_banner.config(text="DO NOT DISCONNECT THE DEVICE OR LET THE HOST SLEEP")
self.warn_banner.pack(fill="x", pady=6, before=self.log.master)
self._log("▶ " + p["name"])
elif kind == "status":
self.status_lbl.config(text=p["message"])
self._log(p["message"])
elif kind == "log":
self._log(" " + p["line"])
elif kind == "progress":
if self.bar["mode"] == "indeterminate":
self.bar.stop(); self.bar.config(mode="determinate")
total = p["total"] or 1
self.bar.config(maximum=total, value=p["completed"])
elif kind == "indeterminate":
if p["active"]:
self.bar.config(mode="indeterminate"); self.bar.start(12)
self.status_lbl.config(text=p.get("note", ""))
else:
self.bar.stop(); self.bar.config(mode="determinate")
elif kind == "warning":
self._log("! " + p["message"])
elif kind == "ok":
self._log("✓ " + p["message"])
elif kind == "error":
self._log("✗ " + p["message"])
elif kind == "confirm":
self._show_confirm_dialog(p)
elif kind == "done":
self._finish(p)
elif kind == "failed":
self._fail(p.get("error", "unknown error"))
def _finish(self, r):
self.flashing = False
if self.bar["mode"] == "indeterminate":
self.bar.stop()
self.warn_banner.pack_forget()
self._set_controls(True)
if r.get("aborted"):
self.state = IDLE
self.phase_lbl.config(text="Cancelled - device unchanged.")
elif r.get("errors"): # e.g. uninstall couldn't remove everything
self.state = FAILED
self.phase_lbl.config(text="Finished with problems - see log.")
else:
self.state = SUCCESS
self.bar.config(mode="determinate", maximum=100, value=100)
if self.mode.get() == "install":
self.phase_lbl.config(text="Flashed ✓ One more step ↓")
self._install_followup()
elif self.mode.get() == "remove":
self.phase_lbl.config(text="Removed ✓ Delete the app to finish.")
else:
self.phase_lbl.config(text="Done ✓ Power-cycle the device.")
self._refresh_state_labels()
def _install_followup(self):
"""The UI is embedded in the flashed image, so first boot installs diskOS on its own -
no microSD step. Offer an OPTIONAL SD copy only as a recovery fallback."""
import tkinter as tk
from tkinter import ttk
self._clear_finish_extra()
box = tk.Frame(self.root, bg=OKC)
box.pack(fill="x", padx=16, pady=(0, 8))
self._finish_extra = box
tk.Label(box, bg=OKC, fg="#111", font=self.f_h, justify="left", wraplength=500,
text="Done - just power-cycle the device. diskOS is embedded in the flash and "
"installs itself on first boot; no microSD step needed.").pack(
anchor="w", padx=10, pady=(8, 4))
tk.Label(box, bg=OKC, fg="#111", font=self.f_b, justify="left", wraplength=500,
text="Optional: you can also stage a fallback copy on a microSD (used only if the "
"embedded copy is ever unreadable).").pack(anchor="w", padx=10, pady=(0, 2))
ttk.Button(box, text="Copy fallback UI to microSD…", command=self._export_ui).pack(
anchor="w", padx=10, pady=(0, 8))
def _export_ui(self):
from tkinter import filedialog, messagebox
src = bundle.data("mq_ui", required=False)
if not src:
messagebox.showerror("diskOS Installer", "bundled mq_ui not found.")
return
d = filedialog.askdirectory(title="Select your microSD card (root)")
if not d:
return
try:
dest_dir = os.path.join(d, "diskos")
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, "mq_ui")
with open(src, "rb") as s, open(dest, "wb") as o:
o.write(s.read())
os.chmod(dest, 0o755)
self._log(f"✓ copied UI to {dest}")
messagebox.showinfo("diskOS Installer",
f"Copied a fallback diskOS UI to:\n{dest}\n\nThis is optional - the "
"device installs the embedded copy on its own. The card is only used "
"if that embedded copy is ever unreadable.")
except OSError as e:
messagebox.showerror("diskOS Installer", f"Could not copy to the card:\n{e}")
def _fail(self, msg):
self.flashing = False
try:
self.bar.stop()
except Exception:
pass
self.warn_banner.pack_forget()
self.state = FAILED
self._set_controls(True)
self.phase_lbl.config(text="Failed")
self._log("✗ " + msg)
from tkinter import messagebox
messagebox.showerror("diskOS Installer", msg)
def _tick_elapsed(self):
if self.state == RUNNING and self.start_time:
secs = int(time.monotonic() - self.start_time)
self.elapsed_lbl.config(text=f"elapsed {secs // 60}m{secs % 60:02d}s")
self.root.after(1000, self._tick_elapsed)
def _tick_device(self):
# keep the device-status line fresh, but never scan USB mid-operation
if self.state != RUNNING:
self._refresh_state_labels()
self.root.after(4000, self._tick_device)
def _on_close(self):
from tkinter import messagebox
if self.flashing:
messagebox.showwarning(
"Flash in progress",
"A flash is in progress. Closing now can leave the device needing a "
"mask-ROM recovery. Please wait until it finishes.")
return
if self.state == RUNNING:
if not messagebox.askokcancel(
"Operation in progress",
"An operation is still running. Close anyway?"):
return
self.root.destroy()
def main():
try:
import tkinter as tk
except Exception as e:
sys.stderr.write(f"diskOS Installer: Tkinter unavailable ({e}). Use the CLI: "
"diskos-installer --help\n")
return 2
try:
root = tk.Tk()
App(root)
root.mainloop()
return 0
except Exception as e:
# A packaged GUI hides early tracebacks - persist one and show a dialog.
import tempfile
import traceback
# Unique, 0600 crash log (a fixed name in a world-writable temp dir could be pre-placed as a
# symlink to truncate an arbitrary target). The exact path is shown in the message below.
try:
fd, logp = tempfile.mkstemp(prefix="diskos-installer-startup-", suffix=".log")
with os.fdopen(fd, "w") as f:
f.write(traceback.format_exc())
except OSError:
logp = "(could not write a log file)"
sys.stderr.write(f"diskOS Installer failed to start: {e}\n(log: {logp})\n"
"Try the CLI: diskos-installer doctor\n")
try:
import tkinter.messagebox as mb
mb.showerror("diskOS Installer",
f"Failed to start:\n{e}\n\nDetails: {logp}\n"
"You can still use the command line: diskos-installer doctor")
except Exception:
pass
return 1
if __name__ == "__main__":
sys.exit(main())
+511
View File
@@ -0,0 +1,511 @@
"""Build a flashable diskOS image from the user's OWN stock firmware.
Faithful Python port of the proven mkdiskos.sh / extract_stock_rootfs.sh logic:
extract_stock_rootfs(zip) -> stock rootfs.squashfs (byte-exact from FiiO's zip)
build_image(stock, mq_ui, variant) -> diskos_<variant>.bin (76021760 bytes)
We NEVER ship FiiO's rootfs; the user supplies their official firmware zip and we
build locally. squashfs pack/unpack delegates to the bundled mksquashfs/unsquashfs
(reference tools) - we do not reimplement squashfs.
"""
import os
import re
import subprocess
import zipfile
from . import bundle
from .reporter import CLIReporter
IMG_SIZE = 76021760 # diskOS image size: 580 NAND blocks (~72.5 MiB); written to the start of the mtd2 rootfs partition (RO squashfs need not fill the 128 MB partition)
# Known-good stock rootfs.squashfs, verified out-of-band (NOT trusting the in-zip OTA manifest,
# which an attacker could modify consistently). A tested firmware whose extracted rootfs does not
# match its pin is refused: this rejects a modified/tampered/corrupt rootfs before it is patched
# and flashed. Map: MAIN_OS_VER -> (sha256, size_bytes). Add a version's pin only after hashing an
# authentic copy of that firmware.
PINNED_ROOTFS = {
"228": ("0ffd877bca2c69ddff9ca70f4494da0d9e580c18d0f587e2c6d9921f2db82bd2", 72957952),
"209": ("f1e3c69fb0e88b923c135558e01f4387a661f68839c8118e8ad490bdc9fc74e6", 75919360),
}
# Firmware versions diskOS has been flash-tested against. Others have DIFFERENT command-tag
# meanings, so diskOS built on them can send wrong commands and misbehave/reboot.
TESTED_FW = {"209", "228"}
SQUASH_MAGIC = b"hsqs"
def validate_stock_rootfs(stock_squashfs, rep=None):
"""Validate that `stock_squashfs` is a genuine, supported, known-good Snowsky Disc rootfs -
a SHARED gate called BEFORE the image is saved as the recovery copy, BEFORE build, and BEFORE
every restore-flash (so a wrong-device or crafted rootfs can never be saved or flashed on the
strength of a size/magic preflight alone). Raises BuildError (E220 not-a-Disc-rootfs / E221
untested version / E224 hash mismatch). Returns the MAIN_OS_VER string. Only extracts the tiny
version.in - cheap enough to run on every path. DISKOS_ALLOW_UNTESTED_FW=1 relaxes E221/E224."""
rep = rep or CLIReporter()
import hashlib, tempfile, shutil
unsq = bundle.native("unsquashfs")
with open(stock_squashfs, "rb") as f:
if f.read(4) != SQUASH_MAGIC:
raise BuildError("not a squashfs image (bad magic) - not a Snowsky Disc rootfs", code="E220")
tmp = tempfile.mkdtemp(prefix="diskos-vchk-")
try:
_run([unsq, "-d", os.path.join(tmp, "x"), "-f", stock_squashfs,
"etc/product_version/version.in"], capture_output=True, text=True)
ver_in = os.path.join(tmp, "x", "etc/product_version/version.in")
prod = _grep1(ver_in, r"PRODUCT=([A-Za-z0-9_]+)")
mver = _grep1(ver_in, r"MAIN_OS_VER=([0-9]+)")
finally:
shutil.rmtree(tmp, ignore_errors=True)
override = os.environ.get("DISKOS_ALLOW_UNTESTED_FW") == "1"
if prod != "SNOWSKY_DISC":
raise BuildError(f"not a Snowsky Disc rootfs (PRODUCT={prod!r})", code="E220")
if mver not in TESTED_FW and not override:
raise BuildError(
f"firmware MAIN_OS_VER={mver or '?'} is not tested (supported: {', '.join(sorted(TESTED_FW))}). "
"Other versions can have incompatible command meanings. Re-run with "
"DISKOS_ALLOW_UNTESTED_FW=1 at your own risk.", code="E221")
pin = PINNED_ROOTFS.get(mver)
if pin:
exp_sha, exp_sz = pin
got_sz = os.path.getsize(stock_squashfs)
# The pin is over the EXACT extracted rootfs (exp_sz bytes). But the SAVED recovery copy is
# those same bytes zero-PADDED to the partition size (_save_stock pads to IMG_SIZE), so this
# gate is called with both the unpadded (install/build) and padded (restore) forms. Validate
# the first exp_sz bytes against the pin and require every byte AFTER to be zero padding - so a
# padded copy verifies identically to the original, while arbitrary appended data still fails.
got_sha, tail_zero = None, False
if exp_sz <= got_sz <= IMG_SIZE:
with open(stock_squashfs, "rb") as f:
h = hashlib.sha256(); remaining = exp_sz
while remaining > 0:
chunk = f.read(min(1 << 20, remaining))
if not chunk:
break
h.update(chunk); remaining -= len(chunk)
got_sha = h.hexdigest() if remaining == 0 else None
tail_zero = True # anything past exp_sz must be pure zero padding
while True:
chunk = f.read(1 << 20)
if not chunk:
break
if chunk.strip(b"\x00"):
tail_zero = False; break
if got_sha != exp_sha or not tail_zero:
if override:
rep.warning(f"stock rootfs hash {(got_sha or '?')[:12]}... != pinned V{mver} - proceeding (override set)")
else:
raise BuildError(
f"stock rootfs does not match the known-good V{mver} image (got "
f"{(got_sha or 'short/oversize')[:12]}..., expected {exp_sha[:12]}...). The firmware "
"may be modified, corrupt, or repackaged - re-download the official FiiO firmware. "
"(Set DISKOS_ALLOW_UNTESTED_FW=1 at your own risk.)", code="E224")
else:
rep.log(f"stock rootfs matches the pinned known-good V{mver} image (sha256 verified)")
elif mver in TESTED_FW:
rep.warning(f"no pinned hash for V{mver} yet - rootfs authenticity is NOT verified against a pin")
rep.log(f"stock rootfs OK: PRODUCT={prod} MAIN_OS_VER={mver or '?'}")
return mver
from .errors import BuildError # coded (E2xx); re-exported so imagebuild.BuildError still resolves
# --- safe zip extraction (reject traversal / symlink escape / bombs) ---------
def _safe_extract_member(zf, member, dest_root):
name = member.filename
if name.startswith("/") or os.path.isabs(name) or ".." in name.replace("\\", "/").split("/"):
raise BuildError(f"unsafe path in zip: {name!r}", code="E202")
target = os.path.realpath(os.path.join(dest_root, name))
if not (target == os.path.realpath(dest_root) or
target.startswith(os.path.realpath(dest_root) + os.sep)):
raise BuildError(f"zip entry escapes extraction dir: {name!r}", code="E202")
return target
# FiiO chunks the rootfs and wraps each chunk (and the manifest) in AES-256-CBC
# (openssl -pbkdf2) under the fixed key "fo123" (their reused OTA string -
# obfuscation, not protection). We decrypt + concatenate in index order. We only
# READ the image; no signature is involved.
_OTA_KEY = "fo123"
def _openssl_aes_decrypt(data, password):
"""Replicate `openssl enc -d -aes-256-cbc -pbkdf2 -k <password>`:
'Salted__' + 8-byte salt header, PBKDF2-HMAC-SHA256 (10000 iters) -> 32B key +
16B IV, AES-256-CBC, PKCS7 padding."""
import hashlib
from Crypto.Cipher import AES # pycryptodome (bundled)
if data[:8] != b"Salted__":
raise BuildError("encrypted blob missing openssl 'Salted__' header (not a FiiO OTA chunk?)", code="E211")
salt = data[8:16]
ct = data[16:]
if len(ct) == 0 or len(ct) % 16 != 0:
raise BuildError("encrypted blob has bad length (truncated chunk?)", code="E211")
keyiv = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 10000, 48)
pt = AES.new(keyiv[:32], AES.MODE_CBC, keyiv[32:48]).decrypt(ct)
pad = pt[-1] if pt else 0
if pad < 1 or pad > 16 or pt[-pad:] != bytes([pad]) * pad:
raise BuildError("bad PKCS7 padding after AES decrypt (wrong key or firmware?)", code="E211")
return pt[:-pad]
def extract_stock_rootfs(fw_zip, out_squashfs, workdir, rep=None):
"""Pull the exact stock rootfs.squashfs out of FiiO's official update zip by
decrypting + reassembling the main_os OTA chunks. Byte-exact; written only
after it validates (manifest size + squashfs magic), so a failure never
clobbers the output."""
rep = rep or CLIReporter()
rep.phase("Extracting stock firmware")
if not zipfile.is_zipfile(fw_zip):
raise BuildError(f"not a zip archive: {fw_zip}", code="E201")
ex = os.path.join(workdir, "fw_unzip")
import shutil
if os.path.isdir(ex): # fresh every time - never mix two firmwares' chunks
shutil.rmtree(ex, ignore_errors=True)
os.makedirs(ex)
total_uncompressed = 0
with zipfile.ZipFile(fw_zip) as zf:
infos = zf.infolist()
if len(infos) > 20000: # per-entry count bound (not just aggregate size)
raise BuildError("zip has an implausible number of entries - refusing.", code="E202")
for m in infos:
total_uncompressed += m.file_size
if total_uncompressed > 4 * (1 << 30): # 4 GiB bomb guard
raise BuildError("zip expands beyond 4 GiB - refusing (possible zip bomb)", code="E202")
rep.status("Unpacking firmware zip")
for i, m in enumerate(infos):
tgt = _safe_extract_member(zf, m, ex)
if m.is_dir():
os.makedirs(tgt, exist_ok=True)
else:
os.makedirs(os.path.dirname(tgt), exist_ok=True)
with zf.open(m) as src, open(tgt, "wb") as dst:
while True:
chunk = src.read(1 << 20)
if not chunk:
break
dst.write(chunk)
rep.progress(i + 1, len(infos))
# locate the ONE main_os OTA manifest (refuse ambiguity)
import glob
mans = sorted(glob.glob(os.path.join(ex, "**", "main_os", "ota_v*", "ota_update.in.enc"),
recursive=True))
if len(mans) == 0:
raise BuildError("no main_os/ota_v*/ota_update.in.enc in this zip - not a Disc "
"main-OS firmware?", code="E210")
if len(mans) > 1:
raise BuildError(f"{len(mans)} main_os manifests in this zip - ambiguous, aborting.", code="E210")
man_enc = mans[0]
ota_dir = os.path.dirname(man_enc)
rep.log(f"OTA dir: {os.path.relpath(ota_dir, ex)}")
manifest = _openssl_aes_decrypt(open(man_enc, "rb").read(), _OTA_KEY).decode("utf-8", "ignore")
img_name, img_size = _parse_rootfs_manifest(manifest)
if not img_name:
raise BuildError("no rootfs image in the OTA manifest.", code="E210")
# img_name must be a bare basename (no path separators / traversal)
if img_name != os.path.basename(img_name) or img_name in ("", ".", "..") or "/" in img_name or "\\" in img_name:
raise BuildError(f"OTA manifest rootfs image name is not a safe basename: {img_name!r}", code="E210")
if img_size is not None:
if not img_size.isdigit() or not (0 < int(img_size) <= 256 * (1 << 20)):
raise BuildError(f"OTA manifest img_size is implausible: {img_size!r}", code="E210")
rep.log(f"rootfs image={img_name} expected_size={img_size or '?'}")
# order the $img.NNNN.enc chunks by numeric index (skip ota_sha256_* etc.);
# reject DUPLICATE indices and require a contiguous 0..N-1 sequence.
by_idx = {}
for f in glob.glob(os.path.join(ota_dir, glob.escape(img_name) + ".*.enc")):
rest = os.path.basename(f)[len(img_name) + 1:] # "NNNN.<hash>.enc"
idx = rest.split(".", 1)[0]
if not idx.isdigit():
continue
i = int(idx)
if i in by_idx:
raise BuildError(f"duplicate rootfs chunk index {i} in the OTA dir - refusing.", code="E212")
by_idx[i] = f
if not by_idx:
raise BuildError(f"no {img_name}.NNNN.*.enc chunks found in the OTA dir.", code="E212")
idxs = sorted(by_idx)
if idxs != list(range(len(idxs))):
raise BuildError(f"rootfs chunk indices are not a contiguous 0..{len(idxs)-1} sequence "
f"(got {idxs[:3]}…{idxs[-1]}) - missing chunk, refusing.", code="E212")
chunks = [(i, by_idx[i]) for i in idxs]
tmpout = os.path.join(workdir, "rootfs.assembled")
rep.status("Decrypting + assembling rootfs")
with open(tmpout, "wb") as out:
for i, (_idx, f) in enumerate(chunks):
out.write(_openssl_aes_decrypt(open(f, "rb").read(), _OTA_KEY))
rep.progress(i + 1, len(chunks))
got = os.path.getsize(tmpout)
rep.log(f"assembled {len(chunks)} chunks ({got} bytes)")
if img_size and got != int(img_size):
raise BuildError(f"assembled size {got} != manifest {img_size} (missing/dup chunk?)", code="E213")
with open(tmpout, "rb") as f:
if f.read(4) != SQUASH_MAGIC:
raise BuildError("assembled output is not a squashfs (bad magic) - wrong key/firmware.", code="E213")
os.replace(tmpout, out_squashfs)
rep.ok(f"stock rootfs extracted -> {out_squashfs} ({got} bytes)")
return out_squashfs
def _parse_rootfs_manifest(text):
"""From the decrypted ota_update.in, return (img_name, img_size) for the block
whose img_type=rootfs (mirrors the awk in extract_stock_rootfs.sh)."""
in_rootfs = False
name = size = None
for line in text.splitlines():
line = line.strip()
if line == "img_type=rootfs" or line.endswith("=rootfs") and line.startswith("img_type"):
in_rootfs = True
continue
if in_rootfs:
if line.startswith("img_name="):
name = line[len("img_name="):]
elif line.startswith("img_size="):
size = line[len("img_size="):]
if name and size:
break
return name, size
def _copyfile(src, dst):
with open(src, "rb") as s, open(dst, "wb") as d:
while True:
b = s.read(1 << 20)
if not b:
break
d.write(b)
# --- fiio_init.sh boot-hook patch (python-native, single-match-or-refuse) ----
_OLD_IF = 'if [ "$COREDUMP_FLAG" == "1" ]; then'
_LAUNCH = _OLD_IF + "\n /usr/data/mq_ui &"
_BLOCK = (
"if [ -f /usr/data/mq_ui ] && [ -f /usr/data/mq_player ]; then\n"
" # diskOS override: run our UI + the player from /usr/data (persists across\n"
" # rootfs flashes). Falls back to the stock rootfs binaries if either is absent.\n"
" /usr/data/mq_ui &\n sleep 2\n /usr/data/mq_player &\n"
'elif [ "$COREDUMP_FLAG" == "1" ]; then'
)
def _patch_fiio_init(path, rep):
with open(path, encoding="utf-8", errors="surrogateescape") as f:
s = f.read()
# We always start from freshly-extracted OFFICIAL stock, so the file must be
# UNpatched: refuse anything already containing our marker (corrupt/re-used tree)
# rather than trusting it.
if "diskOS override" in s:
raise BuildError("fiio_init.sh already contains a diskOS marker - refusing to "
"patch a non-pristine rootfs. Re-extract from official firmware.", code="E223")
n = s.count(_LAUNCH)
if n != 1:
raise BuildError(
"boot-hook anchor (COREDUMP launch block) "
f"{'not found' if n == 0 else 'ambiguous'} in fiio_init.sh - "
"incompatible firmware boot structure; do not ship this base untested.", code="E223")
i = s.index(_LAUNCH)
s = s[:i] + _BLOCK + s[i + len(_OLD_IF):]
with open(path, "w", encoding="utf-8", errors="surrogateescape") as f:
f.write(s)
# --- ELF sanity for the UI binary -------------------------------------------
def _validate_ui_elf(ui_path):
if not os.path.exists(ui_path):
raise BuildError(f"UI binary not found: {ui_path}", code="E222")
with open(ui_path, "rb") as f:
hdr = f.read(20)
if hdr[:4] != b"\x7fELF":
raise BuildError(f"'{ui_path}' is not an ELF", code="E222")
if hdr[4:6] != b"\x01\x01":
raise BuildError(f"'{ui_path}' is not ELF32 little-endian (EI_CLASS/DATA)", code="E222")
if hdr[18:20] != b"\x08\x00":
raise BuildError(f"'{ui_path}' e_machine is not MIPS-LE", code="E222")
def _run(cmd, **kw):
return subprocess.run(cmd, env=bundle.native_env(), **kw)
def _validate_squashfs_output(sq_path, unsq, expect_ui_sha, expect_ui_sz, rep=None):
"""Validate a freshly-repacked squashfs by its CONTENT, not the repacker's exit status. Does a
COMPLETE independent extraction (so silent corruption ANYWHERE fails, not just in two files),
then verifies every boot-critical artefact: the boot-hook patch in fiio_init.sh, the executable
first-boot installer hook, the manifest (agreeing with the embedded UI), and the embedded UI
itself (exact sha256 + size + exec bit). Raises BuildError (E232) on any problem."""
import hashlib, tempfile, shutil
with open(sq_path, "rb") as f:
if f.read(4) != b"hsqs":
raise BuildError("repacked image is not a valid squashfs (bad superblock magic) - "
"the repacker produced a corrupt file", code="E232")
tmp = tempfile.mkdtemp(prefix="diskos-sqcheck-")
try:
dst = os.path.join(tmp, "x")
# FULL extraction (no file subset): a corrupt inode / metadata block / file anywhere in the
# bootable filesystem makes this fail, which two-file extraction would miss.
r = _run([unsq, "-d", dst, "-f", sq_path], capture_output=True, text=True)
if r.returncode != 0:
raise BuildError(f"repacked image failed full extraction (unsquashfs rc={r.returncode}) - "
f"corrupt/truncated repack: {r.stderr.strip()[:200]}", code="E232")
def _need(rel, what):
p = os.path.join(dst, rel)
if not os.path.exists(p):
raise BuildError(f"repacked image is missing {what} ({rel}) - do NOT flash", code="E232")
return p
# boot hook present + patched
with open(_need("usr/project/fiio_init.sh", "boot script"), encoding="utf-8", errors="ignore") as f:
if "diskOS override" not in f.read():
raise BuildError("repacked image: fiio_init.sh lacks the diskOS boot-hook patch", code="E232")
# first-boot installer hook present + executable
s97 = _need("etc/init.d/S97diskos_install", "first-boot installer hook")
if not (os.stat(s97).st_mode & 0o111):
raise BuildError("repacked image: S97diskos_install is not executable", code="E232")
# embedded UI: exact identity + exec bit
ui = _need("opt/diskos/mq_ui", "embedded UI")
if os.path.getsize(ui) != expect_ui_sz:
raise BuildError(f"repacked image: embedded UI size {os.path.getsize(ui)} != {expect_ui_sz}", code="E232")
if hashlib.sha256(open(ui, "rb").read()).hexdigest() != expect_ui_sha:
raise BuildError("repacked image: embedded UI hash mismatch (repack corrupted it) - do NOT flash", code="E232")
if not (os.stat(ui).st_mode & 0o111):
raise BuildError("repacked image: embedded UI is not executable", code="E232")
# manifest present + agrees with the embedded UI (the on-device hook trusts it)
man = _need("etc/diskos_manifest", "diskOS manifest")
if (_grep1(man, r"SHA256=([0-9a-fA-F]+)") != expect_ui_sha
or _grep1(man, r"SIZE=([0-9]+)") != str(expect_ui_sz)):
raise BuildError("repacked image: manifest/UI mismatch - do NOT flash", code="E232")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if rep is not None:
rep.log("output squashfs validated (full extraction + boot hook + S97 + manifest + UI hash/mode)")
def build_image(stock_squashfs, ui_binary, variant, out_bin, workdir, rep=None):
"""Build diskos_<variant>.bin from a stock rootfs + the diskOS UI."""
rep = rep or CLIReporter()
if variant not in ("public", "dev"):
raise BuildError(f"variant must be 'public' or 'dev', got {variant!r}", code="E250")
rep.phase(f"Building diskOS image ({variant})")
unsq = bundle.native("unsquashfs")
mksq = bundle.native("mksquashfs")
rf = os.path.join(workdir, "rf")
if os.path.isdir(rf):
import shutil
shutil.rmtree(rf)
rep.status("[1/6] unpacking stock rootfs")
r = _run([unsq, "-d", rf, stock_squashfs], capture_output=True, text=True)
if r.returncode != 0:
raise BuildError(f"unsquashfs failed: {r.stderr.strip()[:400]}", code="E230")
rep.status("[2/6] validating base is a Snowsky Disc rootfs")
validate_stock_rootfs(stock_squashfs, rep) # product / tested-version / known-good-hash gate
rep.status("[3/6] validating the diskOS UI binary")
_validate_ui_elf(ui_binary)
rep.status("[4/6] patching fiio_init.sh + installing first-boot hook")
fiio_path = os.path.join(rf, "usr/project/fiio_init.sh")
_assert_within_rf(rf, fiio_path) # a crafted rootfs must not redirect the in-place patch
_patch_fiio_init(fiio_path, rep)
_install(bundle.data("S97diskos_install"), os.path.join(rf, "etc/init.d/S97diskos_install"), 0o755, rf)
import hashlib
ui_sha = hashlib.sha256(open(ui_binary, "rb").read()).hexdigest()
ui_sz = os.path.getsize(ui_binary)
import time
manifest_path = os.path.join(rf, "etc/diskos_manifest")
_assert_within_rf(rf, manifest_path)
if os.path.islink(manifest_path):
os.unlink(manifest_path) # never follow a planted symlink at the manifest path
with open(manifest_path, "w") as f:
f.write(f"SHA256={ui_sha}\nSIZE={ui_sz}\nARCH=mips-le\nVARIANT={variant}\n"
f"BUILT={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n")
# Embed the UI INSIDE the rootfs at a fixed path the S97 hook installs from FIRST (SD is only a
# fallback source). This makes first boot need no SD card: flash -> reboot -> diskOS. The hook
# still verify_ui's this copy against the manifest above, so a corrupt flash can't run it.
_install(ui_binary, os.path.join(rf, "opt/diskos/mq_ui"), 0o755, rf)
# Debug-access tooling (BOTH variants): the diskos-debug helper + a static dropbear. mq_ui's
# "Debug Mode" toggle drives these to start SSH (random per-enable password) and/or the USB
# serial shell on demand. Shipping them in the public image too means a normal user can enable
# debug access from the UI without needing the dev build.
_install(bundle.data("dropbearmulti"), os.path.join(rf, "usr/project/dropbearmulti"), 0o755, rf)
_install(bundle.data("diskos-debug.sh"), os.path.join(rf, "usr/project/diskos-debug.sh"), 0o755, rf)
if variant == "dev":
# Dev only: an ALWAYS-ON USB serial recovery shell (builds the gadget + attaches the one
# diskos-debug shell at boot), so a dev build is reachable over USB even before the UI runs.
_install(bundle.data("S99usbserial"), os.path.join(rf, "etc/init.d/S99usbserial"), 0o755, rf)
rep.status("[5/6] repacking squashfs (stock params: lzo, -b 131072)")
out_sq = os.path.join(workdir, "out.squashfs")
if os.path.exists(out_sq):
os.remove(out_sq)
r = _run([mksq, rf, out_sq, "-comp", "lzo", "-b", "131072",
"-no-xattrs", "-all-root", "-noappend"], capture_output=True, text=True)
if r.returncode != 0:
raise BuildError(f"mksquashfs failed: {r.stderr.strip()[:400]}", code="E230")
sqsz = os.path.getsize(out_sq)
if sqsz > IMG_SIZE:
raise BuildError(
f"squashfs is {sqsz} > {IMG_SIZE} partition - refusing (truncating would "
"make it unbootable). Trim content or use a smaller UI.", code="E231")
# Trust the OUTPUT, not the repacker's exit code: confirm the superblock magic AND that the
# embedded UI extracts byte-identical (an independent unsquashfs round-trip). Catches a
# silently-corrupt/truncated repack - the exact failure mode a nonzero-exit-but-valid (or, worse,
# zero-exit-but-corrupt) mksquashfs could hide - before it ever reaches the device.
_validate_squashfs_output(out_sq, unsq, ui_sha, ui_sz, rep)
rep.status("[6/6] finalizing image (pad to partition size)")
_copyfile(out_sq, out_bin)
with open(out_bin, "r+b") as f: # pad to exact partition size
f.truncate(IMG_SIZE)
import hashlib as _h
md5 = _h.md5(open(out_bin, "rb").read()).hexdigest()
rep.ok(f"image built: {out_bin} ({os.path.getsize(out_bin)} bytes) md5={md5}")
return out_bin
def _grep1(path, pattern):
try:
with open(path, encoding="utf-8", errors="ignore") as f:
m = re.search(pattern, f.read())
return m.group(1) if m else None
except OSError:
return None
def _assert_within_rf(rf, dst, code="E233"):
"""Refuse a write target that, via a symlink AT the target or in any PARENT component, resolves
OUTSIDE the extracted rootfs `rf`. Image building runs with the caller's privileges (often sudo,
since mask-ROM USB needs it), so a crafted stock squashfs that ships e.g.
`etc/init.d/S97diskos_install -> /etc/cron.d/x` could make a plain open() clobber a host file.
realpath() resolves every symlink on the path, so an escape is caught here before any write."""
rroot = os.path.realpath(rf)
real = os.path.realpath(dst)
if not (real == rroot or real.startswith(rroot + os.sep)):
raise BuildError(
f"refusing to write outside the rootfs: {dst!r} resolves to {real!r} via a symlink - "
"the stock firmware may be crafted or corrupt. Re-download the official FiiO firmware.",
code=code)
def _install(src, dst, mode, rf):
_assert_within_rf(rf, dst)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.islink(dst):
os.unlink(dst) # replace a planted symlink with a real file - never follow it
_copyfile(src, dst)
os.chmod(dst, mode)
+104
View File
@@ -0,0 +1,104 @@
"""Host OS/arch detection and Snowsky-Disc device presence checks.
Device USB identities we care about:
- Ingenic X2000 mask-ROM (flashing mode): VID:PID 0a108:eaef (a108:eaef)
- normal running device (not used for flashing) is a different id.
We enumerate USB via pyusb (bundled) so we don't depend on `lsusb` being present.
"""
import platform
import sys
MASKROM_VID = 0x0a108 & 0xFFFF # printed as a108 by lsusb; VID field is 0xa108
MASKROM_PID = 0xeaef
# lsusb shows "a108:eaef"; libusb reports idVendor=0xa108 idProduct=0xeaef
MASKROM_VID = 0xa108
def host():
"""Return (os_key, arch_key) e.g. ('linux','x86_64') / ('macos','arm64')."""
sysname = platform.system().lower()
if sysname == "darwin":
os_key = "macos"
elif sysname == "linux":
os_key = "linux"
else:
os_key = sysname # 'windows' etc. - unsupported for now
machine = platform.machine().lower()
arch = {
"x86_64": "x86_64", "amd64": "x86_64",
"arm64": "arm64", "aarch64": "arm64",
}.get(machine, machine)
return os_key, arch
def host_tag():
o, a = host()
return f"{o}-{a}"
def is_supported():
o, _ = host()
return o in ("linux", "macos")
def _bundled_libusb_backend():
"""A pyusb libusb1 backend pointed at OUR bundled libusb, so USB enumeration
works in the frozen app even when the system has no libusb. Returns a backend
or None (caller falls back to pyusb's default search)."""
try:
import glob
import os
import usb.backend.libusb1 as libusb1
from . import bundle
libdir = os.path.join(bundle.vendor_dir(), "lib")
# macOS dylib or Linux .so, whatever we bundled
for pat in ("libusb-1.0*.dylib", "libusb-1.0.so*", "libusb-1.0*"):
hits = sorted(glob.glob(os.path.join(libdir, pat)))
if hits:
return libusb1.get_backend(find_library=lambda _n, _p=hits[0]: _p)
except Exception:
pass
return None
def _maskrom_count_pyusb():
"""Count devices in Ingenic mask-ROM mode via pyusb. Returns int or None if
pyusb/backend is unavailable."""
try:
import usb.core # pyusb (bundled)
except Exception:
return None
try:
backend = _bundled_libusb_backend() # prefer our bundled libusb
devs = list(usb.core.find(find_all=True, idVendor=MASKROM_VID,
idProduct=MASKROM_PID, backend=backend))
return len(devs)
except Exception:
return None
def _maskrom_count_lsusb():
"""Fallback: parse `lsusb` if present (Linux)."""
import shutil
import subprocess
if not shutil.which("lsusb"):
return None
try:
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=10).stdout
except Exception:
return None
return sum(1 for ln in out.splitlines() if "a108:eaef" in ln.lower())
def maskrom_count():
"""How many devices are currently in mask-ROM mode. Prefers pyusb, falls back
to lsusb, returns -1 if neither is available (caller should warn)."""
n = _maskrom_count_pyusb()
if n is not None:
return n
n = _maskrom_count_lsusb()
if n is not None:
return n
return -1
+137
View File
@@ -0,0 +1,137 @@
"""Reporter interface - decouples the engine (imagebuild/flasher/state) from the
front-end. The engine reports FACTS; it never touches widgets or prints directly.
CLIReporter -> renders to the terminal via ui.py
QueueReporter -> enqueues immutable events for the Tk GUI to drain on its main
thread (the engine runs on a worker thread)
Contract:
phase(name, destructive=False) a new stage began
status(message) transient 'what's happening now' line
log(line) detail/log line
progress(completed, total) determinate progress (total > 0)
indeterminate(active, note="") long op with no count (the flash) on/off
warning(message) / ok(message) / error(message)
"""
import threading
from . import ui
class Reporter:
def phase(self, name, destructive=False): ...
def status(self, message): ...
def log(self, line): ...
def progress(self, completed, total): ...
def indeterminate(self, active, note="", expect_secs=None): ...
def warning(self, message): ...
def ok(self, message): ...
def error(self, message): ...
class CLIReporter(Reporter):
"""Terminal renderer - preserves the existing CLI look via ui.py."""
def __init__(self):
self._phase = ""
self._bar = None
self._hb = None
self._hb_stop = None
self._hb_thread = None
def phase(self, name, destructive=False):
self._end_bar()
self._phase = name
ui.step(name + (" (this rewrites the device)" if destructive else ""))
def status(self, message):
self._end_bar()
ui.info(message)
def log(self, line):
ui.info(ui.dim(line))
def progress(self, completed, total):
if not total or total <= 0:
return
if self._bar is None:
self._bar = ui.Bar(self._phase or "working", total)
self._bar.update(completed)
if completed >= total:
self._bar.done()
self._bar = None
def _end_bar(self):
if self._bar is not None:
self._bar.done()
self._bar = None
def indeterminate(self, active, note="", expect_secs=None):
if active:
if self._hb is not None:
return
self._hb = ui.Heartbeat(note or self._phase or "working", expect_secs=expect_secs)
self._hb_stop = threading.Event()
def _run(hb, stop):
while not stop.is_set():
hb.tick()
stop.wait(0.5)
hb.stop()
self._hb_thread = threading.Thread(target=_run, args=(self._hb, self._hb_stop), daemon=True)
self._hb_thread.start()
else:
if self._hb_stop:
self._hb_stop.set()
if self._hb_thread:
self._hb_thread.join(timeout=2)
self._hb = self._hb_stop = self._hb_thread = None
def warning(self, message):
self._end_bar()
ui.warn(message)
def ok(self, message):
self._end_bar()
ui.ok(message)
def error(self, message):
self._end_bar()
ui.err(message)
class QueueReporter(Reporter):
"""Enqueues immutable (kind, payload) events for the GUI to drain via
root.after on the main thread. NEVER touches Tk widgets itself."""
def __init__(self, q):
self.q = q
def _emit(self, kind, **kw):
self.q.put((kind, kw))
def phase(self, name, destructive=False):
self._emit("phase", name=name, destructive=destructive)
def status(self, message):
self._emit("status", message=message)
def log(self, line):
self._emit("log", line=line)
def progress(self, completed, total):
self._emit("progress", completed=completed, total=total)
def indeterminate(self, active, note="", expect_secs=None):
self._emit("indeterminate", active=active, note=note, expect_secs=expect_secs)
def warning(self, message):
self._emit("warning", message=message)
def ok(self, message):
self._emit("ok", message=message)
def error(self, message):
self._emit("error", message=message)
+168
View File
@@ -0,0 +1,168 @@
"""Application service - the install / restore-stock / remove flows, independent
of any front-end. CLI and GUI both call these with a Reporter and a `confirm`
callback, so the two front-ends can never diverge in behaviour or safety checks.
`confirm(summary: dict) -> bool` is the destructive-action gate:
- CLI: a y/N prompt.
- GUI: the CONFIRMATION screen (acknowledge checkbox + explicit button),
driven from the worker thread via a blocking Event.
Each flow returns a result dict; raises BuildError/FlashError on failure.
"""
import os
import time
from . import bundle, flasher, imagebuild, state
def _save_stock(stock_sq, rep):
"""Copy the user's bone-stock image into state (padded to the partition size)
so restore-stock can always reflash exactly it. Returns its sha256."""
rep.status("Saving your stock rootfs image (so diskOS can always be deactivated/reverted)")
sz = os.path.getsize(stock_sq)
if sz > imagebuild.IMG_SIZE:
# NEVER truncate a larger image - a truncated file can still look flashable
# (hsqs magic + right size) yet be a corrupt, unbootable stock.
raise imagebuild.BuildError(
f"stock rootfs is {sz} bytes > {imagebuild.IMG_SIZE} partition - refusing "
"(truncating it would produce a corrupt 'stock' image).", code="E240")
if sz < imagebuild.IMG_SIZE:
padded = stock_sq + ".padded"
imagebuild._copyfile(stock_sq, padded)
with open(padded, "r+b") as f:
f.truncate(imagebuild.IMG_SIZE)
src = padded
else:
src = stock_sq
digest = state.save_stock_image(src, progress=lambda r, t: rep.progress(r, t))
rep.ok(f"bone-stock image saved (sha256={digest[:16]}…)")
return digest
def _save_state_soft(st, rep):
"""Persist state, but NEVER let a bookkeeping failure masquerade as a flash
failure. Call only AFTER a verified flash."""
try:
state.save(st)
except Exception as e: # disk full, permissions, etc.
rep.warning(f"flash succeeded, but saving local history failed: {e}")
def do_install(params, rep, confirm):
"""params: dict(firmware=?, stock=?, ui_binary=?, variant='public'|'dev').
Extract -> save bone-stock -> build -> confirm -> flash. Returns result dict."""
ui_bin = params.get("ui_binary") or bundle.data("mq_ui", required=False)
if not ui_bin or not os.path.exists(ui_bin):
raise imagebuild.BuildError("no diskOS UI binary (bundled 'mq_ui' missing).", code="E102",
action="the install is incomplete; re-download the installer")
variant = params.get("variant", "public")
work = state.build_dir()
st = state.load()
st.update({"phase": "prepared", "variant": variant})
state.save(st)
# 1) obtain the stock rootfs + save it as the restore image
stock_sq = os.path.join(work, "stock_rootfs.squashfs")
if params.get("stock"):
imagebuild._copyfile(params["stock"], stock_sq)
elif params.get("firmware"):
imagebuild.extract_stock_rootfs(params["firmware"], stock_sq, work, rep=rep)
else:
raise imagebuild.BuildError("need a firmware .zip or a stock rootfs.squashfs.", code="E140",
action="pass your official FiiO firmware .zip")
# Validate the stock image is a genuine, supported, known-good Disc rootfs BEFORE it overwrites
# the saved recovery copy - so a wrong/corrupt image can't destroy a good recovery then abort.
imagebuild.validate_stock_rootfs(stock_sq, rep)
_save_stock(stock_sq, rep)
# 2) build the diskOS image
out_bin = os.path.join(work, f"diskos_{variant}.bin")
imagebuild.build_image(stock_sq, ui_bin, variant, out_bin, work, rep=rep)
# 3) preflight + confirm (destructive gate), then flash
flasher.preflight(out_bin, rep)
summary = {
"action": "install",
"variant": variant,
"image": out_bin,
"duration": "~60-90 minutes",
"consequence": "This rewrites the device root filesystem. Do not disconnect.",
}
if not confirm(summary):
rep.warning("aborted before flashing (nothing written to the device).")
return {"ok": False, "aborted": True}
st.update({"phase": "flash-started"})
_save_state_soft(st, rep)
# From here, flash() either raises (real failure) or returns verified. A later
# state-save error must NOT turn a verified flash into a reported failure.
d = flasher.flash(out_bin, log_path=os.path.join(state.state_dir(), "last-flash.log"), rep=rep)
st.update({"phase": "flash-verified", "installed": True,
"installed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
_save_state_soft(st, rep)
rep.ok("diskOS flashed and verified. Power-cycle the device to boot it.")
return {"ok": True, "debug": d}
def do_restore(params, rep, confirm):
"""Reflash the saved stock-rootfs image (deactivates diskOS; leaves /usr/data files). If no image
is saved, a firmware .zip must be provided to rebuild it."""
stock_bin, _ = state.stock_paths()
if not state.have_stock_image():
if not params.get("firmware"):
raise flasher.FlashError(
"no saved bone-stock image", code="E141",
action="provide FiiO's firmware .zip so I can rebuild your stock rootfs image")
work = state.build_dir()
stock_sq = os.path.join(work, "stock_rootfs.squashfs")
imagebuild.extract_stock_rootfs(params["firmware"], stock_sq, work, rep=rep)
imagebuild.validate_stock_rootfs(stock_sq, rep)
_save_stock(stock_sq, rep)
# Never restore-flash an unvalidated image: validate whatever is about to be written (the saved
# copy or the freshly-extracted one) - product/version/known-good-hash, not just size+magic.
imagebuild.validate_stock_rootfs(stock_bin, rep)
flasher.preflight(stock_bin, rep)
summary = {
"action": "restore-stock",
"image": stock_bin,
"duration": "~60-90 minutes",
"consequence": "This reflashes bone-stock and removes diskOS. Do not disconnect.",
}
if not confirm(summary):
rep.warning("aborted (device unchanged).")
return {"ok": False, "aborted": True}
st = state.load()
st.update({"phase": "restore-started"})
_save_state_soft(st, rep)
d = flasher.flash(stock_bin, log_path=os.path.join(state.state_dir(), "last-restore.log"), rep=rep)
st.update({"phase": "restore-verified", "installed": False})
_save_state_soft(st, rep)
rep.ok("Stock system reflashed. Power-cycle to boot stock.")
rep.warning("This deactivates diskOS but is not a byte-for-byte factory wipe: the diskOS "
"files under /usr/data (a separate partition) remain, inert - they do nothing "
"without the boot hook this reflash removed. (The UI embedded in the diskOS "
"rootfs is gone, since this reflash overwrote that partition with stock.)")
return {"ok": True, "debug": d}
def do_remove(params, rep, confirm):
"""Delete everything the tool created on this computer (its full uninstall
footprint). Nothing was installed system-wide, so this is the whole cleanup."""
if state.load().get("installed") and not params.get("force"):
if not confirm({"action": "remove-tool",
"consequence": "diskOS still appears to be on the device. This only "
"removes the installer + its saved files from THIS "
"computer (run restore-stock first to clear the device)."}):
return {"ok": False, "aborted": True}
d, errors = state.wipe_all()
if errors:
rep.error(f"could not fully remove {len(errors)} item(s) under {d}:")
for p, m in errors[:8]:
rep.log(f" {p}: {m}")
return {"ok": False, "errors": errors, "removed": d}
rep.ok(f"removed all tool state: {d}")
return {"ok": True, "removed": d}
+258
View File
@@ -0,0 +1,258 @@
"""Per-user state so the tool can always put the device back to bone-stock and
so `remove` can clean up after itself.
Everything the tool creates lives under ONE directory (state_dir()); nothing is
written system-wide. `remove` deletes that directory (and, if the user asks, the
tool itself) - that's the whole uninstall footprint on Linux/macOS.
Contents:
state.json what we've done (installed?, versions, timestamps)
stock/stock.bin the saved stock-rootfs image, rebuilt from the user's firmware (for restore-stock)
stock/stock.sha256 its checksum
build/ scratch for extract/build (cleaned opportunistically)
"""
import hashlib
import json
import os
import shutil
import sys
import time
from . import errors
APP = "diskos-installer"
# A marker file dropped in our state dir. wipe_all() refuses to recursively delete any directory
# that does not contain it, so a mis-set DISKOS_INSTALLER_HOME can never delete an arbitrary tree.
SENTINEL = ".diskos-installer-state"
# The diskOS install/restore lifecycle values written to state.json["phase"]. Used to recognise a
# legacy (pre-sentinel) state dir by CONTENT, specifically enough that a foreign project's state.json
# cannot masquerade as ours. Keep in sync with service.py.
_KNOWN_PHASES = {"prepared", "flash-started", "flash-verified", "restore-started", "restore-verified"}
def _fsync_dir(path):
"""Fsync a directory so a rename/create inside it is durable across a crash."""
try:
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except OSError:
pass
def _state_base():
"""Compute the state-dir PATH only - no directory creation, no side effects.
Used by wipe_all() so validation never re-creates the sentinel it is about to check."""
env = os.environ.get("DISKOS_INSTALLER_HOME")
if env:
return env
if sys.platform == "darwin":
return os.path.expanduser(f"~/Library/Application Support/{APP}")
xdg = os.environ.get("XDG_DATA_HOME")
return os.path.join(xdg, APP) if xdg else os.path.expanduser(f"~/.local/share/{APP}")
def _is_our_statedir(base):
"""STRONG test that `base` is (or was) a diskOS state dir - governs whether we may adopt it and,
in wipe_all(), recursively delete it. Requires GENUINE evidence, never a merely-common basename,
so an unrelated directory that happens to contain a 'build/' or a 'state.json' is NEVER matched:
- our sentinel file, OR
- our exact stock-recovery pair stock/stock.bin + stock/stock.sha256, OR
- a state.json that parses AND matches our specific schema (a boolean `installed` plus a
`phase` drawn from our known lifecycle values) - not merely the generic key NAMES, so an
unrelated project's state.json cannot be mistaken for ours and later deleted."""
if os.path.isfile(os.path.join(base, SENTINEL)):
return True
if (os.path.isfile(os.path.join(base, "stock", "stock.bin"))
and os.path.isfile(os.path.join(base, "stock", "stock.sha256"))):
return True
sj = os.path.join(base, "state.json")
if os.path.isfile(sj):
try:
with open(sj) as f:
d = json.load(f)
if (isinstance(d, dict) and isinstance(d.get("installed"), bool)
and isinstance(d.get("phase"), str) and d.get("phase") in _KNOWN_PHASES):
return True
except (OSError, ValueError, TypeError):
pass
return False
def state_dir():
"""A single, per-user, non-system directory for all tool state (created on first use)."""
base = _state_base()
marker = os.path.join(base, SENTINEL)
# SAFETY, only for an EXPLICITLY-set home: refuse to adopt a pre-existing, non-empty directory
# that shows NO sign of being ours, so a mis-set DISKOS_INSTALLER_HOME (e.g. $HOME) can't be
# tagged here and later wiped by 'remove'. The DEFAULT XDG/APP path is always ours; and an
# existing state dir (even one predating the sentinel) is recognised by its contents.
if (os.environ.get("DISKOS_INSTALLER_HOME") and os.path.isdir(base)
and not _is_our_statedir(base)):
try:
nonempty = bool(os.listdir(base))
except OSError:
nonempty = True
if nonempty:
raise errors.PreflightError(
f"DISKOS_INSTALLER_HOME={base!r} is a non-empty directory that is not a diskOS "
"state dir - refusing to use it. Point it at a new or empty path.", code="E142")
os.makedirs(base, exist_ok=True)
if not os.path.exists(marker):
try:
with open(marker, "w") as f:
f.write("diskOS installer state directory - safe to delete via 'diskos-installer remove'\n")
except OSError:
pass
return base
def _state_path():
return os.path.join(state_dir(), "state.json")
def load():
"""Return the saved state. A corrupt file is reported (not silently erased) so
'never installed' and 'state damaged' stay distinguishable."""
p = _state_path()
if not os.path.exists(p):
return {}
try:
with open(p) as f:
return json.load(f)
except ValueError:
return {"_corrupt": True}
except OSError:
return {}
def save(d):
d = {k: v for k, v in d.items() if k != "_corrupt"}
d["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
tmp = _state_path() + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, _state_path())
_fsync_dir(state_dir())
def sha256_file(path, progress=None):
h = hashlib.sha256()
total = os.path.getsize(path)
read = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
h.update(chunk)
read += len(chunk)
if progress:
progress(read, total)
return h.hexdigest()
def stock_paths():
d = os.path.join(state_dir(), "stock")
os.makedirs(d, exist_ok=True)
return os.path.join(d, "stock.bin"), os.path.join(d, "stock.sha256")
def save_stock_image(src_bin, progress=None):
"""Copy the saved stock-rootfs flashable image into state so restore-stock can
always reflash exactly it. Atomic + validated: the previous good image is only
replaced once the new one is fully written, hashed, magic-checked, and fsynced -
so a crash/disk-full can't destroy your recovery image. Returns its sha256."""
dst, shafile = stock_paths()
d = os.path.dirname(dst)
tmp_bin, tmp_sha = dst + ".tmp", shafile + ".tmp"
h = hashlib.sha256()
total = os.path.getsize(src_bin)
read = 0
with open(src_bin, "rb") as s, open(tmp_bin, "wb") as o:
while True:
chunk = s.read(1 << 20)
if not chunk:
break
o.write(chunk)
h.update(chunk)
read += len(chunk)
if progress:
progress(read, total)
o.flush()
os.fsync(o.fileno())
digest = h.hexdigest()
# validate BEFORE committing (never publish a truncated/non-squashfs image)
with open(tmp_bin, "rb") as f:
if f.read(4) != b"hsqs":
os.unlink(tmp_bin)
raise ValueError("refusing to save a non-squashfs stock image (bad magic).")
with open(tmp_sha, "w") as f:
f.write(digest + "\n")
f.flush()
os.fsync(f.fileno())
# commit: rename sha first, then the image, then fsync the dir. If interrupted
# between renames, have_stock_image() sees a mismatch and reports 'no valid image'
# rather than trusting a half-committed pair.
os.replace(tmp_sha, shafile)
os.replace(tmp_bin, dst)
_fsync_dir(d)
return digest
def stock_image_exists():
"""Fast existence check (no hashing) - safe to call on the UI thread."""
dst, shafile = stock_paths()
return os.path.exists(dst) and os.path.exists(shafile)
def have_stock_image():
dst, shafile = stock_paths()
try:
if not (os.path.exists(dst) and os.path.exists(shafile)):
return False
want = open(shafile).read().strip()
return sha256_file(dst) == want
except OSError:
return False
def build_dir():
d = os.path.join(state_dir(), "build")
os.makedirs(d, exist_ok=True)
return d
def wipe_all():
"""Delete the entire tool state directory (the full uninstall footprint).
Returns (path, errors): errors is a list of (path, message) for anything that
could NOT be removed, so the caller reports the truth instead of a false success."""
d = _state_base() # PATH only - never re-create the sentinel we are about to verify
errs = []
# SAFETY: only ever recursively delete a directory we can PROVE is our own state dir. This
# guards against DISKOS_INSTALLER_HOME being set to '/', '$HOME', the cwd, or any other tree.
real = os.path.realpath(d)
forbidden = {os.path.realpath(os.sep), os.path.realpath(os.path.expanduser("~"))}
try:
forbidden.add(os.path.realpath(os.getcwd()))
except OSError:
pass
if real in forbidden:
return d, [(d, "refusing to delete a root/home/current directory")]
if not (os.path.isdir(d) and _is_our_statedir(d)):
return d, [(d, "not a diskOS state dir (no sentinel or known state) - refusing to delete")]
try:
if os.stat(d).st_uid != os.getuid():
return d, [(d, "state dir is not owned by you - refusing to delete")]
except OSError as e:
return d, [(d, str(e))]
shutil.rmtree(d, onerror=lambda fn, path, exc: errs.append((path, str(exc[1]))))
return d, errs
+153
View File
@@ -0,0 +1,153 @@
"""Terminal progress + messaging. TTY-aware: renders live bars/spinners on a
terminal, and degrades to plain timestamped log lines when piped to a file so a
saved log stays readable."""
import sys
import time
# --- colour (only on a TTY that isn't dumb) ---------------------------------
_TTY = sys.stdout.isatty()
def _c(code, s):
return f"\033[{code}m{s}\033[0m" if _TTY else s
def bold(s): return _c("1", s)
def dim(s): return _c("2", s)
def red(s): return _c("31", s)
def green(s): return _c("32", s)
def yellow(s): return _c("33", s)
def cyan(s): return _c("36", s)
def _t():
return time.strftime("%H:%M:%S")
def info(msg):
print(f"{dim(_t())} {msg}", flush=True)
def step(msg):
print(f"\n{cyan('▶')} {bold(msg)}", flush=True)
def ok(msg):
print(f"{green('✓')} {msg}", flush=True)
def warn(msg):
print(f"{yellow('!')} {msg}", flush=True)
def err(msg):
print(f"{red('✗')} {msg}", file=sys.stderr, flush=True)
def fmt_dur(secs):
secs = int(secs)
h, rem = divmod(secs, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}h{m:02d}m{s:02d}s"
if m:
return f"{m}m{s:02d}s"
return f"{s}s"
class Bar:
"""A determinate progress bar for steps with a known total (0..total)."""
def __init__(self, label, total, width=28):
self.label = label
self.total = max(1, total)
self.width = width
self.start = time.monotonic()
self.last_len = 0
self.update(0)
def update(self, done, note=""):
frac = min(1.0, done / self.total)
elapsed = time.monotonic() - self.start
eta = (elapsed / frac - elapsed) if frac > 0.02 else 0
if _TTY:
fill = int(self.width * frac)
bar = "█" * fill + "·" * (self.width - fill)
line = (f"\r {self.label} {cyan(bar)} {int(frac*100):3d}%"
f" {dim(fmt_dur(elapsed))}"
+ (f" · ETA {fmt_dur(eta)}" if eta > 0 else "")
+ (f" {note}" if note else ""))
pad = max(0, self.last_len - len(line))
sys.stdout.write(line + " " * pad)
sys.stdout.flush()
self.last_len = len(line)
else:
# non-TTY: emit occasional milestone lines, not a live bar
pct = int(frac * 100)
if pct in (0, 25, 50, 75, 100) and pct != getattr(self, "_last_pct", -1):
self._last_pct = pct
print(f" {self.label}: {pct}% ({fmt_dur(elapsed)}){(' ' + note) if note else ''}",
flush=True)
def done(self, note=""):
self.update(self.total, note)
if _TTY:
sys.stdout.write("\n")
sys.stdout.flush()
class Heartbeat:
"""Indeterminate progress for a long step with no reliable progress channel
(the ~60-90 min mask-ROM flash). Shows elapsed time + a spinner and a fixed
'do not disconnect' reminder. Call tick() periodically; stop() to finish."""
FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
def __init__(self, label, expect_secs=None, reminder="do NOT disconnect the device"):
self.label = label
self.expect = expect_secs
self.reminder = reminder
self.start = time.monotonic()
self.i = 0
self.last_len = 0
def tick(self, note=""):
elapsed = time.monotonic() - self.start
self.i = (self.i + 1) % len(self.FRAMES)
if _TTY:
spin = self.FRAMES[self.i]
approx = ""
if self.expect:
approx = f" / ~{fmt_dur(self.expect)}"
line = (f"\r {cyan(spin)} {self.label} {dim(fmt_dur(elapsed) + approx)}"
f" {yellow('· ' + self.reminder)}"
+ (f" {note}" if note else ""))
pad = max(0, self.last_len - len(line))
sys.stdout.write(line + " " * pad)
sys.stdout.flush()
self.last_len = len(line)
else:
# non-TTY: a line every ~30s so a log shows liveness without spamming
if int(elapsed) % 30 == 0 and int(elapsed) != getattr(self, "_last_log", -1):
self._last_log = int(elapsed)
print(f" {self.label}: {fmt_dur(elapsed)} elapsed ({self.reminder})", flush=True)
def stop(self):
if _TTY:
sys.stdout.write("\n")
sys.stdout.flush()
def confirm(prompt, default=False):
"""Yes/no prompt. Non-interactive stdin -> returns default (never blocks a pipe)."""
if not sys.stdin.isatty():
return default
d = "Y/n" if default else "y/N"
try:
ans = input(f"{yellow('?')} {prompt} [{d}] ").strip().lower()
except EOFError:
return default
if not ans:
return default
return ans in ("y", "yes")