Skip to content

feat: cross-platform controllers (Linux + macOS + Windows stub) - #87

Open
mikejgray wants to merge 4 commits into
devfrom
feat/cross-platform-controllers
Open

feat: cross-platform controllers (Linux + macOS + Windows stub)#87
mikejgray wants to merge 4 commits into
devfrom
feat/cross-platform-controllers

Conversation

@mikejgray

@mikejgray mikejgray commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactors the skill to delegate all OS-specific work to a controller selected at runtime by sys.platform. Brings macOS support over from OscillateLabsLLC/skill-mac-application-launcher and leaves a clean hook for a future Windows contribution.

The skill class becomes pure orchestration; platform-specific code lives entirely in controllers/{linux,macos,windows}.py.

Architecture

ovos_skill_application_launcher/
├── __init__.py              # Skill class — pure orchestration
└── controllers/
    ├── __init__.py          # get_controller() factory
    ├── base.py              # ApplicationController abstract base
    ├── linux.py             # .desktop files + wmctrl + psutil
    ├── macos.py             # .app bundles + AppleScript + open
    └── windows.py           # NotImplementedError stub with design notes
  • controllers/base.py defines the abstract interface: app_aliases, launch_app, close_app, is_running are required; can_switch_windows, switch_to_app, match_process, refresh_app_cache are optional with safe defaults.
  • controllers/linux.py is a verbatim refactor of the existing Linux logic (.desktop files, wmctrl, psutil) — no behaviour changes, just moved into a class.
  • controllers/macos.py is the controller from the macOS fork: .app bundle parsing via plistlib, AppleScript via osascript for window switching and graceful quit, open / open -a for launching, psutil process matching as a fallback. Discovers apps from /Applications, /System/Applications, /Applications/Utilities, /System/Library/CoreServices, ~/Applications.
  • controllers/windows.py is a stub that raises NotImplementedError with a design sketch in the module docstring (registry-based discovery via App Paths, Get-StartApps via PowerShell, os.startfile / subprocess.Popen for launch, optional pygetwindow/pywin32 for window management). A future contributor can fill this in without re-architecting the skill.
  • controllers/__init__.py exposes get_controller(settings, native_langs) which dispatches on sys.platform and accepts a controller_override setting for testing or unusual environments.

Backward compatibility

The historical public methods on ApplicationLauncherSkill are preserved as thin delegates so any third-party caller that did from ovos_skill_application_launcher import ApplicationLauncherSkill and called parse_desktop_file, get_desktop_apps, match_window, close_by_window, close_by_process, match_process, switch_window, close_window, applist, or wmctrl continues to work:

  • The static methods (parse_desktop_file, get_desktop_apps) delegate to LinuxApplicationController because Linux remains the canonical implementation for the legacy public API.
  • The instance methods delegate to whichever controller is active on the host OS.

Tests

Adds 24 unit tests in test/test_controllers.py and 2 e2e scaffold tests in test/end2end/test_launch_intent.py, matching the OVOS singular-test/ convention used by the existing CI workflows.

Unit coverage:

  • Factory dispatch — linux, linux2, darwin, macos, win32, unknown fallback, native_langs threading, controller_override escape hatch
  • Abstract base contract — cannot instantiate directly, default methods are safe no-ops
  • Linux launch/close — subprocess.Popen mocked, threshold cutoff
  • macOS launch — open vs open -a for paths vs bare names
  • macOS AppleScript — activate/quit codepaths with subprocess.run mocked
  • macOS .app bundle parsing — missing plist, full metadata, directory-name fallback
  • Windows stub raises NotImplementedError

All unit tests run on any platform; OS-specific calls are mocked. 24/24 unit + 1 e2e import smoke passing locally, 1 e2e marked skip pending a launch_app monkeypatch (documented in the module docstring).

Manual verification on macOS

controller class: MacOSApplicationController
applist len: 252
can_switch_windows: True
parse_desktop_file is static: True

End-to-end real Mac integration works: factory selected the right controller, discovered 252 real .app bundles, can_switch_windows() reports True (osascript present), and the legacy ApplicationLauncherSkill.parse_desktop_file is still callable.

setup.py

Adds the new subpackage cleanly under the existing root-layout pattern:

