diff --git a/wox.core/plugin/host/host_nodejs.go b/wox.core/plugin/host/host_nodejs.go index f5e93bc1..b3be7f01 100644 --- a/wox.core/plugin/host/host_nodejs.go +++ b/wox.core/plugin/host/host_nodejs.go @@ -6,6 +6,7 @@ import ( "path" "strings" "wox/plugin" + "wox/setting" "wox/util" "wox/util/shell" @@ -37,6 +38,17 @@ func (n *NodejsHost) Start(ctx context.Context) error { func (n *NodejsHost) findNodejsPath(ctx context.Context) string { util.GetLogger().Debug(ctx, "start finding nodejs path") + // Check if user has configured a custom Node.js path + customPath := setting.GetSettingManager().GetWoxSetting(ctx).CustomNodejsPath.Get() + if customPath != "" { + if util.IsFileExists(customPath) { + util.GetLogger().Info(ctx, fmt.Sprintf("using custom nodejs path: %s", customPath)) + return customPath + } else { + util.GetLogger().Warn(ctx, fmt.Sprintf("custom nodejs path not found, falling back to auto-detection: %s", customPath)) + } + } + var possibleNodejsPaths = []string{ "/opt/homebrew/bin/node", "/usr/local/bin/node", diff --git a/wox.core/plugin/host/host_python.go b/wox.core/plugin/host/host_python.go index 79592e26..a1f29141 100644 --- a/wox.core/plugin/host/host_python.go +++ b/wox.core/plugin/host/host_python.go @@ -6,6 +6,7 @@ import ( "path" "strings" "wox/plugin" + "wox/setting" "wox/util" "wox/util/shell" @@ -37,6 +38,17 @@ func (n *PythonHost) Start(ctx context.Context) error { func (n *PythonHost) findPythonPath(ctx context.Context) string { util.GetLogger().Debug(ctx, "start finding python path") + // Check if user has configured a custom Python path + customPath := setting.GetSettingManager().GetWoxSetting(ctx).CustomPythonPath.Get() + if customPath != "" { + if util.IsFileExists(customPath) { + util.GetLogger().Info(ctx, fmt.Sprintf("using custom python path: %s", customPath)) + return customPath + } else { + util.GetLogger().Warn(ctx, fmt.Sprintf("custom python path not found, falling back to auto-detection: %s", customPath)) + } + } + var possiblePythonPaths = []string{ "/opt/homebrew/bin/python3", "/usr/local/bin/python3", diff --git a/wox.core/plugin/host/host_script.go b/wox.core/plugin/host/host_script.go index 9821b956..dae4e681 100644 --- a/wox.core/plugin/host/host_script.go +++ b/wox.core/plugin/host/host_script.go @@ -10,6 +10,7 @@ import ( "strings" "time" "wox/plugin" + "wox/setting" "wox/util" "wox/util/shell" ) @@ -201,7 +202,7 @@ func (sp *ScriptPlugin) executeScriptRaw(ctx context.Context, request map[string } // Determine the interpreter based on file extension - interpreter, err := sp.getInterpreter() + interpreter, err := sp.getInterpreter(ctx) if err != nil { return nil, err } @@ -307,13 +308,23 @@ func (sp *ScriptPlugin) handleActionResult(ctx context.Context, result map[strin } // getInterpreter determines the appropriate interpreter for the script based on file extension -func (sp *ScriptPlugin) getInterpreter() (string, error) { +func (sp *ScriptPlugin) getInterpreter(ctx context.Context) (string, error) { ext := strings.ToLower(filepath.Ext(sp.scriptPath)) switch ext { case ".py": + // Check if user has configured a custom Python path + customPath := setting.GetSettingManager().GetWoxSetting(ctx).CustomPythonPath.Get() + if customPath != "" && util.IsFileExists(customPath) { + return customPath, nil + } return "python3", nil case ".js": + // Check if user has configured a custom Node.js path + customPath := setting.GetSettingManager().GetWoxSetting(ctx).CustomNodejsPath.Get() + if customPath != "" && util.IsFileExists(customPath) { + return customPath, nil + } return "node", nil case ".sh": return "bash", nil diff --git a/wox.core/resource/lang/en_US.json b/wox.core/resource/lang/en_US.json index dc17da3a..dd09920f 100644 --- a/wox.core/resource/lang/en_US.json +++ b/wox.core/resource/lang/en_US.json @@ -58,6 +58,18 @@ "ui_plugins": "Plugins", "ui_store_plugins": "Store Plugins", "ui_installed_plugins": "Installed Plugins", + "ui_runtime_settings": "Runtime Settings", + "ui_runtime_python_path": "Python Path", + "ui_runtime_python_path_tips": "Custom Python executable path. Leave empty to use auto-detection.", + "ui_runtime_python_path_placeholder": "e.g., /usr/local/bin/python3", + "ui_runtime_nodejs_path": "Node.js Path", + "ui_runtime_nodejs_path_tips": "Custom Node.js executable path. Leave empty to use auto-detection.", + "ui_runtime_nodejs_path_placeholder": "e.g., /usr/local/bin/node", + "ui_runtime_browse": "Browse", + "ui_runtime_clear": "Clear", + "ui_runtime_validating": "Validating...", + "ui_runtime_validation_failed": "Invalid executable", + "ui_runtime_validation_error": "Validation error", "ui_themes": "Themes", "ui_store_themes": "Store Themes", "ui_installed_themes": "Installed Themes", diff --git a/wox.core/resource/lang/zh_CN.json b/wox.core/resource/lang/zh_CN.json index 1eb08511..90e27526 100644 --- a/wox.core/resource/lang/zh_CN.json +++ b/wox.core/resource/lang/zh_CN.json @@ -58,6 +58,18 @@ "ui_plugins": "插件", "ui_store_plugins": "插件商店", "ui_installed_plugins": "已安装插件", + "ui_runtime_settings": "运行时设置", + "ui_runtime_python_path": "Python 路径", + "ui_runtime_python_path_tips": "自定义 Python 可执行文件路径。留空则使用自动检测。", + "ui_runtime_python_path_placeholder": "例如:/usr/local/bin/python3", + "ui_runtime_nodejs_path": "Node.js 路径", + "ui_runtime_nodejs_path_tips": "自定义 Node.js 可执行文件路径。留空则使用自动检测。", + "ui_runtime_nodejs_path_placeholder": "例如:/usr/local/bin/node", + "ui_runtime_browse": "浏览", + "ui_runtime_clear": "清除", + "ui_runtime_validating": "正在验证...", + "ui_runtime_validation_failed": "无效的可执行文件", + "ui_runtime_validation_error": "验证错误", "ui_themes": "主题", "ui_store_themes": "主题商店", "ui_installed_themes": "已安装主题", diff --git a/wox.core/setting/manager.go b/wox.core/setting/manager.go index c3de6d39..b0fae277 100644 --- a/wox.core/setting/manager.go +++ b/wox.core/setting/manager.go @@ -252,6 +252,10 @@ func (m *Manager) UpdateWoxSetting(ctx context.Context, key, value string) error return parseErr } m.woxSetting.MaxResultCount = maxResultCount + } else if key == "CustomPythonPath" { + m.woxSetting.CustomPythonPath.Set(value) + } else if key == "CustomNodejsPath" { + m.woxSetting.CustomNodejsPath.Set(value) } else { return fmt.Errorf("unknown key: %s", key) } @@ -486,6 +490,10 @@ func (m *Manager) tryPartialLoad(settingPath string, defaultSetting WoxSetting) m.extractStringFieldSafely(rawData, "LastQueryMode", &woxSetting.LastQueryMode) m.extractStringFieldSafely(rawData, "ThemeId", &woxSetting.ThemeId) + // Extract platform-specific string fields + m.extractPlatformStringFieldSafely(rawData, "CustomPythonPath", &woxSetting.CustomPythonPath) + m.extractPlatformStringFieldSafely(rawData, "CustomNodejsPath", &woxSetting.CustomNodejsPath) + // Sanitize the loaded values m.sanitizeWoxSetting(&woxSetting, defaultSetting) @@ -524,6 +532,29 @@ func (m *Manager) extractStringFieldSafely(rawData map[string]interface{}, field } } +// extractPlatformStringFieldSafely safely extracts a platform-specific string field from raw JSON data +func (m *Manager) extractPlatformStringFieldSafely(rawData map[string]interface{}, fieldName string, target *PlatformSettingValue[string]) { + if value, exists := rawData[fieldName]; exists { + if platformValue, ok := value.(map[string]interface{}); ok { + if winVal, exists := platformValue["WinValue"]; exists { + if strVal, ok := winVal.(string); ok { + target.WinValue = strVal + } + } + if macVal, exists := platformValue["MacValue"]; exists { + if strVal, ok := macVal.(string); ok { + target.MacValue = strVal + } + } + if linuxVal, exists := platformValue["LinuxValue"]; exists { + if strVal, ok := linuxVal.(string); ok { + target.LinuxValue = strVal + } + } + } + } +} + // sanitizeWoxSetting ensures all values are within acceptable ranges func (m *Manager) sanitizeWoxSetting(setting *WoxSetting, defaultSetting WoxSetting) { // Sanitize AppWidth diff --git a/wox.core/setting/wox_setting.go b/wox.core/setting/wox_setting.go index 5d9a2199..bdbe719b 100644 --- a/wox.core/setting/wox_setting.go +++ b/wox.core/setting/wox_setting.go @@ -24,8 +24,10 @@ type WoxSetting struct { LastQueryMode LastQueryMode ShowPosition PositionType AIProviders []AIProvider - EnableAutoBackup bool // Enable automatic data backup - EnableAutoUpdate bool // Enable automatic update check and download + EnableAutoBackup bool // Enable automatic data backup + EnableAutoUpdate bool // Enable automatic update check and download + CustomPythonPath PlatformSettingValue[string] // Custom Python executable path + CustomNodejsPath PlatformSettingValue[string] // Custom Node.js executable path // HTTP proxy settings HttpProxyEnabled PlatformSettingValue[bool] @@ -127,6 +129,16 @@ func GetDefaultWoxSetting(ctx context.Context) WoxSetting { MacValue: "", LinuxValue: "", }, + CustomPythonPath: PlatformSettingValue[string]{ + WinValue: "", + MacValue: "", + LinuxValue: "", + }, + CustomNodejsPath: PlatformSettingValue[string]{ + WinValue: "", + MacValue: "", + LinuxValue: "", + }, EnableAutoBackup: true, EnableAutoUpdate: true, } diff --git a/wox.core/ui/dto/setting_dto.go b/wox.core/ui/dto/setting_dto.go index 8182ba41..ebc3eebd 100644 --- a/wox.core/ui/dto/setting_dto.go +++ b/wox.core/ui/dto/setting_dto.go @@ -24,6 +24,8 @@ type WoxSettingDto struct { ShowPosition setting.PositionType EnableAutoBackup bool EnableAutoUpdate bool + CustomPythonPath string + CustomNodejsPath string // UI related AppWidth int diff --git a/wox.core/ui/router.go b/wox.core/ui/router.go index 86e2bcc6..a0dfdc7b 100644 --- a/wox.core/ui/router.go +++ b/wox.core/ui/router.go @@ -500,6 +500,8 @@ func handleSettingWox(w http.ResponseWriter, r *http.Request) { settingDto.QueryHotkeys = woxSetting.QueryHotkeys.Get() settingDto.HttpProxyEnabled = woxSetting.HttpProxyEnabled.Get() settingDto.HttpProxyUrl = woxSetting.HttpProxyUrl.Get() + settingDto.CustomPythonPath = woxSetting.CustomPythonPath.Get() + settingDto.CustomNodejsPath = woxSetting.CustomNodejsPath.Get() writeSuccessResponse(w, settingDto) } diff --git a/wox.ui.flutter/wox/lib/entity/wox_setting.dart b/wox.ui.flutter/wox/lib/entity/wox_setting.dart index 155b1bd8..330ef794 100644 --- a/wox.ui.flutter/wox/lib/entity/wox_setting.dart +++ b/wox.ui.flutter/wox/lib/entity/wox_setting.dart @@ -22,6 +22,8 @@ class WoxSetting { late String httpProxyUrl; late bool enableAutoBackup; late bool enableAutoUpdate; + late String customPythonPath; + late String customNodejsPath; WoxSetting({ required this.enableAutostart, @@ -45,6 +47,8 @@ class WoxSetting { required this.httpProxyUrl, required this.enableAutoBackup, required this.enableAutoUpdate, + required this.customPythonPath, + required this.customNodejsPath, }); WoxSetting.fromJson(Map json) { @@ -95,6 +99,8 @@ class WoxSetting { httpProxyUrl = json['HttpProxyUrl'] ?? ''; enableAutoBackup = json['EnableAutoBackup'] ?? false; enableAutoUpdate = json['EnableAutoUpdate'] ?? true; + customPythonPath = json['CustomPythonPath'] ?? ''; + customNodejsPath = json['CustomNodejsPath'] ?? ''; } Map toJson() { @@ -120,6 +126,8 @@ class WoxSetting { data['HttpProxyUrl'] = httpProxyUrl; data['EnableAutoBackup'] = enableAutoBackup; data['EnableAutoUpdate'] = enableAutoUpdate; + data['CustomPythonPath'] = customPythonPath; + data['CustomNodejsPath'] = customNodejsPath; return data; } } diff --git a/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_runtime_view.dart b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_runtime_view.dart new file mode 100644 index 00000000..6f8a8d7f --- /dev/null +++ b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_runtime_view.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:get/get.dart'; +import 'package:uuid/v4.dart'; +import 'package:wox/modules/setting/views/wox_setting_base.dart'; +import 'package:wox/utils/picker.dart'; + +class WoxSettingRuntimeView extends WoxSettingBaseView { + WoxSettingRuntimeView({super.key}); + + // Validation states + final RxString pythonValidationMessage = ''.obs; + final RxString nodejsValidationMessage = ''.obs; + final RxBool isPythonValidating = false.obs; + final RxBool isNodejsValidating = false.obs; + + // Text controllers for immediate updates + late final TextEditingController pythonController; + late final TextEditingController nodejsController; + + // Debounce timers for validation + Timer? _pythonValidationTimer; + Timer? _nodejsValidationTimer; + + // Validation methods + Future validatePythonPath(String path) async { + if (path.isEmpty) { + pythonValidationMessage.value = ''; + return; + } + + isPythonValidating.value = true; + try { + final result = await Process.run(path, ['--version']); + if (result.exitCode == 0) { + final version = result.stdout.toString().trim(); + pythonValidationMessage.value = '✓ $version'; + } else { + pythonValidationMessage.value = '✗ ${controller.tr("ui_runtime_validation_failed")}'; + } + } catch (e) { + pythonValidationMessage.value = '✗ ${controller.tr("ui_runtime_validation_error")}: ${e.toString()}'; + } finally { + isPythonValidating.value = false; + } + } + + Future validateNodejsPath(String path) async { + if (path.isEmpty) { + nodejsValidationMessage.value = ''; + return; + } + + isNodejsValidating.value = true; + try { + final result = await Process.run(path, ['-v']); + if (result.exitCode == 0) { + final version = result.stdout.toString().trim(); + nodejsValidationMessage.value = '✓ $version'; + } else { + nodejsValidationMessage.value = '✗ ${controller.tr("ui_runtime_validation_failed")}'; + } + } catch (e) { + nodejsValidationMessage.value = '✗ ${controller.tr("ui_runtime_validation_error")}: ${e.toString()}'; + } finally { + isNodejsValidating.value = false; + } + } + + void updatePythonPath(String value) { + controller.updateConfig("CustomPythonPath", value); + + // Cancel previous timer + _pythonValidationTimer?.cancel(); + + // Start new timer for debounced validation + _pythonValidationTimer = Timer(const Duration(milliseconds: 500), () { + validatePythonPath(value); + }); + } + + void updateNodejsPath(String value) { + controller.updateConfig("CustomNodejsPath", value); + + // Cancel previous timer + _nodejsValidationTimer?.cancel(); + + // Start new timer for debounced validation + _nodejsValidationTimer = Timer(const Duration(milliseconds: 500), () { + validateNodejsPath(value); + }); + } + + void dispose() { + _pythonValidationTimer?.cancel(); + _nodejsValidationTimer?.cancel(); + pythonController.dispose(); + nodejsController.dispose(); + } + + @override + Widget build(BuildContext context) { + // Initialize controllers with current values + pythonController = TextEditingController(text: controller.woxSetting.value.customPythonPath); + nodejsController = TextEditingController(text: controller.woxSetting.value.customNodejsPath); + + // Initial validation + if (pythonController.text.isNotEmpty) { + validatePythonPath(pythonController.text); + } + if (nodejsController.text.isNotEmpty) { + validateNodejsPath(nodejsController.text); + } + return Obx(() { + return form(children: [ + formField( + label: controller.tr("ui_runtime_python_path"), + tips: controller.tr("ui_runtime_python_path_tips"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextBox( + controller: pythonController, + placeholder: controller.tr("ui_runtime_python_path_placeholder"), + onChanged: (value) { + updatePythonPath(value); + }, + ), + ), + const SizedBox(width: 10), + Button( + child: Text(controller.tr("ui_runtime_browse")), + onPressed: () async { + final result = await FileSelector.pick( + const UuidV4().generate(), + FileSelectorParams(isDirectory: false), + ); + if (result.isNotEmpty) { + pythonController.text = result.first; + updatePythonPath(result.first); + } + }, + ), + const SizedBox(width: 10), + Button( + child: Text(controller.tr("ui_runtime_clear")), + onPressed: () { + pythonController.clear(); + updatePythonPath(""); + }, + ), + ], + ), + const SizedBox(height: 5), + Obx(() { + if (isPythonValidating.value) { + return Row( + children: [ + const SizedBox(width: 16, height: 16, child: ProgressRing()), + const SizedBox(width: 8), + Text(controller.tr("ui_runtime_validating")), + ], + ); + } else if (pythonValidationMessage.value.isNotEmpty) { + return Text( + pythonValidationMessage.value, + style: TextStyle( + color: pythonValidationMessage.value.startsWith('✓') ? Colors.green : Colors.red, + fontSize: 12, + ), + ); + } + return const SizedBox.shrink(); + }), + ], + ), + ), + formField( + label: controller.tr("ui_runtime_nodejs_path"), + tips: controller.tr("ui_runtime_nodejs_path_tips"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextBox( + controller: nodejsController, + placeholder: controller.tr("ui_runtime_nodejs_path_placeholder"), + onChanged: (value) { + updateNodejsPath(value); + }, + ), + ), + const SizedBox(width: 10), + Button( + child: Text(controller.tr("ui_runtime_browse")), + onPressed: () async { + final result = await FileSelector.pick( + const UuidV4().generate(), + FileSelectorParams(isDirectory: false), + ); + if (result.isNotEmpty) { + nodejsController.text = result.first; + updateNodejsPath(result.first); + } + }, + ), + const SizedBox(width: 10), + Button( + child: Text(controller.tr("ui_runtime_clear")), + onPressed: () { + nodejsController.clear(); + updateNodejsPath(""); + }, + ), + ], + ), + const SizedBox(height: 5), + Obx(() { + if (isNodejsValidating.value) { + return Row( + children: [ + const SizedBox(width: 16, height: 16, child: ProgressRing()), + const SizedBox(width: 8), + Text(controller.tr("ui_runtime_validating")), + ], + ); + } else if (nodejsValidationMessage.value.isNotEmpty) { + return Text( + nodejsValidationMessage.value, + style: TextStyle( + color: nodejsValidationMessage.value.startsWith('✓') ? Colors.green : Colors.red, + fontSize: 12, + ), + ); + } + return const SizedBox.shrink(); + }), + ], + ), + ), + ]); + }); + } +} diff --git a/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_view.dart b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_view.dart index 9d6a82f7..6a380859 100644 --- a/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_view.dart +++ b/wox.ui.flutter/wox/lib/modules/setting/views/wox_setting_view.dart @@ -15,6 +15,7 @@ import 'package:wox/utils/wox_theme_util.dart'; import 'wox_setting_plugin_view.dart'; import 'wox_setting_general_view.dart'; import 'wox_setting_network_view.dart'; +import 'wox_setting_runtime_view.dart'; class WoxSettingView extends GetView { const WoxSettingView({super.key}); @@ -118,6 +119,11 @@ class WoxSettingView extends GetView { await controller.switchToPluginList(false); }, ), + PaneItem( + icon: const Icon(FluentIcons.code), + title: Text(controller.tr('ui_runtime_settings')), + body: WoxSettingRuntimeView(), + ), ], ), PaneItemExpander( diff --git a/wox.ui.flutter/wox/lib/utils/picker.dart b/wox.ui.flutter/wox/lib/utils/picker.dart index 408fc887..317ebf13 100644 --- a/wox.ui.flutter/wox/lib/utils/picker.dart +++ b/wox.ui.flutter/wox/lib/utils/picker.dart @@ -19,6 +19,15 @@ class FileSelector { } } + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + ); + + if (result != null && result.files.isNotEmpty) { + return result.files.map((e) => e.path ?? "").toList(); + } + return []; } }