mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 10:00:04 +08:00
feat: Hermes Agent 多引擎架构核心代码
- 新增 src/engines/hermes/ 完整引擎(仪表盘/服务管理/模型配置/Agent管理/对话) - 新增 src/lib/engine-manager.js 引擎管理器(切换/检测/状态) - 新增 src-tauri/src/commands/hermes.rs 后端命令(Gateway控制/配置读写/Agent Run SSE) - sidebar 引擎切换器 UI - i18n 新增 engine 模块(中/英/繁体) - 多安装清理工具(gateway-ownership.js) - 晴辰助手文件访问开关 - Hermes 对话工具调用可视化、SSE 流式输出 - Cargo.lock / dev-api.js 同步更新
This commit is contained in:
69
src-tauri/Cargo.lock
generated
69
src-tauri/Cargo.lock
generated
@@ -351,12 +351,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
@@ -364,6 +365,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
@@ -891,6 +893,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1898,8 +1911,15 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -2405,7 +2425,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2944,6 +2964,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -3119,6 +3148,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -3553,7 +3595,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -3756,6 +3798,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -5440,6 +5493,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -1339,10 +1339,8 @@ pub fn calibrate_openclaw_config(mode: String) -> Result<Value, String> {
|
||||
///
|
||||
/// Issue #127: 修复配置合并时丢失 browser.* 等合法字段的问题
|
||||
///
|
||||
/// 保留的字段:
|
||||
/// - `browser.*` - OpenClaw browser profiles
|
||||
/// - `agents.list` - OpenClaw agent list
|
||||
/// - 其他 OpenClaw schema 定义的字段
|
||||
/// 策略:对所有顶级 Object 类型字段做浅合并(新值覆盖旧值,旧值中新配置没有的字段保留)。
|
||||
/// 这样用户通过 CLI / 手动编辑添加的自定义子字段不会被前端的部分配置所覆盖掉。
|
||||
///
|
||||
/// 清理的字段:
|
||||
/// - UI 专属字段(通过 strip_ui_fields 处理)
|
||||
@@ -1354,27 +1352,22 @@ fn merge_configs_preserving_fields(existing: &Value, new: &Value) -> Value {
|
||||
let mut merged = existing_obj.clone();
|
||||
|
||||
for (key, new_value) in new_obj {
|
||||
if key == "browser" || key == "agents" {
|
||||
// 保留现有配置中的 browser 和 agents
|
||||
// 如果新配置有对应的值且是对象,进行深度合并
|
||||
if let Some(existing_value) = existing_obj.get(key) {
|
||||
if let (Value::Object(existing_sub), Value::Object(new_sub)) =
|
||||
(existing_value, new_value)
|
||||
{
|
||||
let mut sub_merged = existing_sub.clone();
|
||||
for (sub_key, sub_value) in new_sub {
|
||||
sub_merged.insert(sub_key.clone(), sub_value.clone());
|
||||
}
|
||||
merged.insert(key.clone(), Value::Object(sub_merged));
|
||||
} else {
|
||||
// 新值不是对象,直接使用新值
|
||||
merged.insert(key.clone(), new_value.clone());
|
||||
if let Some(existing_value) = existing_obj.get(key) {
|
||||
if let (Value::Object(existing_sub), Value::Object(new_sub)) =
|
||||
(existing_value, new_value)
|
||||
{
|
||||
// 两边都是对象:浅合并(新值覆盖,旧值保留未覆盖的 key)
|
||||
let mut sub_merged = existing_sub.clone();
|
||||
for (sub_key, sub_value) in new_sub {
|
||||
sub_merged.insert(sub_key.clone(), sub_value.clone());
|
||||
}
|
||||
merged.insert(key.clone(), Value::Object(sub_merged));
|
||||
} else {
|
||||
// 类型不同或不是对象,直接使用新值
|
||||
merged.insert(key.clone(), new_value.clone());
|
||||
}
|
||||
} else {
|
||||
// 其他字段直接使用新配置的值
|
||||
// 现有配置没有此 key,使用新值
|
||||
merged.insert(key.clone(), new_value.clone());
|
||||
}
|
||||
}
|
||||
@@ -1703,37 +1696,9 @@ fn sync_providers_to_agent_models(config: &Value) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清理已删除的 models
|
||||
if let Some(dst_models) =
|
||||
dst_obj.get_mut("models").and_then(|m| m.as_array_mut())
|
||||
{
|
||||
let src_model_ids: std::collections::HashSet<String> = src_provider
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| m.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let before = dst_models.len();
|
||||
dst_models.retain(|m| {
|
||||
let id = m
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| m.as_str())
|
||||
.unwrap_or("");
|
||||
src_model_ids.contains(id)
|
||||
});
|
||||
if dst_models.len() != before {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// 注意:不删除 agent models.json 中用户手动添加的模型。
|
||||
// 只同步连接信息(baseUrl/apiKey/api),保留用户通过 CLI
|
||||
// 或手动编辑添加的自定义模型。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2371
src-tauri/src/commands/hermes.rs
Normal file
2371
src-tauri/src/commands/hermes.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,25 @@ fn put_csv_array_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并渠道配置:将新的表单字段覆盖到现有配置上,保留用户通过 CLI 或手动编辑的自定义字段。
|
||||
/// 例如用户手动添加的 streaming / retry / dmPolicy 等不会被丢弃。
|
||||
fn merge_channel_entry(
|
||||
channels_map: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
new_entry: Map<String, Value>,
|
||||
) {
|
||||
let merged = if let Some(Value::Object(existing)) = channels_map.get(key) {
|
||||
let mut m = existing.clone();
|
||||
for (k, v) in new_entry {
|
||||
m.insert(k, v);
|
||||
}
|
||||
m
|
||||
} else {
|
||||
new_entry
|
||||
};
|
||||
channels_map.insert(key.to_string(), Value::Object(merged));
|
||||
}
|
||||
|
||||
fn normalize_binding_match_value(value: &Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
@@ -636,17 +655,6 @@ pub async fn save_messaging_platform(
|
||||
entry.insert("token".into(), Value::String(t.trim().into()));
|
||||
}
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
entry.insert("groupPolicy".into(), Value::String("allowlist".into()));
|
||||
entry.insert("dm".into(), json!({ "enabled": false }));
|
||||
entry.insert(
|
||||
"retry".into(),
|
||||
json!({
|
||||
"attempts": 3,
|
||||
"minDelayMs": 500,
|
||||
"maxDelayMs": 30000,
|
||||
"jitter": 0.1
|
||||
}),
|
||||
);
|
||||
|
||||
// guildId + channelId 展开为 guilds 嵌套结构
|
||||
let guild_id = form_obj
|
||||
@@ -681,7 +689,19 @@ pub async fn save_messaging_platform(
|
||||
);
|
||||
}
|
||||
|
||||
channels_map.insert("discord".into(), Value::Object(entry));
|
||||
// 合并到现有配置,保留用户通过 CLI 设置的 streaming / retry / dmPolicy 等
|
||||
merge_channel_entry(channels_map, "discord", entry);
|
||||
// 仅在首次创建时设置默认值,不覆盖用户已有的设置
|
||||
if let Some(Value::Object(d)) = channels_map.get_mut("discord") {
|
||||
d.entry("groupPolicy").or_insert(Value::String("allowlist".into()));
|
||||
d.entry("dm").or_insert(json!({ "enabled": false }));
|
||||
d.entry("retry").or_insert(json!({
|
||||
"attempts": 3,
|
||||
"minDelayMs": 500,
|
||||
"maxDelayMs": 30000,
|
||||
"jitter": 0.1
|
||||
}));
|
||||
}
|
||||
}
|
||||
"telegram" => {
|
||||
let mut entry = Map::new();
|
||||
@@ -704,7 +724,7 @@ pub async fn save_messaging_platform(
|
||||
}
|
||||
}
|
||||
|
||||
channels_map.insert("telegram".into(), Value::Object(entry));
|
||||
merge_channel_entry(channels_map, "telegram", entry);
|
||||
}
|
||||
"qqbot" => {
|
||||
let app_id = form_obj
|
||||
@@ -808,10 +828,10 @@ pub async fn save_messaging_platform(
|
||||
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
|
||||
accounts_obj.insert(acct.clone(), Value::Object(entry));
|
||||
} else {
|
||||
channels_map.insert(storage_key.clone(), Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
} else {
|
||||
channels_map.insert(storage_key.clone(), Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
ensure_plugin_allowed(&mut cfg, "openclaw-lark")?;
|
||||
// 禁用旧版 feishu 插件,防止新旧插件同时运行冲突
|
||||
@@ -863,7 +883,7 @@ pub async fn save_messaging_platform(
|
||||
);
|
||||
}
|
||||
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
|
||||
ensure_chat_completions_enabled(&mut cfg)?;
|
||||
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
|
||||
@@ -912,7 +932,7 @@ pub async fn save_messaging_platform(
|
||||
form_string(form_obj, "groupPolicy"),
|
||||
);
|
||||
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
"whatsapp" => {
|
||||
let mut entry = Map::new();
|
||||
@@ -925,7 +945,7 @@ pub async fn save_messaging_platform(
|
||||
);
|
||||
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
|
||||
put_bool_from_form(&mut entry, "enabled", &form_string(form_obj, "enabled"));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
"signal" => {
|
||||
let account = form_string(form_obj, "account");
|
||||
@@ -947,7 +967,7 @@ pub async fn save_messaging_platform(
|
||||
form_string(form_obj, "groupPolicy"),
|
||||
);
|
||||
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
"matrix" => {
|
||||
let homeserver = form_string(form_obj, "homeserver");
|
||||
@@ -977,7 +997,7 @@ pub async fn save_messaging_platform(
|
||||
);
|
||||
put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee"));
|
||||
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
ensure_plugin_allowed(&mut cfg, "matrix")?;
|
||||
}
|
||||
"msteams" => {
|
||||
@@ -1009,7 +1029,7 @@ pub async fn save_messaging_platform(
|
||||
form_string(form_obj, "groupPolicy"),
|
||||
);
|
||||
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
ensure_plugin_allowed(&mut cfg, "msteams")?;
|
||||
}
|
||||
_ => {
|
||||
@@ -1019,7 +1039,7 @@ pub async fn save_messaging_platform(
|
||||
entry.insert(k.clone(), v.clone());
|
||||
}
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
merge_channel_entry(channels_map, &storage_key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2219,7 +2239,6 @@ pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, St
|
||||
return Err("plugin_id 不能为空".into());
|
||||
}
|
||||
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let mut cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
|
||||
|
||||
if enabled {
|
||||
@@ -2228,8 +2247,8 @@ pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, St
|
||||
disable_legacy_plugin(&mut cfg, plugin_id);
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(&cfg).map_err(|e| format!("序列化失败: {e}"))?;
|
||||
std::fs::write(&config_path, content).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
// 使用 save_openclaw_json 写入(含备份和 UI 字段清理),而非直接 fs::write
|
||||
super::config::save_openclaw_json(&cfg)?;
|
||||
|
||||
Ok(json!({ "ok": true, "enabled": enabled, "pluginId": plugin_id }))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod config;
|
||||
pub mod device;
|
||||
pub mod diagnose;
|
||||
pub mod extensions;
|
||||
pub mod hermes;
|
||||
pub mod logs;
|
||||
pub mod memory;
|
||||
pub mod messaging;
|
||||
@@ -28,9 +29,54 @@ pub mod skillhub;
|
||||
pub mod skills;
|
||||
pub mod update;
|
||||
|
||||
/// 默认 OpenClaw 配置目录(ClawPanel 自身配置始终在此)
|
||||
/// 默认 OpenClaw 配置目录
|
||||
/// Windows 上优先使用 USERPROFILE(与 Node.js os.homedir() 一致),
|
||||
/// 并自动检测已有 openclaw.json 的目录,避免创建第二个 .openclaw
|
||||
fn default_openclaw_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".openclaw")
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
// 优先 USERPROFILE(与 Node.js os.homedir() 一致)
|
||||
if let Ok(up) = std::env::var("USERPROFILE") {
|
||||
let p = PathBuf::from(up.trim());
|
||||
if !p.as_os_str().is_empty() {
|
||||
candidates.push(p);
|
||||
}
|
||||
}
|
||||
// dirs::home_dir() 作为补充(Windows API SHGetKnownFolderPath)
|
||||
if let Some(dh) = dirs::home_dir() {
|
||||
if !candidates.iter().any(|c| panel_path_key(c) == panel_path_key(&dh)) {
|
||||
candidates.push(dh);
|
||||
}
|
||||
}
|
||||
// HOMEDRIVE+HOMEPATH(域控/企业环境可能指向网络盘)
|
||||
if let (Ok(hd), Ok(hp)) = (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) {
|
||||
let combined = format!("{}{}", hd.trim(), hp.trim());
|
||||
let p = PathBuf::from(&combined);
|
||||
if !combined.is_empty()
|
||||
&& !candidates.iter().any(|c| panel_path_key(c) == panel_path_key(&p))
|
||||
{
|
||||
candidates.push(p);
|
||||
}
|
||||
}
|
||||
// 优先选已有 openclaw.json 的目录(自动对齐已安装的 OpenClaw)
|
||||
for home in &candidates {
|
||||
let dir = home.join(".openclaw");
|
||||
if dir.join("openclaw.json").exists() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
// 都没有 → 用第一个候选(USERPROFILE)
|
||||
candidates
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.join(".openclaw")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dirs::home_dir().unwrap_or_default().join(".openclaw")
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_path_key(path: &std::path::Path) -> String {
|
||||
|
||||
@@ -4,8 +4,8 @@ mod tray;
|
||||
mod utils;
|
||||
|
||||
use commands::{
|
||||
agent, assistant, config, device, diagnose, extensions, logs, memory, messaging, pairing,
|
||||
service, skills, update,
|
||||
agent, assistant, config, device, diagnose, extensions, hermes, logs, memory, messaging,
|
||||
pairing, service, skills, update,
|
||||
};
|
||||
|
||||
pub fn run() {
|
||||
@@ -215,6 +215,22 @@ pub fn run() {
|
||||
update::download_frontend_update,
|
||||
update::rollback_frontend_update,
|
||||
update::get_update_status,
|
||||
// Hermes Agent 管理
|
||||
hermes::check_python,
|
||||
hermes::check_hermes,
|
||||
hermes::install_hermes,
|
||||
hermes::configure_hermes,
|
||||
hermes::hermes_gateway_action,
|
||||
hermes::hermes_health_check,
|
||||
hermes::hermes_api_proxy,
|
||||
hermes::hermes_agent_run,
|
||||
hermes::hermes_read_config,
|
||||
hermes::hermes_fetch_models,
|
||||
hermes::hermes_update_model,
|
||||
hermes::hermes_detect_environments,
|
||||
hermes::hermes_set_gateway_url,
|
||||
hermes::update_hermes,
|
||||
hermes::uninstall_hermes,
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
// 关闭窗口时最小化到托盘,不退出应用
|
||||
|
||||
Reference in New Issue
Block a user