muxplex-deck 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,530 @@
1
+ """In-process Stream Deck+ emulator: virtual device + localhost HTTP control UI.
2
+
3
+ Emulates at the `device.DeckDevice` seam, not at USB/HID level -- the
4
+ physical HID layer is already hardware-proven by `deck_probe`. This module
5
+ makes everything *above* that layer (state machine, muxplex client,
6
+ rendering, interaction flow) fully real and testable with zero hardware:
7
+ no `streamdeck` library import, no hidapi, no `DeviceManager` construction.
8
+
9
+ `EmulatorDeviceManager` starts a background HTTP server (stdlib
10
+ `http.server`, threaded) that serves a small control UI and the same JSON
11
+ endpoints the UI itself polls/posts -- so a human on a Mac and an agent
12
+ running curl/httpx see identical behavior. The server runs for the whole
13
+ sidecar process lifetime: it *is* the virtual USB bus. "Unplug" and "plug"
14
+ just toggle a flag on the one virtual device; they don't tear the server
15
+ down, mirroring how a real USB bus stays powered while a cable is pulled.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import json
22
+ import logging
23
+ import threading
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+ from urllib.parse import urlparse
26
+
27
+ from PIL import Image
28
+
29
+ from .device import (
30
+ DeckDevice,
31
+ DeviceProbeError,
32
+ DialCallback,
33
+ DialEventType,
34
+ KeyCallback,
35
+ TouchCallback,
36
+ TouchscreenEventType,
37
+ )
38
+
39
+ __all__ = ["EmulatorDevice", "EmulatorDeviceManager"]
40
+
41
+ logger = logging.getLogger("muxplex_deck")
42
+
43
+ KEY_COUNT = 8
44
+ KEY_LAYOUT = (2, 4) # rows x cols, same as the real Stream Deck+
45
+ DIAL_COUNT = 4
46
+ KEY_SIZE = (120, 120)
47
+ STRIP_SIZE = (800, 100)
48
+ DEFAULT_PORT = 8484
49
+ # Real Elgato vendor id + the real Stream Deck+ USB product id (from the
50
+ # `streamdeck` library's own `USBVendorIDs`/`USBProductIDs`), so a capability
51
+ # report against the emulator shows the same "usb id" a real Stream Deck+
52
+ # would -- there's no meaningful "emulated" vendor/product id to invent, and
53
+ # this keeps `describe_capabilities()` output consistent between backends.
54
+ VENDOR_ID_ELGATO = 0x0FD9
55
+ PRODUCT_ID_STREAMDECK_PLUS = 0x84
56
+ # Simulates real hardware's dim firmware power-on default (real-hardware
57
+ # feedback: the deck never asserted brightness itself and stayed dim) --
58
+ # NOT the sidecar's target brightness. `main._run_active` calls
59
+ # `deck.set_brightness(FULL_BRIGHTNESS_PERCENT)` on every bring-up; starting
60
+ # the emulator below that value makes the call's effect observable via
61
+ # `snapshot_state()["brightness"]`.
62
+ INITIAL_BRIGHTNESS_PERCENT = 40
63
+
64
+
65
+ def _encode_jpeg(image: Image.Image) -> bytes:
66
+ buf = io.BytesIO()
67
+ image.save(buf, "JPEG", quality=100)
68
+ return buf.getvalue()
69
+
70
+
71
+ def _blank_key_jpeg() -> bytes:
72
+ return _encode_jpeg(Image.new("RGB", KEY_SIZE, "black"))
73
+
74
+
75
+ def _blank_strip_jpeg() -> bytes:
76
+ return _encode_jpeg(Image.new("RGB", STRIP_SIZE, "black"))
77
+
78
+
79
+ class EmulatorDevice:
80
+ """A virtual Stream Deck+ satisfying the `DeckDevice` protocol.
81
+
82
+ Same image formats (8x 120x120 JPEG keys, 800x100 JPEG touch strip) as
83
+ the real StreamDeckPlus, so `rendering.py`'s `PILHelper`-based drawing
84
+ works completely unchanged. `plugged` models the virtual USB cable --
85
+ `connected()` reflects it directly; `is_open()` reflects our own
86
+ open()/close() calls, independent of the cable, exactly like the real
87
+ transport.
88
+ """
89
+
90
+ def __init__(self) -> None:
91
+ self._lock = threading.RLock()
92
+ self._is_open = False
93
+ self.plugged = False
94
+ self._brightness = INITIAL_BRIGHTNESS_PERCENT
95
+ self._key_images: list[bytes] = [_blank_key_jpeg() for _ in range(KEY_COUNT)]
96
+ self._strip_image: bytes = _blank_strip_jpeg()
97
+ self._key_callback: KeyCallback | None = None
98
+ self._dial_callback: DialCallback | None = None
99
+ self._touch_callback: TouchCallback | None = None
100
+
101
+ # --- DeckDevice protocol -------------------------------------------------
102
+
103
+ def open(self) -> None:
104
+ with self._lock:
105
+ self._is_open = True
106
+
107
+ def close(self) -> None:
108
+ with self._lock:
109
+ self._is_open = False
110
+
111
+ def reset(self) -> None:
112
+ with self._lock:
113
+ self._key_images = [_blank_key_jpeg() for _ in range(KEY_COUNT)]
114
+ self._strip_image = _blank_strip_jpeg()
115
+
116
+ def is_open(self) -> bool:
117
+ return self._is_open
118
+
119
+ def connected(self) -> bool:
120
+ return self.plugged
121
+
122
+ def key_count(self) -> int:
123
+ return KEY_COUNT
124
+
125
+ def key_layout(self) -> tuple[int, int]:
126
+ return KEY_LAYOUT
127
+
128
+ def dial_count(self) -> int:
129
+ return DIAL_COUNT
130
+
131
+ def is_touch(self) -> bool:
132
+ return True
133
+
134
+ def touch_key_count(self) -> int:
135
+ # The Plus's touch surface is the 800x100 touchscreen strip, not
136
+ # discrete touch buttons (that's the Neo) -- zero, same as the
137
+ # real Stream Deck+ reports.
138
+ return 0
139
+
140
+ def is_visual(self) -> bool:
141
+ return True
142
+
143
+ def vendor_id(self) -> int:
144
+ return VENDOR_ID_ELGATO
145
+
146
+ def product_id(self) -> int:
147
+ return PRODUCT_ID_STREAMDECK_PLUS
148
+
149
+ def deck_type(self) -> str:
150
+ return "Stream Deck + (emulated)"
151
+
152
+ def get_serial_number(self) -> str:
153
+ return "EMULATED-0001"
154
+
155
+ def get_firmware_version(self) -> str:
156
+ return "emulator-1.0"
157
+
158
+ def key_image_format(self) -> dict:
159
+ return {
160
+ "size": KEY_SIZE,
161
+ "format": "JPEG",
162
+ "flip": (False, False),
163
+ "rotation": 0,
164
+ }
165
+
166
+ def touchscreen_image_format(self) -> dict:
167
+ return {
168
+ "size": STRIP_SIZE,
169
+ "format": "JPEG",
170
+ "flip": (False, False),
171
+ "rotation": 0,
172
+ }
173
+
174
+ def set_brightness(self, percent: float) -> None:
175
+ with self._lock:
176
+ self._brightness = max(0, min(100, int(percent)))
177
+
178
+ def set_key_image(self, key: int, image: bytes) -> None:
179
+ with self._lock:
180
+ self._key_images[key] = image
181
+
182
+ def set_touchscreen_image(
183
+ self,
184
+ image: bytes,
185
+ x_pos: int = 0,
186
+ y_pos: int = 0,
187
+ width: int = 0,
188
+ height: int = 0,
189
+ ) -> None:
190
+ """Paint a region of the touch strip.
191
+
192
+ Mirrors the real library's own validation: a non-empty image with a
193
+ zero-size region is invalid (this is the exact bug fixed in
194
+ `rendering._paint_full_touchscreen` -- see its docstring). Anything
195
+ else is composited onto the current strip image, so partial-zone
196
+ updates (like `deck_probe`'s dial counters) behave the same as on
197
+ real hardware.
198
+ """
199
+ with self._lock:
200
+ if not image:
201
+ self._strip_image = _blank_strip_jpeg()
202
+ return
203
+ if width <= 0 or height <= 0:
204
+ raise IndexError(f"Invalid draw width {width}")
205
+ if (x_pos, y_pos, (width, height)) == (0, 0, STRIP_SIZE):
206
+ self._strip_image = image
207
+ return
208
+ base = Image.open(io.BytesIO(self._strip_image)).convert("RGB")
209
+ patch = Image.open(io.BytesIO(image)).convert("RGB")
210
+ base.paste(patch, (x_pos, y_pos))
211
+ self._strip_image = _encode_jpeg(base)
212
+
213
+ def set_key_callback(self, callback: KeyCallback | None) -> None:
214
+ with self._lock:
215
+ self._key_callback = callback
216
+
217
+ def set_dial_callback(self, callback: DialCallback | None) -> None:
218
+ with self._lock:
219
+ self._dial_callback = callback
220
+
221
+ def set_touchscreen_callback(self, callback: TouchCallback | None) -> None:
222
+ with self._lock:
223
+ self._touch_callback = callback
224
+
225
+ def __enter__(self) -> None:
226
+ self._lock.acquire()
227
+
228
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
229
+ self._lock.release()
230
+
231
+ # --- HTTP-facing helpers (not part of DeckDevice) -------------------------
232
+
233
+ def snapshot_state(self) -> dict:
234
+ with self._lock:
235
+ return {
236
+ "plugged": self.plugged,
237
+ "is_open": self._is_open,
238
+ "brightness": self._brightness,
239
+ "key_count": KEY_COUNT,
240
+ "dial_count": DIAL_COUNT,
241
+ }
242
+
243
+ def key_image(self, index: int) -> bytes:
244
+ with self._lock:
245
+ return self._key_images[index]
246
+
247
+ def strip_image(self) -> bytes:
248
+ with self._lock:
249
+ return self._strip_image
250
+
251
+ def fire_key(self, key: int, pressed: bool) -> None:
252
+ callback = self._key_callback
253
+ if callback is not None:
254
+ callback(self, key, pressed)
255
+
256
+ def fire_dial(self, dial: int, event_type: DialEventType, value: object) -> None:
257
+ callback = self._dial_callback
258
+ if callback is not None:
259
+ callback(self, dial, event_type, value)
260
+
261
+ def fire_touch(self, event_type: TouchscreenEventType, value: dict) -> None:
262
+ callback = self._touch_callback
263
+ if callback is not None:
264
+ callback(self, event_type, value)
265
+
266
+
267
+ _UI_HTML = """<!doctype html>
268
+ <html>
269
+ <head>
270
+ <meta charset="utf-8">
271
+ <title>muxplex-deck emulator</title>
272
+ <style>
273
+ body { font-family: monospace; background: #111; color: #eee; }
274
+ #keys { display: grid; grid-template-columns: repeat(4, 120px); gap: 8px; }
275
+ #keys img { width: 120px; height: 120px; cursor: pointer; border: 1px solid #555; }
276
+ #strip img { width: 800px; height: 100px; border: 1px solid #555; margin-top: 8px; cursor: crosshair; }
277
+ button { margin: 2px; }
278
+ </style>
279
+ </head>
280
+ <body>
281
+ <h3>muxplex-deck emulator</h3>
282
+ <div id="status">loading...</div>
283
+ <button onclick="plug()">Plug in</button>
284
+ <button onclick="unplug()">Unplug</button>
285
+ <div id="keys"></div>
286
+ <div id="strip"><img id="stripimg" src="/strip.jpg"></div>
287
+ <h4>Dials</h4>
288
+ <div id="dials"></div>
289
+ <script>
290
+ const KEY_COUNT = 8;
291
+ const DIAL_COUNT = 4;
292
+
293
+ function buildKeys() {
294
+ const div = document.getElementById('keys');
295
+ for (let i = 0; i < KEY_COUNT; i++) {
296
+ const img = document.createElement('img');
297
+ img.id = 'key' + i;
298
+ img.src = '/keys/' + i + '.jpg';
299
+ img.onclick = () => clickKey(i);
300
+ div.appendChild(img);
301
+ }
302
+ }
303
+
304
+ function buildDials() {
305
+ const div = document.getElementById('dials');
306
+ for (let i = 0; i < DIAL_COUNT; i++) {
307
+ const row = document.createElement('div');
308
+ row.innerHTML = 'dial ' + i + ': ' +
309
+ '<button onclick="turnDial(' + i + ', -1)">-</button>' +
310
+ '<button onclick="turnDial(' + i + ', 1)">+</button>' +
311
+ '<button onclick="pushDial(' + i + ')">push</button>';
312
+ div.appendChild(row);
313
+ }
314
+ }
315
+
316
+ async function post(path, body) {
317
+ await fetch(path, { method: 'POST', body: JSON.stringify(body || {}) });
318
+ }
319
+
320
+ async function clickKey(i) { await post('/input/key', { key: i, action: 'click' }); }
321
+ async function turnDial(i, ticks) { await post('/input/dial', { dial: i, action: 'turn', ticks: ticks }); }
322
+ async function pushDial(i) {
323
+ await post('/input/dial', { dial: i, action: 'push', pressed: true });
324
+ await post('/input/dial', { dial: i, action: 'push', pressed: false });
325
+ }
326
+ async function plug() { await post('/plug'); }
327
+ async function unplug() { await post('/unplug'); }
328
+
329
+ async function refresh() {
330
+ const t = Date.now();
331
+ for (let i = 0; i < KEY_COUNT; i++) {
332
+ document.getElementById('key' + i).src = '/keys/' + i + '.jpg?t=' + t;
333
+ }
334
+ document.getElementById('stripimg').src = '/strip.jpg?t=' + t;
335
+ try {
336
+ const res = await fetch('/state');
337
+ const state = await res.json();
338
+ document.getElementById('status').textContent =
339
+ 'plugged=' + state.plugged + ' open=' + state.is_open +
340
+ ' brightness=' + state.brightness + '% keys=' + state.key_count;
341
+ } catch (e) {
342
+ document.getElementById('status').textContent = 'server unreachable?';
343
+ }
344
+ }
345
+
346
+ document.getElementById('stripimg').onclick = (e) => {
347
+ const rect = e.target.getBoundingClientRect();
348
+ const x = Math.round((e.clientX - rect.left) * (800 / rect.width));
349
+ const y = Math.round((e.clientY - rect.top) * (100 / rect.height));
350
+ post('/input/touch', { type: 'short', x: x, y: y });
351
+ };
352
+
353
+ buildKeys();
354
+ buildDials();
355
+ refresh();
356
+ setInterval(refresh, 400);
357
+ </script>
358
+ </body>
359
+ </html>
360
+ """
361
+
362
+
363
+ class _EmulatorHandler(BaseHTTPRequestHandler):
364
+ """Serves the control UI + JSON/JPEG endpoints for one `EmulatorDevice`.
365
+
366
+ `device` and `manager` are class attributes, bound per-server by
367
+ `_bound_handler_class()` below -- `BaseHTTPRequestHandler` instances are
368
+ constructed by `HTTPServer` internals with a fixed signature, so this is
369
+ the standard way to hand instance-specific state to request handlers.
370
+ """
371
+
372
+ device: EmulatorDevice
373
+ manager: EmulatorDeviceManager
374
+
375
+ def log_message(self, format: str, *args: object) -> None:
376
+ pass # the sidecar's own logger already covers what matters
377
+
378
+ def _send_json(self, status: int, payload: dict) -> None:
379
+ body = json.dumps(payload).encode("utf-8")
380
+ self.send_response(status)
381
+ self.send_header("Content-Type", "application/json")
382
+ self.send_header("Content-Length", str(len(body)))
383
+ self.end_headers()
384
+ self.wfile.write(body)
385
+
386
+ def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
387
+ self.send_response(status)
388
+ self.send_header("Content-Type", content_type)
389
+ self.send_header("Content-Length", str(len(body)))
390
+ self.send_header("Cache-Control", "no-store")
391
+ self.end_headers()
392
+ self.wfile.write(body)
393
+
394
+ def _read_json(self) -> dict:
395
+ length = int(self.headers.get("Content-Length", "0") or "0")
396
+ raw = self.rfile.read(length) if length else b""
397
+ return json.loads(raw) if raw else {}
398
+
399
+ def do_GET(self) -> None:
400
+ path = urlparse(self.path).path
401
+ if path == "/":
402
+ self._send_bytes(200, "text/html; charset=utf-8", _UI_HTML.encode("utf-8"))
403
+ return
404
+ if path == "/state":
405
+ self._send_json(200, self.device.snapshot_state())
406
+ return
407
+ if path == "/strip.jpg":
408
+ self._send_bytes(200, "image/jpeg", self.device.strip_image())
409
+ return
410
+ if path.startswith("/keys/") and path.endswith(".jpg"):
411
+ try:
412
+ index = int(path[len("/keys/") : -len(".jpg")])
413
+ self._send_bytes(200, "image/jpeg", self.device.key_image(index))
414
+ except (ValueError, IndexError):
415
+ self._send_json(404, {"error": "unknown key index"})
416
+ return
417
+ self._send_json(404, {"error": "not found"})
418
+
419
+ def do_POST(self) -> None:
420
+ path = urlparse(self.path).path
421
+ try:
422
+ if path == "/plug":
423
+ self.manager.plug()
424
+ self._send_json(200, self.device.snapshot_state())
425
+ elif path == "/unplug":
426
+ self.manager.unplug()
427
+ self._send_json(200, self.device.snapshot_state())
428
+ elif path == "/input/key":
429
+ self._handle_key_input(self._read_json())
430
+ self._send_json(200, {"ok": True})
431
+ elif path == "/input/dial":
432
+ self._handle_dial_input(self._read_json())
433
+ self._send_json(200, {"ok": True})
434
+ elif path == "/input/touch":
435
+ self._handle_touch_input(self._read_json())
436
+ self._send_json(200, {"ok": True})
437
+ else:
438
+ self._send_json(404, {"error": "not found"})
439
+ except (KeyError, ValueError, TypeError) as exc:
440
+ self._send_json(400, {"error": str(exc)})
441
+
442
+ def _handle_key_input(self, body: dict) -> None:
443
+ key = int(body["key"])
444
+ action = body.get("action", "click")
445
+ if action == "press":
446
+ self.device.fire_key(key, True)
447
+ elif action == "release":
448
+ self.device.fire_key(key, False)
449
+ elif action == "click":
450
+ self.device.fire_key(key, True)
451
+ self.device.fire_key(key, False)
452
+ else:
453
+ raise ValueError(f"unknown key action {action!r}")
454
+
455
+ def _handle_dial_input(self, body: dict) -> None:
456
+ dial = int(body["dial"])
457
+ action = body.get("action")
458
+ if action == "turn":
459
+ self.device.fire_dial(dial, DialEventType.TURN, int(body["ticks"]))
460
+ elif action == "push":
461
+ self.device.fire_dial(dial, DialEventType.PUSH, bool(body["pressed"]))
462
+ else:
463
+ raise ValueError(f"unknown dial action {action!r}")
464
+
465
+ def _handle_touch_input(self, body: dict) -> None:
466
+ kind = body["type"]
467
+ if kind == "short":
468
+ value = {"x": int(body["x"]), "y": int(body["y"])}
469
+ self.device.fire_touch(TouchscreenEventType.SHORT, value)
470
+ elif kind == "long":
471
+ value = {"x": int(body["x"]), "y": int(body["y"])}
472
+ self.device.fire_touch(TouchscreenEventType.LONG, value)
473
+ elif kind == "drag":
474
+ value = {
475
+ "x": int(body["x"]),
476
+ "y": int(body["y"]),
477
+ "x_out": int(body["x_out"]),
478
+ "y_out": int(body["y_out"]),
479
+ }
480
+ self.device.fire_touch(TouchscreenEventType.DRAG, value)
481
+ else:
482
+ raise ValueError(f"unknown touch type {kind!r}")
483
+
484
+
485
+ def _bound_handler_class(
486
+ device: EmulatorDevice, manager: EmulatorDeviceManager
487
+ ) -> type[_EmulatorHandler]:
488
+ """Create a `_EmulatorHandler` subclass bound to one device+manager pair."""
489
+ return type(
490
+ "BoundEmulatorHandler",
491
+ (_EmulatorHandler,),
492
+ {"device": device, "manager": manager},
493
+ )
494
+
495
+
496
+ class EmulatorDeviceManager:
497
+ """Backend-level 'find device' seam for the emulator -- the virtual USB bus.
498
+
499
+ The HTTP server runs for the lifetime of this manager (i.e. the whole
500
+ sidecar process); unplug/plug toggle a flag on the single virtual
501
+ device rather than tearing the server down.
502
+ """
503
+
504
+ def __init__(self, port: int = DEFAULT_PORT) -> None:
505
+ self.device = EmulatorDevice()
506
+ handler_class = _bound_handler_class(self.device, self)
507
+ try:
508
+ self._httpd = ThreadingHTTPServer(("127.0.0.1", port), handler_class)
509
+ except OSError as exc:
510
+ raise DeviceProbeError(
511
+ f"Could not bind emulator UI to 127.0.0.1:{port}: {exc}\n"
512
+ "Pass a different port with --emulator-port <N>."
513
+ ) from exc
514
+ self.port = self._httpd.server_address[1]
515
+ self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
516
+ self._thread.start()
517
+ logger.info("emulator UI: http://127.0.0.1:%d", self.port)
518
+
519
+ def find_device(self) -> DeckDevice | None:
520
+ return self.device if self.device.plugged else None
521
+
522
+ def plug(self) -> None:
523
+ self.device.plugged = True
524
+
525
+ def unplug(self) -> None:
526
+ self.device.plugged = False
527
+
528
+ def shutdown(self) -> None:
529
+ self._httpd.shutdown()
530
+ self._httpd.server_close()
muxplex_deck/focus.py ADDED
@@ -0,0 +1,92 @@
1
+ """Bring the local muxplex PWA to the foreground (platform seam).
2
+
3
+ One public function, `focus_app(name)`, called from a background thread when
4
+ a key press switches the active session (see `main._ActiveRuntime`). It is
5
+ strictly best-effort: it never raises, never blocks longer than a short
6
+ timeout, and a failure here must never disturb the session switch itself.
7
+
8
+ Platform dispatch happens here and only here:
9
+
10
+ - macOS (``darwin``): implemented via ``open -a <name>`` (see
11
+ `_focus_macos` for why that beats ``osascript``).
12
+ - Everything else: logs ONE INFO line per process ("macOS-only for now")
13
+ and no-ops, keeping a clean seam for a Windows implementation later.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import subprocess
20
+ import sys
21
+
22
+ logger = logging.getLogger("muxplex_deck")
23
+
24
+ # `open -a` normally returns in well under a second; if it hangs, give up
25
+ # rather than tying up the connect worker thread.
26
+ FOCUS_TIMEOUT_SECONDS = 2.0
27
+
28
+ _unsupported_logged = False
29
+
30
+
31
+ def macos_focus_command(name: str) -> list[str]:
32
+ """The exact argv the macOS path runs to activate `name`.
33
+
34
+ ``open -a`` (rather than ``osascript -e 'tell application ... to
35
+ activate'``) because it activates the app if running, launches it if
36
+ not, works for Chrome/Safari-installed PWAs (which are real ``.app``
37
+ bundles under e.g. ``~/Applications/Chrome Apps.localized/``), and
38
+ needs no AppleScript/Automation permission prompt. List-args, never a
39
+ shell, so the app name is passed verbatim.
40
+ """
41
+ return ["open", "-a", name]
42
+
43
+
44
+ def focus_app(name: str) -> None:
45
+ """Bring the named application to the foreground, best-effort.
46
+
47
+ Never raises. Empty `name` is a pure no-op (feature disabled). On
48
+ non-macOS platforms, logs one INFO line per process and no-ops.
49
+ """
50
+ if not name:
51
+ return
52
+
53
+ if sys.platform == "darwin":
54
+ _focus_macos(name)
55
+ return
56
+
57
+ # TODO(windows): implement for the user's Windows box -- likely via
58
+ # pywin32 SetForegroundWindow or an `explorer.exe`/PowerShell shim.
59
+ global _unsupported_logged
60
+ if not _unsupported_logged:
61
+ logger.info(
62
+ "focus_app=%r is configured, but foreground focus is macOS-only "
63
+ "for now; ignoring",
64
+ name,
65
+ )
66
+ _unsupported_logged = True
67
+
68
+
69
+ def _focus_macos(name: str) -> None:
70
+ command = macos_focus_command(name)
71
+ logger.debug("focus: running %s", command)
72
+ try:
73
+ result = subprocess.run(
74
+ command,
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=FOCUS_TIMEOUT_SECONDS,
78
+ check=False,
79
+ )
80
+ except (OSError, subprocess.TimeoutExpired) as exc:
81
+ logger.warning("focus: could not activate %r: %s", name, exc)
82
+ return
83
+ if result.returncode != 0:
84
+ # Most common cause: the app name doesn't match any installed app
85
+ # ("Unable to find application named ..."). Warn with the real
86
+ # stderr so the user can fix the config, but never raise.
87
+ logger.warning(
88
+ "focus: %r failed (rc=%d): %s",
89
+ " ".join(command),
90
+ result.returncode,
91
+ result.stderr.strip() or "(no stderr)",
92
+ )