feat(script_plugin): Add script plugin support

- Introduced a new runtime type for script plugins in `runtime.go`.
- Created script plugin templates for JavaScript, Python, and Bash in the resource directory.
- Implemented functionality to create script plugins in `wpm.go`, including handling of template files.
- Added a new `ScriptHost` to manage script plugin execution and communication via JSON-RPC.
- Enhanced the `Location` utility to manage directories for user script plugins and templates.
- Updated resource extraction to include script plugin templates.
- Implemented JSON-RPC handling in script templates for query and action methods.
This commit is contained in:
qianlifeng 2025-05-25 14:42:02 +08:00
parent a6b014823d
commit d488d98eec
No known key found for this signature in database
9 changed files with 1379 additions and 3 deletions

View File

@ -0,0 +1,362 @@
package host
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"wox/plugin"
"wox/util"
)
func init() {
host := &ScriptHost{}
plugin.AllHosts = append(plugin.AllHosts, host)
}
type ScriptHost struct {
// Script host doesn't need persistent connections like websocket hosts
}
func (s *ScriptHost) GetRuntime(ctx context.Context) plugin.Runtime {
return plugin.PLUGIN_RUNTIME_SCRIPT
}
func (s *ScriptHost) Start(ctx context.Context) error {
// Script host doesn't need to start any background processes
util.GetLogger().Info(ctx, "Script host started")
return nil
}
func (s *ScriptHost) Stop(ctx context.Context) {
// Script host doesn't need to stop any background processes
util.GetLogger().Info(ctx, "Script host stopped")
}
func (s *ScriptHost) IsStarted(ctx context.Context) bool {
// Script host is always "started" since it doesn't maintain persistent connections
return true
}
func (s *ScriptHost) LoadPlugin(ctx context.Context, metadata plugin.Metadata, pluginDirectory string) (plugin.Plugin, error) {
// For script plugins, the actual script file is in the user script plugins directory
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
scriptPath := filepath.Join(userScriptPluginDirectory, metadata.Entry)
// Check if script file exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("script file not found: %s", scriptPath)
}
// Make sure script is executable
if err := os.Chmod(scriptPath, 0755); err != nil {
util.GetLogger().Warn(ctx, fmt.Sprintf("Failed to make script executable: %s", err.Error()))
}
util.GetLogger().Info(ctx, fmt.Sprintf("Loaded script plugin: %s", metadata.Name))
return NewScriptPlugin(metadata, scriptPath), nil
}
func (s *ScriptHost) UnloadPlugin(ctx context.Context, metadata plugin.Metadata) {
// Script plugins don't need explicit unloading since they're not persistent
util.GetLogger().Info(ctx, fmt.Sprintf("Unloaded script plugin: %s", metadata.Name))
}
// ScriptPlugin represents a script-based plugin
type ScriptPlugin struct {
metadata plugin.Metadata
scriptPath string
}
func NewScriptPlugin(metadata plugin.Metadata, scriptPath string) *ScriptPlugin {
return &ScriptPlugin{
metadata: metadata,
scriptPath: scriptPath,
}
}
func (sp *ScriptPlugin) Init(ctx context.Context, initParams plugin.InitParams) {
// Script plugins don't need initialization since they're executed on-demand
util.GetLogger().Debug(ctx, fmt.Sprintf("Script plugin %s initialized", sp.metadata.Name))
}
func (sp *ScriptPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult {
// Prepare JSON-RPC request
request := map[string]interface{}{
"jsonrpc": "2.0",
"method": "query",
"params": map[string]interface{}{
"search": query.Search,
"trigger_keyword": query.TriggerKeyword,
"command": query.Command,
"raw_query": query.RawQuery,
},
"id": util.GetContextTraceId(ctx),
}
// Execute script and get results
results, err := sp.executeScript(ctx, request)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Script plugin %s query failed: %s", sp.metadata.Name, err.Error()))
return []plugin.QueryResult{}
}
return results
}
// executeScript executes the script with the given JSON-RPC request and returns the results
func (sp *ScriptPlugin) executeScript(ctx context.Context, request map[string]interface{}) ([]plugin.QueryResult, error) {
// Convert request to JSON
requestJSON, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Determine the interpreter based on file extension
interpreter, err := sp.getInterpreter()
if err != nil {
return nil, err
}
// Prepare command
var cmd *exec.Cmd
if interpreter != "" {
cmd = exec.CommandContext(ctx, interpreter, sp.scriptPath)
} else {
cmd = exec.CommandContext(ctx, sp.scriptPath)
}
// Set up stdin with the JSON-RPC request
cmd.Stdin = strings.NewReader(string(requestJSON))
// Set timeout for script execution
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd = exec.CommandContext(timeoutCtx, cmd.Args[0], cmd.Args[1:]...)
cmd.Stdin = strings.NewReader(string(requestJSON))
// Execute script
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("script execution failed: %w", err)
}
// Parse JSON-RPC response
var response map[string]interface{}
if err := json.Unmarshal(output, &response); err != nil {
return nil, fmt.Errorf("failed to parse script response: %w", err)
}
// Check for JSON-RPC error
if errorData, exists := response["error"]; exists {
return nil, fmt.Errorf("script returned error: %v", errorData)
}
// Extract results
result, exists := response["result"]
if !exists {
return []plugin.QueryResult{}, nil
}
resultMap, ok := result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
items, exists := resultMap["items"]
if !exists {
return []plugin.QueryResult{}, nil
}
itemsArray, ok := items.([]interface{})
if !ok {
return nil, fmt.Errorf("invalid items format")
}
// Convert items to QueryResult
var queryResults []plugin.QueryResult
for _, item := range itemsArray {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
queryResult := plugin.QueryResult{
Title: getStringFromMap(itemMap, "title"),
SubTitle: getStringFromMap(itemMap, "subtitle"),
Score: int64(getFloatFromMap(itemMap, "score")),
}
// Handle action if present
if actionData, exists := itemMap["action"]; exists {
if actionMap, ok := actionData.(map[string]interface{}); ok {
queryResult.Actions = []plugin.QueryResultAction{
{
Name: "Execute",
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
sp.executeAction(ctx, actionMap)
},
},
}
}
}
queryResults = append(queryResults, queryResult)
}
return queryResults, nil
}
// executeAction executes an action from a script plugin result
func (sp *ScriptPlugin) executeAction(ctx context.Context, actionData map[string]interface{}) {
// Prepare JSON-RPC request for action
request := map[string]interface{}{
"jsonrpc": "2.0",
"method": "action",
"params": map[string]interface{}{
"id": getStringFromMap(actionData, "id"),
"data": getStringFromMap(actionData, "data"),
},
"id": util.GetContextTraceId(ctx),
}
// Execute script for action
err := sp.executeScriptAction(ctx, request)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("Script plugin %s action failed: %s", sp.metadata.Name, err.Error()))
}
}
// executeScriptAction executes the script for action requests
func (sp *ScriptPlugin) executeScriptAction(ctx context.Context, request map[string]interface{}) error {
// Convert request to JSON
requestJSON, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// Determine the interpreter based on file extension
interpreter, err := sp.getInterpreter()
if err != nil {
return err
}
// Prepare command
var cmd *exec.Cmd
if interpreter != "" {
cmd = exec.CommandContext(ctx, interpreter, sp.scriptPath)
} else {
cmd = exec.CommandContext(ctx, sp.scriptPath)
}
// Set up stdin with the JSON-RPC request
cmd.Stdin = strings.NewReader(string(requestJSON))
// Set timeout for script execution
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd = exec.CommandContext(timeoutCtx, cmd.Args[0], cmd.Args[1:]...)
cmd.Stdin = strings.NewReader(string(requestJSON))
// Execute script
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("script execution failed: %w", err)
}
// Parse JSON-RPC response
var response map[string]interface{}
if err := json.Unmarshal(output, &response); err != nil {
return fmt.Errorf("failed to parse script response: %w", err)
}
// Check for JSON-RPC error
if errorData, exists := response["error"]; exists {
return fmt.Errorf("script returned error: %v", errorData)
}
// Handle action result if present
if result, exists := response["result"]; exists {
if resultMap, ok := result.(map[string]interface{}); ok {
sp.handleActionResult(ctx, resultMap)
}
}
return nil
}
// handleActionResult handles the result from an action execution
func (sp *ScriptPlugin) handleActionResult(ctx context.Context, result map[string]interface{}) {
actionType := getStringFromMap(result, "action")
switch actionType {
case "open-url":
url := getStringFromMap(result, "url")
if url != "" {
// TODO: Implement URL opening functionality
util.GetLogger().Info(ctx, fmt.Sprintf("Script plugin %s requested to open URL: %s", sp.metadata.Name, url))
}
case "notify":
message := getStringFromMap(result, "message")
if message != "" {
// TODO: Implement notification functionality
util.GetLogger().Info(ctx, fmt.Sprintf("Script plugin %s notification: %s", sp.metadata.Name, message))
}
case "copy-to-clipboard":
text := getStringFromMap(result, "text")
if text != "" {
// TODO: Implement clipboard functionality
util.GetLogger().Info(ctx, fmt.Sprintf("Script plugin %s requested to copy to clipboard: %s", sp.metadata.Name, text))
}
default:
util.GetLogger().Warn(ctx, fmt.Sprintf("Script plugin %s returned unknown action type: %s", sp.metadata.Name, actionType))
}
}
// getInterpreter determines the appropriate interpreter for the script based on file extension
func (sp *ScriptPlugin) getInterpreter() (string, error) {
ext := strings.ToLower(filepath.Ext(sp.scriptPath))
switch ext {
case ".py":
return "python3", nil
case ".js":
return "node", nil
case ".sh":
return "bash", nil
case ".rb":
return "ruby", nil
case ".pl":
return "perl", nil
case "": // No extension, assume it's executable
return "", nil
default:
return "", fmt.Errorf("unsupported script type: %s", ext)
}
}
// Helper functions to safely extract values from maps
func getStringFromMap(m map[string]interface{}, key string) string {
if value, exists := m[key]; exists {
if str, ok := value.(string); ok {
return str
}
}
return ""
}
func getFloatFromMap(m map[string]interface{}, key string) float64 {
if value, exists := m[key]; exists {
if num, ok := value.(float64); ok {
return num
}
if num, ok := value.(int); ok {
return float64(num)
}
}
return 0
}

