"""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}