diff --git a/Makefile b/Makefile
index 56b399ef..da575b8a 100644
--- a/Makefile
+++ b/Makefile
@@ -109,4 +109,22 @@ beta: clean_rootfs rootfs release.sh
.PHONY: clean
clean: stop_file_server
@rm -rf out/
- @rm -rf release.sh
\ No newline at end of file
+ @rm -rf release.sh
+
+# Run the web UI locally with a fully mocked backend (for UI development).
+# See dev/dev-ui/README.md. UI available at http://localhost:8888 (pw: bolt).
+.PHONY: dev-ui
+dev-ui:
+ @cd dev/dev-ui && docker compose up --build
+
+.PHONY: dev-ui-build
+dev-ui-build:
+ @cd dev/dev-ui && docker compose build
+
+.PHONY: dev-ui-shell
+dev-ui-shell:
+ @cd dev/dev-ui && docker compose exec dev-ui bash
+
+.PHONY: dev-ui-clean
+dev-ui-clean:
+ @cd dev/dev-ui && docker compose down -v --rmi local
diff --git a/dev/dev-ui/Dockerfile b/dev/dev-ui/Dockerfile
new file mode 100644
index 00000000..f2e56c43
--- /dev/null
+++ b/dev/dev-ui/Dockerfile
@@ -0,0 +1,23 @@
+# Mocked UI dev container for the myNode web UI.
+# Runs the real Flask app from the repo (volume-mounted read-only at the same
+# paths used on a device) with a mock shim layer so no real backend is needed.
+FROM python:3.11-slim-bookworm
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends procps && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY requirements-dev.txt /tmp/requirements-dev.txt
+RUN pip install --no-cache-dir -r /tmp/requirements-dev.txt
+
+# Python path order matters:
+# 1. /opt/mynode/dev (dev server + dev blueprint)
+# 2. /opt/mynode/dev/mock_pynode (shim layer - shadows pynode/www module names)
+# 3. /var/www/mynode (real Flask app)
+# 4. /var/pynode (real backend library)
+# This mirrors the pynode.pth mechanism used on a real device (setup_device.sh).
+ENV PYTHONPATH=/opt/mynode/dev/mock_pynode:/var/www/mynode:/var/pynode \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /opt/mynode/dev
+CMD ["bash", "/opt/mynode/dev/entrypoint.sh"]
diff --git a/dev/dev-ui/README.md b/dev/dev-ui/README.md
new file mode 100644
index 00000000..9fb8c291
--- /dev/null
+++ b/dev/dev-ui/README.md
@@ -0,0 +1,107 @@
+# myNode UI Dev Mode (mocked backend)
+
+Run the real myNode web UI in a Docker container with a fully mocked backend —
+no bitcoind, no LND, no systemd, no real device. For developing and testing UI
+changes (templates, CSS, JS, Flask routes) with instant hot reload.
+
+## Quick start
+
+```bash
+make dev-ui # from the repo root; builds + starts the container
+```
+
+Open **http://localhost:8888** and log in with password **`bolt`**
+(configurable via `DEV_PASSWORD` in docker-compose.yml; set `DEV_AUTOLOGIN=1`
+to skip login entirely).
+
+Other targets: `make dev-ui-build`, `make dev-ui-shell`, `make dev-ui-clean`.
+
+Edit any file under `rootfs/standard/var/www/mynode/` (templates, static
+assets, python) or `rootfs/standard/var/pynode/` on the host — templates apply
+on refresh, python triggers Flask auto-reload. The source is mounted
+**read-only**, so nothing in this container can modify real code. `git status`
+stays clean.
+
+## The DEV panel
+
+Every page gets a floating **DEV** button (bottom-right) that opens the state
+switcher:
+
+- **Device state** — flip `/tmp/.mynode_status` to any state (`stable`,
+ `drive_missing`, `drive_formatting`, `choose_network`, `quicksync_*`,
+ `upgrading`, `shutting_down`, ...) to render every state page.
+- **Bitcoin** — synced / syncing at an arbitrary progress (renders the real
+ syncing page), or the "Launching MyNode Services" starting page.
+- **Lightning** — LND ready/not-ready, wallet exists/missing (wallet-create flow).
+- **Warnings** — undervoltage / throttled / capped / fsck / usb warning pages.
+- **System** — drive-almost-full error, upgrade banner, premium vs community
+ edition, simulated reboot/shutdown, and **RESET ALL** back to the golden state.
+- **Apps** — one-click install/uninstall (marker files only, no real install).
+
+Everything the panel does is also available as plain HTTP endpoints under
+`/dev/...` (no auth) — see `dev_blueprint.py`. Examples:
+
+```bash
+curl 'localhost:8888/dev/state?value=drive_missing'
+curl 'localhost:8888/dev/sync?synced=0&progress=0.62'
+curl 'localhost:8888/dev/app?name=specter&installed=1'
+curl 'localhost:8888/dev/service?name=electrs&status=failed'
+curl 'localhost:8888/dev/reset'
+```
+
+The full install flow also works end-to-end from the normal UI: installing an
+app from the marketplace shows the real "Installing..." page with a (fake)
+live install log, a simulated reboot, and the app appears installed afterward.
+Reboot/shutdown from settings play out the real reboot page (the mock resets a
+fake boot-time file; the page redirects home when it sees uptime decrease).
+
+## How it works (architecture)
+
+**Zero changes to real code.** The container runs the unmodified Flask app
+with three mock layers around it:
+
+1. **PYTHONPATH shim (`mock_pynode/`)** — sits before `/var/www/mynode` and
+ `/var/pynode` on the python path, so `import bitcoin_info` etc. resolve to
+ the mocks. Each mock exec's the real module, patches ONLY the functions
+ that touch the outside world *into the real module's namespace*, and
+ re-exports everything (see `_mockutil.py` for why patching the real
+ namespace matters). What gets mocked:
+ - `bitcoin_info` — fake `AuthServiceProxy` (bitcoind RPC) backed by
+ `fixtures/bitcoin.json`; the real update/caching code runs unchanged.
+ - `lightning_info` — `lnd_get`/`lnd_get_v2` return `fixtures/lightning.json`.
+ - `electrum_info` — fake `requests` serving prometheus metrics.
+ - `systemctl_info` — service states from `/tmp/mock_services/
Use the floating DEV button (bottom-right). " + "It is injected into every page of the UI as well.
" + "" in body and "/dev/overlay.js" not in body: + body = body.replace("", '', 1) + response.set_data(body) + except Exception: + pass + return response + + +def _ok(**extra): + data = {"result": "ok"} + data.update(extra) + return jsonify(data) + + +@mynode_dev.route("/dev/status") +def dev_status(): + status = "unknown" + try: + with open("/tmp/.mynode_status") as f: + status = f.read().strip() + except Exception: + pass + version = "?" + latest = "?" + try: + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + with open("/usr/share/mynode/latest_version") as f: + latest = f.read().strip() + except Exception: + pass + return jsonify({ + "device_state": status, + "device_states": DEVICE_STATES, + "bitcoin_synced": os.path.isfile(BITCOIN_SYNCED_FILE), + "bitcoin_knob": get_knob("bitcoin", {"behind_blocks": 0}), + "starting": bool(get_knob("misc", {}).get("starting")), + "lnd_ready": bool(get_knob("lnd", {"ready": True}).get("ready", True)), + "lnd_wallet": lightning_info.lnd_wallet_exists(), + "drive": get_knob("drive", {"data": "61%", "os": "34%"}), + "upgrade_available": version != latest, + "warning": _current_warning_file(), + "autologin": os.environ.get("DEV_AUTOLOGIN") == "1", + "premium": not device_info.is_community_edition(), + "premium_plus_warning": _premium_plus_warning_state(), + }) + + +def _current_warning_file(): + try: + with open("/tmp/get_throttled_data") as f: + value = int(f.read().strip(), 16) + for name, bit in THROTTLE_BITS.items(): + if value & bit: + return name + except Exception: + pass + return "none" + + +CHECK_IN_FILE = "/tmp/check_in_response.json" + + +def _load_check_in(): + try: + with open(CHECK_IN_FILE) as f: + return json.load(f) + except Exception: + return {"status": "OK", "support": {"active": True, "days_remaining": 300}} + + +def _premium_plus_warning_state(): + pp = _load_check_in().get("premium_plus") + if isinstance(pp, dict) and "active" in pp and "days_remaining" in pp: + try: + days = int(pp["days_remaining"]) + except (TypeError, ValueError): + return "off" + if -45 <= days <= 45: + return "expiring" if pp["active"] else "expired" + return "off" + + +@mynode_dev.route("/dev/state") +def dev_state(): + value = request.args.get("value", "") + if value not in DEVICE_STATES: + return jsonify({"result": "error", "message": "unknown state", "valid": DEVICE_STATES}), 400 + with open("/tmp/.mynode_status", "w") as f: + f.write(value) + if value == "drive_clone": + clone_state = request.args.get("clone", "detecting") + with open("/tmp/.clone_state", "w") as f: + f.write(clone_state) + with open("/tmp/.clone_progress", "w") as f: + f.write("42.00% complete (289 GB / 689 GB) - mocked") + return _ok(state=value) + + +@mynode_dev.route("/dev/sync") +def dev_sync(): + synced = request.args.get("synced", "1") == "1" + if synced: + set_knob("bitcoin", {"behind_blocks": 0}) + if not os.path.isfile(BITCOIN_SYNCED_FILE): + open(BITCOIN_SYNCED_FILE, "a").close() + else: + knob = {"behind_blocks": int(request.args.get("behind", 340000))} + if request.args.get("progress"): + knob["progress"] = float(request.args.get("progress")) + set_knob("bitcoin", knob) + if os.path.isfile(BITCOIN_SYNCED_FILE): + os.remove(BITCOIN_SYNCED_FILE) + bitcoin_info.update_bitcoin_main_info() + open("/tmp/homepage_needs_refresh", "a").close() + return _ok(synced=synced) + + +@mynode_dev.route("/dev/starting") +def dev_starting(): + starting = request.args.get("value", "1") == "1" + misc = get_knob("misc", {}) + misc["starting"] = starting + set_knob("misc", misc) + real("thread_functions").has_updated_btc_info = not starting + return _ok(starting=starting) + + +@mynode_dev.route("/dev/apps") +def dev_apps(): + apps = [] + for app in application_info.get_all_applications(order_by="alphabetic"): + if app.get("show_on_marketplace_page") or app.get("show_on_application_page"): + apps.append({ + "short_name": app["short_name"], + "name": app["name"], + "is_installed": app["is_installed"], + "is_enabled": app["is_enabled"], + }) + return jsonify(apps) + + +@mynode_dev.route("/dev/app") +def dev_app(): + name = request.args.get("name", "") + installed = request.args.get("installed", "1") == "1" + if not application_info.is_application_valid(name): + return jsonify({"result": "error", "message": "unknown app"}), 400 + if installed: + application_info.mark_app_installed(name) + try: + info = application_info.get_application(name) + if info and info.get("latest_version") not in (None, "unknown", "error"): + with open("/home/bitcoin/.mynode/{}_version".format(name), "w") as f: + f.write(info["latest_version"]) + except Exception: + pass + application_info.enable_service(name) + else: + application_info.disable_service(name) + application_info.clear_app_installed(name) + application_info.clear_application_cache() + application_info.trigger_application_refresh() + open("/tmp/homepage_needs_refresh", "a").close() + return _ok(app=name, installed=installed) + + +@mynode_dev.route("/dev/service") +def dev_service(): + name = request.args.get("name", "") + status = request.args.get("status", "running") + if not name or status not in ("running", "stopped", "failed", "disabled"): + return jsonify({"result": "error", "message": "usage: /dev/service?name=x&status=running|stopped|failed|disabled"}), 400 + os.makedirs("/tmp/mock_services", exist_ok=True) + with open(os.path.join("/tmp/mock_services", name), "w") as f: + f.write(status) + systemctl_info.clear_service_enabled_cache() + return _ok(service=name, status=status) + + +@mynode_dev.route("/dev/warning") +def dev_warning(): + name = request.args.get("name", "clear") + dev_info_real = real("device_info") + if name in THROTTLE_BITS: + with open("/tmp/get_throttled_data", "w") as f: + f.write("0x{:x}".format(THROTTLE_BITS[name])) + # Re-arm in case the warning was previously skipped via the UI + for marker in glob.glob("/tmp/warning_skipped_*"): + os.remove(marker) + dev_info_real.reload_throttled_data() + elif name == "fsck": + open("/tmp/fsck_error", "a").close() + with open("/tmp/fsck_results", "w") as f: + f.write("(mock) fsck found and repaired 3 inode errors on /dev/sda1") + elif name == "usb": + open("/tmp/usb_error", "a").close() + elif name == "clear": + for path in ["/tmp/get_throttled_data", "/tmp/fsck_error", "/tmp/fsck_results", "/tmp/usb_error"]: + if os.path.exists(path): + os.remove(path) + for marker in glob.glob("/tmp/warning_skipped_*"): + os.remove(marker) + dev_info_real.cached_data["get_throttled_data"] = "" + else: + return jsonify({"result": "error", "message": "unknown warning", "valid": list(THROTTLE_BITS) + ["fsck", "usb", "clear"]}), 400 + return _ok(warning=name) + + +@mynode_dev.route("/dev/drive") +def dev_drive(): + knob = get_knob("drive", {"data": "61%", "os": "34%"}) + for key in ("data", "os"): + if request.args.get(key): + value = request.args.get(key) + knob[key] = value if value.endswith("%") else value + "%" + set_knob("drive", knob) + return _ok(drive=knob) + + +@mynode_dev.route("/dev/lnd") +def dev_lnd(): + knob = get_knob("lnd", {"ready": True}) + if request.args.get("ready") is not None: + knob["ready"] = request.args.get("ready") == "1" + set_knob("lnd", knob) + real("lightning_info").lnd_ready = knob["ready"] + lightning_info.update_lightning_info() + if request.args.get("wallet") is not None: + wallet_file = lightning_info.get_lightning_wallet_file() + if request.args.get("wallet") == "1": + open(wallet_file, "a").close() + elif os.path.isfile(wallet_file): + os.remove(wallet_file) + return _ok(lnd=knob, wallet=lightning_info.lnd_wallet_exists()) + + +@mynode_dev.route("/dev/edition") +def dev_edition(): + """Toggle between the premium default (product key present + healthy + check-in data) and community edition.""" + premium = request.args.get("premium", "1") == "1" + device_info.delete_product_key_error() + seed_fixture_fs.seed_edition(premium=premium, force=True) + return _ok(premium=premium) + + +@mynode_dev.route("/dev/premium_plus_warning") +def dev_premium_plus_warning(): + """Show/hide the homepage Premium+ subscription warning by setting the + premium_plus days_remaining in the mocked check-in data. + state=expiring -> active, 10 days left ('will expire in 10 days') + state=expired -> inactive, 5 days ago ('expired 5 days ago') + state=off -> remove the premium_plus block (no warning)""" + state = request.args.get("state", "off") + data = _load_check_in() + if state == "expiring": + data["premium_plus"] = {"active": True, "days_remaining": 10} + elif state == "expired": + data["premium_plus"] = {"active": False, "days_remaining": -5} + else: + state = "off" + data.pop("premium_plus", None) + with open(CHECK_IN_FILE, "w") as f: + json.dump(data, f) + # Un-dismiss so the banner is not suppressed by a prior dismissal + device_info.delete_file("/tmp/dismiss_expiration_warning") + return _ok(premium_plus_warning=state) + + +@mynode_dev.route("/dev/upgrade_available") +def dev_upgrade_available(): + with open("/usr/share/mynode/version") as f: + version = f.read().strip() + latest = "v99.9" if request.args.get("value", "1") == "1" else version + with open("/usr/share/mynode/latest_version", "w") as f: + f.write(latest) + return _ok(current=version, latest=latest) + + +@mynode_dev.route("/dev/reboot") +def dev_reboot(): + device_info.reboot_device() + return _ok(message="simulated reboot started") + + +@mynode_dev.route("/dev/shutdown") +def dev_shutdown(): + device_info.shutdown_device() + return _ok(message="simulated shutdown started (auto power-on in ~15s)") + + +@mynode_dev.route("/dev/reset") +def dev_reset(): + # ensure(force=True) restores the premium-edition default + seed_fixture_fs.ensure(force=True) + set_knob("lnd", {"ready": True}) + set_knob("misc", {"starting": False}) + real("lightning_info").lnd_ready = True + real("device_info").cached_data["get_throttled_data"] = "" + application_info.clear_application_cache() + systemctl_info.clear_service_enabled_cache() + seed_runtime_data(log_errors=True) + return _ok(message="fixture state restored") + + +@mynode_dev.route("/dev/panel") +def dev_panel(): + return Response( + "
" + "