iOS remote client: native UIKit surface, WS logging, client-side layout persistence

- Add remote-client-ios: full native UIKit app with Objects/View/UILayout/Functions pattern
- Widget hierarchy: BaseWidget → Fader, Toggle, Transport, Title, Feedback, LogWidget
- LoggingVC: child VC embedded in LogWidget for live WS message inspection
- Codable model: ModelPresetLoadToRemoteDevice with snake_case decoder strategy
- Layout persistence: UserDefaults keyed by preset_uuid, fully client-side
- WS logging: dedicated WS In/Out floating panels on desktop app
- Remove HTTP server and JS client (replaced by native iOS app)
- Add Xcode noise to .gitignore (xcuserstate, xcuserdata)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-01 00:02:10 -04:00
parent 4db7165797
commit 7a74cfa75e
38 changed files with 2422 additions and 636 deletions
+135 -10
View File
@@ -72,7 +72,6 @@ from osc_sender import (
)
from ws_server import WSServer
from http_server import start_http_server, stop_http_server
def set_window(w):
global _window
@@ -83,7 +82,6 @@ _osc_server = None
_osc_thread = None
ws_server = None
_http_server = None
@@ -366,6 +364,11 @@ button_tablet_log = QPushButton("Tablet")
button_tablet_log.setFixedWidth(65)
button_tablet_log.setCheckable(True)
# Widget: button_ws_log
button_ws_log = QPushButton("WS")
button_ws_log.setFixedWidth(50)
button_ws_log.setCheckable(True)
# Widget: label_tablet_status
label_tablet_status = QLabel("")
label_tablet_status.setStyleSheet("color: #8e24aa; font-size: 11px; font-family: monospace;")
@@ -587,6 +590,62 @@ textedit_midi_out_log.setStyleSheet("""
}
""")
# Container: container_ws_in_log
container_ws_in_log = QWidget(None, Qt.WindowType.Window)
container_ws_in_log.setWindowTitle("WS In — Incoming from Tablet")
container_ws_in_log.setStyleSheet("background-color: #1a1a1a;")
container_ws_in_log.setFixedWidth(420)
vstack_ws_in_log = QVBoxLayout(container_ws_in_log)
vstack_ws_in_log.setContentsMargins(6, 6, 6, 6)
vstack_ws_in_log.setSpacing(4)
hstack_ws_in_log_header = QHBoxLayout()
label_ws_in_log_title = QLabel("WS In — Incoming from Tablet")
label_ws_in_log_title.setStyleSheet("color: #00acc1; font-weight: bold; font-size: 11px;")
button_ws_in_clear = QPushButton("Clear")
button_ws_in_clear.setFixedSize(45, 18)
textedit_ws_in_log = QTextEdit()
textedit_ws_in_log.setReadOnly(True)
textedit_ws_in_log.setStyleSheet("""
QTextEdit {
background-color: #111111;
color: #00e5ff;
font-family: monospace;
font-size: 10px;
border: none;
}
""")
# Container: container_ws_out_log
container_ws_out_log = QWidget(None, Qt.WindowType.Window)
container_ws_out_log.setWindowTitle("WS Out — Outgoing to Tablet")
container_ws_out_log.setStyleSheet("background-color: #1a1a1a;")
container_ws_out_log.setFixedWidth(420)
vstack_ws_out_log = QVBoxLayout(container_ws_out_log)
vstack_ws_out_log.setContentsMargins(6, 6, 6, 6)
vstack_ws_out_log.setSpacing(4)
hstack_ws_out_log_header = QHBoxLayout()
label_ws_out_log_title = QLabel("WS Out — Outgoing to Tablet")
label_ws_out_log_title.setStyleSheet("color: #ff6f00; font-weight: bold; font-size: 11px;")
button_ws_out_clear = QPushButton("Clear")
button_ws_out_clear.setFixedSize(45, 18)
textedit_ws_out_log = QTextEdit()
textedit_ws_out_log.setReadOnly(True)
textedit_ws_out_log.setStyleSheet("""
QTextEdit {
background-color: #111111;
color: #ffb300;
font-family: monospace;
font-size: 10px;
border: none;
}
""")
# Container: container_tablet_log
container_tablet_log = QWidget(None, Qt.WindowType.Window)
container_tablet_log.setWindowTitle("Tablet Server")
@@ -1045,6 +1104,7 @@ class MainWindow(QMainWindow):
hstack_osc_buttons.addSpacing(16)
hstack_osc_buttons.addWidget(button_tablet_server)
hstack_osc_buttons.addWidget(button_tablet_log)
hstack_osc_buttons.addWidget(button_ws_log)
hstack_osc_buttons.addWidget(label_tablet_status)
hstack_osc_buttons.addStretch()
@@ -1177,6 +1237,24 @@ class MainWindow(QMainWindow):
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
container_midi_out_log.setVisible(False)
#MARK: WS IN LOG CONTAINER
hstack_ws_in_log_header.addWidget(label_ws_in_log_title)
hstack_ws_in_log_header.addStretch()
hstack_ws_in_log_header.addWidget(button_ws_in_clear)
vstack_ws_in_log.addLayout(hstack_ws_in_log_header)
vstack_ws_in_log.addWidget(textedit_ws_in_log)
button_ws_in_clear.clicked.connect(lambda: textedit_ws_in_log.clear())
container_ws_in_log.setVisible(False)
#MARK: WS OUT LOG CONTAINER
hstack_ws_out_log_header.addWidget(label_ws_out_log_title)
hstack_ws_out_log_header.addStretch()
hstack_ws_out_log_header.addWidget(button_ws_out_clear)
vstack_ws_out_log.addLayout(hstack_ws_out_log_header)
vstack_ws_out_log.addWidget(textedit_ws_out_log)
button_ws_out_clear.clicked.connect(lambda: textedit_ws_out_log.clear())
container_ws_out_log.setVisible(False)
#MARK: TABLET LOG CONTAINER
hstack_tablet_log_header.addWidget(label_tablet_log_title)
hstack_tablet_log_header.addStretch()
@@ -1188,6 +1266,7 @@ class MainWindow(QMainWindow):
button_tablet_server.clicked.connect(toggle_tablet_server)
button_tablet_log.clicked.connect(tablet_log_show_hide)
button_ws_log.clicked.connect(ws_log_show_hide)
#MARK: LAYOUT PANEL
container_layout_panel.setVisible(True)
@@ -1701,6 +1780,32 @@ def log_osc_outgoing(addr, val):
line = f"<span style='color:#555'>{ts}</span> <span style='color:#1e88e5'>{addr}</span> <span style='color:#d4d4d4'>{val}</span>"
_append_log(textedit_osc_out_log, line)
def log_ws_incoming(message):
import json
from datetime import datetime
ts = datetime.now().strftime("%H:%M:%S")
try:
obj = json.loads(message)
pretty = json.dumps(obj, indent=2)
except Exception:
pretty = message
header = f"<span style='color:#555'>{ts}</span> <span style='color:#00acc1'>◀ WS In</span>"
body = f"<pre style='color:#00e5ff; margin:0; font-size:10px'>{pretty}</pre>"
textedit_ws_in_log.append(header)
textedit_ws_in_log.append(body)
textedit_ws_in_log.append("<span style='color:#333'>───────────────────</span>")
def log_ws_outgoing(data):
import json
from datetime import datetime
ts = datetime.now().strftime("%H:%M:%S")
pretty = json.dumps(data, indent=2)
header = f"<span style='color:#555'>{ts}</span> <span style='color:#ff6f00'>▶ WS Out</span>"
body = f"<pre style='color:#ffb300; margin:0; font-size:10px'>{pretty}</pre>"
textedit_ws_out_log.append(header)
textedit_ws_out_log.append(body)
textedit_ws_out_log.append("<span style='color:#333'>───────────────────</span>")
def update_osc_server_ip(ip):
set_osc_target(ip=ip.strip() or "127.0.0.1")
save_io_config()
@@ -2245,6 +2350,7 @@ def _build_tablet_snapshot():
snapshot = {
"preset_uuid": label_preset_uuid.text().strip(),
"preset_name": name,
"big_title": big_title_edit.text() if big_title_edit else "",
"faders": [{"uid": f.uid, "label": f.title_edit.text(), "value": f.fader.value(), "visible": True, "color": PALETTE_HEX.get(f.color_name, "#00e676")} for f in faders],
"toggles": [{"uid": t.uid, "label": t.title_edit.text(), "state": t.toggle_state, "visible": True, "color": PALETTE_HEX.get(t.color_name, "#00e676")} for t in toggles],
"transports": [{"uid": t.uid, "label": t.title_edit.text(), "visible": True} for t in transports],
@@ -2256,6 +2362,7 @@ def _on_tablet_connected():
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
log_ws_outgoing(snapshot)
def _on_tablet_layout_saved(preset_uuid, layout):
@@ -2267,6 +2374,9 @@ def _on_tablet_layout_saved(preset_uuid, layout):
def preset_load(preset):
global selected_strips
selected_strips = []
fader_states = preset.get("faders", [])
while len(faders) < len(fader_states):
fader_add()
@@ -2391,6 +2501,7 @@ def preset_load(preset):
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
log_ws_outgoing(snapshot)
def export_preset():
import json as _json
@@ -2714,6 +2825,24 @@ def tablet_log_window_calc():
container_tablet_log.move(geo.right() + 732, geo.top())
def ws_log_show_hide(checked):
container_ws_in_log.setVisible(checked)
container_ws_out_log.setVisible(checked)
if checked:
ws_log_window_calc()
def ws_log_window_calc():
geo = app_window.frameGeometry()
half = geo.height() // 2 - 2
container_ws_in_log.setFixedWidth(360)
container_ws_in_log.setFixedHeight(half)
container_ws_in_log.move(geo.right() + 4, geo.top())
container_ws_out_log.setFixedWidth(360)
container_ws_out_log.setFixedHeight(half)
container_ws_out_log.move(geo.right() + 4, geo.top() + half + 4)
def log_tablet(line):
from datetime import datetime
ts = datetime.now().strftime("%H:%M:%S")
@@ -2729,14 +2858,14 @@ def toggle_tablet_server():
def _start_tablet_server():
global ws_server, _http_server
global ws_server
import socket as _socket
ws_server = WSServer(port=8765, parent=app_window)
ws_server.log_signal.connect(log_tablet)
ws_server.raw_in_signal.connect(log_ws_incoming)
ws_server.control_received.connect(lambda uid, val: midi_receiver.hw_cc_signal.emit(uid, val))
ws_server.layout_saved.connect(_on_tablet_layout_saved)
ws_server.client_connected.connect(_on_tablet_connected)
_http_server = start_http_server(port=8080)
try:
s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
@@ -2745,19 +2874,15 @@ def _start_tablet_server():
except Exception:
ip = "localhost"
button_tablet_server.setText("Stop Tablet")
label_tablet_status.setText(f"http://{ip}:8080")
log_tablet(f"HTTP http://{ip}:8080")
label_tablet_status.setText(f"ws://{ip}:8765")
log_tablet(f"WS ws://{ip}:8765")
def _stop_tablet_server():
global ws_server, _http_server
global ws_server
if ws_server:
ws_server.stop()
ws_server = None
if _http_server:
stop_http_server(_http_server)
_http_server = None
button_tablet_server.setText("Start Tablet")
label_tablet_status.setText("")
log_tablet("server stopped")