Skip to content

Commit e1cb4e3

Browse files
jack-arturoclaudeCopilot
authored
feat(profile): refuse writes while Elgato app is running + restart_app path fix (#16)
## Summary Follow-up to #15. Closes a silent data-loss hole and fixes a pre-existing bug in the app-restart helper — both surfaced by the Phase 1 on-device smoke test. - **Data loss bug:** the Elgato desktop app caches every profile in memory and rewrites the on-disk manifests from its snapshot on quit. Edits made via `streamdeck_write_page` while the app is running are silently wiped the next time it closes (observed live during Phase 1 testing). This PR makes \`write_page\` refuse to run while the app is up and exposes a single additive \`auto_quit_app\` opt-in field for callers that want the tool to quit it for them (AppleScript → killall fallback). - **restart_app -600 bug:** \`open -a \"Stream Deck\"\` / \`open -a \"Elgato Stream Deck\"\` both fail with \`LSOpenURLsWithCompletionHandler -600\` on some macOS installs. Launching by explicit \`/Applications/Elgato Stream Deck.app\` path bypasses LaunchServices entirely. \`STREAMDECK_APP_PATH\` env var overrides the default for non-standard installs. - **Tool count unchanged** (still 6 tools). The new behavior is a single \`auto_quit_app: bool = false\` field on the existing \`streamdeck_write_page\` tool — per Jack's \"avoid tool bloat\" direction. ## Workflow (added to README) 1. \`streamdeck_write_page(auto_quit_app: true, …)\` — tool quits the app, writes, leaves app quit 2. Additional writes can follow without re-passing \`auto_quit_app\` (app stays quit) 3. \`streamdeck_restart_app()\` once done → device picks up the changes ## Test plan - [x] \`uv run pytest tests/\` — 44 passed (5 new) - [x] \`uv run ruff check .\` - [x] \`uv run ruff format --check .\` - [ ] Manual: reproduce the Phase 1 scenario end-to-end (app running → write fails with clear error → retry with auto_quit_app=true → write succeeds → restart_app shows the button on the deck) ## Dependencies Orthogonal to #15 — no merge-order requirement. Either can land first. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com>
1 parent f4a8402 commit e1cb4e3

4 files changed

Lines changed: 308 additions & 26 deletions

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,20 @@ uvx --from streamdeck-mcp streamdeck-mcp-usb
7777
- Generated icons are stored in `~/.streamdeck-mcp/generated-icons/`.
7878
- Generated shell scripts are stored in `~/StreamDeckScripts/`.
7979

80+
## Editing Workflow (Important)
81+
82+
The Elgato desktop app keeps every profile in memory and rewrites the on-disk manifests from that snapshot when it quits, so any edit made while the app is running is wiped the next time it closes. The profile writer enforces a quit → write → relaunch cycle:
83+
84+
1. Ensure the Elgato app is not running, or pass `auto_quit_app: true` to `streamdeck_write_page` to have it quit the app for you (AppleScript first, `killall` fallback).
85+
2. Make as many `streamdeck_write_page` calls as you need — the app stays quit across them.
86+
3. Call `streamdeck_restart_app` when you are done. The device re-reads the manifests on launch and your changes appear.
87+
88+
`streamdeck_write_page` raises a `StreamDeckAppRunningError` when the app is running and `auto_quit_app` is not set, so you cannot accidentally write changes that will be silently discarded.
89+
90+
If your Elgato app is installed somewhere other than `/Applications/Elgato Stream Deck.app`, set `STREAMDECK_APP_PATH` to the bundle path.
91+
8092
## Usage Notes
8193

82-
- After writing profiles, the Elgato desktop app may need a restart to pick up the new manifests.
8394
- `streamdeck_create_action` is the safest way to build shell-command buttons because it writes a standalone script and returns the native Open action block for it.
8495
- The profile writer does not require exclusive USB access.
8596

profile_manager.py

Lines changed: 130 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import string
2121
import subprocess
2222
import sys
23+
import time
2324
import uuid
2425
from dataclasses import dataclass
2526
from pathlib import Path
@@ -70,6 +71,13 @@
7071
"UI Stream Deck": {KEYPAD: (4, 2)},
7172
}
7273

