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:
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user