View File

@ -8,6 +8,7 @@ import (
"math"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strings"
@ -24,6 +25,7 @@ import (
"wox/util/selection"
"github.com/Masterminds/semver/v3"
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/jinzhu/copier"
"github.com/samber/lo"
@ -47,6 +49,10 @@ type Manager struct {
aiProviders *util.HashMap[common.ProviderName, ai.Provider]
activeBrowserUrl string //active browser url before wox is activated
// Script plugin monitoring
scriptPluginWatcher *fsnotify.Watcher
scriptReloadTimers *util.HashMap[string, *time.Timer]
}
func GetPluginManager() *Manager {
@ -55,6 +61,7 @@ func GetPluginManager() *Manager {
resultCache: util.NewHashMap[string, *QueryResultCache](),
debounceQueryTimer: util.NewHashMap[string, *debounceTimer](),
aiProviders: util.NewHashMap[common.ProviderName, ai.Provider](),
scriptReloadTimers: util.NewHashMap[string, *time.Timer](),
}
logger = util.GetLogger()
})
@ -69,6 +76,11 @@ func (m *Manager) Start(ctx context.Context, ui common.UI) error {
return fmt.Errorf("failed to load plugins: %w", loadErr)
}
// Start script plugin monitoring
util.Go(ctx, "start script plugin monitoring", func() {
m.startScriptPluginMonitoring(util.NewTraceContext())
})
util.Go(ctx, "start store manager", func() {
GetStoreManager().Start(util.NewTraceContext())
})
@ -77,6 +89,11 @@ func (m *Manager) Start(ctx context.Context, ui common.UI) error {
}
func (m *Manager) Stop(ctx context.Context) {
// Stop script plugin monitoring
if m.scriptPluginWatcher != nil {
m.scriptPluginWatcher.Close()
}
for _, host := range AllHosts {
host.Stop(ctx)
}
@ -141,6 +158,15 @@ func (m *Manager) loadPlugins(ctx context.Context) error {
}
metaDataList = append(metaDataList, MetadataWithDirectory{Metadata: metadata, Directory: pluginDirectory})
}
// Load script plugins
scriptMetaDataList, err := m.loadScriptPlugins(ctx)
if err != nil {
logger.Error(ctx, fmt.Sprintf("failed to load script plugins: %s", err.Error()))
} else {
metaDataList = append(metaDataList, scriptMetaDataList...)
}
logger.Info(ctx, fmt.Sprintf("start loading user plugins, found %d user plugins", len(metaDataList)))
for _, host := range AllHosts {
@ -169,6 +195,44 @@ func (m *Manager) loadPlugins(ctx context.Context) error {
return nil
}
// loadScriptPlugins loads script plugins from the user script plugins directory
func (m *Manager) loadScriptPlugins(ctx context.Context) ([]MetadataWithDirectory, error) {
logger.Debug(ctx, "start loading script plugin metadata")
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
scriptFiles, readErr := os.ReadDir(userScriptPluginDirectory)
if readErr != nil {
return nil, fmt.Errorf("failed to read user script plugin directory: %w", readErr)
}
var metaDataList []MetadataWithDirectory
for _, entry := range scriptFiles {
if entry.Name() == ".DS_Store" || entry.Name() == "README.md" {
continue
}
if entry.IsDir() {
continue
}
scriptPath := path.Join(userScriptPluginDirectory, entry.Name())
metadata, metadataErr := m.ParseScriptMetadata(ctx, scriptPath)
if metadataErr != nil {
logger.Error(ctx, fmt.Sprintf("failed to parse script plugin metadata for %s: %s", entry.Name(), metadataErr.Error()))
continue
}
// Create a virtual directory for the script plugin
virtualDirectory := path.Join(userScriptPluginDirectory, metadata.Id)
metaDataList = append(metaDataList, MetadataWithDirectory{
Metadata: metadata,
Directory: virtualDirectory,
})
}
logger.Debug(ctx, fmt.Sprintf("found %d script plugins", len(metaDataList)))
return metaDataList, nil
}
func (m *Manager) ReloadPlugin(ctx context.Context, metadata MetadataWithDirectory) error {
logger.Info(ctx, fmt.Sprintf("start reloading dev plugin: %s", metadata.Metadata.Name))
@ -357,6 +421,275 @@ func (m *Manager) ParseMetadata(ctx context.Context, pluginDirectory string) (Me
return metadata, nil
}
// ParseScriptMetadata parses metadata from script plugin file comments
func (m *Manager) ParseScriptMetadata(ctx context.Context, scriptPath string) (Metadata, error) {
content, err := os.ReadFile(scriptPath)
if err != nil {
return Metadata{}, fmt.Errorf("failed to read script file: %w", err)
}
lines := strings.Split(string(content), "\n")
metadata := Metadata{
Runtime: string(PLUGIN_RUNTIME_SCRIPT),
Entry: filepath.Base(scriptPath),
Version: "1.0.0", // Default version
}
// Parse metadata from comments
for _, line := range lines {
line = strings.TrimSpace(line)
// Stop parsing when we reach non-comment lines (except shebang)
if !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "//") && !strings.HasPrefix(line, "#!/") {
if line != "" {
break
}
continue
}
// Remove comment markers
line = strings.TrimPrefix(line, "#")
line = strings.TrimPrefix(line, "//")
line = strings.TrimPrefix(line, "#!/usr/bin/env")
line = strings.TrimPrefix(line, "#!/bin/")
line = strings.TrimSpace(line)
// Parse @wox.xxx metadata
if strings.HasPrefix(line, "@wox.") {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimPrefix(parts[0], "@wox.")
value := strings.TrimSpace(parts[1])
switch key {
case "id":
metadata.Id = value
case "name":
metadata.Name = value
case "author":
metadata.Author = value
case "version":
metadata.Version = value
case "description":
metadata.Description = value
case "icon":
metadata.Icon = value
case "keywords":
// Split keywords by comma or space
keywords := strings.FieldsFunc(value, func(c rune) bool {
return c == ',' || c == ' '
})
for i, keyword := range keywords {
keywords[i] = strings.TrimSpace(keyword)
}
metadata.TriggerKeywords = keywords
case "minWoxVersion":
metadata.MinWoxVersion = value
}
}
}
// Validate required fields
if metadata.Id == "" {
return Metadata{}, fmt.Errorf("missing required field: @wox.id")
}
if metadata.Name == "" {
return Metadata{}, fmt.Errorf("missing required field: @wox.name")
}
if len(metadata.TriggerKeywords) == 0 {
return Metadata{}, fmt.Errorf("missing required field: @wox.keywords")
}
// Set default values
if metadata.Author == "" {
metadata.Author = "Unknown"
}
if metadata.Description == "" {
metadata.Description = "A script plugin"
}
if metadata.Icon == "" {
metadata.Icon = "📝"
}
if metadata.MinWoxVersion == "" {
metadata.MinWoxVersion = "2.0.0"
}
// Set supported OS to all platforms by default
metadata.SupportedOS = []string{"Windows", "Linux", "Macos"}
return metadata, nil
}
// startScriptPluginMonitoring starts monitoring the user script plugins directory for changes
func (m *Manager) startScriptPluginMonitoring(ctx context.Context) {
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
// Create file system watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to create script plugin watcher: %s", err.Error()))
return
}
m.scriptPluginWatcher = watcher
// Add the script plugins directory to the watcher
err = watcher.Add(userScriptPluginDirectory)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to watch script plugin directory: %s", err.Error()))
watcher.Close()
return
}
logger.Info(ctx, fmt.Sprintf("Started monitoring script plugins directory: %s", userScriptPluginDirectory))
// Start watching for events
for {
select {
case event, ok := <-watcher.Events:
if !ok {
logger.Info(ctx, "Script plugin watcher closed")
return
}
m.handleScriptPluginEvent(ctx, event)
case err, ok := <-watcher.Errors:
if !ok {
logger.Info(ctx, "Script plugin watcher error channel closed")
return
}
logger.Error(ctx, fmt.Sprintf("Script plugin watcher error: %s", err.Error()))
case <-ctx.Done():
logger.Info(ctx, "Script plugin monitoring stopped due to context cancellation")
watcher.Close()
return
}
}
}
// handleScriptPluginEvent handles file system events for script plugins
func (m *Manager) handleScriptPluginEvent(ctx context.Context, event fsnotify.Event) {
// Skip non-script files and temporary files
fileName := filepath.Base(event.Name)
if strings.HasPrefix(fileName, ".") || strings.HasSuffix(fileName, "~") || strings.HasSuffix(fileName, ".tmp") {
return
}
// Skip directories
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
return
}
logger.Info(ctx, fmt.Sprintf("Script plugin file event: %s (%s)", event.Name, event.Op))
switch event.Op {
case fsnotify.Create, fsnotify.Write:
// File created or modified - reload the plugin
m.debounceScriptPluginReload(ctx, event.Name, "file changed")
case fsnotify.Remove, fsnotify.Rename:
// File deleted or renamed - unload the plugin
m.unloadScriptPluginByPath(ctx, event.Name)
case fsnotify.Chmod:
// File permissions changed - ignore for now
logger.Debug(ctx, fmt.Sprintf("Script plugin file permissions changed: %s", event.Name))
}
}
// debounceScriptPluginReload debounces script plugin reload to avoid multiple reloads in a short time
func (m *Manager) debounceScriptPluginReload(ctx context.Context, scriptPath, reason string) {
fileName := filepath.Base(scriptPath)
// Cancel existing timer if any
if timer, exists := m.scriptReloadTimers.Load(fileName); exists {
timer.Stop()
}
// Create new timer
timer := time.AfterFunc(2*time.Second, func() {
m.reloadScriptPlugin(util.NewTraceContext(), scriptPath, reason)
m.scriptReloadTimers.Delete(fileName)
})
m.scriptReloadTimers.Store(fileName, timer)
}
// reloadScriptPlugin reloads a script plugin
func (m *Manager) reloadScriptPlugin(ctx context.Context, scriptPath, reason string) {
logger.Info(ctx, fmt.Sprintf("Reloading script plugin: %s, reason: %s", scriptPath, reason))
// Check if file still exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
logger.Warn(ctx, fmt.Sprintf("Script plugin file no longer exists: %s", scriptPath))
return
}
// Parse metadata from the script file
metadata, err := m.ParseScriptMetadata(ctx, scriptPath)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to parse script plugin metadata: %s", err.Error()))
return
}
// Find and unload existing plugin instance if any
existingInstance, exists := lo.Find(m.instances, func(instance *Instance) bool {
return instance.Metadata.Id == metadata.Id
})
if exists {
logger.Info(ctx, fmt.Sprintf("Unloading existing script plugin instance: %s", metadata.Name))
m.UnloadPlugin(ctx, existingInstance)
}
// Create metadata with directory for loading
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
virtualDirectory := path.Join(userScriptPluginDirectory, metadata.Id)
metadataWithDirectory := MetadataWithDirectory{
Metadata: metadata,
Directory: virtualDirectory,
}
// Find script plugin host
scriptHost, hostExists := lo.Find(AllHosts, func(host Host) bool {
return host.GetRuntime(ctx) == PLUGIN_RUNTIME_SCRIPT
})
if !hostExists {
logger.Error(ctx, "Script plugin host not found")
return
}
// Load the plugin
err = m.loadHostPlugin(ctx, scriptHost, metadataWithDirectory)
if err != nil {
logger.Error(ctx, fmt.Sprintf("Failed to reload script plugin: %s", err.Error()))
return
}
logger.Info(ctx, fmt.Sprintf("Successfully reloaded script plugin: %s", metadata.Name))
}
// unloadScriptPluginByPath unloads a script plugin by its file path
func (m *Manager) unloadScriptPluginByPath(ctx context.Context, scriptPath string) {
fileName := filepath.Base(scriptPath)
logger.Info(ctx, fmt.Sprintf("Unloading script plugin: %s", fileName))
// Find plugin instance by script file name
var pluginToUnload *Instance
for _, instance := range m.instances {
if instance.Metadata.Runtime == string(PLUGIN_RUNTIME_SCRIPT) && instance.Metadata.Entry == fileName {
pluginToUnload = instance
break
}
}
if pluginToUnload != nil {
logger.Info(ctx, fmt.Sprintf("Found script plugin to unload: %s", pluginToUnload.Metadata.Name))
m.UnloadPlugin(ctx, pluginToUnload)
} else {
logger.Debug(ctx, fmt.Sprintf("No script plugin found for file: %s", fileName))
}
}
func (m *Manager) GetPluginInstances() []*Instance {
return m.instances
}

View File

@ -8,11 +8,12 @@ const (
PLUGIN_RUNTIME_GO Runtime = "GO"
PLUGIN_RUNTIME_PYTHON Runtime = "PYTHON"
PLUGIN_RUNTIME_NODEJS Runtime = "NODEJS"
PLUGIN_RUNTIME_SCRIPT Runtime = "SCRIPT"
)
func IsSupportedRuntime(runtime string) bool {
runtimeUpper := strings.ToUpper(runtime)
return runtimeUpper == string(PLUGIN_RUNTIME_PYTHON) || runtimeUpper == string(PLUGIN_RUNTIME_NODEJS) || runtimeUpper == string(PLUGIN_RUNTIME_GO)
return runtimeUpper == string(PLUGIN_RUNTIME_PYTHON) || runtimeUpper == string(PLUGIN_RUNTIME_NODEJS) || runtimeUpper == string(PLUGIN_RUNTIME_GO) || runtimeUpper == string(PLUGIN_RUNTIME_SCRIPT)
}
func ConvertToRuntime(runtime string) Runtime {

View File

@ -31,6 +31,24 @@ var pluginTemplates = []pluginTemplate{
},
}
var scriptPluginTemplates = []pluginTemplate{
{
Runtime: plugin.PLUGIN_RUNTIME_SCRIPT,
Name: "JavaScript Script Plugin",
Url: "template.js",
},
{
Runtime: plugin.PLUGIN_RUNTIME_SCRIPT,
Name: "Python Script Plugin",
Url: "template.py",
},
{
Runtime: plugin.PLUGIN_RUNTIME_SCRIPT,
Name: "Bash Script Plugin",
Url: "template.sh",
},
}
type LocalPlugin struct {
Path string
}
@ -297,12 +315,16 @@ func (w *WPMPlugin) createCommand(ctx context.Context, query plugin.Query) []plu
}
var results []plugin.QueryResult
// Add regular plugin templates with group
for _, template := range pluginTemplates {
templateCopy := template // Create a copy for the closure
results = append(results, plugin.QueryResult{
Id: uuid.NewString(),
Title: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_wpm_create_plugin"), string(template.Runtime)),
SubTitle: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_wpm_plugin_name"), query.Search),
Icon: wpmIcon,
Group: "Regular Plugins",
Actions: []plugin.QueryResultAction{
{
Name: "i18n:plugin_wpm_create",
@ -310,7 +332,34 @@ func (w *WPMPlugin) createCommand(ctx context.Context, query plugin.Query) []plu
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
pluginName := query.Search
util.Go(ctx, "create plugin", func() {
w.createPlugin(ctx, template, pluginName, query)
w.createPlugin(ctx, templateCopy, pluginName, query)
})
w.api.ChangeQuery(ctx, common.PlainQuery{
QueryType: plugin.QueryTypeInput,
QueryText: fmt.Sprintf("%s create ", query.TriggerKeyword),
})
},
},
}})
}
// Add script plugin templates with group
for _, template := range scriptPluginTemplates {
templateCopy := template // Create a copy for the closure
results = append(results, plugin.QueryResult{
Id: uuid.NewString(),
Title: fmt.Sprintf("Create %s", template.Name),
SubTitle: fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_wpm_plugin_name"), query.Search),
Icon: wpmIcon,
Group: "Script Plugins",
Actions: []plugin.QueryResultAction{
{
Name: "i18n:plugin_wpm_create",
PreventHideAfterAction: true,
Action: func(ctx context.Context, actionContext plugin.ActionContext) {
pluginName := query.Search
util.Go(ctx, "create script plugin", func() {
w.createScriptPluginWithTemplate(ctx, templateCopy, pluginName, query)
})
w.api.ChangeQuery(ctx, common.PlainQuery{
QueryType: plugin.QueryTypeInput,
@ -425,7 +474,7 @@ func (w *WPMPlugin) listDevCommand(ctx context.Context) []plugin.QueryResult {
PreviewType: plugin.WoxPreviewTypeMarkdown,
PreviewData: fmt.Sprintf(`
- **Directory**: %s
- **Name**: %s
- **Name**: %s
- **Description**: %s
- **Author**: %s
- **Website**: %s
@ -693,3 +742,102 @@ func (w *WPMPlugin) reloadLocalDistPlugin(ctx context.Context, localPlugin plugi
w.api.Notify(ctx, fmt.Sprintf(i18n.GetI18nManager().TranslateWox(ctx, "plugin_wpm_reload_success"), localPlugin.Metadata.Name, reason))
return nil
}
// createScriptPluginWithTemplate creates a script plugin from a specific template
func (w *WPMPlugin) createScriptPluginWithTemplate(ctx context.Context, template pluginTemplate, pluginName string, query plugin.Query) {
w.creatingProcess = "Creating script plugin..."
// Get user script plugins directory
userScriptPluginDirectory := util.GetLocation().GetUserScriptPluginsDirectory()
// Ensure the directory exists
if err := util.GetLocation().EnsureDirectoryExist(userScriptPluginDirectory); err != nil {
w.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to create user script plugin directory: %s", err.Error()))
w.creatingProcess = fmt.Sprintf("Failed to create script plugin directory: %s", err.Error())
return
}
// Use the template file specified in the template
templateFile := template.Url // We store the template filename in the Url field
var fileExtension string
switch templateFile {
case "template.js":
fileExtension = ".js"
case "template.py":
fileExtension = ".py"
case "template.sh":
fileExtension = ".sh"
default:
fileExtension = ".js" // Default fallback
}
w.creatingProcess = "Copying template..."
// Read template from embedded resources
scriptTemplateDirectory := util.GetLocation().GetScriptPluginTemplatesDirectory()
templatePath := path.Join(scriptTemplateDirectory, templateFile)
templateContent, err := os.ReadFile(templatePath)
if err != nil {
w.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to read template file: %s", err.Error()))
w.creatingProcess = fmt.Sprintf("Failed to read template: %s", err.Error())
return
}
// Generate script file name
cleanPluginName := strings.TrimSpace(pluginName)
if cleanPluginName == "" {
cleanPluginName = "my-plugin"
}
scriptFileName := strings.ReplaceAll(strings.ToLower(cleanPluginName), " ", "-") + fileExtension
scriptFilePath := path.Join(userScriptPluginDirectory, scriptFileName)
// Check if file already exists
if _, err := os.Stat(scriptFilePath); err == nil {
w.api.Notify(ctx, fmt.Sprintf("Script plugin file already exists: %s", scriptFileName))
w.creatingProcess = ""
return
}
// Replace template variables
templateString := string(templateContent)
pluginId := strings.ReplaceAll(strings.ToLower(cleanPluginName), " ", "-")
triggerKeyword := strings.ToLower(strings.ReplaceAll(cleanPluginName, " ", ""))
if len(triggerKeyword) > 10 {
triggerKeyword = triggerKeyword[:10]
}
// Replace template placeholders
templateString = strings.ReplaceAll(templateString, "script-plugin-template", pluginId)
templateString = strings.ReplaceAll(templateString, "python-script-template", pluginId)
templateString = strings.ReplaceAll(templateString, "bash-script-template", pluginId)
templateString = strings.ReplaceAll(templateString, "Script Plugin Template", cleanPluginName)
templateString = strings.ReplaceAll(templateString, "Python Script Template", cleanPluginName)
templateString = strings.ReplaceAll(templateString, "Bash Script Template", cleanPluginName)
templateString = strings.ReplaceAll(templateString, "spt", triggerKeyword)
templateString = strings.ReplaceAll(templateString, "pst", triggerKeyword)
templateString = strings.ReplaceAll(templateString, "bst", triggerKeyword)
templateString = strings.ReplaceAll(templateString, "A template for Wox script plugins", fmt.Sprintf("A script plugin for %s", cleanPluginName))
templateString = strings.ReplaceAll(templateString, "A Python template for Wox script plugins", fmt.Sprintf("A script plugin for %s", cleanPluginName))
templateString = strings.ReplaceAll(templateString, "A Bash template for Wox script plugins", fmt.Sprintf("A script plugin for %s", cleanPluginName))
// Write the script file
err = os.WriteFile(scriptFilePath, []byte(templateString), 0755)
if err != nil {
w.api.Log(ctx, plugin.LogLevelError, fmt.Sprintf("Failed to write script file: %s", err.Error()))
w.creatingProcess = fmt.Sprintf("Failed to create script file: %s", err.Error())
return
}
w.creatingProcess = ""
w.api.Notify(ctx, fmt.Sprintf("Script plugin created successfully: %s", scriptFileName))
w.api.Log(ctx, plugin.LogLevelInfo, fmt.Sprintf("Created script plugin: %s", scriptFilePath))
// Change query to show script plugins or open the file
w.api.ChangeQuery(ctx, common.PlainQuery{
QueryType: plugin.QueryTypeInput,
QueryText: fmt.Sprintf("%s ", triggerKeyword),
})
}

View File

@ -28,6 +28,9 @@ var appIconWindows []byte
//go:embed others
var OthersFS embed.FS
//go:embed script_plugin_templates
var ScriptPluginTemplatesFS embed.FS
var embedThemes = []string{}
func Extract(ctx context.Context) error {
@ -47,6 +50,11 @@ func Extract(ctx context.Context) error {
return othersErr
}
scriptPluginTemplatesErr := extractFiles(ctx, ScriptPluginTemplatesFS, util.GetLocation().GetScriptPluginTemplatesDirectory(), "script_plugin_templates", false)
if scriptPluginTemplatesErr != nil {
return scriptPluginTemplatesErr
}
themeErr := parseThemes(ctx)
if themeErr != nil {
return themeErr

View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
// Required parameters:
// @wox.id script-plugin-template
// @wox.name Script Plugin Template
// @wox.keywords spt
// Optional parameters:
// @wox.icon 📝
// @wox.version 1.0.0
// @wox.author Wox Team
// @wox.description A template for Wox script plugins
// @wox.minWoxVersion 2.0.0
/**
* Wox Script Plugin Template
*
* This is a template for creating Wox script plugins.
* Script plugins are single-file plugins that are executed once per query.
*
* Communication with Wox is done via JSON-RPC over stdin/stdout.
*
* Available methods:
* - query: Process user queries and return results
* - action: Handle user selection of a result
*/
// Parse input from command line or stdin
let request;
try {
if (process.argv.length > 2) {
// From command line arguments
request = JSON.parse(process.argv[2]);
} else {
// From stdin
const data = require('fs').readFileSync(0, 'utf-8');
request = JSON.parse(data);
}
} catch (e) {
console.log(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32700,
message: "Parse error",
data: e.message
},
id: null
}));
process.exit(1);
}
// Validate JSON-RPC request
if (request.jsonrpc !== "2.0") {
console.log(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32600,
message: "Invalid Request",
data: "Expected JSON-RPC 2.0"
},
id: request.id || null
}));
process.exit(1);
}
// Handle different methods
switch (request.method) {
case "query":
handleQuery(request);
break;
case "action":
handleAction(request);
break;
default:
// Method not found
console.log(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32601,
message: "Method not found",
data: `Method '${request.method}' not supported`
},
id: request.id
}));
break;
}
/**
* Handle query requests
* @param {Object} request - The JSON-RPC request
*/
function handleQuery(request) {
const query = request.params.search || "";
// Generate results based on the query
const results = [
{
title: `You searched for: ${query}`,
subtitle: "This is a template result",
score: 100,
action: {
id: "example-action",
data: query
}
},
{
title: "Another result",
subtitle: "With a different action",
score: 90,
action: {
id: "open-url",
data: "https://github.com/Wox-launcher/Wox"
}
}
];
// Return results
console.log(JSON.stringify({
jsonrpc: "2.0",
result: {
items: results
},
id: request.id
}));
}
/**
* Handle action requests
* @param {Object} request - The JSON-RPC request
*/
function handleAction(request) {
const actionId = request.params.id;
const actionData = request.params.data;
// Handle different action types
switch (actionId) {
case "example-action":
// Example action that returns a message
console.log(JSON.stringify({
jsonrpc: "2.0",
result: {
action: "notify",
message: `You selected: ${actionData}`
},
id: request.id
}));
break;
case "open-url":
// Open URL action
console.log(JSON.stringify({
jsonrpc: "2.0",
result: {
action: "open-url",
url: actionData
},
id: request.id
}));
break;
default:
// Unknown action
console.log(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Unknown action",
data: `Action '${actionId}' not supported`
},
id: request.id
}));
break;
}
}

View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
# Required parameters:
# @wox.id python-script-template
# @wox.name Python Script Template
# @wox.keywords pst
# Optional parameters:
# @wox.icon 🐍
# @wox.version 1.0.0
# @wox.author Wox Team
# @wox.description A Python template for Wox script plugins
# @wox.minWoxVersion 2.0.0
"""
Wox Python Script Plugin Template
This is a template for creating Wox script plugins in Python.
Script plugins are single-file plugins that are executed once per query.
Communication with Wox is done via JSON-RPC over stdin/stdout.
Available methods:
- query: Process user queries and return results
- action: Handle user selection of a result
"""
import sys
import json
def handle_query(params, request_id):
"""
Handle query requests
Args:
params: The parameters from the JSON-RPC request
request_id: The ID of the JSON-RPC request
Returns:
A JSON-RPC response with the query results
"""
query = params.get("search", "")
# Generate results based on the query
results = [
{
"title": f"You searched for: {query}",
"subtitle": "This is a template result",
"score": 100,
"action": {
"id": "example-action",
"data": query
}
},
{
"title": "Another result",
"subtitle": "With a different action",
"score": 90,
"action": {
"id": "open-url",
"data": "https://github.com/Wox-launcher/Wox"
}
}
]
# Return results
return {
"jsonrpc": "2.0",
"result": {
"items": results
},
"id": request_id
}
def handle_action(params, request_id):
"""
Handle action requests
Args:
params: The parameters from the JSON-RPC request
request_id: The ID of the JSON-RPC request
Returns:
A JSON-RPC response with the action result
"""
action_id = params.get("id", "")
action_data = params.get("data", "")
# Handle different action types
if action_id == "example-action":
# Example action that returns a message
return {
"jsonrpc": "2.0",
"result": {
"action": "notify",
"message": f"You selected: {action_data}"
},
"id": request_id
}
elif action_id == "open-url":
# Open URL action
return {
"jsonrpc": "2.0",
"result": {
"action": "open-url",
"url": action_data
},
"id": request_id
}
else:
# Unknown action
return {
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "Unknown action",
"data": f"Action '{action_id}' not supported"
},
"id": request_id
}
def main():
"""Main entry point for the script plugin"""
# Parse input
try:
if len(sys.argv) > 1:
# From command line arguments
request = json.loads(sys.argv[1])
else:
# From stdin
request = json.loads(sys.stdin.read())
except json.JSONDecodeError as e:
# Return parse error
print(json.dumps({
"jsonrpc": "2.0",
"error": {
"code": -32700,
"message": "Parse error",
"data": str(e)
},
"id": None
}))
return 1
# Validate JSON-RPC request
if request.get("jsonrpc") != "2.0":
print(json.dumps({
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": "Invalid Request",
"data": "Expected JSON-RPC 2.0"
},
"id": request.get("id")
}))
return 1
# Handle different methods
method = request.get("method")
params = request.get("params", {})
request_id = request.get("id")
if method == "query":
response = handle_query(params, request_id)
elif method == "action":
response = handle_action(params, request_id)
else:
# Method not found
response = {
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found",
"data": f"Method '{method}' not supported"
},
"id": request_id
}
# Output response
print(json.dumps(response))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,152 @@
#!/bin/bash
# Required parameters:
# @wox.id bash-script-template
# @wox.name Bash Script Template
# @wox.keywords bst
# Optional parameters:
# @wox.icon 🐚
# @wox.version 1.0.0
# @wox.author Wox Team
# @wox.description A Bash template for Wox script plugins
# @wox.minWoxVersion 2.0.0
# Wox Bash Script Plugin Template
#
# This is a template for creating Wox script plugins in Bash.
# Script plugins are single-file plugins that are executed once per query.
#
# Communication with Wox is done via JSON-RPC over stdin/stdout.
#
# Available methods:
# - query: Process user queries and return results
# - action: Handle user selection of a result
# Read input from command line or stdin
if [ $# -gt 0 ]; then
# From command line arguments
REQUEST="$1"
else
# From stdin
REQUEST=$(cat)
fi
# Parse JSON-RPC request
# Note: This is a simple JSON parser for Bash
# For more complex JSON parsing, consider using jq if available
METHOD=$(echo "$REQUEST" | grep -o '"method"[^,}]*' | sed 's/"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
ID=$(echo "$REQUEST" | grep -o '"id"[^,}]*' | sed 's/"id"[[:space:]]*:[[:space:]]*\([^,}]*\).*/\1/')
JSONRPC=$(echo "$REQUEST" | grep -o '"jsonrpc"[^,}]*' | sed 's/"jsonrpc"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
# Extract params based on method
if [ "$METHOD" = "query" ]; then
SEARCH=$(echo "$REQUEST" | grep -o '"search"[^,}]*' | sed 's/"search"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
elif [ "$METHOD" = "action" ]; then
ACTION_ID=$(echo "$REQUEST" | grep -o '"id"[^,}]*' | sed 's/"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
ACTION_DATA=$(echo "$REQUEST" | grep -o '"data"[^,}]*' | sed 's/"data"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
fi
# Validate JSON-RPC version
if [ "$JSONRPC" != "2.0" ]; then
echo '{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request","data":"Expected JSON-RPC 2.0"},"id":null}'
exit 1
fi
# Handle different methods
case "$METHOD" in
"query")
# Handle query request
# Generate results based on the query
cat << EOF
{
"jsonrpc": "2.0",
"result": {
"items": [
{
"title": "You searched for: $SEARCH",
"subtitle": "This is a template result",
"score": 100,
"action": {
"id": "example-action",
"data": "$SEARCH"
}
},
{
"title": "System Information",
"subtitle": "Show system information",
"score": 90,
"action": {
"id": "system-info",
"data": ""
}
}
]
},
"id": $ID
}
EOF
;;
"action")
# Handle action request
case "$ACTION_ID" in
"example-action")
# Example action that returns a message
cat << EOF
{
"jsonrpc": "2.0",
"result": {
"action": "notify",
"message": "You selected: $ACTION_DATA"
},
"id": $ID
}
EOF
;;
"system-info")
# Get system information
OS=$(uname -s)
HOSTNAME=$(hostname)
UPTIME=$(uptime)
cat << EOF
{
"jsonrpc": "2.0",
"result": {
"action": "notify",
"message": "System: $OS\nHostname: $HOSTNAME\nUptime: $UPTIME"
},
"id": $ID
}
EOF
;;
*)
# Unknown action
cat << EOF
{
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "Unknown action",
"data": "Action '$ACTION_ID' not supported"
},
"id": $ID
}
EOF
;;
esac
;;
*)
# Method not found
cat << EOF
{
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found",
"data": "Method '$METHOD' not supported"
},
"id": $ID
}
EOF
;;
esac

View File

@ -103,6 +103,12 @@ func (l *Location) Init() error {
if directoryErr := l.EnsureDirectoryExist(l.GetOthersDirectory()); directoryErr != nil {
return directoryErr
}
if directoryErr := l.EnsureDirectoryExist(l.GetScriptPluginTemplatesDirectory()); directoryErr != nil {
return directoryErr
}
if directoryErr := l.EnsureDirectoryExist(l.GetUserScriptPluginsDirectory()); directoryErr != nil {
return directoryErr
}
if directoryErr := l.EnsureDirectoryExist(l.GetCacheDirectory()); directoryErr != nil {
return directoryErr
}
@ -147,6 +153,10 @@ func (l *Location) GetPluginDirectory() string {
return path.Join(l.userDataDirectory, "plugins")
}
func (l *Location) GetUserScriptPluginsDirectory() string {
return path.Join(l.GetPluginDirectory(), "scripts")
}
func (l *Location) GetThemeDirectory() string {
return path.Join(l.userDataDirectory, "themes")
}
@ -183,6 +193,10 @@ func (l *Location) GetOthersDirectory() string {
return path.Join(l.woxDataDirectory, "others")
}
func (l *Location) GetScriptPluginTemplatesDirectory() string {
return path.Join(l.woxDataDirectory, "script_plugin_templates")
}
func (l *Location) GetCacheDirectory() string {
return path.Join(l.woxDataDirectory, "cache")
}