diff --git a/.gitignore b/.gitignore index f060a4ff5..3e3fd3ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ __pycache__/ Release/ __debug_bin* Wox/log/ +Wox.Plugin.Python/dist/ +Wox.Plugin.Python/wox_plugin.egg-info/ \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/__main__.py b/Wox.Plugin.Host.Python/__main__.py index 6b0e21896..fea06ca67 100644 --- a/Wox.Plugin.Host.Python/__main__.py +++ b/Wox.Plugin.Host.Python/__main__.py @@ -1,22 +1,51 @@ import asyncio import sys import uuid +import os import logger from host import start_websocket if len(sys.argv) != 4: - print('Usage: python python-host.pyz ') + print('Usage: python python-host.pyz ') sys.exit(1) port = int(sys.argv[1]) -log_directory = (sys.argv[2]) +log_directory = sys.argv[2] +wox_pid = int(sys.argv[3]) -trace_id = f"{uuid.uuid4()}" +trace_id = str(uuid.uuid4()) host_id = f"python-{uuid.uuid4()}" logger.update_log_directory(log_directory) -logger.info(trace_id, "----------------------------------------") -logger.info(trace_id, f"start python host: {host_id}") -logger.info(trace_id, f"port: {port}") -asyncio.run(start_websocket(port)) +def check_wox_process(): + """Check if Wox process is still alive""" + try: + os.kill(wox_pid, 0) + return True + except OSError: + return False + +async def monitor_wox_process(): + """Monitor Wox process and exit if it's not alive""" + await logger.info(trace_id, "start monitor wox process") + while True: + if not check_wox_process(): + await logger.error(trace_id, "wox process is not alive, exit") + sys.exit(1) + await asyncio.sleep(1) + +async def main(): + """Main function""" + # Log startup information + await logger.info(trace_id, "----------------------------------------") + await logger.info(trace_id, f"start python host: {host_id}") + await logger.info(trace_id, f"port: {port}") + await logger.info(trace_id, f"wox pid: {wox_pid}") + + # Start tasks + monitor_task = asyncio.create_task(monitor_wox_process()) + websocket_task = asyncio.create_task(start_websocket(port)) + await asyncio.gather(monitor_task, websocket_task) + +asyncio.run(main()) diff --git a/Wox.Plugin.Host.Python/constants.py b/Wox.Plugin.Host.Python/constants.py new file mode 100644 index 000000000..690fa843d --- /dev/null +++ b/Wox.Plugin.Host.Python/constants.py @@ -0,0 +1,5 @@ +"""Constants used across the application""" + +PLUGIN_JSONRPC_TYPE_REQUEST = "WOX_JSONRPC_REQUEST" +PLUGIN_JSONRPC_TYPE_RESPONSE = "WOX_JSONRPC_RESPONSE" +PLUGIN_JSONRPC_TYPE_SYSTEM_LOG = "WOX_JSONRPC_SYSTEM_LOG" \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/host.py b/Wox.Plugin.Host.Python/host.py index c2b1f94c6..5c13b1561 100644 --- a/Wox.Plugin.Host.Python/host.py +++ b/Wox.Plugin.Host.Python/host.py @@ -1,23 +1,76 @@ #!/usr/bin/env python import asyncio -import pkgutil +import json +import uuid +from typing import Dict, Any import websockets -from loguru import logger +import logger +from wox_plugin import Context, new_context_with_value +from constants import PLUGIN_JSONRPC_TYPE_REQUEST, PLUGIN_JSONRPC_TYPE_RESPONSE +from plugin_manager import waiting_for_response +from jsonrpc import handle_request_from_wox +async def handle_message(ws: websockets.WebSocketServerProtocol, message: str): + """Handle incoming WebSocket message""" + try: + msg_data = json.loads(message) + trace_id = msg_data.get("TraceId", str(uuid.uuid4())) + ctx = new_context_with_value("traceId", trace_id) -async def handler(websocket): - while True: - message = await websocket.recv() - logger.info(message) - # my_module = importlib.import_module('os.path') + if PLUGIN_JSONRPC_TYPE_RESPONSE in message: + # Handle response from Wox + if msg_data.get("Id") in waiting_for_response: + deferred = waiting_for_response[msg_data["Id"]] + if msg_data.get("Error"): + deferred.reject(msg_data["Error"]) + else: + deferred.resolve(msg_data.get("Result")) + del waiting_for_response[msg_data["Id"]] + elif PLUGIN_JSONRPC_TYPE_REQUEST in message: + # Handle request from Wox + try: + result = await handle_request_from_wox(ctx, msg_data, ws) + response = { + "TraceId": trace_id, + "Id": msg_data["Id"], + "Method": msg_data["Method"], + "Type": PLUGIN_JSONRPC_TYPE_RESPONSE, + "Result": result + } + await ws.send(json.dumps(response)) + except Exception as e: + error_response = { + "TraceId": trace_id, + "Id": msg_data["Id"], + "Method": msg_data["Method"], + "Type": PLUGIN_JSONRPC_TYPE_RESPONSE, + "Error": str(e) + } + await logger.error(trace_id, f"handle request failed: {str(e)}") + await ws.send(json.dumps(error_response)) + else: + await logger.error(trace_id, f"unknown message type: {message}") + except Exception as e: + await logger.error(str(uuid.uuid4()), f"receive and handle msg error: {message}, err: {str(e)}") +async def handler(websocket: websockets.WebSocketServerProtocol): + """WebSocket connection handler""" + logger.update_websocket(websocket) + + try: + async for message in websocket: + await handle_message(websocket, message) + except websockets.exceptions.ConnectionClosed: + await logger.info(str(uuid.uuid4()), "connection closed") + except Exception as e: + await logger.error(str(uuid.uuid4()), f"connection error: {str(e)}") + finally: + logger.update_websocket(None) async def start_websocket(websocket_port: int): + """Start WebSocket server""" + await logger.info(str(uuid.uuid4()), "start websocket server") async with websockets.serve(handler, "", websocket_port): - await asyncio.Future() # run forever - - -def load_plugin(): - pkgutil.iter_modules(['plugins']) + await asyncio.Future() # run forever \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/jsonrpc.py b/Wox.Plugin.Host.Python/jsonrpc.py new file mode 100644 index 000000000..6fbe84b74 --- /dev/null +++ b/Wox.Plugin.Host.Python/jsonrpc.py @@ -0,0 +1,204 @@ +import json +import importlib.util +import sys +from typing import Any, Dict, Optional +import uuid +import websockets +import logger +from wox_plugin import ( + Context, + Plugin, + Query, + QueryType, + Selection, + QueryEnv, + Result, + new_context_with_value, + PluginInitParams +) +from constants import PLUGIN_JSONRPC_TYPE_REQUEST, PLUGIN_JSONRPC_TYPE_RESPONSE +from plugin_manager import plugin_instances, waiting_for_response +from plugin_api import PluginAPI + +async def handle_request_from_wox(ctx: Context, request: Dict[str, Any], ws: websockets.WebSocketServerProtocol) -> Any: + """Handle incoming request from Wox""" + method = request.get("Method") + plugin_name = request.get("PluginName") + + await logger.info(ctx["Values"]["traceId"], f"invoke <{plugin_name}> method: {method}") + + if method == "loadPlugin": + return await load_plugin(ctx, request) + elif method == "init": + return await init_plugin(ctx, request, ws) + elif method == "query": + return await query(ctx, request) + elif method == "action": + return await action(ctx, request) + elif method == "refresh": + return await refresh(ctx, request) + elif method == "unloadPlugin": + return await unload_plugin(ctx, request) + else: + await logger.info(ctx["Values"]["traceId"], f"unknown method handler: {method}") + raise Exception(f"unknown method handler: {method}") + +async def load_plugin(ctx: Context, request: Dict[str, Any]) -> None: + """Load a plugin""" + plugin_directory = request["Params"]["PluginDirectory"] + entry = request["Params"]["Entry"] + plugin_id = request["PluginId"] + plugin_name = request["PluginName"] + + try: + # Add plugin directory to Python path + if plugin_directory not in sys.path: + sys.path.append(plugin_directory) + + # Import the plugin module + spec = importlib.util.spec_from_file_location("plugin", entry) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load plugin from {entry}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if not hasattr(module, "plugin"): + raise AttributeError("Plugin module does not have a 'plugin' attribute") + + plugin_instances[plugin_id] = { + "module": module, + "plugin": module.plugin, + "directory": plugin_directory, + "entry": entry, + "name": plugin_name, + "api": None + } + + await logger.info(ctx["Values"]["traceId"], f"<{plugin_name}> load plugin successfully") + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin_name}> load plugin failed: {str(e)}") + raise e + +async def init_plugin(ctx: Context, request: Dict[str, Any], ws: websockets.WebSocketServerProtocol) -> None: + """Initialize a plugin""" + plugin_id = request["PluginId"] + plugin = plugin_instances.get(plugin_id) + if not plugin: + raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?") + + try: + # Create plugin API instance + api = PluginAPI(ws, plugin_id, plugin["name"]) + plugin["api"] = api + + # Call plugin's init method if it exists + if hasattr(plugin["plugin"], "init"): + init_params = PluginInitParams(API=api, PluginDirectory=plugin["directory"]) + await plugin["plugin"].init(ctx, init_params) + + await logger.info(ctx["Values"]["traceId"], f"<{plugin['name']}> init plugin successfully") + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> init plugin failed: {str(e)}") + raise e + +async def query(ctx: Context, request: Dict[str, Any]) -> list: + """Handle query request""" + plugin_id = request["PluginId"] + plugin = plugin_instances.get(plugin_id) + if not plugin: + raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?") + + try: + if not hasattr(plugin["plugin"], "query"): + return [] + + query_params = Query( + Type=QueryType(request["Params"]["Type"]), + RawQuery=request["Params"]["RawQuery"], + TriggerKeyword=request["Params"]["TriggerKeyword"], + Command=request["Params"]["Command"], + Search=request["Params"]["Search"], + Selection=Selection(**json.loads(request["Params"]["Selection"])), + Env=QueryEnv(**json.loads(request["Params"]["Env"])) + ) + + results = await plugin["plugin"].query(ctx, query_params) + + # Ensure each result has an ID + if results: + for result in results: + if not result.Id: + result.Id = str(uuid.uuid4()) + if result.Actions: + for action in result.Actions: + if not action.Id: + action.Id = str(uuid.uuid4()) + + return [result.__dict__ for result in results] if results else [] + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> query failed: {str(e)}") + raise e + +async def action(ctx: Context, request: Dict[str, Any]) -> Any: + """Handle action request""" + plugin_id = request["PluginId"] + plugin = plugin_instances.get(plugin_id) + if not plugin: + raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?") + + try: + action_id = request["Params"]["ActionId"] + context_data = request["Params"].get("ContextData") + + # Find the action in the plugin's results + if hasattr(plugin["plugin"], "handle_action"): + return await plugin["plugin"].handle_action(action_id, context_data) + + return None + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> action failed: {str(e)}") + raise e + +async def refresh(ctx: Context, request: Dict[str, Any]) -> Any: + """Handle refresh request""" + plugin_id = request["PluginId"] + plugin = plugin_instances.get(plugin_id) + if not plugin: + raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?") + + try: + result_id = request["Params"]["ResultId"] + + # Find the refresh callback in the plugin's results + if hasattr(plugin["plugin"], "handle_refresh"): + return await plugin["plugin"].handle_refresh(result_id) + + return None + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> refresh failed: {str(e)}") + raise e + +async def unload_plugin(ctx: Context, request: Dict[str, Any]) -> None: + """Unload a plugin""" + plugin_id = request["PluginId"] + plugin = plugin_instances.get(plugin_id) + if not plugin: + raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?") + + try: + # Call plugin's unload method if it exists + if hasattr(plugin["plugin"], "unload"): + await plugin["plugin"].unload() + + # Remove plugin from instances + del plugin_instances[plugin_id] + + # Remove plugin directory from Python path + if plugin["directory"] in sys.path: + sys.path.remove(plugin["directory"]) + + await logger.info(ctx["Values"]["traceId"], f"<{plugin['name']}> unload plugin successfully") + except Exception as e: + await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> unload plugin failed: {str(e)}") + raise e \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/logger.py b/Wox.Plugin.Host.Python/logger.py index 5b5db69f4..1b435f3f9 100644 --- a/Wox.Plugin.Host.Python/logger.py +++ b/Wox.Plugin.Host.Python/logger.py @@ -1,22 +1,41 @@ -from loguru import logger as loguru_logger - - -def update_log_directory(log_directory: str) -> None: - loguru_logger.remove() - loguru_logger.add(f"{log_directory}/python.log", format="{time:YYYY-MM-DD HH:mm:ss.SSS} [{level}] {message}", rotation="100 MB", retention="3 days") - - -def debug(trace_id: str, message: str) -> None: - __inner_log(trace_id, message, "debug") - - -def info(trace_id: str, message: str) -> None: - __inner_log(trace_id, message, "info") - - -def error(trace_id: str, message: str) -> None: - __inner_log(trace_id, message, "error") - - -def __inner_log(trace_id: str, message: str, level: str) -> None: - loguru_logger.log(__level=level, __message=f"{trace_id} {message}") +import json +from typing import Optional +import websockets +from loguru import logger + +PLUGIN_JSONRPC_TYPE_SYSTEM_LOG = "WOX_JSONRPC_SYSTEM_LOG" +websocket: Optional[websockets.WebSocketServerProtocol] = None + +def update_log_directory(log_directory: str): + """Update the log directory for the logger""" + logger.remove() + logger.add(f"{log_directory}/python.log", format="{time} {message}") + +def update_websocket(ws: Optional[websockets.WebSocketServerProtocol]): + """Update the websocket connection for logging""" + global websocket + websocket = ws + +async def log(trace_id: str, level: str, msg: str): + """Log a message to both file and websocket if available""" + logger.log(level.upper(), f"{trace_id} [{level}] {msg}") + + if websocket: + try: + await websocket.send(json.dumps({ + "Type": PLUGIN_JSONRPC_TYPE_SYSTEM_LOG, + "TraceId": trace_id, + "Level": level, + "Message": msg + })) + except Exception as e: + logger.error(f"Failed to send log message through websocket: {e}") + +async def debug(trace_id: str, msg: str): + await log(trace_id, "debug", msg) + +async def info(trace_id: str, msg: str): + await log(trace_id, "info", msg) + +async def error(trace_id: str, msg: str): + await log(trace_id, "error", msg) diff --git a/Wox.Plugin.Host.Python/plugin_api.py b/Wox.Plugin.Host.Python/plugin_api.py new file mode 100644 index 000000000..bdc472293 --- /dev/null +++ b/Wox.Plugin.Host.Python/plugin_api.py @@ -0,0 +1,144 @@ +import asyncio +import json +import uuid +from typing import Any, Dict, Callable, Optional +import websockets +import logger +from wox_plugin import ( + Context, + PublicAPI, + ChangeQueryParam, + MetadataCommand, + PluginSettingDefinitionItem, + MapString, + Conversation, + ChatStreamFunc, +) +from jsonrpc import PLUGIN_JSONRPC_TYPE_REQUEST, waiting_for_response + +class PluginAPI(PublicAPI): + def __init__(self, ws: websockets.WebSocketServerProtocol, plugin_id: str, plugin_name: str): + self.ws = ws + self.plugin_id = plugin_id + self.plugin_name = plugin_name + self.setting_change_callbacks: Dict[str, Callable[[str, str], None]] = {} + self.get_dynamic_setting_callbacks: Dict[str, Callable[[str], PluginSettingDefinitionItem]] = {} + self.deep_link_callbacks: Dict[str, Callable[[MapString], None]] = {} + self.unload_callbacks: Dict[str, Callable[[], None]] = {} + self.llm_stream_callbacks: Dict[str, ChatStreamFunc] = {} + + async def invoke_method(self, ctx: Context, method: str, params: Dict[str, Any]) -> Any: + """Invoke a method on Wox""" + request_id = str(uuid.uuid4()) + trace_id = ctx["Values"]["traceId"] + + if method != "Log": + await logger.info(trace_id, f"<{self.plugin_name}> start invoke method to Wox: {method}, id: {request_id}") + + request = { + "TraceId": trace_id, + "Id": request_id, + "Method": method, + "Type": PLUGIN_JSONRPC_TYPE_REQUEST, + "Params": params, + "PluginId": self.plugin_id, + "PluginName": self.plugin_name + } + + await self.ws.send(json.dumps(request)) + + # Create a Future to wait for the response + future = asyncio.Future() + waiting_for_response[request_id] = future + + try: + return await future + except Exception as e: + await logger.error(trace_id, f"invoke method failed: {str(e)}") + raise e + + async def change_query(self, ctx: Context, query: ChangeQueryParam) -> None: + """Change the query in Wox""" + params = { + "QueryType": query.QueryType, + "QueryText": query.QueryText, + "QuerySelection": query.QuerySelection.__dict__ if query.QuerySelection else None + } + await self.invoke_method(ctx, "ChangeQuery", params) + + async def hide_app(self, ctx: Context) -> None: + """Hide the Wox window""" + await self.invoke_method(ctx, "HideApp", {}) + + async def show_app(self, ctx: Context) -> None: + """Show the Wox window""" + await self.invoke_method(ctx, "ShowApp", {}) + + async def notify(self, ctx: Context, message: str) -> None: + """Show a notification message""" + await self.invoke_method(ctx, "Notify", {"message": message}) + + async def log(self, ctx: Context, level: str, msg: str) -> None: + """Write log""" + await self.invoke_method(ctx, "Log", { + "level": level, + "message": msg + }) + + async def get_translation(self, ctx: Context, key: str) -> str: + """Get a translation for a key""" + result = await self.invoke_method(ctx, "GetTranslation", {"key": key}) + return str(result) if result is not None else key + + async def get_setting(self, ctx: Context, key: str) -> str: + """Get a setting value""" + result = await self.invoke_method(ctx, "GetSetting", {"key": key}) + return str(result) if result is not None else "" + + async def save_setting(self, ctx: Context, key: str, value: str, is_platform_specific: bool) -> None: + """Save a setting value""" + await self.invoke_method(ctx, "SaveSetting", { + "key": key, + "value": value, + "isPlatformSpecific": is_platform_specific + }) + + async def on_setting_changed(self, ctx: Context, callback: Callable[[str, str], None]) -> None: + """Register setting changed callback""" + callback_id = str(uuid.uuid4()) + self.setting_change_callbacks[callback_id] = callback + await self.invoke_method(ctx, "OnSettingChanged", {"callbackId": callback_id}) + + async def on_get_dynamic_setting(self, ctx: Context, callback: Callable[[str], PluginSettingDefinitionItem]) -> None: + """Register dynamic setting callback""" + callback_id = str(uuid.uuid4()) + self.get_dynamic_setting_callbacks[callback_id] = callback + await self.invoke_method(ctx, "OnGetDynamicSetting", {"callbackId": callback_id}) + + async def on_deep_link(self, ctx: Context, callback: Callable[[MapString], None]) -> None: + """Register deep link callback""" + callback_id = str(uuid.uuid4()) + self.deep_link_callbacks[callback_id] = callback + await self.invoke_method(ctx, "OnDeepLink", {"callbackId": callback_id}) + + async def on_unload(self, ctx: Context, callback: Callable[[], None]) -> None: + """Register unload callback""" + callback_id = str(uuid.uuid4()) + self.unload_callbacks[callback_id] = callback + await self.invoke_method(ctx, "OnUnload", {"callbackId": callback_id}) + + async def register_query_commands(self, ctx: Context, commands: list[MetadataCommand]) -> None: + """Register query commands""" + await self.invoke_method(ctx, "RegisterQueryCommands", { + "commands": json.dumps([command.__dict__ for command in commands]) + }) + + async def llm_stream(self, ctx: Context, conversations: list[Conversation], callback: ChatStreamFunc) -> None: + """Chat using LLM""" + callback_id = str(uuid.uuid4()) + self.llm_stream_callbacks[callback_id] = callback + await self.invoke_method(ctx, "LLMStream", { + "callbackId": callback_id, + "conversations": json.dumps([conv.__dict__ for conv in conversations]) + }) + \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/plugin_manager.py b/Wox.Plugin.Host.Python/plugin_manager.py new file mode 100644 index 000000000..5a97c9cea --- /dev/null +++ b/Wox.Plugin.Host.Python/plugin_manager.py @@ -0,0 +1,6 @@ +"""Plugin manager for handling plugin instances and responses""" +from typing import Dict, Any + +# Global state +plugin_instances: Dict[str, Dict[str, Any]] = {} +waiting_for_response: Dict[str, Any] = {} \ No newline at end of file diff --git a/Wox.Plugin.Host.Python/requirements.txt b/Wox.Plugin.Host.Python/requirements.txt index 4e0c8459e..2f5b41187 100644 --- a/Wox.Plugin.Host.Python/requirements.txt +++ b/Wox.Plugin.Host.Python/requirements.txt @@ -1,2 +1,3 @@ +websockets==12.0 loguru==0.7.2 -websockets==11.0.3 +wox-plugin==0.0.1 diff --git a/Wox.Plugin.Python/README.md b/Wox.Plugin.Python/README.md new file mode 100644 index 000000000..1921243f3 --- /dev/null +++ b/Wox.Plugin.Python/README.md @@ -0,0 +1,27 @@ +# Wox Plugin Python + +This package provides type definitions for developing Wox plugins in Python. + +## Installation + +```bash +pip install wox-plugin +``` + +## Usage + +```python +from wox_plugin import Plugin, Query, Result, Context, PluginInitParams + +class MyPlugin(Plugin): + async def init(self, ctx: Context, params: PluginInitParams) -> None: + self.api = params.API + + async def query(self, ctx: Context, query: Query) -> list[Result]: + # Your plugin logic here + return [] +``` + +## License + +MIT \ No newline at end of file diff --git a/Wox.Plugin.Python/plugin.py b/Wox.Plugin.Python/plugin.py deleted file mode 100644 index 99d50e393..000000000 --- a/Wox.Plugin.Python/plugin.py +++ /dev/null @@ -1,188 +0,0 @@ -from typing import List, Dict, Any, Optional, Callable, Union -from abc import ABC, abstractmethod -from enum import Enum - -class Platform(Enum): - WINDOWS = "windows" - DARWIN = "darwin" - LINUX = "linux" - -class SelectionType(Enum): - TEXT = "text" - FILE = "file" - -class Selection: - def __init__(self, type: SelectionType, text: Optional[str] = None, file_paths: Optional[List[str]] = None): - self.Type = type - self.Text = text - self.FilePaths = file_paths - -class QueryEnv: - def __init__(self, active_window_title: str): - self.ActiveWindowTitle = active_window_title - -class Query: - def __init__(self, type: str, raw_query: str, trigger_keyword: Optional[str], command: Optional[str], search: str, selection: Optional[Selection], env: QueryEnv): - self.Type = type - self.RawQuery = raw_query - self.TriggerKeyword = trigger_keyword - self.Command = command - self.Search = search - self.Selection = selection - self.Env = env - - def is_global_query(self) -> bool: - return self.TriggerKeyword is None or self.TriggerKeyword == "" - -class WoxImageType(Enum): - ABSOLUTE = "absolute" - RELATIVE = "relative" - BASE64 = "base64" - SVG = "svg" - URL = "url" - EMOJI = "emoji" - LOTTIE = "lottie" - -class WoxImage: - def __init__(self, image_type: WoxImageType, image_data: str): - self.ImageType = image_type - self.ImageData = image_data - -class WoxPreviewType(Enum): - MARKDOWN = "markdown" - TEXT = "text" - IMAGE = "image" - URL = "url" - FILE = "file" - -class WoxPreview: - def __init__(self, preview_type: WoxPreviewType, preview_data: str, preview_properties: Dict[str, str]): - self.PreviewType = preview_type - self.PreviewData = preview_data - self.PreviewProperties = preview_properties - -class ResultTail: - def __init__(self, type: str, text: Optional[str] = None, image: Optional[WoxImage] = None): - self.Type = type - self.Text = text - self.Image = image - -class ActionContext: - def __init__(self, context_data: str): - self.ContextData = context_data - -class ResultAction: - def __init__(self, id: Optional[str], name: str, icon: Optional[WoxImage], is_default: bool, prevent_hide_after_action: bool, action: Callable[[ActionContext], None], hotkey: Optional[str]): - self.Id = id - self.Name = name - self.Icon = icon - self.IsDefault = is_default - self.PreventHideAfterAction = prevent_hide_after_action - self.Action = action - self.Hotkey = hotkey - -class Result: - def __init__(self, id: Optional[str], title: str, sub_title: Optional[str], icon: WoxImage, preview: Optional[WoxPreview], score: Optional[float], group: Optional[str], group_score: Optional[float], tails: Optional[List[ResultTail]], context_data: Optional[str], actions: Optional[List[ResultAction]], refresh_interval: Optional[int], on_refresh: Optional[Callable[['RefreshableResult'], 'RefreshableResult']]): - self.Id = id - self.Title = title - self.SubTitle = sub_title - self.Icon = icon - self.Preview = preview - self.Score = score - self.Group = group - self.GroupScore = group_score - self.Tails = tails - self.ContextData = context_data - self.Actions = actions - self.RefreshInterval = refresh_interval - self.OnRefresh = on_refresh - -class RefreshableResult: - def __init__(self, title: str, sub_title: str, icon: WoxImage, preview: WoxPreview, context_data: str, refresh_interval: int): - self.Title = title - self.SubTitle = sub_title - self.Icon = icon - self.Preview = preview - self.ContextData = context_data - self.RefreshInterval = refresh_interval - -class ChangeQueryParam: - def __init__(self, query_type: str, query_text: Optional[str] = None, query_selection: Optional[Selection] = None): - self.QueryType = query_type - self.QueryText = query_text - self.QuerySelection = query_selection - -class Context: - def __init__(self): - self.Values = {} - - def get(self, key: str) -> Optional[str]: - return self.Values.get(key) - - def set(self, key: str, value: str): - self.Values[key] = value - - def exists(self, key: str) -> bool: - return key in self.Values - -class PublicAPI: - @staticmethod - async def change_query(ctx: Context, query: ChangeQueryParam): - pass - - @staticmethod - async def hide_app(ctx: Context): - pass - - @staticmethod - async def show_app(ctx: Context): - pass - - @staticmethod - async def notify(ctx: Context, title: str, description: Optional[str] = None): - pass - - @staticmethod - async def log(ctx: Context, level: str, msg: str): - pass - - @staticmethod - async def get_translation(ctx: Context, key: str) -> str: - pass - - @staticmethod - async def get_setting(ctx: Context, key: str) -> str: - pass - - @staticmethod - async def save_setting(ctx: Context, key: str, value: str, is_platform_specific: bool): - pass - - @staticmethod - async def on_setting_changed(ctx: Context, callback: Callable[[str, str], None]): - pass - -class PluginInitParams: - def __init__(self, api: PublicAPI, plugin_directory: str): - self.API = api - self.PluginDirectory = plugin_directory - -class WoxPlugin(ABC): - @abstractmethod - async def init(self, ctx: Context, init_params: PluginInitParams): - pass - - @abstractmethod - async def query(self, ctx: Context, query: Query) -> List[Result]: - pass - -def new_context() -> Context: - return Context() - -def new_context_with_value(key: str, value: str) -> Context: - ctx = Context() - ctx.set(key, value) - return ctx - -def new_base64_wox_image(image_data: str) -> WoxImage: - return WoxImage(WoxImageType.BASE64, image_data) diff --git a/Wox.Plugin.Python/publish.py b/Wox.Plugin.Python/publish.py new file mode 100644 index 000000000..ae189d275 --- /dev/null +++ b/Wox.Plugin.Python/publish.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +import os +import sys +import subprocess +import re +from pathlib import Path + +def run_command(command: str) -> int: + """Run command and return exit code""" + print(f"\n>>> Running: {command}") + return subprocess.call(command, shell=True) + +def update_version(version_type: str) -> str: + """Update version number + version_type: major, minor, or patch + """ + # Read setup.py + setup_path = Path("setup.py") + content = setup_path.read_text() + + # Find current version + version_match = re.search(r'version="(\d+)\.(\d+)\.(\d+)"', content) + if not version_match: + print("Error: Could not find version in setup.py") + sys.exit(1) + + major, minor, patch = map(int, version_match.groups()) + + # Update version number + if version_type == "major": + major += 1 + minor = 0 + patch = 0 + elif version_type == "minor": + minor += 1 + patch = 0 + else: # patch + patch += 1 + + new_version = f"{major}.{minor}.{patch}" + + # Update setup.py + new_content = re.sub( + r'version="(\d+)\.(\d+)\.(\d+)"', + f'version="{new_version}"', + content + ) + setup_path.write_text(new_content) + + # Update __init__.py + init_path = Path("wox_plugin/__init__.py") + init_content = init_path.read_text() + new_init_content = re.sub( + r'__version__ = "(\d+)\.(\d+)\.(\d+)"', + f'__version__ = "{new_version}"', + init_content + ) + init_path.write_text(new_init_content) + + return new_version + +def main(): + # Check command line arguments + if len(sys.argv) != 2 or sys.argv[1] not in ["major", "minor", "patch"]: + print("Usage: python publish.py [major|minor|patch]") + sys.exit(1) + + version_type = sys.argv[1] + + # Clean previous build files + if run_command("rm -rf dist/ build/ *.egg-info"): + print("Error: Failed to clean old build files") + sys.exit(1) + + # Update version number + new_version = update_version(version_type) + print(f"Updated version to {new_version}") + + # Build package + if run_command("python -m build"): + print("Error: Build failed") + sys.exit(1) + + # Upload to PyPI + if run_command("python -m twine upload dist/*"): + print("Error: Upload to PyPI failed") + sys.exit(1) + + print(f"\nSuccessfully published version {new_version} to PyPI!") + print("Package can be installed with:") + print(f"pip install wox-plugin=={new_version}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Wox.Plugin.Python/publish.sh b/Wox.Plugin.Python/publish.sh new file mode 100644 index 000000000..829f875f2 --- /dev/null +++ b/Wox.Plugin.Python/publish.sh @@ -0,0 +1 @@ +python publish.py patch \ No newline at end of file diff --git a/Wox.Plugin.Python/setup.py b/Wox.Plugin.Python/setup.py new file mode 100644 index 000000000..ae1578bd0 --- /dev/null +++ b/Wox.Plugin.Python/setup.py @@ -0,0 +1,34 @@ +from setuptools import setup, find_packages + +setup( + name="wox-plugin", + version="0.0.1", + description="All Python plugins for Wox should use types in this package", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + author="Wox-launcher", + author_email="", + url="https://github.com/Wox-launcher/Wox", + packages=find_packages(), + install_requires=[ + "typing_extensions>=4.0.0; python_version < '3.8'" + ], + python_requires=">=3.8", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: OS Independent", + "Typing :: Typed", + ], + keywords="wox launcher plugin types", + project_urls={ + "Bug Reports": "https://github.com/Wox-launcher/Wox/issues", + "Source": "https://github.com/Wox-launcher/Wox", + }, +) \ No newline at end of file diff --git a/Wox.Plugin.Python/wox_plugin/__init__.py b/Wox.Plugin.Python/wox_plugin/__init__.py new file mode 100644 index 000000000..02ee05ab3 --- /dev/null +++ b/Wox.Plugin.Python/wox_plugin/__init__.py @@ -0,0 +1,55 @@ +from .types import ( + # Basic types + MapString, + Platform, + + # Context + Context, + new_context, + new_context_with_value, + + # Selection + SelectionType, + Selection, + + # Query + QueryType, + Query, + QueryEnv, + + # Result + WoxImageType, + WoxImage, + new_base64_wox_image, + WoxPreviewType, + WoxPreview, + ResultTailType, + ResultTail, + ActionContext, + ResultAction, + Result, + RefreshableResult, + + # Plugin API + ChangeQueryParam, + + # AI + ConversationRole, + ChatStreamDataType, + Conversation, + ChatStreamFunc, + + # Settings + PluginSettingDefinitionType, + PluginSettingValueStyle, + PluginSettingDefinitionValue, + PluginSettingDefinitionItem, + MetadataCommand, + + # Plugin Interface + Plugin, + PublicAPI, + PluginInitParams, +) + +__version__ = "0.0.82" \ No newline at end of file diff --git a/Wox.Plugin.Python/wox_plugin/types.py b/Wox.Plugin.Python/wox_plugin/types.py new file mode 100644 index 000000000..df57d3246 --- /dev/null +++ b/Wox.Plugin.Python/wox_plugin/types.py @@ -0,0 +1,261 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional, Protocol, Union, Callable, Any, TypedDict, Literal +import uuid + +# Basic types +MapString = Dict[str, str] +Platform = Literal["windows", "darwin", "linux"] + +# Context +class Context(TypedDict): + Values: Dict[str, str] + +def new_context() -> Context: + return {"Values": {"traceId": str(uuid.uuid4())}} + +def new_context_with_value(key: str, value: str) -> Context: + ctx = new_context() + ctx["Values"][key] = value + return ctx + +# Selection +class SelectionType(str, Enum): + TEXT = "text" + FILE = "file" + +@dataclass +class Selection: + Type: SelectionType + Text: Optional[str] = None + FilePaths: Optional[List[str]] = None + +# Query Environment +@dataclass +class QueryEnv: + ActiveWindowTitle: str + +# Query +class QueryType(str, Enum): + INPUT = "input" + SELECTION = "selection" + +@dataclass +class Query: + Type: QueryType + RawQuery: str + TriggerKeyword: Optional[str] + Command: Optional[str] + Search: str + Selection: Selection + Env: QueryEnv + + def is_global_query(self) -> bool: + return self.Type == QueryType.INPUT and not self.TriggerKeyword + +# Result +class WoxImageType(str, Enum): + ABSOLUTE = "absolute" + RELATIVE = "relative" + BASE64 = "base64" + SVG = "svg" + URL = "url" + EMOJI = "emoji" + LOTTIE = "lottie" + +@dataclass +class WoxImage: + ImageType: WoxImageType + ImageData: str + +def new_base64_wox_image(image_data: str) -> WoxImage: + return WoxImage(ImageType=WoxImageType.BASE64, ImageData=image_data) + +class WoxPreviewType(str, Enum): + MARKDOWN = "markdown" + TEXT = "text" + IMAGE = "image" + URL = "url" + FILE = "file" + +@dataclass +class WoxPreview: + PreviewType: WoxPreviewType + PreviewData: str + PreviewProperties: Dict[str, str] + +class ResultTailType(str, Enum): + TEXT = "text" + IMAGE = "image" + +@dataclass +class ResultTail: + Type: ResultTailType + Text: Optional[str] = None + Image: Optional[WoxImage] = None + +@dataclass +class ActionContext: + ContextData: str + +@dataclass +class ResultAction: + Id: Optional[str] + Name: str + Icon: Optional[WoxImage] + IsDefault: Optional[bool] + PreventHideAfterAction: Optional[bool] + Action: Callable[[ActionContext], None] + Hotkey: Optional[str] + +@dataclass +class Result: + Id: Optional[str] + Title: str + SubTitle: Optional[str] + Icon: WoxImage + Preview: Optional[WoxPreview] + Score: Optional[float] + Group: Optional[str] + GroupScore: Optional[float] + Tails: Optional[List[ResultTail]] + ContextData: Optional[str] + Actions: Optional[List[ResultAction]] + RefreshInterval: Optional[int] + OnRefresh: Optional[Callable[["RefreshableResult"], "RefreshableResult"]] + +@dataclass +class RefreshableResult: + Title: str + SubTitle: str + Icon: WoxImage + Preview: WoxPreview + Tails: List[ResultTail] + ContextData: str + RefreshInterval: int + Actions: List[ResultAction] + +# Plugin API +@dataclass +class ChangeQueryParam: + QueryType: QueryType + QueryText: Optional[str] + QuerySelection: Optional[Selection] + +# AI +class ConversationRole(str, Enum): + USER = "user" + SYSTEM = "system" + +class ChatStreamDataType(str, Enum): + STREAMING = "streaming" + FINISHED = "finished" + ERROR = "error" + +@dataclass +class Conversation: + Role: ConversationRole + Text: str + Timestamp: int + +ChatStreamFunc = Callable[[ChatStreamDataType, str], None] + +# Settings +class PluginSettingDefinitionType(str, Enum): + HEAD = "head" + TEXTBOX = "textbox" + CHECKBOX = "checkbox" + SELECT = "select" + LABEL = "label" + NEWLINE = "newline" + TABLE = "table" + DYNAMIC = "dynamic" + +@dataclass +class PluginSettingValueStyle: + PaddingLeft: int + PaddingTop: int + PaddingRight: int + PaddingBottom: int + Width: int + LabelWidth: int + +@dataclass +class PluginSettingDefinitionValue: + def get_key(self) -> str: + raise NotImplementedError + + def get_default_value(self) -> str: + raise NotImplementedError + + def translate(self, translator: Callable[[Context, str], str]) -> None: + raise NotImplementedError + +@dataclass +class PluginSettingDefinitionItem: + Type: PluginSettingDefinitionType + Value: PluginSettingDefinitionValue + DisabledInPlatforms: List[Platform] + IsPlatformSpecific: bool + +@dataclass +class MetadataCommand: + Command: str + Description: str + +# Plugin Interface +class Plugin(Protocol): + async def init(self, ctx: Context, init_params: "PluginInitParams") -> None: + ... + + async def query(self, ctx: Context, query: Query) -> List[Result]: + ... + +# Public API Interface +class PublicAPI(Protocol): + async def change_query(self, ctx: Context, query: ChangeQueryParam) -> None: + ... + + async def hide_app(self, ctx: Context) -> None: + ... + + async def show_app(self, ctx: Context) -> None: + ... + + async def notify(self, ctx: Context, message: str) -> None: + ... + + async def log(self, ctx: Context, level: str, msg: str) -> None: + ... + + async def get_translation(self, ctx: Context, key: str) -> str: + ... + + async def get_setting(self, ctx: Context, key: str) -> str: + ... + + async def save_setting(self, ctx: Context, key: str, value: str, is_platform_specific: bool) -> None: + ... + + async def on_setting_changed(self, ctx: Context, callback: Callable[[str, str], None]) -> None: + ... + + async def on_get_dynamic_setting(self, ctx: Context, callback: Callable[[str], PluginSettingDefinitionItem]) -> None: + ... + + async def on_deep_link(self, ctx: Context, callback: Callable[[MapString], None]) -> None: + ... + + async def on_unload(self, ctx: Context, callback: Callable[[], None]) -> None: + ... + + async def register_query_commands(self, ctx: Context, commands: List[MetadataCommand]) -> None: + ... + + async def llm_stream(self, ctx: Context, conversations: List[Conversation], callback: ChatStreamFunc) -> None: + ... + +@dataclass +class PluginInitParams: + API: PublicAPI + PluginDirectory: str \ No newline at end of file diff --git a/Wox/plugin/host/host_python.go b/Wox/plugin/host/host_python.go index df0d34e67..feea3cda2 100644 --- a/Wox/plugin/host/host_python.go +++ b/Wox/plugin/host/host_python.go @@ -2,9 +2,14 @@ package host import ( "context" + "fmt" "path" + "strings" "wox/plugin" "wox/util" + + "github.com/Masterminds/semver/v3" + "github.com/mitchellh/go-homedir" ) func init() { @@ -25,7 +30,61 @@ func (n *PythonHost) GetRuntime(ctx context.Context) plugin.Runtime { } func (n *PythonHost) Start(ctx context.Context) error { - return n.websocketHost.StartHost(ctx, "python", path.Join(util.GetLocation().GetHostDirectory(), "python-host.pyz")) + return n.websocketHost.StartHost(ctx, n.findPythonPath(ctx), path.Join(util.GetLocation().GetHostDirectory(), "python-host.pyz")) +} + +func (n *PythonHost) findPythonPath(ctx context.Context) string { + util.GetLogger().Debug(ctx, "start finding python path") + + var possiblePythonPaths = []string{ + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/usr/local/python3", + } + + pyenvPaths, _ := homedir.Expand("~/.pyenv/versions") + if util.IsDirExists(pyenvPaths) { + versions, _ := util.ListDir(pyenvPaths) + for _, v := range versions { + possiblePythonPaths = append(possiblePythonPaths, path.Join(pyenvPaths, v, "bin", "python3")) + } + } + + foundVersion, _ := semver.NewVersion("v0.0.1") + foundPath := "" + for _, p := range possiblePythonPaths { + if util.IsFileExists(p) { + versionOriginal, versionErr := util.ShellRunOutput(p, "--version") + if versionErr != nil { + util.GetLogger().Error(ctx, fmt.Sprintf("failed to get python version: %s, path=%s", versionErr, p)) + continue + } + // Python version output format is like "Python 3.9.0" + version := strings.TrimSpace(string(versionOriginal)) + version = strings.TrimPrefix(version, "Python ") + version = "v" + version + installedVersion, err := semver.NewVersion(version) + if err != nil { + util.GetLogger().Error(ctx, fmt.Sprintf("failed to parse python version: %s, path=%s", err, p)) + continue + } + util.GetLogger().Debug(ctx, fmt.Sprintf("found python path: %s, version: %s", p, installedVersion.String())) + + if installedVersion.GreaterThan(foundVersion) { + foundPath = p + foundVersion = installedVersion + } + } + } + + if foundPath != "" { + util.GetLogger().Info(ctx, fmt.Sprintf("finally use python path: %s, version: %s", foundPath, foundVersion.String())) + return foundPath + } + + util.GetLogger().Info(ctx, "finally use default python3 from env path") + return "python3" } func (n *PythonHost) IsStarted(ctx context.Context) bool { diff --git a/Wox/plugin/manager.go b/Wox/plugin/manager.go index 295845cc9..bd4b09448 100644 --- a/Wox/plugin/manager.go +++ b/Wox/plugin/manager.go @@ -141,8 +141,7 @@ func (m *Manager) loadPlugins(ctx context.Context) error { } logger.Info(ctx, fmt.Sprintf("start loading user plugins, found %d user plugins", len(metaDataList))) - for _, h := range AllHosts { - host := h + for _, host := range AllHosts { util.Go(ctx, fmt.Sprintf("[%s] start host", host.GetRuntime(ctx)), func() { newCtx := util.NewTraceContext() hostErr := host.Start(newCtx)