fix(openclaw): stabilize windows install and pairing

This commit is contained in:
晴天
2026-06-07 03:28:10 +08:00
parent a458f77c35
commit e16ff2baee
21 changed files with 912 additions and 131 deletions

View File

@@ -814,6 +814,65 @@ pub fn save_openclaw_json(config: &Value) -> Result<(), String> {
write_openclaw_config(config.clone())
}
fn model_env_values_for_config(config: &Value) -> HashMap<String, String> {
let mut values = HashMap::new();
if let Some(env) = config.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if !is_valid_env_key(key) {
continue;
}
if let Some(s) = value.as_str() {
values.insert(key.clone(), s.to_string());
} else if value.is_number() || value.is_boolean() {
values.insert(key.clone(), value.to_string());
}
}
}
let env_path = super::openclaw_dir().join(".env");
if let Ok(content) = fs::read_to_string(env_path) {
for line in content.lines() {
if let Some((key, value)) = parse_dotenv_line(line) {
values.entry(key).or_insert(value);
}
}
}
values
}
fn validate_model_provider_env_refs(config: &Value) -> Result<(), String> {
let values = model_env_values_for_config(config);
let Some(providers) = config
.get("models")
.and_then(|v| v.get("providers"))
.and_then(|v| v.as_object())
else {
return Ok(());
};
for (provider_name, provider) in providers {
let Some(api_key) = provider.get("apiKey").and_then(|v| v.as_str()) else {
continue;
};
let Some(env_key) = model_api_key_env_ref(api_key)? else {
continue;
};
let configured = values
.get(&env_key)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let process_env = std::env::var(&env_key)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if !configured && !process_env {
return Err(format!(
"模型服务商 \"{provider_name}\" 的 API Key 引用了缺失的环境变量 \"{env_key}\"。请先在 OpenClaw env、~/.openclaw/.env 或当前进程环境中补齐,或删除该服务商后再保存。"
));
}
}
Ok(())
}
/// 供其他模块复用:触发 Gateway 重载
pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result<String, String> {
reload_gateway_internal(Some(app)).await
@@ -845,6 +904,7 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
// 清理 UI 专属字段,避免 CLI schema 校验失败
let cleaned = strip_ui_fields(merged);
validate_model_provider_env_refs(&cleaned)?;
// 写入
let json = serde_json::to_string_pretty(&cleaned).map_err(|e| format!("序列化失败: {e}"))?;
@@ -3093,6 +3153,29 @@ fn npm_global_bin_dir() -> Option<PathBuf> {
}
}
fn npm_openclaw_cli_path() -> Option<PathBuf> {
let bin_dir = npm_global_bin_dir()?;
#[cfg(target_os = "windows")]
{
for name in [
"openclaw.cmd",
"openclaw.exe",
"openclaw.bat",
"openclaw.js",
] {
let candidate = bin_dir.join(name);
if candidate.exists() {
return Some(candidate);
}
}
Some(bin_dir.join("openclaw.cmd"))
}
#[cfg(not(target_os = "windows"))]
{
Some(bin_dir.join("openclaw"))
}
}
/// 尝试从 standalone 独立安装包安装 OpenClaw自带 Node.js零依赖
/// 动态查询 latest.json 获取最新版本,下载对应平台的归档并解压
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 R2/npm
@@ -3313,6 +3396,21 @@ async fn try_standalone_install(
return Err("standalone 解压后未找到 openclaw 可执行文件".into());
}
let verified_version = read_version_from_installation(&openclaw_bin)
.ok_or_else(|| "standalone 安装后无法读取目标 CLI 版本,已保留旧绑定".to_string())?;
if !versions_match(&verified_version, remote_version) {
return Err(format!(
"standalone 安装校验失败:目标 CLI 版本为 {verified_version},清单版本为 {remote_version},已保留旧绑定"
));
}
let _ = app.emit(
"upgrade-log",
format!(
"目标 CLI 验证通过: {} ({verified_version})",
openclaw_bin.display()
),
);
// 7. 添加到 PATHWindows 用户 PATHUnix 创建 symlink
#[cfg(target_os = "windows")]
{
@@ -3387,10 +3485,19 @@ async fn try_standalone_install(
format!("安装目录: {}", install_dir.display()),
);
// 刷新 CLI 检测缓存
crate::commands::service::invalidate_cli_detection_cache();
match bind_openclaw_cli_path(&openclaw_bin) {
Ok(()) => {
let _ = app.emit(
"upgrade-log",
format!("已切换当前 CLI: {}", openclaw_bin.display()),
);
}
Err(err) => {
let _ = app.emit("upgrade-log", format!("⚠️ 自动绑定当前 CLI 失败: {err}"));
}
}
Ok(remote_version.to_string())
Ok(verified_version)
}
/// 尝试从 R2 CDN 下载预装归档安装 OpenClaw跳过 npm 依赖解析)
@@ -3710,6 +3817,32 @@ async fn upgrade_openclaw_inner(
.or(recommended_version.as_deref())
.unwrap_or("latest");
let pkg = format!("{}@{}", pkg_name, ver);
let active_cli_before = crate::utils::resolve_openclaw_cli_path();
let current_version_before = get_local_version().await;
let installations_before = scan_all_installations(&active_cli_before);
let _ = app.emit("upgrade-log", "升级前扫描当前 OpenClaw 安装...");
let _ = app.emit(
"upgrade-log",
format!(
"当前使用: {}{}",
active_cli_before
.as_deref()
.unwrap_or("未检测到 openclaw CLI"),
current_version_before
.as_ref()
.map(|v| format!(" ({v})"))
.unwrap_or_default()
),
);
if installations_before.len() > 1 {
let _ = app.emit(
"upgrade-log",
format!(
"检测到 {} 个 OpenClaw 安装;升级成功后会切换到新版,旧安装不会自动删除。",
installations_before.len()
),
);
}
// ── standalone 安装auto / standalone-r2 / standalone-github ──
let try_standalone = source != "official"
@@ -3730,6 +3863,10 @@ async fn upgrade_openclaw_inner(
crate::commands::service::invalidate_cli_detection_cache();
let msg = format!("✅ standalone (GitHub) 安装完成,当前版本: {installed_ver}");
let _ = app.emit("upgrade-log", &msg);
let _ = app.emit(
"upgrade-log",
"旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。",
);
return Ok(msg);
}
Err(reason) => {
@@ -3745,6 +3882,10 @@ async fn upgrade_openclaw_inner(
crate::commands::service::invalidate_cli_detection_cache();
let msg = format!("✅ standalone (CDN) 安装完成,当前版本: {installed_ver}");
let _ = app.emit("upgrade-log", &msg);
let _ = app.emit(
"upgrade-log",
"旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。",
);
return Ok(msg);
}
Err(cdn_reason) => {
@@ -3763,6 +3904,10 @@ async fn upgrade_openclaw_inner(
"✅ standalone (GitHub) 安装完成,当前版本: {installed_ver}"
);
let _ = app.emit("upgrade-log", &msg);
let _ = app.emit(
"upgrade-log",
"旧安装已保留。如需清理,请在“安装管理与清理”里确认后处理。",
);
return Ok(msg);
}
Err(gh_reason) => {
@@ -4108,6 +4253,55 @@ async fn upgrade_openclaw_inner(
}
}
if need_uninstall_old {
let _ = app.emit(
"upgrade-log",
"正在修复 npm CLI 入口(避免旧包卸载删除 openclaw.cmd...",
);
let mut repair_cmd = npm_command_elevated();
repair_cmd.args(["install", "-g", &pkg, "--force", "--registry", registry]);
apply_git_install_env(&mut repair_cmd);
match repair_cmd
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
{
Ok(o) if o.status.success() => {
let _ = app.emit("upgrade-log", "npm CLI 入口已确认");
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
return Err(format!(
"安装完成但修复 npm CLI 入口失败: {}",
stderr.trim()
));
}
Err(e) => return Err(format!("安装完成但修复 npm CLI 入口失败: {e}")),
}
}
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let npm_cli = npm_openclaw_cli_path()
.ok_or_else(|| "安装完成但无法确定 npm openclaw CLI 路径".to_string())?;
let new_ver = read_version_from_installation(&npm_cli)
.or_else(|| {
crate::utils::resolve_openclaw_cli_path()
.and_then(|p| read_version_from_installation(std::path::Path::new(&p)))
})
.ok_or_else(|| format!("安装完成但无法读取 OpenClaw 版本: {}", npm_cli.display()))?;
if ver != "latest" && !versions_match(&new_ver, ver) {
return Err(format!(
"安装校验失败:目标 CLI 版本为 {new_ver},期望版本为 {ver}"
));
}
bind_openclaw_cli_path(&npm_cli)?;
let _ = app.emit(
"upgrade-log",
format!("已切换当前 CLI: {} ({new_ver})", npm_cli.display()),
);
// 切换源后重装 Gateway 服务
if need_uninstall_old {
let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)...");
@@ -4157,7 +4351,6 @@ async fn upgrade_openclaw_inner(
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
let msg = format!("✅ 安装完成,当前版本: {new_ver}");
let _ = app.emit("upgrade-log", &msg);
Ok(msg)
@@ -5541,7 +5734,7 @@ fn scan_json_client_file(
.unwrap_or_else(|| default_model.to_string());
let (api_key, status) = first_env_ref(env_keys);
let warning = if status == "missing" {
"未在当前进程环境中检测到对应 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。"
"未在当前进程环境中检测到对应 API Key 环境变量。请先在 OpenClaw env 或 .env 中补齐后再导入"
} else {
""
};
@@ -5557,7 +5750,7 @@ fn scan_json_client_file(
&api_key,
&status,
vec![model],
true,
status != "missing",
"",
warning,
);
@@ -5633,7 +5826,7 @@ pub fn scan_model_client_configs() -> Result<Value, String> {
} else if status == "none" {
"Codex 配置没有声明可安全引用的 env_key无法自动导入 API Key。请在 Codex 配置中添加 env_key或在 OpenClaw 中手动配置服务商密钥。"
} else if status == "missing" {
"未在当前进程环境中检测到 Codex 配置引用的 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。"
"未在当前进程环境中检测到 Codex 配置引用的 API Key 环境变量。请先在 OpenClaw env 或 .env 中补齐后再导入"
} else {
""
};
@@ -5649,7 +5842,7 @@ pub fn scan_model_client_configs() -> Result<Value, String> {
&api_key,
status,
vec![model],
!is_external_codex && status != "none",
!is_external_codex && status != "none" && status != "missing",
if is_external_codex {
"hermes auth login openai-codex"
} else {
@@ -6537,6 +6730,23 @@ pub fn write_panel_config(config: Value) -> Result<(), String> {
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
}
fn bind_openclaw_cli_path(cli_path: &std::path::Path) -> Result<(), String> {
let mut config = read_panel_config().unwrap_or_else(|_| json!({}));
if !config.is_object() {
config = json!({});
}
if let Some(obj) = config.as_object_mut() {
obj.insert(
"openclawCliPath".into(),
Value::String(cli_path.to_string_lossy().to_string()),
);
}
write_panel_config(config)?;
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
Ok(())
}
/// 重启应用(用于设置变更后自动重启)
#[tauri::command]
pub async fn relaunch_app(app: tauri::AppHandle) -> Result<(), String> {

View File

@@ -1,5 +1,219 @@
/// 设备配对命令
/// 自动向 Gateway 注册设备,跳过手动配对流程
use base64::Engine;
const CONTROL_UI_CLIENT_ID: &str = "openclaw-control-ui";
const CONTROL_UI_CLIENT_MODE: &str = "ui";
const CONTROL_UI_ROLE: &str = "operator";
const CONTROL_UI_DEVICE_FAMILY: &str = "desktop";
const CONTROL_UI_SCOPES: &[&str] = &[
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
];
fn scope_values() -> Vec<serde_json::Value> {
CONTROL_UI_SCOPES
.iter()
.map(|scope| serde_json::Value::String((*scope).to_string()))
.collect()
}
fn generate_pairing_token() -> String {
let bytes: [u8; 32] = rand::random();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn ensure_string_field(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: &str,
) -> bool {
if obj.get(key).and_then(|v| v.as_str()) == Some(value) {
return false;
}
obj.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
true
}
fn ensure_array_contains(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
required: &[&str],
) -> bool {
let mut changed = false;
let mut values: Vec<String> = obj
.get(key)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for item in required {
if !values.iter().any(|existing| existing == item) {
values.push((*item).to_string());
changed = true;
}
}
let normalized: Vec<serde_json::Value> = values
.iter()
.map(|item| serde_json::Value::String(item.clone()))
.collect();
if obj.get(key).and_then(|v| v.as_array()) != Some(&normalized) {
obj.insert(key.to_string(), serde_json::Value::Array(normalized));
changed = true;
}
changed
}
fn operator_token_is_usable(value: Option<&serde_json::Value>) -> bool {
let Some(obj) = value.and_then(|v| v.as_object()) else {
return false;
};
if obj
.get("revokedAtMs")
.map(|v| !v.is_null())
.unwrap_or(false)
{
return false;
}
if obj.get("role").and_then(|v| v.as_str()) != Some(CONTROL_UI_ROLE) {
return false;
}
if !obj
.get("token")
.and_then(|v| v.as_str())
.map(|token| !token.trim().is_empty())
.unwrap_or(false)
{
return false;
}
let scopes: Vec<String> = obj
.get("scopes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
CONTROL_UI_SCOPES
.iter()
.all(|scope| scopes.iter().any(|existing| existing == scope))
}
fn ensure_operator_token(
obj: &mut serde_json::Map<String, serde_json::Value>,
now_ms: u64,
) -> bool {
let tokens = obj.entry("tokens").or_insert_with(|| serde_json::json!({}));
if !tokens.is_object() {
*tokens = serde_json::json!({});
}
let Some(tokens_obj) = tokens.as_object_mut() else {
return false;
};
if operator_token_is_usable(tokens_obj.get(CONTROL_UI_ROLE)) {
return false;
}
let existing = tokens_obj.get(CONTROL_UI_ROLE).and_then(|v| v.as_object());
let token = existing
.and_then(|entry| entry.get("token"))
.and_then(|v| v.as_str())
.filter(|token| !token.trim().is_empty())
.map(|token| token.to_string())
.unwrap_or_else(generate_pairing_token);
let created_at_ms = existing
.and_then(|entry| entry.get("createdAtMs"))
.and_then(|v| v.as_u64())
.filter(|v| *v > 0)
.unwrap_or(now_ms);
tokens_obj.insert(
CONTROL_UI_ROLE.to_string(),
serde_json::json!({
"token": token,
"role": CONTROL_UI_ROLE,
"scopes": scope_values(),
"createdAtMs": created_at_ms,
"rotatedAtMs": now_ms,
"lastUsedAtMs": existing
.and_then(|entry| entry.get("lastUsedAtMs"))
.and_then(|v| v.as_u64()),
}),
);
true
}
fn normalize_control_ui_pairing(
entry: &mut serde_json::Value,
device_id: &str,
public_key: &str,
platform: &str,
now_ms: u64,
) -> bool {
if !entry.is_object() {
*entry = serde_json::json!({});
}
let Some(obj) = entry.as_object_mut() else {
return false;
};
let mut changed = false;
changed |= ensure_string_field(obj, "deviceId", device_id);
changed |= ensure_string_field(obj, "publicKey", public_key);
changed |= ensure_string_field(obj, "platform", platform);
changed |= ensure_string_field(obj, "deviceFamily", CONTROL_UI_DEVICE_FAMILY);
changed |= ensure_string_field(obj, "clientId", CONTROL_UI_CLIENT_ID);
changed |= ensure_string_field(obj, "clientMode", CONTROL_UI_CLIENT_MODE);
changed |= ensure_string_field(obj, "role", CONTROL_UI_ROLE);
changed |= ensure_array_contains(obj, "roles", &[CONTROL_UI_ROLE]);
changed |= ensure_array_contains(obj, "scopes", CONTROL_UI_SCOPES);
changed |= ensure_array_contains(obj, "approvedScopes", CONTROL_UI_SCOPES);
changed |= ensure_operator_token(obj, now_ms);
if !obj
.get("createdAtMs")
.and_then(|v| v.as_u64())
.map(|v| v > 0)
.unwrap_or(false)
{
obj.insert("createdAtMs".into(), serde_json::json!(now_ms));
changed = true;
}
if changed
|| !obj
.get("approvedAtMs")
.and_then(|v| v.as_u64())
.map(|v| v > 0)
.unwrap_or(false)
{
obj.insert("approvedAtMs".into(), serde_json::json!(now_ms));
changed = true;
}
changed
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
#[tauri::command]
pub fn auto_pair_device() -> Result<String, String> {
@@ -31,63 +245,42 @@ pub fn auto_pair_device() -> Result<String, String> {
let os_platform = std::env::consts::OS; // "windows" | "macos" | "linux"
// 如果已配对,档查 platform 字段是否正确;不正确则覆盖更新,
// 避免 Gateway 因 metadata-upgrade 拒绝静默自动配对
let now_ms = now_ms();
// 如果已配对,仍要补齐控制 UI 必需字段。
// 旧版本可能只写入了 publicKey但缺失 role/scopes/approvedScopes
// Gateway 会把它视为 roleFrom=<none>,从而拒绝 operator 握手。
if let Some(existing) = paired.get_mut(&device_id) {
let current_platform = existing
.get("platform")
.and_then(|v| v.as_str())
.unwrap_or("");
if current_platform != os_platform {
if let Some(obj) = existing.as_object_mut() {
obj.insert(
"platform".to_string(),
serde_json::Value::String(os_platform.to_string()),
);
obj.insert(
"deviceFamily".to_string(),
serde_json::Value::String("desktop".to_string()),
);
}
if normalize_control_ui_pairing(existing, &device_id, &public_key, os_platform, now_ms) {
let new_content = serde_json::to_string_pretty(&paired)
.map_err(|e| format!("序列化 paired.json 失败: {e}"))?;
std::fs::write(&paired_path, new_content)
.map_err(|e| format!("更新 paired.json 失败: {e}"))?;
return Ok("设备已配对(已修正平台字段)".into());
return Ok("设备已配对(已修正权限字段)".into());
}
return Ok("设备已配对".into());
}
// 添加设备到配对列表
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
paired[&device_id] = serde_json::json!({
"deviceId": device_id,
"publicKey": public_key,
"platform": os_platform,
"deviceFamily": "desktop",
"clientId": "openclaw-control-ui",
"clientMode": "ui",
"role": "operator",
"roles": ["operator"],
"scopes": [
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write"
],
"approvedScopes": [
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write"
],
"tokens": {},
"deviceFamily": CONTROL_UI_DEVICE_FAMILY,
"clientId": CONTROL_UI_CLIENT_ID,
"clientMode": CONTROL_UI_CLIENT_MODE,
"role": CONTROL_UI_ROLE,
"roles": [CONTROL_UI_ROLE],
"scopes": scope_values(),
"approvedScopes": scope_values(),
"tokens": {
(CONTROL_UI_ROLE): {
"token": generate_pairing_token(),
"role": CONTROL_UI_ROLE,
"scopes": scope_values(),
"createdAtMs": now_ms
}
},
"createdAtMs": now_ms,
"approvedAtMs": now_ms
});
@@ -249,3 +442,71 @@ pub async fn pairing_approve_channel(
}
run_pairing_command(args).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_existing_pairing_upgrades_missing_operator_fields() {
let mut entry = serde_json::json!({
"deviceId": "device-1",
"publicKey": "old-key",
"clientId": "openclaw-control-ui"
});
let changed =
normalize_control_ui_pairing(&mut entry, "device-1", "new-key", "windows", 1234);
assert!(changed);
assert_eq!(entry["publicKey"], "new-key");
assert_eq!(entry["platform"], "windows");
assert_eq!(entry["deviceFamily"], "desktop");
assert_eq!(entry["clientMode"], "ui");
assert_eq!(entry["role"], "operator");
assert_eq!(entry["roles"], serde_json::json!(["operator"]));
assert_eq!(entry["scopes"], serde_json::json!(scope_values()));
assert_eq!(entry["approvedScopes"], serde_json::json!(scope_values()));
assert_eq!(entry["tokens"]["operator"]["role"], "operator");
assert_eq!(
entry["tokens"]["operator"]["scopes"],
serde_json::json!(scope_values())
);
assert!(entry["tokens"]["operator"]["token"]
.as_str()
.map(|token| !token.is_empty())
.unwrap_or(false));
assert_eq!(entry["approvedAtMs"], serde_json::json!(1234));
}
#[test]
fn normalize_existing_pairing_keeps_complete_entry_unchanged() {
let mut entry = serde_json::json!({
"deviceId": "device-1",
"publicKey": "key",
"platform": "windows",
"deviceFamily": "desktop",
"clientId": "openclaw-control-ui",
"clientMode": "ui",
"role": "operator",
"roles": ["operator"],
"scopes": scope_values(),
"approvedScopes": scope_values(),
"tokens": {
"operator": {
"token": "existing-token",
"role": "operator",
"scopes": scope_values(),
"createdAtMs": 1
}
},
"createdAtMs": 1,
"approvedAtMs": 1
});
let changed = normalize_control_ui_pairing(&mut entry, "device-1", "key", "windows", 1234);
assert!(!changed);
assert_eq!(entry["approvedAtMs"], serde_json::json!(1));
}
}

View File

@@ -312,8 +312,25 @@ pub fn classify_cli_source(cli_path: &str) -> String {
if lower.contains("openclaw-zh") || lower.contains("@qingchencloud") {
return "npm-zh".into();
}
#[cfg(target_os = "windows")]
{
if lower.ends_with("/openclaw.cmd") || lower.ends_with("/openclaw.bat") {
if let Ok(content) = std::fs::read_to_string(cli_path) {
let content = content.to_lowercase();
if content.contains("@qingchencloud") || content.contains("openclaw-zh") {
return "npm-zh".into();
}
if content.contains("/node_modules/openclaw/")
|| content.contains("\\node_modules\\openclaw\\")
{
return "npm-official".into();
}
}
}
}
// npm 全局(大概率官方版)
if lower.contains("/npm/") || lower.contains("/node_modules/") {
if lower.contains("/npm/") || lower.contains("/npm-global/") || lower.contains("/node_modules/")
{
return "npm-official".into();
}
// Homebrew