package_dir = {
    SKILL_PKG: \"\",
    "{SKILL_PKG}.controllers\": \"controllers\",
}
packages = [SKILL_PKG, "{SKILL_PKG}.controllers\"]

Test plan

  • pytest test/ — 25 passing, 1 skipped, covers all three controllers
  • Manual smoke test on macOS confirms real-world .app discovery and the factory dispatch
  • CI on Linux runner
  • Manual smoke test on Linux (someone with a Linux box, ideally with wmctrl present)

Out of scope

  • Windows implementation (intentionally a stub — see controllers/windows.py docstring for the design sketch)
  • Refactoring or modernising the Linux logic itself (this PR moves it but doesn't change it)
  • Updating dependencies — no new packages added; psutil was already present and is used by both Linux and macOS controllers
  • Real e2e launch test (the scaffold is in place; wiring up a per-test launch_app monkeypatch is a follow-up)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Cross-platform app control with dedicated Linux and macOS controllers; Windows placeholder added.
    • Improved app discovery, launching, closing, and window switching on supported platforms.
  • Refactor

    • Reworked controller architecture to delegate runtime platform behavior and provide backward-compatible shims.
  • Tests

    • Added unit and end-to-end smoke tests covering controller selection and platform behaviors.

mikejgray and others added 2 commits April 8, 2026 22:43
Refactors the skill to delegate all OS-specific work to a controller
selected at runtime by sys.platform. The skill class itself becomes
pure orchestration; platform-specific code lives entirely in
controllers/{linux,macos,windows}.py.

Architecture:
- controllers/base.py defines an abstract ApplicationController with
  app_aliases, launch_app, close_app, is_running as required, and
  can_switch_windows / switch_to_app / match_process / refresh_app_cache
  as optional overrides with safe defaults.
- controllers/linux.py is a verbatim refactor of the existing Linux
  logic (.desktop files, wmctrl, psutil) with no behaviour changes.
- controllers/macos.py is the macOS controller from
  OscillateLabsLLC/skill-mac-application-launcher: .app bundle
  parsing via plistlib, AppleScript via osascript for window
  switching and graceful quit, `open` / `open -a` for launching,
  and psutil-based process matching as a fallback.
- controllers/windows.py is a stub that raises NotImplementedError
  with a design sketch in the module docstring (registry-based app
  discovery, os.startfile / Get-StartApps, optional pygetwindow for
  window management). Future contributors can implement this without
  re-architecting the skill.
- controllers/__init__.py exposes get_controller(), which dispatches
  on sys.platform with a `controller_override` setting escape hatch
  for testing and unusual environments.

Backward compatibility:
- ApplicationLauncherSkill still exposes the historical public methods
  (parse_desktop_file, get_desktop_apps, match_window, close_by_window,
  close_by_process, match_process, switch_window, close_window,
  applist, wmctrl). These are now thin delegates — the static methods
  go to LinuxApplicationController (Linux remains the canonical
  implementation for the legacy public API), the instance methods
  delegate to whichever controller is active. Third-party skill
  consumers should not see any breakage.

Tests:
- The upstream had no tests despite an existing build-tests workflow
  pointing at a non-existent test/ directory; pytest was effectively
  a no-op for the entire history of the skill. This PR adds 24 tests
  in test/test_controllers.py covering:
    * factory dispatch (linux, linux2, darwin, macos, win32, unknown
      fallback, native_langs threading, controller_override)
    * abstract base class contract (cannot instantiate, default
      methods are safe no-ops)
    * Linux launch/close paths with subprocess.Popen mocked
    * macOS launch with `open` vs `open -a` for paths vs bare names
    * macOS AppleScript activate / quit codepaths
    * macOS .app bundle parsing (missing plist, full metadata,
      directory-name fallback)
    * Windows stub raises NotImplementedError
- All tests run on any platform; OS-specific calls are mocked.
- 24/24 passing locally.
- Manually smoke-tested on macOS: factory selects MacOSApplicationController,
  discovers 252 real .app bundles, can_switch_windows() reports True,
  legacy ApplicationLauncherSkill.parse_desktop_file is still callable.

setup.py:
- packages = [SKILL_PKG, f"{SKILL_PKG}.controllers"] so the new
  subpackage installs cleanly via the existing
  package_dir={SKILL_PKG: ""} root-layout pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The repo's ovoscope.yml workflow has been pointing at a non-existent
test/end2end/ directory since the skill was created, so the e2e suite
has been a silent no-op alongside the unit test suite.

Add a minimal scaffold:
- test_ovoscope_imports_cleanly: import smoke that proves the
  ovoscope dep installs and its public End2EndTest API is intact
- test_launch_firefox_intent_match: real e2e for the launch path,
  marked skip until a per-test monkeypatch of
  ApplicationController.launch_app is wired in to avoid actually
  launching GUI applications on the CI runner. Module docstring
  documents the design.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Refactors the skill to use a runtime ApplicationController abstraction selected by get_controller(...), moving platform-specific app discovery, launching, closing, and window management into controllers (Linux, macOS, Windows) and adding backward-compatible shims on the skill. UTF‑8 decoding for locale intents added.

Changes

Cohort / File(s) Summary
Core Skill
__init__.py
Replaced in-file platform logic with controller delegation (self.controller = get_controller(...)), updated intent handling and lifecycle methods to call controller APIs, added backward-compatibility shims, and switched locale intent file reads to explicit UTF‑8.
Controller Factory
controllers/__init__.py
New get_controller(settings, native_langs) factory that picks/instantiates platform controllers based on settings.controller_override or sys.platform; exports ApplicationController and get_controller.
Controller Base
controllers/base.py
New abstract ApplicationController defining interface: app_aliases, launch_app, close_app, is_running, plus default helpers (refresh_app_cache, can_switch_windows, switch_to_app, match_process).
Linux Controller
controllers/linux.py
New LinuxApplicationController: .desktop parsing, alias cache, wmctrl-based window ops with psutil subprocess fallbacks, fuzzy matching, launch/close/is_running implementations, window/process mapping and helper functions.
macOS Controller
controllers/macos.py
New MacOSApplicationController: .app bundle discovery & plist parsing, alias cache and rebuild logic, AppleScript (osascript) + open spawning/activation, process matching/termination fallbacks, and cache management.
Windows Controller
controllers/windows.py
Added WindowsApplicationController placeholder that raises NotImplementedError on instantiation; methods declared but not implemented to prevent accidental use.
Packaging
setup.py
Included controllers subpackage in packaging metadata (package_dir / packages) so controllers are shipped.
Tests
test/test_controllers.py, test/end2end/test_launch_intent.py
Added controller factory/unit tests (mocked OS interactions), Linux/macOS behavioral tests including plist parsing and AppleScript escaping, and a scaffolded (skipped) end-to-end smoke test for launch intent.

Sequence Diagram(s)

sequenceDiagram
    participant Skill as ApplicationLauncherSkill
    participant Controller as ApplicationController
    participant OS as Operating System

    Note over Skill: Initialize and bind controller
    Skill->>Controller: get_controller(settings, native_langs)
    Skill->>Skill: self.controller = Controller

    Note over Skill: Handle Launch Intent
    Skill->>Skill: handle_async_prompt(message)
    Skill->>Controller: launch_app(app_name)
    Controller->>Controller: match alias / decide method
    alt spawn command
        Controller->>OS: spawn process / run command
    else window activation
        Controller->>OS: activate window (wmctrl / osascript)
    end
    OS-->>Controller: result
    Controller-->>Skill: success / failure

    Note over Skill: Close Flow
    Skill->>Skill: handle_fallback(message)
    Skill->>Controller: close_app(app_name)
    Controller->>Controller: try window-close or process termination
    Controller->>OS: close/terminate
    OS-->>Controller: result
    Controller-->>Skill: success / failure
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through controllers, neat and bright,
Linux, macOS, Windows now in sight,
Shims keep old calls snug and warm,
Locale files read in UTF‑8 form,
A little rabbit cheers the new launch flight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main change: introducing cross-platform application controllers for Linux, macOS, and Windows stub, which is the core refactoring effort described throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cross-platform-controllers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (7)
test/end2end/test_launch_intent.py (1)

77-81: expected_messages appears to be a placeholder.

The expected_messages is set to [utterance] (the input message), which likely isn't the actual expected response. This seems like a placeholder that should contain the expected bus messages (e.g., mycroft.acknowledge, skill responses). Since the test is skipped anyway, consider adding a comment clarifying this is a placeholder or updating to show the intended expected messages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/end2end/test_launch_intent.py` around lines 77 - 81, The test currently
uses End2EndTest(... expected_messages=[utterance] ...) which looks like a
placeholder instead of the actual expected bus messages; update the call to
either populate expected_messages with the intended bus events (e.g.,
"mycroft.acknowledge", any skill response message IDs or payloads) or add an
inline comment explaining this is intentionally a placeholder while the test is
skipped. Locate the End2EndTest invocation in test_launch_intent.py (references:
End2EndTest, expected_messages, SKILL_ID, utterance, execute) and replace
[utterance] with the real expected message list or add a clear comment above the
call describing the placeholder intent.
controllers/macos.py (2)

75-101: Consider extracting the repeated match-and-rebuild pattern.

The same pattern appears four times: call match_one, catch exception, check cache validity, attempt rebuild, retry match_one. This could be extracted to a helper method to reduce duplication:

def _match_app(self, app: str) -> Optional[Tuple[str, float]]:
    """Match app name with automatic cache rebuild on failure."""
    try:
        return match_one(app.title(), self.app_aliases)
    except (IndexError, ValueError):
        if not self.is_cache_valid() and self._ensure_cache_or_rebuild():
            try:
                return match_one(app.title(), self.app_aliases)
            except (IndexError, ValueError):
                pass
        return None

Also applies to: 117-152, 279-295, 317-352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/macos.py` around lines 75 - 101, Extract the repeated
match-and-rebuild logic into a helper (e.g. create a method named _match_app)
that encapsulates calling match_one(app.title(), self.app_aliases), catching
(IndexError, ValueError), checking self.is_cache_valid(), invoking
self._ensure_cache_or_rebuild() and retrying match_one once; update launch_app
(and the other locations mentioned) to call _match_app and handle a None return
(meaning no match) instead of duplicating the try/except/cache-rebuild flow.
Ensure _match_app returns a tuple (cmd, score) or None and preserve existing
score-threshold and subprocess launching behavior in launch_app.

304-315: Sorting all processes on every call may be expensive.

match_process sorts all system processes by create_time on each invocation. For systems with many processes, this could add noticeable latency. Consider whether sorting is necessary (the docstring doesn't specify ordering requirements) or cache the sorted list briefly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/macos.py` around lines 304 - 315, The current code builds a
sorted list named processes by calling psutil.process_iter([...]) and sorting by
proc.info["create_time"] on every invocation, which is expensive; update the
function (the block using processes, psutil.process_iter, fuzzy_match, app_name,
bundle_name and yielding proc) to avoid full sorting on each call: either remove
the sorting and iterate psutil.process_iter(...) directly applying the
fuzzy_match checks, or if recent processes are required, use heapq.nlargest to
pick the top N by create_time or introduce a short-lived cache (TTL) for the
sorted list and reuse it between calls; implement one of these approaches and
ensure the rest of the logic (skipping zombies via proc.status(), computing
score1/score2 and yielding proc) remains unchanged.
controllers/linux.py (1)

134-134: Rename loop variable l to lang for clarity.

Single-letter variable l (lowercase L) is easily confused with 1 (one) and shadows the implicit built-in. Use lang instead.

♻️ Suggested fix
-    extra_langs = [standardize_lang_tag(l) for l in extra_langs]
+    extra_langs = [standardize_lang_tag(lang) for lang in extra_langs]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/linux.py` at line 134, The list comprehension uses a
single-letter loop variable `l` which is unclear and can be confused with `1`;
change it to a descriptive name by replacing `l` with `lang` so the line becomes
extra_langs = [standardize_lang_tag(lang) for lang in extra_langs]; update any
nearby usages or similar comprehensions in the same scope that use `l` to `lang`
to keep naming consistent and avoid shadowing or readability issues (referencing
function standardize_lang_tag and variable extra_langs).
controllers/__init__.py (1)

46-64: Add test coverage for additional platform aliases.

The factory supports aliases "mac", "win", and "windows" (lines 50 and 54), but the parameterized tests only cover "linux", "linux2", "darwin", and "macos". Extend the test parameters to include "mac" for macOS and "win"/"windows" for Windows to ensure these aliases dispatch correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/__init__.py` around lines 46 - 64, Update the parameterized tests
that exercise the platform dispatch to include the missing aliases so the
factory branches for Mac and Windows are covered; add "mac" to the macOS test
cases and add both "win" and "windows" to the Windows test cases so calls
dispatch to MacOSApplicationController and WindowsApplicationController
respectively (the factory uses platform values compared against
("darwin","macos","mac") and ("win32","windows","win")). Ensure the tests still
assert the correct controller classes (LinuxApplicationController,
MacOSApplicationController, WindowsApplicationController) and keep existing
cases for "linux" and "linux2".
__init__.py (2)

137-138: Missing error feedback when launch fails.

If self.launch_app(app) returns False, the user receives no feedback since acknowledge() is only called on success. Consider adding a failure dialog or at minimum logging the result.

💡 Suggested improvement
         # launch
-        self.launch_app(app)
+        if not self.launch_app(app):
+            self.speak_dialog("launch_failed", {"application": app})

Note: This assumes a launch_failed.dialog exists or will be added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__init__.py` around lines 137 - 138, The call to self.launch_app(app) doesn't
handle failures; update the launcher flow in the method containing
self.launch_app(app) so it checks the boolean return value and on False emits
feedback: either open the failure dialog (e.g. show "launch_failed.dialog") or
at minimum call the logger to record the failure (use the same logger used
elsewhere), while keeping acknowledge() for the success path; ensure you
reference self.launch_app and acknowledge so callers get appropriate UI or log
feedback when launching fails.

45-48: Consider graceful degradation on unsupported platforms.

If WindowsApplicationController.__init__ raises NotImplementedError, the skill will fail to initialize entirely on Windows. Consider wrapping the get_controller() call in a try/except that either falls back to a no-op controller or logs a warning and disables the skill gracefully.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__init__.py` around lines 45 - 48, Wrap the get_controller(...) call in a
try/except that catches NotImplementedError (raised by
WindowsApplicationController.__init__) and gracefully substitutes a no-op or
disabled ApplicationController; for example, in the except block construct or
import a NoOpApplicationController and assign it to self.controller, and log a
warning explaining the skill is disabled on this platform via the existing
logger. Ensure the code still sets self.controller to an object conforming to
the ApplicationController interface so callers do not error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@controllers/linux.py`:
- Around line 285-293: The subprocess.run call in switch_window should include a
timeout to avoid hanging: add a timeout argument (preferably a configurable
attribute like self.subprocess_timeout or a constant, e.g., timeout=5) to
subprocess.run([...]) and catch subprocess.TimeoutExpired separately, logging
the timeout exception via LOG.error with details and returning False; maintain
existing handling for non-zero return codes. Apply the same pattern (add
timeout, catch subprocess.TimeoutExpired, log and return False) to the other
wmctrl-invoking methods referenced around lines 295-303 and 305-327 so all
subprocess.run usages are protected from hangs.
- Line 58: The current call to subprocess.Popen using shlex.split(cmd) and
self.settings.get("shell", False) can execute user-controlled commands with
shell=True; update the call to avoid shell execution by default and either
remove the shell option or validate/require an explicit, auditable opt-in:
ensure subprocess.Popen is always invoked with shell=False and pass a
safely-split argv (use shlex.split(cmd) or a pre-validated list), or if you must
support shell=True keep it behind a strict setting (e.g., require
settings["allow_untrusted_shell"] True), validate/sanitize cmd, and emit a
security warning/log when shell=True; change the code around subprocess.Popen,
shlex.split, and self.settings.get("shell", False) accordingly.
- Around line 223-237: The call to match_one in match_process can raise when the
alias cache is empty or no match is found; wrap the match_one(app.title(),
self.app_aliases) invocation in a try/except (catch the same exception type the
macOS controller uses, or a broad Exception if the project uses that pattern)
and handle failure by returning/continuing without raising (e.g., return an
empty iterator or simply exit the function). Ensure you still normalize cmd as
before only when match_one succeeds, and keep the rest of match_process (process
iteration and fuzzy_match) unchanged; reference the match_process method and the
match_one(app.title(), self.app_aliases) call to locate where to add the
try/except.
- Around line 53-62: Wrap the call to match_one in launch_app so
IndexError/ValueError from an empty cache or no matches are caught (same as
macOS controller): move match_one and its score check inside a try/except that
catches IndexError and ValueError, log or ignore the exception, and return False
if matching fails; ensure the existing try/except around subprocess.Popen still
handles process launch errors. Apply the same pattern to match_process: catch
IndexError/ValueError thrown by match_one, return an empty result (or False as
appropriate) and prevent those exceptions from propagating.

In `@controllers/macos.py`:
- Around line 137-142: Sanitize the derived app_name before embedding it into
the AppleScript string: replace or escape any double quotes, backslashes and
newlines in app_name so the f-string applescript (the variable applescript where
app_name is interpolated) cannot break AppleScript syntax; apply the same
sanitization to the other occurrence of app_name (the block referenced around
lines 337-342). Implement a small helper (e.g., sanitize_app_name) and call it
where app_name is computed so the AppleScript always receives a safe, escaped
application name.

---

Nitpick comments:
In `@__init__.py`:
- Around line 137-138: The call to self.launch_app(app) doesn't handle failures;
update the launcher flow in the method containing self.launch_app(app) so it
checks the boolean return value and on False emits feedback: either open the
failure dialog (e.g. show "launch_failed.dialog") or at minimum call the logger
to record the failure (use the same logger used elsewhere), while keeping
acknowledge() for the success path; ensure you reference self.launch_app and
acknowledge so callers get appropriate UI or log feedback when launching fails.
- Around line 45-48: Wrap the get_controller(...) call in a try/except that
catches NotImplementedError (raised by WindowsApplicationController.__init__)
and gracefully substitutes a no-op or disabled ApplicationController; for
example, in the except block construct or import a NoOpApplicationController and
assign it to self.controller, and log a warning explaining the skill is disabled
on this platform via the existing logger. Ensure the code still sets
self.controller to an object conforming to the ApplicationController interface
so callers do not error.

In `@controllers/__init__.py`:
- Around line 46-64: Update the parameterized tests that exercise the platform
dispatch to include the missing aliases so the factory branches for Mac and
Windows are covered; add "mac" to the macOS test cases and add both "win" and
"windows" to the Windows test cases so calls dispatch to
MacOSApplicationController and WindowsApplicationController respectively (the
factory uses platform values compared against ("darwin","macos","mac") and
("win32","windows","win")). Ensure the tests still assert the correct controller
classes (LinuxApplicationController, MacOSApplicationController,
WindowsApplicationController) and keep existing cases for "linux" and "linux2".

In `@controllers/linux.py`:
- Line 134: The list comprehension uses a single-letter loop variable `l` which
is unclear and can be confused with `1`; change it to a descriptive name by
replacing `l` with `lang` so the line becomes extra_langs =
[standardize_lang_tag(lang) for lang in extra_langs]; update any nearby usages
or similar comprehensions in the same scope that use `l` to `lang` to keep
naming consistent and avoid shadowing or readability issues (referencing
function standardize_lang_tag and variable extra_langs).

In `@controllers/macos.py`:
- Around line 75-101: Extract the repeated match-and-rebuild logic into a helper
(e.g. create a method named _match_app) that encapsulates calling
match_one(app.title(), self.app_aliases), catching (IndexError, ValueError),
checking self.is_cache_valid(), invoking self._ensure_cache_or_rebuild() and
retrying match_one once; update launch_app (and the other locations mentioned)
to call _match_app and handle a None return (meaning no match) instead of
duplicating the try/except/cache-rebuild flow. Ensure _match_app returns a tuple
(cmd, score) or None and preserve existing score-threshold and subprocess
launching behavior in launch_app.
- Around line 304-315: The current code builds a sorted list named processes by
calling psutil.process_iter([...]) and sorting by proc.info["create_time"] on
every invocation, which is expensive; update the function (the block using
processes, psutil.process_iter, fuzzy_match, app_name, bundle_name and yielding
proc) to avoid full sorting on each call: either remove the sorting and iterate
psutil.process_iter(...) directly applying the fuzzy_match checks, or if recent
processes are required, use heapq.nlargest to pick the top N by create_time or
introduce a short-lived cache (TTL) for the sorted list and reuse it between
calls; implement one of these approaches and ensure the rest of the logic
(skipping zombies via proc.status(), computing score1/score2 and yielding proc)
remains unchanged.

In `@test/end2end/test_launch_intent.py`:
- Around line 77-81: The test currently uses End2EndTest(...
expected_messages=[utterance] ...) which looks like a placeholder instead of the
actual expected bus messages; update the call to either populate
expected_messages with the intended bus events (e.g., "mycroft.acknowledge", any
skill response message IDs or payloads) or add an inline comment explaining this
is intentionally a placeholder while the test is skipped. Locate the End2EndTest
invocation in test_launch_intent.py (references: End2EndTest, expected_messages,
SKILL_ID, utterance, execute) and replace [utterance] with the real expected
message list or add a clear comment above the call describing the placeholder
intent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be1230ec-f966-44e0-95b3-5c8250ccc599

📥 Commits

Reviewing files that changed from the base of the PR and between 0cc0389 and f961ba2.

📒 Files selected for processing (11)
  • __init__.py
  • controllers/__init__.py
  • controllers/base.py
  • controllers/linux.py
  • controllers/macos.py
  • controllers/windows.py
  • setup.py
  • test/__init__.py
  • test/end2end/__init__.py
  • test/end2end/test_launch_intent.py
  • test/test_controllers.py

Comment thread controllers/linux.py
Comment thread controllers/linux.py
if score >= self.settings.get("thresh", 0.85):
LOG.info(f"Matched application: {app} (command: {cmd})")
try:
subprocess.Popen(shlex.split(cmd), shell=self.settings.get("shell", False))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider the security implications of shell=True in settings.

The shell setting defaults to False, but if set to True, user-controlled commands would be executed through the shell. While this may be intentional for flexibility, document the security implications or consider removing this option if not needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/linux.py` at line 58, The current call to subprocess.Popen using
shlex.split(cmd) and self.settings.get("shell", False) can execute
user-controlled commands with shell=True; update the call to avoid shell
execution by default and either remove the shell option or validate/require an
explicit, auditable opt-in: ensure subprocess.Popen is always invoked with
shell=False and pass a safely-split argv (use shlex.split(cmd) or a
pre-validated list), or if you must support shell=True keep it behind a strict
setting (e.g., require settings["allow_untrusted_shell"] True),
validate/sanitize cmd, and emit a security warning/log when shell=True; change
the code around subprocess.Popen, shlex.split, and self.settings.get("shell",
False) accordingly.

Comment thread controllers/linux.py
Comment thread controllers/linux.py
Comment thread controllers/macos.py
mikejgray and others added 2 commits April 8, 2026 23:07
When the skill runs under a launchd LaunchAgent, subprocess.Popen of
`open` inherits the agent's restricted spawn context. macOS frequently
refuses with `RBSRequestErrorDomain Code=5 / Launchd job spawn failed`
(errno 163) — the LaunchAgent does not have the right entitlements to
spawn arbitrary GUI apps.

Routing the launch through `osascript` -> `tell application "X" to
activate` hands the request off to the user's loginwindow session,
which has the entitlements GUI app spawning needs. This is the same
trick the controller already uses for switch_to_app and
close_by_applescript, just applied to launch.

Falls back to the original `open` / `open -a` codepath if osascript
is missing or returns non-zero, so behavior on systems without
osascript is unchanged.

Reproduced live: "launch book trove" matched correctly through the
fallback pipeline, controller resolved to /Applications/BookTrove.app,
but launchd refused with error 163.

Tests:
- AppleScript path is preferred when osascript is present, `open` is
  not invoked
- Fallback to `open` (.app bundle path) on AppleScript failure
- Fallback to `open -a` (bare name) on AppleScript failure
- Direct `open` path when osascript is unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Linux:
- launch_app and match_process now catch (IndexError, ValueError) from
  match_one on empty alias caches instead of letting them bubble.
  Pre-existing latent bug that the macOS fork already handled — the
  Linux side never matched the same defensive shape.
- Add a 5s timeout to all wmctrl subprocess.run calls (switch_window,
  close_window, get_window_process_mapping) so a wedged X server can't
  hang the skill. subprocess.TimeoutExpired is logged and surfaced as
  a clean failure.
- Rename ambiguous loop var `l` -> `lang` in parse_desktop_file
  comprehensions (silences ruff E741, addresses CodeRabbit comment).

macOS:
- Extract the duplicated match-rebuild-rematch dance into a single
  _match_app() helper used by launch_app, switch_to_app,
  close_by_applescript, and match_process. Previously each method
  carried its own copy of the logic.
- Sanitize app names before AppleScript interpolation via a new
  _escape_applescript() helper. App names rarely contain quotes or
  backslashes, but the controller also accepts user-defined aliases,
  so the safest move is to belt-and-braces escape every value before
  it lands inside `tell application "..."`.
- match_process no longer pre-sorts psutil.process_iter() by
  create_time. The function never promised any ordering and the
  close_by_process consumer doesn't depend on one — sorting was a
  per-call O(n log n) waste. Also tightened the iteration to use
  ["pid", "name"] only and to handle NoSuchProcess/AccessDenied
  during status() inspection.

Tests:
- Linux launch_app / match_process empty-cache paths
- Linux switch_window / get_window_process_mapping timeout handling
- macOS _match_app success and unrecoverable-miss returns None
- macOS _escape_applescript covers plain names, quotes, backslashes,
  and combined edge cases
- macOS launch_app with a hostile alias proves the escape lands in
  the AppleScript string literal without breaking syntax

End2end:
- Tightened the placeholder comment on the skipped launch test so
  future contributors know expected_messages=[utterance] is a
  documented placeholder, not a real assertion.

35 passing, 1 skipped (was 27/1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
controllers/macos.py (3)

147-166: Consider adding a timeout to the AppleScript subprocess call.

The subprocess.run call lacks a timeout. If osascript hangs (e.g., waiting for user interaction on a modal dialog), this could block the skill indefinitely. The PR objectives mention 5s timeouts were added to wmctrl calls on Linux for similar reasons.

⏱️ Suggested timeout
             try:
                 result = subprocess.run(
                     [self.osascript, "-e", applescript],
                     capture_output=True,
                     text=True,
                     check=False,
+                    timeout=5,
                 )
                 if result.returncode == 0:
                     return True
                 LOG.warning(
                     "AppleScript activate failed for %s (%s); falling back to `open`",
                     app_name,
                     result.stderr.strip(),
                 )
-            except Exception as e:
+            except subprocess.TimeoutExpired:
+                LOG.warning(
+                    "AppleScript activate timed out for %s; falling back to `open`",
+                    app_name,
+                )
+            except Exception as e:
                 LOG.warning(
                     "AppleScript activate raised for %s (%s); falling back to `open`",
                     app_name,
                     e,
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/macos.py` around lines 147 - 166, The subprocess.run call
invoking self.osascript with the applescript should include a timeout (e.g., 5
seconds) to avoid hangs; update the call in the activate block (where
subprocess.run is used and result.returncode is checked) to pass timeout=5 (or a
configurable constant) and handle subprocess.TimeoutExpired in the except
clause—log a warning via LOG including app_name and the timeout exception and
then fall back to the existing `open` behavior.

396-405: Same timeout concern applies here.

The subprocess.run call to execute the AppleScript quit command also lacks a timeout. For consistency with the recommended changes in _spawn and switch_to_app, consider adding timeout=5.

⏱️ Suggested timeout
         try:
             result = subprocess.run(
-                [self.osascript, "-e", applescript], capture_output=True, text=True, check=False
+                [self.osascript, "-e", applescript], capture_output=True, text=True, check=False, timeout=5
             )
             if result.returncode == 0:
                 return True
             LOG.debug(f"AppleScript quit failed for {app_name}: {result.stderr}")
+        except subprocess.TimeoutExpired:
+            LOG.debug(f"AppleScript quit timed out for {app_name}")
         except Exception as e:
             LOG.exception(f"Failed to close {app} via AppleScript: {e}")
         return False
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/macos.py` around lines 396 - 405, Add a timeout=5 to the
subprocess.run call that runs self.osascript with the applescript (the call that
sets result = subprocess.run(...)), and handle subprocess.TimeoutExpired by
logging via LOG.exception or LOG.debug consistent with surrounding error
handling (keep the existing return False on failure). Ensure the call still sets
capture_output=True, text=True, check=False, and that the existing success check
(result.returncode == 0) remains unchanged.

210-219: Same timeout concern applies here.

Like _spawn, this subprocess.run call to osascript lacks a timeout. Consider adding timeout=5 and handling subprocess.TimeoutExpired for consistency.

⏱️ Suggested timeout
         try:
             result = subprocess.run(
-                [self.osascript, "-e", applescript], capture_output=True, text=True, check=False
+                [self.osascript, "-e", applescript], capture_output=True, text=True, check=False, timeout=5
             )
             if result.returncode == 0:
                 return True
             LOG.error(f"AppleScript error: {result.stderr}")
+        except subprocess.TimeoutExpired:
+            LOG.error(f"AppleScript timed out switching to {app}")
         except Exception as e:
             LOG.exception(f"Failed to switch to {app}: {e}")
         return False
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/macos.py` around lines 210 - 219, The subprocess.run call that
invokes self.osascript with the applescript (the same pattern as _spawn) needs a
timeout to avoid hanging; add timeout=5 to the subprocess.run call in the method
that switches to an app (the block using self.osascript, applescript and
returning True on returncode==0), catch subprocess.TimeoutExpired specifically
(in addition to the generic Exception) and log a clear message (e.g.,
LOG.warning/LOG.error including the app and timeout), then return False on
timeout; keep check=False and existing handling for nonzero returncode.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@controllers/macos.py`:
- Around line 147-166: The subprocess.run call invoking self.osascript with the
applescript should include a timeout (e.g., 5 seconds) to avoid hangs; update
the call in the activate block (where subprocess.run is used and
result.returncode is checked) to pass timeout=5 (or a configurable constant) and
handle subprocess.TimeoutExpired in the except clause—log a warning via LOG
including app_name and the timeout exception and then fall back to the existing
`open` behavior.
- Around line 396-405: Add a timeout=5 to the subprocess.run call that runs
self.osascript with the applescript (the call that sets result =
subprocess.run(...)), and handle subprocess.TimeoutExpired by logging via
LOG.exception or LOG.debug consistent with surrounding error handling (keep the
existing return False on failure). Ensure the call still sets
capture_output=True, text=True, check=False, and that the existing success check
(result.returncode == 0) remains unchanged.
- Around line 210-219: The subprocess.run call that invokes self.osascript with
the applescript (the same pattern as _spawn) needs a timeout to avoid hanging;
add timeout=5 to the subprocess.run call in the method that switches to an app
(the block using self.osascript, applescript and returning True on
returncode==0), catch subprocess.TimeoutExpired specifically (in addition to the
generic Exception) and log a clear message (e.g., LOG.warning/LOG.error
including the app and timeout), then return False on timeout; keep check=False
and existing handling for nonzero returncode.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e37d8ad-62d2-44f3-89a6-405a3457ee2d

📥 Commits

Reviewing files that changed from the base of the PR and between 4469329 and 4c116f6.

📒 Files selected for processing (4)
  • controllers/linux.py
  • controllers/macos.py
  • test/end2end/test_launch_intent.py
  • test/test_controllers.py
✅ Files skipped from review due to trivial changes (1)
  • test/test_controllers.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/end2end/test_launch_intent.py
  • controllers/linux.py

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

The automated pipeline has reached its destination. 🏁

I've aggregated the results of the automated checks for this PR below.

🔒 Security (pip-audit)

I've performed a digital frisk of this contribution. 👮‍♂️

✅ No known vulnerabilities found (68 packages scanned).

📊 Coverage

Let's see how much of the code is actually being tested... 🧐

0.0% total coverage

Files below 80% coverage (10 files)
File Coverage Missing lines
__init__.py 0.0% 150
controllers/__init__.py 0.0% 26
controllers/base.py 0.0% 21
controllers/linux.py 0.0% 223
controllers/macos.py 0.0% 267
controllers/windows.py 0.0% 8
setup.py 0.0% 55
test/end2end/test_launch_intent.py 0.0% 14
test/test_controllers.py 0.0% 176
version.py 0.0% 4

Full report: download the coverage-report artifact.

📋 Repo Health

The repo's annual physical is complete! 🩺

✅ All required files present.

Latest Version: 0.5.17a2

version.py — Version file
README.md — README
LICENSE — License file
⚠️ pyproject.toml — pyproject.toml
setup.py — setup.py
CHANGELOG.md — Changelog
requirements.txt — Requirements
version.py has valid version block markers

🔌 Skill Tests (ovoscope)

I ran the end-to-end skill tests to see how your skill behaves in the real world! 🎤

1/2 passed, 1 skipped

❌ **test_launch_firefox_intent_match** — 0/1
Test Result
test_launch_firefox_intent_match ⚠️ skipped
✅ **test_ovoscope_imports_cleanly** — 1/1

🚌 Bus Coverage

Is the code wearing its bus-suit? Let's see. 👔

⚠️ Bus coverage report unavailable — check the job log.

🔍 Lint

Ensuring the codebase remains stable and healthy. 🛡️

ruff: issues found — see job log

🏷️ Release Preview

Ensuring the 'Thanks' section includes your name! 🤝

Current: 0.5.17a2Next: 0.6.0a1

Signal Value
Label (none)
PR title feat: cross-platform controllers (Linux + macOS + Windows stub)
Bump minor

✅ PR title follows conventional commit format.


🚀 Release Channel Compatibility

Predicted next version: 0.6.0a1

Channel Status Note Current Constraint
Stable Too new (must be <0.6.0) ovos-skill-application-launcher>=0.5.13,<0.6.0
Testing Compatible ovos-skill-application-launcher>=0.5.14,<1.0.0
Alpha Compatible ovos-skill-application-launcher>=0.5.15a2

⚖️ License Check

I've performed a legal sanity check on this PR. 🧠

✅ No license violations found (49 packages).

License distribution: 12× MIT License, 8× MIT, 6× Apache Software License, 5× Apache-2.0, 3× BSD-3-Clause, 2× ISC License (ISCL), 2× PSF-2.0, 2× Python Software Foundation License, +8 more

Full breakdown — 49 packages
Package Version License URL
audioop-lts 0.2.2 PSF-2.0 link
build 1.4.2 MIT link
certifi 2026.2.25 Mozilla Public License 2.0 (MPL 2.0) link
charset-normalizer 3.4.7 MIT link
click 8.3.2 BSD-3-Clause link
combo_lock 0.3.1 Apache-2.0 link
filelock 3.25.2 MIT link
idna 3.11 BSD-3-Clause link
importlib_metadata 9.0.0 Apache-2.0 link
json-database 0.10.1 MIT link
kthread 0.2.3 MIT License link
langcodes 3.5.1 MIT License link
markdown-it-py 4.0.0 MIT License link
mdurl 0.1.2 MIT License link
memory-tempfile 2.2.3 MIT License link
ovos-config 2.1.1 Apache-2.0 link
ovos-number-parser 0.5.1 Apache Software License link
ovos-plugin-manager 2.2.0 Apache-2.0 link
ovos-skill-application-launcher 0.5.17a2 Apache2.0 link
ovos-solver-yes-no-plugin 0.2.8 MIT link
ovos-utils 0.8.5 Apache-2.0 link
ovos_bus_client 1.5.0 Apache Software License link
ovos_workshop 8.0.0 apache-2.0 link
packaging 26.0 Apache-2.0 OR BSD-2-Clause link
padacioso 1.0.0 apache-2.0 link
pexpect 4.9.0 ISC License (ISCL) link
psutil 7.2.2 BSD-3-Clause link
ptyprocess 0.7.0 ISC License (ISCL) link
pyee 12.1.1 MIT License link
Pygments 2.20.0 BSD-2-Clause link
pyproject_hooks 1.2.0 MIT License link
python-dateutil 2.9.0.post0 Apache Software License; BSD License link
PyYAML 6.0.3 MIT License link
quebra-frases 0.3.7 Apache Software License link
RapidFuzz 3.14.5 MIT link
regex 2026.4.4 Apache-2.0 AND CNRI-Python link
requests 2.33.1 Apache Software License link
rich 13.9.4 MIT License link
rich-click 1.9.7 MIT License

Copyright (c) 2022 Phil Ewels

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
| link |
| simplematch | 1.4 | MIT License | link |
| six | 1.17.0 | MIT License | link |
| standard-aifc | 3.13.0 | Python Software Foundation License | link |
| standard-chunk | 3.13.0 | Python Software Foundation License | link |
| typing_extensions | 4.15.0 | PSF-2.0 | link |
| unicode-rbnf | 2.4.0 | MIT License | |
| urllib3 | 2.6.3 | MIT | link |
| watchdog | 6.0.0 | Apache Software License | link |
| websocket-client | 1.9.0 | Apache Software License | link |
| zipp | 3.23.0 | MIT | link |

Policy: Apache 2.0 (universal donor). StrongCopyleft / NetworkCopyleft / WeakCopyleft / Other / Error categories fail. MPL allowed.

🎙️ Skill

Does the skill know its own name? Checking manifest... 🏷️

🎙️ (unknown skill_id) — 12 languages

en-us: 2 intents · 3 dialogs · skill.json ⚠️

Translation coverage — 11 languages (8 complete, 3 incomplete)
Language Progress Coverage
ca-ES ██████████ ✅ 100.0% (5/5)
da-DK ██████████ ✅ 100.0% (5/5)
de-DE ██████████ ✅ 100.0% (5/5)
es-ES ██████████ ✅ 100.0% (5/5)
eu-ES ██░░░░░░░░ ❌ 20.0% (1/5)
fa-IR ██████████ ✅ 100.0% (5/5)
fr-FR ██████████ ✅ 100.0% (5/5)
gl-ES ██████████ ✅ 100.0% (5/5)
it-IT ████░░░░░░ ❌ 40.0% (2/5)
nl-NL ██████████ ✅ 100.0% (5/5)
pt-PT ██░░░░░░░░ ❌ 20.0% (1/5)

🔨 Build Tests

The build bots have finished their assembly. 🤖

✅ All versions pass

Python Build Install Tests
3.10
3.11
3.12
3.13
3.14

Keeping the OVOS ecosystem thriving 🌿

@github-actions github-actions Bot added feature and removed feature labels Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant