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,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())
|
||||
Reference in New Issue
Block a user