74+
# The Elgato Stream Deck desktop app caches every profile in memory and rewrites the
75+
# on-disk manifests when it quits, so any edit made while it is running gets clobbered
76+
# the next time the user closes or restarts the app.
77+
STREAM_DECK_APP_PROCESS_NAMES = ("Stream Deck", "Elgato Stream Deck")
78+
DEFAULT_STREAM_DECK_APP_PATH = Path("/Applications/Elgato Stream Deck.app")
79+
STREAM_DECK_APP_PATH_ENV = "STREAMDECK_APP_PATH"
80+
7381
HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
7482
POSITION_PATTERN = re.compile(r"^\d+,\d+$")
7583
UUID_PATTERN = re.compile(
@@ -102,6 +110,16 @@ class ProfileValidationError(ProfileManagerError):
102110
"""Raised when inputs for profile operations are invalid."""
103111

104112

113+
class StreamDeckAppRunningError(ProfileManagerError):
114+
"""Raised when a write is attempted while the Elgato desktop app is running.
115+
116+
The app rewrites every profile manifest from its in-memory snapshot on quit, so
117+
writes made while it is running are silently discarded. Callers must quit the
118+
app first (pass `auto_quit_app=True` to `write_page`) and then call
119+
`restart_app` once their edits are complete to see the changes.
120+
"""
121+
122+
105123
@dataclass
106124
class PageRef:
107125
"""Resolved page directory metadata."""
@@ -277,6 +295,79 @@ def _count_icons(page_dir: Path) -> int:
277295
return len([path for path in images_dir.iterdir() if path.is_file()])
278296

279297

298+
def _resolve_app_path() -> Path:
299+
override = os.environ.get(STREAM_DECK_APP_PATH_ENV)
300+
if override:
301+
return Path(override).expanduser()
302+
return DEFAULT_STREAM_DECK_APP_PATH
303+
304+
305+
def is_stream_deck_app_running() -> bool:
306+
"""Return True if the Elgato Stream Deck desktop app is currently running."""
307+
308+
if sys.platform != "darwin":
309+
return False
310+
311+
for name in STREAM_DECK_APP_PROCESS_NAMES:
312+
result = subprocess.run(
313+
["pgrep", "-x", name],
314+
capture_output=True,
315+
text=True,
316+
check=False,
317+
)
318+
if result.returncode == 0 and result.stdout.strip():
319+
return True
320+
return False
321+
322+
323+
def stop_stream_deck_app(*, graceful_timeout: float = 3.0) -> dict[str, Any]:
324+
"""Quit the Elgato Stream Deck desktop app.
325+
326+
Tries an AppleScript quit first so the app can persist any unrelated state, then
327+
falls back to `killall` if it does not exit in time. Returns a small report about
328+
which path was taken.
329+
"""
330+
331+
if sys.platform != "darwin":
332+
return {"stopped": False, "graceful": [], "forced": [], "reason": "non-darwin platform"}
333+
334+
if not is_stream_deck_app_running():
335+
return {"stopped": False, "graceful": [], "forced": [], "reason": "not running"}
336+
337+
graceful: list[str] = []
338+
for name in STREAM_DECK_APP_PROCESS_NAMES:
339+
result = subprocess.run(
340+
["osascript", "-e", f'tell application "{name}" to quit'],
341+
capture_output=True,
342+
text=True,
343+
check=False,
344+
)
345+
if result.returncode == 0:
346+
graceful.append(name)
347+
348+
deadline = time.monotonic() + graceful_timeout
349+
while time.monotonic() < deadline and is_stream_deck_app_running():
350+
time.sleep(0.2)
351+
352+
forced: list[str] = []
353+
if is_stream_deck_app_running():
354+
for name in STREAM_DECK_APP_PROCESS_NAMES:
355+
result = subprocess.run(
356+
["killall", name],
357+
capture_output=True,
358+
text=True,
359+
check=False,
360+
)
361+
if result.returncode == 0:
362+
forced.append(name)
363+
364+
return {
365+
"stopped": not is_stream_deck_app_running(),
366+
"graceful": graceful,
367+
"forced": forced,
368+
}
369+
370+
280371
class ProfileManager:
281372
"""Read and write Elgato Stream Deck profiles."""
282373

@@ -415,9 +506,30 @@ def write_page(
415506
clear_existing: bool = True,
416507
create_new: bool = False,
417508
make_current: bool = False,
509+
auto_quit_app: bool = False,
418510
) -> dict[str, Any]:
419511
"""Create a page or rewrite an existing page manifest."""
420512

513+
app_stop_report: dict[str, Any] | None = None
514+
if is_stream_deck_app_running():
515+
if not auto_quit_app:
516+
raise StreamDeckAppRunningError(
517+
"The Elgato Stream Deck app is running and will overwrite this "
518+
"edit on quit. Retry with auto_quit_app=True to quit it first, "
519+
"then call streamdeck_restart_app once your edits are complete "
520+
"to apply the changes."
521+
)
522+
app_stop_report = stop_stream_deck_app()
523+
stop_failed = not app_stop_report.get("stopped", False)
524+
still_running = is_stream_deck_app_running()
525+
if stop_failed or still_running:
526+
reason = app_stop_report.get("reason", "")
527+
detail = f" Reason: {reason}." if reason else ""
528+
raise StreamDeckAppRunningError(
529+
f"The Elgato Stream Deck app could not be stopped.{detail} Aborting "
530+
"page write because the running app may overwrite these edits on quit."
531+
)
532+
421533
profile_dir, profile_manifest = self._resolve_profile(
422534
profile_name=profile_name, profile_id=profile_id
423535
)
@@ -518,6 +630,7 @@ def write_page(
518630
"button_count": total_button_count,
519631
"page_name": page_manifest.get("Name", ""),
520632
"manifest_path": str(page_dir / "manifest.json"),
633+
"app_quit": app_stop_report,
521634
}
522635

523636
def create_icon(
@@ -599,40 +712,34 @@ def restart_app(self) -> dict[str, Any]:
599712
"streamdeck_restart_app is currently only supported on macOS."
600713
)
601714

602-
killed = []
603-
for app_name in ("Elgato Stream Deck", "Stream Deck"):
604-
result = subprocess.run(
605-
["killall", app_name],
606-
capture_output=True,
607-
text=True,
608-
check=False,
715+
app_path = _resolve_app_path()
716+
if not app_path.exists():
717+
raise ProfileManagerError(
718+
f"Stream Deck app not found at {app_path}. "
719+
f"Set {STREAM_DECK_APP_PATH_ENV} to override the default install path."
609720
)
610-
if result.returncode == 0:
611-
killed.append(app_name)
612721

613-
open_result = subprocess.run(
614-
["open", "-a", "Stream Deck"],
722+
stop_report = stop_stream_deck_app()
723+
724+
# `open -a <name>` relies on LaunchServices name lookup, which returns error
725+
# -600 on some systems even when the bundle is present. Launching by explicit
726+
# path bypasses that lookup.
727+
result = subprocess.run(
728+
["open", str(app_path)],
615729
capture_output=True,
616730
text=True,
617731
check=False,
618732
)
619-
if open_result.returncode != 0:
620-
fallback = subprocess.run(
621-
["open", "-a", "Elgato Stream Deck"],
622-
capture_output=True,
623-
text=True,
624-
check=False,
625-
)
626-
open_result = fallback
627-
628-
if open_result.returncode != 0:
733+
if result.returncode != 0:
734+
message = result.stderr.strip() or result.stdout.strip()
629735
raise ProfileManagerError(
630-
open_result.stderr.strip() or "Failed to relaunch Stream Deck app."
736+
f"Failed to relaunch Stream Deck ({app_path}): {message or 'unknown error'}"
631737
)
632738

633739
return {
634-
"killed": killed,
635740
"restarted": True,
741+
"app_path": str(app_path),
742+
"stop": stop_report,
636743
}
637744

638745
def _resolve_profile(

profile_server.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ProfileManagerError,
2424
ProfileNotFoundError,
2525
ProfileValidationError,
26+
StreamDeckAppRunningError,
2627
)
2728

2829
logging.basicConfig(
@@ -189,7 +190,12 @@ async def list_tools() -> list[Tool]:
189190
Tool(
190191
name="streamdeck_write_page",
191192
description=(
192-
"Create a new page or replace/update an existing Stream Deck desktop page manifest."
193+
"Create a new page or replace/update an existing Stream Deck desktop "
194+
"page manifest. IMPORTANT: the Elgato desktop app overwrites profile "
195+
"manifests from its in-memory state on quit, so writes made while the "
196+
"app is running are lost. This tool refuses to write when the app is "
197+
"running unless auto_quit_app=True is passed. Call streamdeck_restart_app "
198+
"once your edits are complete to make the changes visible on the device."
193199
),
194200
inputSchema={
195201
"type": "object",
@@ -227,6 +233,16 @@ async def list_tools() -> list[Tool]:
227233
"When true, make the page the active current page after writing."
228234
),
229235
},
236+
"auto_quit_app": {
237+
"type": "boolean",
238+
"description": (
239+
"If true and the Elgato Stream Deck desktop app is "
240+
"running, quit it (graceful AppleScript first, then "
241+
"killall) before writing. Required when the app is "
242+
"running or the write will raise an error. Defaults to "
243+
"false so callers must explicitly consent to quitting it."
244+
),
245+
},
230246
},
231247
},
232248
),
@@ -322,6 +338,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
322338
clear_existing=arguments.get("clear_existing", True),
323339
create_new=arguments.get("create_new", False),
324340
make_current=arguments.get("make_current", False),
341+
auto_quit_app=arguments.get("auto_quit_app", False),
325342
)
326343
return [TextContent(type="text", text=json.dumps(result, indent=2))]
327344

@@ -350,6 +367,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
350367

351368
return [TextContent(type="text", text=f"❌ Unknown tool: {name}")]
352369

370+
except StreamDeckAppRunningError as exc:
371+
return [TextContent(type="text", text=f"⚠️ {exc}")]
353372
except (
354373
ProfileManagerError,
355374
ProfileNotFoundError,

0 commit comments

Comments
 (0)