Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions pyflowlauncher/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down
76 changes: 76 additions & 0 deletions tests/integration/test_v2_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading