feat(setting): add custom Python and Node.js path configuration, fix #4220

* Introduced settings for custom executable paths for Python and Node.js.
* Implemented validation for the provided paths to ensure they are executable.
* Updated UI to allow users to browse and set custom paths.
* Added corresponding translations for new UI elements in English and Chinese.
This commit is contained in:
qianlifeng 2025-07-09 19:54:01 +08:00
parent ea7fe65a0e
commit 4750fc92ce
No known key found for this signature in database
13 changed files with 383 additions and 4 deletions

View File

@ -6,6 +6,7 @@ import (
"path" "path"
"strings" "strings"
"wox/plugin" "wox/plugin"
"wox/setting"
"wox/util" "wox/util"
"wox/util/shell" "wox/util/shell"
@ -37,6 +38,17 @@ func (n *NodejsHost) Start(ctx context.Context) error {
func (n *NodejsHost) findNodejsPath(ctx context.Context) string { func (n *NodejsHost) findNodejsPath(ctx context.Context) string {
util.GetLogger().Debug(ctx, "start finding nodejs path") 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{ var possibleNodejsPaths = []string{
"/opt/homebrew/bin/node", "/opt/homebrew/bin/node",
"/usr/local/bin/node", "/usr/local/bin/node",

View File

@ -6,6 +6,7 @@ import (
"path" "path"
"strings" "strings"
"wox/plugin" "wox/plugin"
"wox/setting"
"wox/util" "wox/util"
"wox/util/shell" "wox/util/shell"
@ -37,6 +38,17 @@ func (n *PythonHost) Start(ctx context.Context) error {
func (n *PythonHost) findPythonPath(ctx context.Context) string { func (n *PythonHost) findPythonPath(ctx context.Context) string {
util.GetLogger().Debug(ctx, "start finding python path") 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{ var possiblePythonPaths = []string{
"/opt/homebrew/bin/python3", "/opt/homebrew/bin/python3",
"/usr/local/bin/python3", "/usr/local/bin/python3",

View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"wox/plugin" "wox/plugin"
"wox/setting"
"wox/util" "wox/util"
"wox/util/shell" "wox/util/shell"
) )
@ -201,7 +202,7 @@ func (sp *ScriptPlugin) executeScriptRaw(ctx context.Context, request map[string
} }
// Determine the interpreter based on file extension // Determine the interpreter based on file extension
interpreter, err := sp.getInterpreter() interpreter, err := sp.getInterpreter(ctx)
if err != nil { if err != nil {
return nil, err 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 // 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)) ext := strings.ToLower(filepath.Ext(sp.scriptPath))
switch ext { switch ext {
case ".py": 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 return "python3", nil
case ".js": 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 return "node", nil
case ".sh": case ".sh":
return "bash", nil return "bash", nil

View File

@ -58,6 +58,18 @@
"ui_plugins": "Plugins", "ui_plugins": "Plugins",
"ui_store_plugins": "Store Plugins", "ui_store_plugins": "Store Plugins",
"ui_installed_plugins": "Installed 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_themes": "Themes",
"ui_store_themes": "Store Themes", "ui_store_themes": "Store Themes",
"ui_installed_themes": "Installed Themes", "ui_installed_themes": "Installed Themes",

View File

@ -58,6 +58,18 @@
"ui_plugins": "插件", "ui_plugins": "插件",
"ui_store_plugins": "插件商店", "ui_store_plugins": "插件商店",
"ui_installed_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_themes": "主题",
"ui_store_themes": "主题商店", "ui_store_themes": "主题商店",
"ui_installed_themes": "已安装主题", "ui_installed_themes": "已安装主题",

View File

@ -252,6 +252,10 @@ func (m *Manager) UpdateWoxSetting(ctx context.Context, key, value string) error
return parseErr return parseErr
} }
m.woxSetting.MaxResultCount = maxResultCount m.woxSetting.MaxResultCount = maxResultCount
} else if key == "CustomPythonPath" {
m.woxSetting.CustomPythonPath.Set(value)
} else if key == "CustomNodejsPath" {
m.woxSetting.CustomNodejsPath.Set(value)
} else { } else {
return fmt.Errorf("unknown key: %s", key) 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, "LastQueryMode", &woxSetting.LastQueryMode)
m.extractStringFieldSafely(rawData, "ThemeId", &woxSetting.ThemeId) 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 // Sanitize the loaded values
m.sanitizeWoxSetting(&woxSetting, defaultSetting) 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 // sanitizeWoxSetting ensures all values are within acceptable ranges
func (m *Manager) sanitizeWoxSetting(setting *WoxSetting, defaultSetting WoxSetting) { func (m *Manager) sanitizeWoxSetting(setting *WoxSetting, defaultSetting WoxSetting) {
// Sanitize AppWidth // Sanitize AppWidth

View File

@ -24,8 +24,10 @@ type WoxSetting struct {
LastQueryMode LastQueryMode LastQueryMode LastQueryMode
ShowPosition PositionType ShowPosition PositionType
AIProviders []AIProvider AIProviders []AIProvider
EnableAutoBackup bool // Enable automatic data backup EnableAutoBackup bool // Enable automatic data backup
EnableAutoUpdate bool // Enable automatic update check and download 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 // HTTP proxy settings
HttpProxyEnabled PlatformSettingValue[bool] HttpProxyEnabled PlatformSettingValue[bool]
@ -127,6 +129,16 @@ func GetDefaultWoxSetting(ctx context.Context) WoxSetting {
MacValue: "", MacValue: "",
LinuxValue: "", LinuxValue: "",
}, },
CustomPythonPath: PlatformSettingValue[string]{
WinValue: "",
MacValue: "",
LinuxValue: "",
},
CustomNodejsPath: PlatformSettingValue[string]{
WinValue: "",
MacValue: "",
LinuxValue: "",
},
EnableAutoBackup: true, EnableAutoBackup: true,
EnableAutoUpdate: true, EnableAutoUpdate: true,
} }

View File

@ -24,6 +24,8 @@ type WoxSettingDto struct {
ShowPosition setting.PositionType ShowPosition setting.PositionType
EnableAutoBackup bool EnableAutoBackup bool
EnableAutoUpdate bool EnableAutoUpdate bool
CustomPythonPath string
CustomNodejsPath string
// UI related // UI related
AppWidth int AppWidth int

View File

@ -500,6 +500,8 @@ func handleSettingWox(w http.ResponseWriter, r *http.Request) {
settingDto.QueryHotkeys = woxSetting.QueryHotkeys.Get() settingDto.QueryHotkeys = woxSetting.QueryHotkeys.Get()
settingDto.HttpProxyEnabled = woxSetting.HttpProxyEnabled.Get() settingDto.HttpProxyEnabled = woxSetting.HttpProxyEnabled.Get()
settingDto.HttpProxyUrl = woxSetting.HttpProxyUrl.Get() settingDto.HttpProxyUrl = woxSetting.HttpProxyUrl.Get()
settingDto.CustomPythonPath = woxSetting.CustomPythonPath.Get()
settingDto.CustomNodejsPath = woxSetting.CustomNodejsPath.Get()
writeSuccessResponse(w, settingDto) writeSuccessResponse(w, settingDto)
} }

View File

@ -22,6 +22,8 @@ class WoxSetting {
late String httpProxyUrl; late String httpProxyUrl;
late bool enableAutoBackup; late bool enableAutoBackup;
late bool enableAutoUpdate; late bool enableAutoUpdate;
late String customPythonPath;
late String customNodejsPath;
WoxSetting({ WoxSetting({
required this.enableAutostart, required this.enableAutostart,
@ -45,6 +47,8 @@ class WoxSetting {
required this.httpProxyUrl, required this.httpProxyUrl,
required this.enableAutoBackup, required this.enableAutoBackup,
required this.enableAutoUpdate, required this.enableAutoUpdate,
required this.customPythonPath,
required this.customNodejsPath,
}); });
WoxSetting.fromJson(Map<String, dynamic> json) { WoxSetting.fromJson(Map<String, dynamic> json) {
@ -95,6 +99,8 @@ class WoxSetting {
httpProxyUrl = json['HttpProxyUrl'] ?? ''; httpProxyUrl = json['HttpProxyUrl'] ?? '';
enableAutoBackup = json['EnableAutoBackup'] ?? false; enableAutoBackup = json['EnableAutoBackup'] ?? false;
enableAutoUpdate = json['EnableAutoUpdate'] ?? true; enableAutoUpdate = json['EnableAutoUpdate'] ?? true;
customPythonPath = json['CustomPythonPath'] ?? '';
customNodejsPath = json['CustomNodejsPath'] ?? '';
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -120,6 +126,8 @@ class WoxSetting {
data['HttpProxyUrl'] = httpProxyUrl; data['HttpProxyUrl'] = httpProxyUrl;
data['EnableAutoBackup'] = enableAutoBackup; data['EnableAutoBackup'] = enableAutoBackup;
data['EnableAutoUpdate'] = enableAutoUpdate; data['EnableAutoUpdate'] = enableAutoUpdate;
data['CustomPythonPath'] = customPythonPath;
data['CustomNodejsPath'] = customNodejsPath;
return data; return data;
} }
} }

View File

@ -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<void> 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<void> 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();
}),
],
),
),
]);
});
}
}

View File

@ -15,6 +15,7 @@ import 'package:wox/utils/wox_theme_util.dart';
import 'wox_setting_plugin_view.dart'; import 'wox_setting_plugin_view.dart';
import 'wox_setting_general_view.dart'; import 'wox_setting_general_view.dart';
import 'wox_setting_network_view.dart'; import 'wox_setting_network_view.dart';
import 'wox_setting_runtime_view.dart';
class WoxSettingView extends GetView<WoxSettingController> { class WoxSettingView extends GetView<WoxSettingController> {
const WoxSettingView({super.key}); const WoxSettingView({super.key});
@ -118,6 +119,11 @@ class WoxSettingView extends GetView<WoxSettingController> {
await controller.switchToPluginList(false); await controller.switchToPluginList(false);
}, },
), ),
PaneItem(
icon: const Icon(FluentIcons.code),
title: Text(controller.tr('ui_runtime_settings')),
body: WoxSettingRuntimeView(),
),
], ],
), ),
PaneItemExpander( PaneItemExpander(

View File

@ -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 []; return [];
} }
} }