diff --git a/.gitignore b/.gitignore
index 9c70709f3..5239a8763 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@ src/seedsigner/models/settings_definition.json
.coverage*
*.po
-*.mo
\ No newline at end of file
+*.mo
+*.bmp
diff --git a/README.md b/README.md
index 171bd6713..1e5b273cd 100644
--- a/README.md
+++ b/README.md
@@ -357,4 +357,5 @@ Letter templates(8.5in * 11in):
See the [SeedSigner OS repo](https://github.com/SeedSigner/seedsigner-os/) for instructions.
# Developer Local Build Instructions
-Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md)
+* Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md)
+* Desktop build for development and simulation. See the [Local SeedSigner Simulator Setup](docs/local_simulator_setup_instructions.md)
diff --git a/docs/img/simulator.png b/docs/img/simulator.png
new file mode 100644
index 000000000..b525bf908
Binary files /dev/null and b/docs/img/simulator.png differ
diff --git a/docs/local_simulator_setup_instructions.md b/docs/local_simulator_setup_instructions.md
new file mode 100644
index 000000000..9e804c80f
--- /dev/null
+++ b/docs/local_simulator_setup_instructions.md
@@ -0,0 +1,89 @@
+# Local SeedSigner Simulator Setup
+
+This guide adds a method to setup SeedSigner testing locally through a simulator GUI for easy development. You can either interact with the buttons at the window or use keyboard arrows, Enter, Numpad_1, Numpad_2, Numpad_3
+
+Note: The has only been tested on macOS (26) using Homebrew and Python 3.14.
+
+
+
+---
+
+### Prerequisites
+
+- A desktop / laptop with webcam
+
+---
+
+### Installing system dependencies
+
+SeedSigner’s simulator depends on Python with `tkinter` support, native libraries for QR scanning and camera access.
+- `tkinter` is Python's built-in library for creating desktop GUI applications.
+- `pyzbar` library requires a backend to be running on the system called `zbar`.
+
+#### MacOS (Homebrew)
+```bash
+brew install python@3.14 # includes tkinter support
+brew install zbar
+```
+#### Linux
+```bash
+sudo apt-get install python3-tk
+sudo apt install libzbar0
+```
+
+### Setting up dev environment
+
+```bash
+git clone https://github.com/SeedSigner/seedsigner
+
+python3 -m venv env
+source env/bin/activate
+
+# should not fail loading tkinter
+python3 -m tkinter
+
+pip install -r tools/emulator/requirements-simulator.txt
+```
+
+#### Camera Permissions (macOS Security)
+
+On first run, macOS will block camera access. You may see logs like:
+
+```
+OpenCV: not authorized to capture video
+OpenCV: camera failed to properly initialize
+```
+
+#### Grant Camera Permission (macOS)
+
+Go to: System Settings → Privacy & Security → Camera
+
+Enable camera access for:
+
+- Terminal (if you run from Terminal)
+- VS Code (if you run from VS Code)
+
+Then restart Terminal and rerun the simulator.
+
+You can also reset permissions if macOS didn’t prompt:
+
+```bash
+tccutil reset Camera
+```
+
+### Running the Simulator
+
+```bash
+python tools/emulator/run_emulator.py
+```
+
+### Notes
+
+- The simulator environment is **not security-hardened** and should never be used with real funds.
+- Camera access, QR decoding, and GUI rendering are all platform-dependent; macOS quirks differ from Raspberry Pi OS.
+- For hardware deployment, follow the official Raspberry Pi OS instructions in the main project README.
+
+### Credits
+
+- The original work is inspired by https://github.com/enteropositivo/seedsigner-emulator, which was the first working emulator POC that directly replaced files in the SeedSigner source code for the camera, buttons, and renderer using Tkinter-based implementations.
+- The modified work from https://github.com/ltcmweb/seedsigner based on @enteropositivo's work added macOS support with local emulation of the GPIO socket.
diff --git a/tools/emulator/patches/camera.py b/tools/emulator/patches/camera.py
new file mode 100644
index 000000000..ed44a0b62
--- /dev/null
+++ b/tools/emulator/patches/camera.py
@@ -0,0 +1,173 @@
+# Forked and modified from @enteropositivo
+# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/hardware/camera.py
+# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/emulator/webcamvideostream.py
+
+from threading import Thread
+import time
+import cv2
+import numpy as np
+from PIL import Image
+
+from seedsigner.models.settings import Settings, SettingsConstants
+from seedsigner.models.singleton import Singleton
+
+
+class MockCamera(Singleton):
+ _video_stream = None
+ _picamera = None
+ _camera_rotation = None
+
+ @classmethod
+ def get_instance(cls):
+ # This is the only way to access the one and only Controller
+ print("INSTANCE")
+ if cls._instance is None:
+ cls._instance = cls.__new__(cls)
+ cls._instance._camera_rotation = int(
+ Settings.get_instance().get_value(
+ SettingsConstants.SETTING__CAMERA_ROTATION
+ )
+ )
+ cls._instance._camera_rotation += 90
+ return cls._instance
+
+ def start_video_stream_mode(
+ self, resolution=(512, 384), framerate=12, format="bgr"
+ ):
+ if self._video_stream is not None:
+ self.stop_video_stream_mode()
+
+ try:
+ self._video_stream = WebcamVideoStream(
+ resolution=resolution, framerate=framerate, format=format
+ )
+ self._video_stream.start()
+ except Exception as e:
+ raise e
+
+ def read_video_stream(self, as_image=False):
+ if not self._video_stream:
+ raise Exception("Must call start_video_stream first.")
+ frame = self._video_stream.read()
+ if not as_image:
+ return frame
+ else:
+ if frame is not None:
+ return Image.fromarray(frame.astype("uint8"), "RGB").rotate(
+ 90 + self._camera_rotation
+ )
+ return None
+
+ def stop_video_stream_mode(self):
+ if self._video_stream is not None:
+ self._video_stream.stop()
+ self._video_stream = None
+
+ def start_single_frame_mode(self, resolution=(720, 480)):
+ if self._video_stream is not None:
+ self.stop_video_stream_mode()
+ if self._picamera is not None:
+ self._picamera.close()
+
+ def capture_frame(self):
+ print("CAPTURE")
+ frame = WebcamVideoStream.single_frame()
+ return Image.fromarray(frame).rotate(90 + self._camera_rotation)
+
+ def stop_single_frame_mode(self):
+ if self._picamera is not None:
+ self._picamera.close()
+ self._picamera = None
+
+
+def resize_and_center_crop_240(frame_bgr: np.ndarray) -> np.ndarray:
+ target = 240
+ h, w = frame_bgr.shape[:2]
+
+ # resize to 240
+ if w < h:
+ scale = target / w
+ else:
+ scale = target / h
+
+ new_w = int(w * scale)
+ new_h = int(h * scale)
+
+ resized = cv2.resize(frame_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
+
+ # center crop
+ x1 = (new_w - target) // 2
+ y1 = (new_h - target) // 2
+
+ cropped = resized[y1 : y1 + target, x1 : x1 + target]
+
+ return cropped
+
+
+class WebcamVideoStream:
+ def __init__(self, resolution=(320, 240), framerate=32, format="bgr", **kwargs):
+ # initialize the camera
+ self.camera = cv2.VideoCapture(0)
+ self.set_resolution(resolution)
+
+ # initialize the frame and the variable used to indicate
+ # if the thread should be stopped
+ self.frame = None
+ self.should_stop = False
+ self.is_stopped = True
+
+ def start(self):
+ # start the thread to read frames from the video stream
+ t = Thread(target=self.update, args=())
+ t.daemon = True
+ t.start()
+ self.is_stopped = False
+ return self
+
+ def update(self):
+ if self.camera.isOpened():
+ # keep looping infinitely until the thread is stopped
+ while not self.should_stop:
+ # grab the frame from the stream and clear the stream in
+ # preparation for the next frame
+ ret, stream = self.camera.read()
+ stream = cv2.resize(stream, (240, 240))
+ stream = cv2.cvtColor(stream, cv2.COLOR_BGR2RGB)
+ time.sleep(0.05)
+ self.frame = stream
+ self.is_stopped = True
+ self.should_stop = False
+
+ def read(self):
+ return self.frame
+
+ def stop(self):
+ # indicate that the thread should be stopped
+ self.should_stop = True
+
+ # Block in this thread until stopped
+ while not self.is_stopped:
+ pass
+
+ def set_resolution(self, resolution):
+ self.camera.set(3, resolution[0])
+ self.camera.set(4, resolution[1])
+
+ @staticmethod
+ def single_frame():
+ cap = cv2.VideoCapture(0)
+
+ # Warm-up, let auto-exposure settle in
+ for _ in range(30):
+ cap.read()
+ time.sleep(0.01)
+
+ ret, frame = cap.read()
+ cap.release()
+
+ if not ret:
+ raise RuntimeError("Camera capture failed")
+
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ frame = resize_and_center_crop_240(frame)
+ return frame
diff --git a/tools/emulator/patches/gpio.py b/tools/emulator/patches/gpio.py
new file mode 100644
index 000000000..9f64bab1d
--- /dev/null
+++ b/tools/emulator/patches/gpio.py
@@ -0,0 +1,52 @@
+# Forked and modifed from @ltcmweb
+# https://github.com/ltcmweb/seedsigner/blob/3d321d50d488919d94fe407f538d8e655ba13eb4/src/seedsigner/hardware/buttons.py#L198
+
+import threading
+import socket
+
+
+class MockGPIO:
+ LOW = 0
+ HIGH = 1
+ BOARD = 10
+ IN = 1
+ PUD_UP = 22
+ RPI_INFO = {"P1_REVISION": 3}
+
+ def __init__(self):
+ self._states = {}
+ self.lock = threading.Lock()
+ self._initialized = False
+
+ def setmode(self, mode):
+ pass
+
+ def setup(self, pin, mode, pull_up_down=None):
+ if not self._initialized:
+ self.init()
+
+ def init(self, socket_path="gpio.sock"):
+ if self._initialized:
+ return
+ self._initialized = True
+ self._states = {}
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.connect(socket_path)
+ threading.Thread(target=self._read_loop, daemon=True).start()
+
+ def _read_loop(self):
+ f = self.sock.makefile("r")
+ while True:
+ line = f.readline()
+ if not line:
+ break
+ try:
+ pin, val = line.strip().split()
+ with self.lock:
+ self._states[int(pin)] = int(val)
+ except Exception:
+ pass
+
+ def input(self, pin):
+ with self.lock:
+ return self._states.get(pin, self.HIGH)
diff --git a/tools/emulator/patches/window.py b/tools/emulator/patches/window.py
new file mode 100644
index 000000000..55d8f9923
--- /dev/null
+++ b/tools/emulator/patches/window.py
@@ -0,0 +1,206 @@
+# Forked and modified from @enteropositivo, @ltcmweb
+# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/emulator/desktopDisplay.py
+# https://github.com/ltcmweb/seedsigner/blob/3d321d50d488919d94fe407f538d8e655ba13eb4/src/gui.py
+
+import os
+import time
+import threading
+import socket
+import numpy as np
+from tkinter import *
+from PIL import Image, ImageTk
+
+import time
+import numpy as np
+
+
+class Window:
+ def __init__(self, HardwareButtons, width: int = 240, height: int = 240):
+ self.width = width
+ self.height = height
+ self.HardwareButtons = HardwareButtons
+
+ self.setup_sock()
+ self.run()
+
+ def run(self):
+ self.root = Tk()
+ self.root.title("SeedSigner")
+
+ self.root.geometry(f"{self.width*2}x{self.height}+240+240")
+ self.root.resizable(False, False)
+ self.root.configure(bg="orange")
+ self.root.attributes("-topmost", True)
+
+ self.label = Label(self.root)
+ self.label.pack()
+
+ self.joystick = Frame(self.root)
+ self.joystick.pack()
+ self.joystick.place(x=20, y=85)
+ self.joystick.configure(bg="orange")
+
+ pixel = PhotoImage(width=1, height=1)
+
+ self.btnL = Button(
+ self.joystick,
+ image=pixel,
+ width=20,
+ height=20,
+ command=self.HardwareButtons.KEY_LEFT_PIN,
+ )
+ self.btnL.grid(row=1, column=0)
+ self.bindButtonClick(self.btnL)
+
+ self.btnR = Button(
+ self.joystick,
+ image=pixel,
+ width=20,
+ height=20,
+ command=self.HardwareButtons.KEY_RIGHT_PIN,
+ )
+ self.btnR.grid(row=1, column=2)
+ self.bindButtonClick(self.btnR)
+
+ self.btnC = Button(
+ self.joystick,
+ image=pixel,
+ width=20,
+ height=20,
+ command=self.HardwareButtons.KEY_PRESS_PIN,
+ )
+ self.btnC.grid(row=1, column=1)
+ self.bindButtonClick(self.btnC)
+
+ self.btnU = Button(
+ self.joystick,
+ image=pixel,
+ width=20,
+ height=20,
+ command=self.HardwareButtons.KEY_UP_PIN,
+ )
+ self.btnU.grid(row=0, column=1)
+ self.bindButtonClick(self.btnU)
+
+ self.btnD = Button(
+ self.joystick,
+ image=pixel,
+ width=20,
+ height=20,
+ command=self.HardwareButtons.KEY_DOWN_PIN,
+ )
+ self.btnD.grid(row=2, column=1)
+ self.bindButtonClick(self.btnD)
+
+ self.btn1 = Button(
+ self.root,
+ image=pixel,
+ width=40,
+ height=20,
+ command=self.HardwareButtons.KEY1_PIN,
+ )
+ self.btn1.place(x=self.width + 160, y=60)
+ self.bindButtonClick(self.btn1)
+
+ self.btn2 = Button(
+ self.root,
+ image=pixel,
+ width=40,
+ height=20,
+ command=self.HardwareButtons.KEY2_PIN,
+ )
+ self.btn2.place(x=self.width + 160, y=116)
+ self.bindButtonClick(self.btn2)
+
+ self.btn3 = Button(
+ self.root,
+ image=pixel,
+ width=40,
+ height=20,
+ command=self.HardwareButtons.KEY3_PIN,
+ )
+ self.btn3.place(x=self.width + 160, y=172)
+ self.bindButtonClick(self.btn3)
+
+ def key_handler(event):
+ if event.keysym == "Up":
+ self.key_press(self.HardwareButtons.KEY_UP_PIN)
+ if event.keysym == "Down":
+ self.key_press(self.HardwareButtons.KEY_DOWN_PIN)
+ if event.keysym == "Left":
+ self.key_press(self.HardwareButtons.KEY_LEFT_PIN)
+ if event.keysym == "Right":
+ self.key_press(self.HardwareButtons.KEY_RIGHT_PIN)
+
+ if event.keysym in ("1", "KP_1"):
+ self.key_press(self.HardwareButtons.KEY1_PIN)
+ if event.keysym in ("2", "KP_2"):
+ self.key_press(self.HardwareButtons.KEY2_PIN)
+ if event.keysym in ("3", "KP_3"):
+ self.key_press(self.HardwareButtons.KEY3_PIN)
+
+ if event.keysym == "Return":
+ self.key_press(self.HardwareButtons.KEY_PRESS_PIN)
+
+ self.root.bind("", key_handler)
+
+ self.periodic_update()
+ self.root.mainloop()
+
+ def key_press(self, key):
+ self.set_input(key, 0)
+ time.sleep(0.1)
+ self.set_input(key, 1)
+
+ def bindButtonClick(self, btn):
+ btn.bind("