From 2a68391e369f71590ecdb3a099c519e6e37f95c9 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:44:50 +0000 Subject: [PATCH] fix: forward built-in Flow.Launcher.* actions back to the host Flow doesn't execute api.open_uri/change_query/etc. locally when a Result's json_rpc_action uses one directly. JsonRPCPluginV2.ExecuteResultAsync sends the built-in method name back to the plugin process via RPC.InvokeAsync, same as a custom action. The V2 dispatch loop had no case for the Flow.Launcher. namespace, so it tried to resolve it as a registered @on_method handler, raised EventNotFound, and answered with an internal error instead of running the action. --- pyflowlauncher/launcher.py | 31 +++++++++-- tests/integration/test_v2_protocol.py | 76 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index dbf78f8..85f4a45 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Awaitable, Callable, Dict, Optional -from .api import Api +from .api import NAME_SPACE, Api from .base import pyFlowLauncherObject from .icons import Icons from .jsonrpc import JsonRPCClient, JsonRPCV2Client @@ -140,9 +140,20 @@ async def run(self, dispatch: Callable[[str, list], Awaitable[Any]]) -> None: # context_menu is excluded: its single list argument IS the ContextData. params = params[0] - task = asyncio.create_task( - self._handle_request(request_id, method, params, dispatch) - ) + if method.startswith(f'{NAME_SPACE}.'): + # Built-in actions (api.open_uri, api.change_query, ...) attached + # directly to a Result's json_rpc_action: Flow doesn't execute + # these itself. JsonRPCPluginV2.ExecuteResultAsync calls + # RPC.InvokeAsync(action.Method, ...), sending the built-in method + # name right back to this process, same as a custom action. Loop + # it back to the host instead of resolving it as one of ours. + task = asyncio.create_task( + self._handle_builtin_action(request_id, method, params) + ) + else: + task = asyncio.create_task( + self._handle_request(request_id, method, params, dispatch) + ) tasks.add(task) if request_id is not None: in_flight[request_id] = task @@ -180,6 +191,18 @@ async def _handle_request( return self._send_response(request_id, method, result) + async def _handle_builtin_action(self, request_id: Any, method: str, params: list) -> None: + try: + await self._client.request(method, params) + except asyncio.CancelledError: + self._client.send({'id': request_id, 'result': None, 'error': { + 'code': -32800, 'message': 'Request cancelled', + }}) + raise + except Exception: + self.logger.exception("Failed to forward built-in action %r to the host", method) + self._respond(request_id, {'hide': True}) + def _respond(self, request_id: Any, result: Any) -> None: """Send a response in the uniform {id, result, error} envelope.""" self._client.send({'id': request_id, 'result': result, 'error': None}) diff --git a/tests/integration/test_v2_protocol.py b/tests/integration/test_v2_protocol.py index 1bfe211..5fcc3e1 100644 --- a/tests/integration/test_v2_protocol.py +++ b/tests/integration/test_v2_protocol.py @@ -417,6 +417,82 @@ def context_menu(data): assert received == [['ctx1', 'ctx2']] +class TestV2BuiltinActions: + """Built-in Flow.Launcher.* actions (api.open_uri, api.change_query, etc.) + attached directly as a Result's json_rpc_action. + + Flow Launcher's host doesn't execute these locally: JsonRPCPluginV2.ExecuteResultAsync + calls RPC.InvokeAsync(action.Method, argument: Parameters), which sends the built-in + method name back to this plugin process to run, exactly like a custom action. The + plugin must recognize the Flow.Launcher. namespace and loop the call back to the + host instead of trying to resolve it as one of its own @on_method handlers. + """ + + def test_builtin_action_forwarded_to_host_and_original_request_acked(self): + plugin = make_plugin() + + async def dispatch(method: str, params: list) -> Any: + return await plugin._event_handler.trigger_event(method, *params) + + stdin_text = ( + json.dumps({'id': 10, 'method': 'Flow.Launcher.OpenAppUri', + 'params': [['playnite://playnite/start/abc']]}) + '\n' + + json.dumps({'id': 1, 'result': None}) + '\n' + + json.dumps({'id': 11, 'method': 'close', 'params': []}) + '\n' + ) + responses = [] + + async def _inner(): + with patch('sys.stdin', StringIO(stdin_text)), \ + patch('sys.stdout', StringIO()) as out: + await plugin._launcher.run(dispatch) + out.seek(0) + for line in out.read().splitlines(): + if line.strip(): + responses.append(json.loads(line)) + + asyncio.run(_inner()) + + forwarded = next(r for r in responses if r.get('method') == 'Flow.Launcher.OpenAppUri') + assert forwarded['params'] == ['playnite://playnite/start/abc'] + + original = query_response(responses, 10) + assert original['result'] == {'hide': True} + assert original.get('error') is None + + def test_builtin_action_not_dispatched_as_a_registered_method(self): + """Regression: must not raise EventNotFound / answer 'Internal error'.""" + plugin = make_plugin() + dispatched = [] + + async def dispatch(method: str, params: list) -> Any: + dispatched.append(method) + return await plugin._event_handler.trigger_event(method, *params) + + stdin_text = ( + json.dumps({'id': 5, 'method': 'Flow.Launcher.ChangeQuery', + 'params': [['q ', False]]}) + '\n' + + json.dumps({'id': 1, 'result': None}) + '\n' + + json.dumps({'id': 6, 'method': 'close', 'params': []}) + '\n' + ) + responses = [] + + async def _inner(): + with patch('sys.stdin', StringIO(stdin_text)), \ + patch('sys.stdout', StringIO()) as out: + await plugin._launcher.run(dispatch) + out.seek(0) + for line in out.read().splitlines(): + if line.strip(): + responses.append(json.loads(line)) + + asyncio.run(_inner()) + + assert 'Flow.Launcher.ChangeQuery' not in dispatched + resp = query_response(responses, 5) + assert resp.get('result', {}).get('debugMessage') != 'Internal error' + + class TestV2Settings: def test_settings_stored_from_query_params(self):