diff --git a/src/commands/builtin_commands.cpp b/src/commands/builtin_commands.cpp index d5804dc0d..2bf7847b8 100644 --- a/src/commands/builtin_commands.cpp +++ b/src/commands/builtin_commands.cpp @@ -3,14 +3,14 @@ #include "history_command.hpp" #include "init_command.hpp" #include "memory_command.hpp" +#include "model_command.hpp" #include "models_command.hpp" #include "proxy_command.hpp" #include "websearch_command.hpp" #include "../config/config.hpp" #include "../config/saved_models.hpp" -#include "../provider/model_context_resolver.hpp" -#include "../provider/model_resolver.hpp" #include "../provider/cwd_model_override.hpp" +#include "../provider/model_resolver.hpp" #include "../provider/provider_swap.hpp" #include "../tool/mcp_manager.hpp" #include "../tool/tool_executor.hpp" @@ -78,166 +78,6 @@ static void cmd_clear(CommandContext& ctx, const std::string& /*args*/) { ctx.state.chat_follow_tail = true; } -// 在 saved_models 中按 name 找;失败时如果 name == "(legacy)" 返回 legacy -// entry。其它情况返回 nullopt。调用方负责报"未知 name"错误。 -static std::optional lookup_entry_by_name(const AppConfig& cfg, - const std::string& name) { - if (name == "(legacy)") return synth_legacy_entry(cfg); - for (const auto& e : cfg.saved_models) { - if (e.name == name) return e; - } - return std::nullopt; -} - -// 把切换结果反馈到 TUI。调用前已做完 swap;这里只更新状态行 / 系统消息。 -// 注意:调用方 MUST NOT 持 ctx.state.mu —— 本函数自己加锁。 -static void announce_switch(CommandContext& ctx, const ModelProfile& entry, - const std::string& scope_note) { - std::lock_guard lk(ctx.state.mu); - ctx.agent_loop.set_context_window(ctx.config.context_window); - ctx.state.token_status = ctx.token_tracker.format_status(ctx.config.context_window); - std::ostringstream oss; - oss << "Switched to " << entry.name - << " (" << entry.provider << "/" << entry.model << ")"; - if (!scope_note.empty()) oss << " " << scope_note; - std::string info = oss.str(); - ctx.state.status_line = info; - ctx.state.conversation.push_back({"system", info, false}); - ctx.state.chat_follow_tail = true; -} - -// 报"未知 name"错误。同 announce 一样自己加锁。 -static void report_unknown_name(CommandContext& ctx, const std::string& name) { - std::lock_guard lk(ctx.state.mu); - std::ostringstream oss; - oss << "Unknown model name: " << name - << ". Run /model to pick from available."; - ctx.state.conversation.push_back({"system", oss.str(), false}); - ctx.state.chat_follow_tail = true; -} - -// 当前 effective entry —— 用 name 与 saved_models 匹配;命中 saved_models 则 -// 复用,否则合成 legacy entry。仅用于 picker 高亮当前选中行。 -static std::string current_effective_name(CommandContext& ctx) { - std::string pname = ctx.provider.name(); - std::string pmodel = ctx.provider.model(); - for (const auto& e : ctx.config.saved_models) { - if (e.provider == pname && e.model == pmodel) return e.name; - } - return "(legacy)"; -} - -// 简单 trim helper —— args 里可能有多余空格。 -static std::string trim(const std::string& s) { - size_t i = 0; - while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; - size_t j = s.size(); - while (j > i && std::isspace(static_cast(s[j - 1]))) --j; - return s.substr(i, j - i); -} - -// 解析 args 为 (flag, name) 对。flag ∈ {"", "--cwd", "--default"}。 -// 返回 false 表示参数格式无效。 -static bool parse_model_args(const std::string& raw, std::string& flag, - std::string& name) { - flag.clear(); - name.clear(); - std::string s = trim(raw); - if (s.empty()) return true; // 无参 → picker - - if (s.rfind("--", 0) == 0) { - // --cwd 或 --default - auto sp = s.find_first_of(" \t"); - if (sp == std::string::npos) { - // 只给了 flag 没给 name - flag = s; - return false; - } - flag = s.substr(0, sp); - name = trim(s.substr(sp + 1)); - if (flag != "--cwd" && flag != "--default") return false; - if (name.empty()) return false; - return true; - } - - // 直接是 name —— in-memory 切换 - name = s; - return true; -} - -// 渲染 picker 列表(纯文本回退,无 FTXUI)。覆盖 saved_models + 兜底 (legacy)。 -// 当前 effective 行加 "*"。这是最小骨架 —— 真正的 FTXUI picker(任务 5.2) -// 留待后续(picker 与 TuiState/screen 耦合,本 phase 用文本展示先解锁验证)。 -static void render_model_picker(CommandContext& ctx) { - std::lock_guard lk(ctx.state.mu); - std::ostringstream oss; - oss << "Available models:\n"; - std::string current = current_effective_name(ctx); - bool legacy_listed_in_saved = false; - for (const auto& e : ctx.config.saved_models) { - if (e.name == "(legacy)") legacy_listed_in_saved = true; - oss << " " << (e.name == current ? "*" : " ") - << " " << e.name << " (" << e.provider << "/" << e.model << ")\n"; - } - if (!legacy_listed_in_saved) { - ModelProfile legacy = synth_legacy_entry(ctx.config); - oss << " " << (legacy.name == current ? "*" : " ") - << " " << legacy.name << " (" << legacy.provider << "/" - << legacy.model << ")\n"; - } - oss << "\nUse: /model - switch this session (in-memory)\n" - << " /model --cwd - switch + persist to this directory\n" - << " /model --default - switch + persist as global default"; - ctx.state.conversation.push_back({"system", oss.str(), false}); - ctx.state.chat_follow_tail = true; -} - -static void cmd_model(CommandContext& ctx, const std::string& args) { - std::string flag, name; - if (!parse_model_args(args, flag, name)) { - std::lock_guard lk(ctx.state.mu); - ctx.state.conversation.push_back({"system", - "Usage: /model | /model | /model --cwd | /model --default ", - false}); - ctx.state.chat_follow_tail = true; - return; - } - - // 无参 → 列表(picker 占位) - if (flag.empty() && name.empty()) { - render_model_picker(ctx); - return; - } - - auto entry = lookup_entry_by_name(ctx.config, name); - if (!entry.has_value()) { - report_unknown_name(ctx, name); - return; - } - - // 切换前保护:provider_handle / provider_mu 缺失则退化为旧行为。理论 - // 上启动期 main.cpp 总会传入这俩,但 init_command 的测试桩不会。 - if (ctx.provider_handle && ctx.provider_mu) { - swap_provider_if_needed(*ctx.provider_handle, *ctx.provider_mu, *entry, ctx.config); - } else { - ctx.provider.set_model(entry->model); - ctx.config.context_window = resolve_model_context_window( - ctx.config, ctx.provider.name(), ctx.provider.model(), - ctx.config.context_window); - } - - std::string scope_note; - if (flag == "--cwd") { - save_cwd_model_override(ctx.cwd, name); - scope_note = "[persisted to cwd]"; - } else if (flag == "--default") { - ctx.config.default_model_name = name; - save_config(ctx.config); - scope_note = "[persisted as default]"; - } - announce_switch(ctx, *entry, scope_note); -} - static void cmd_config(CommandContext& ctx, const std::string& /*args*/) { std::lock_guard lk(ctx.state.mu); std::ostringstream oss; @@ -555,6 +395,15 @@ static void cmd_title(CommandContext& ctx, const std::string& args) { ctx.state.chat_follow_tail = true; } +// 简单 trim helper —— args 里可能有多余空格。 +static std::string trim(const std::string& s) { + size_t i = 0; + while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; + size_t j = s.size(); + while (j > i && std::isspace(static_cast(s[j - 1]))) --j; + return s.substr(i, j - i); +} + // /page-step: 切换 PgUp / PgDn 是否按单行滚动. 兜底给吞 Alt+方向键的终端. // /page-step — 显示当前状态 // /page-step on|single — 改为单行 @@ -1059,7 +908,7 @@ static void cmd_rewind(CommandContext& ctx, const std::string& /*args*/) { void register_builtin_commands(CommandRegistry& registry) { registry.register_command({"help", "Show available commands", cmd_help}); registry.register_command({"clear", "Clear conversation history", cmd_clear}); - registry.register_command({"model", "Show or switch current model", cmd_model}); + register_model_command(registry); registry.register_command({"config", "Show current configuration", cmd_config}); registry.register_command({"tokens", "Show session token usage", cmd_tokens}); registry.register_command({"compact", "Compress conversation history", cmd_compact}); diff --git a/src/commands/model_command.cpp b/src/commands/model_command.cpp new file mode 100644 index 000000000..c81792343 --- /dev/null +++ b/src/commands/model_command.cpp @@ -0,0 +1,186 @@ +#include "model_command.hpp" + +#include "../config/config.hpp" +#include "../config/saved_models.hpp" +#include "../provider/cwd_model_override.hpp" +#include "../provider/model_context_resolver.hpp" +#include "../provider/model_resolver.hpp" +#include "../provider/provider_swap.hpp" + +#include +#include +#include +#include +#include + +namespace acecode { + +namespace { + +// 在 saved_models 中按 name 找;失败时如果 name == "(legacy)" 返回 legacy +// entry。其它情况返回 nullopt。调用方负责报"未知 name"错误。 +std::optional lookup_entry_by_name(const AppConfig& cfg, + const std::string& name) { + if (name == "(legacy)") return synth_legacy_entry(cfg); + for (const auto& e : cfg.saved_models) { + if (e.name == name) return e; + } + return std::nullopt; +} + +// 把切换结果反馈到 TUI。调用前已做完 swap;这里只更新状态行 / 系统消息。 +// 注意:调用方 MUST NOT 持 ctx.state.mu —— 本函数自己加锁。 +void announce_switch(CommandContext& ctx, const ModelProfile& entry, + const std::string& scope_note) { + std::lock_guard lk(ctx.state.mu); + ctx.agent_loop.set_context_window(ctx.config.context_window); + ctx.state.token_status = ctx.token_tracker.format_status(ctx.config.context_window); + std::ostringstream oss; + oss << "Switched to " << entry.name + << " (" << entry.provider << "/" << entry.model << ")"; + if (!scope_note.empty()) oss << " " << scope_note; + std::string info = oss.str(); + ctx.state.status_line = info; + ctx.state.conversation.push_back({"system", info, false}); + ctx.state.chat_follow_tail = true; +} + +// 报"未知 name"错误。同 announce 一样自己加锁。 +void report_unknown_name(CommandContext& ctx, const std::string& name) { + std::lock_guard lk(ctx.state.mu); + std::ostringstream oss; + oss << "Unknown model name: " << name + << ". Run /model to pick from available."; + ctx.state.conversation.push_back({"system", oss.str(), false}); + ctx.state.chat_follow_tail = true; +} + +// 当前 effective entry —— 用 name 与 saved_models 匹配;命中 saved_models 则 +// 复用,否则合成 legacy entry。仅用于 picker 高亮当前选中行。 +std::string current_effective_name(CommandContext& ctx) { + std::string pname = ctx.provider.name(); + std::string pmodel = ctx.provider.model(); + for (const auto& e : ctx.config.saved_models) { + if (e.provider == pname && e.model == pmodel) return e.name; + } + return "(legacy)"; +} + +// 简单 trim helper —— args 里可能有多余空格。 +std::string trim(const std::string& s) { + size_t i = 0; + while (i < s.size() && std::isspace(static_cast(s[i]))) ++i; + size_t j = s.size(); + while (j > i && std::isspace(static_cast(s[j - 1]))) --j; + return s.substr(i, j - i); +} + +// 解析 args 为 (flag, name) 对。flag ∈ {"", "--cwd", "--default"}。 +// 返回 false 表示参数格式无效。 +bool parse_model_args(const std::string& raw, std::string& flag, + std::string& name) { + flag.clear(); + name.clear(); + std::string s = trim(raw); + if (s.empty()) return true; // 无参 → picker + + if (s.rfind("--", 0) == 0) { + // --cwd 或 --default + auto sp = s.find_first_of(" \t"); + if (sp == std::string::npos) { + // 只给了 flag 没给 name + flag = s; + return false; + } + flag = s.substr(0, sp); + name = trim(s.substr(sp + 1)); + if (flag != "--cwd" && flag != "--default") return false; + if (name.empty()) return false; + return true; + } + + // 直接是 name —— in-memory 切换 + name = s; + return true; +} + +// 渲染 picker 列表(纯文本回退,无 FTXUI)。覆盖 saved_models + 兜底 (legacy)。 +// 当前 effective 行加 "*"。这是最小骨架 —— 真正的 FTXUI picker(任务 5.2) +// 留待后续(picker 与 TuiState/screen 耦合,本 phase 用文本展示先解锁验证)。 +void render_model_picker(CommandContext& ctx) { + std::lock_guard lk(ctx.state.mu); + std::ostringstream oss; + oss << "Available models:\n"; + std::string current = current_effective_name(ctx); + bool legacy_listed_in_saved = false; + for (const auto& e : ctx.config.saved_models) { + if (e.name == "(legacy)") legacy_listed_in_saved = true; + oss << " " << (e.name == current ? "*" : " ") + << " " << e.name << " (" << e.provider << "/" << e.model << ")\n"; + } + if (!legacy_listed_in_saved) { + ModelProfile legacy = synth_legacy_entry(ctx.config); + oss << " " << (legacy.name == current ? "*" : " ") + << " " << legacy.name << " (" << legacy.provider << "/" + << legacy.model << ")\n"; + } + oss << "\nUse: /model - switch this session (in-memory)\n" + << " /model --cwd - switch + persist to this directory\n" + << " /model --default - switch + persist as global default"; + ctx.state.conversation.push_back({"system", oss.str(), false}); + ctx.state.chat_follow_tail = true; +} + +void cmd_model(CommandContext& ctx, const std::string& args) { + std::string flag, name; + if (!parse_model_args(args, flag, name)) { + std::lock_guard lk(ctx.state.mu); + ctx.state.conversation.push_back({"system", + "Usage: /model | /model | /model --cwd | /model --default ", + false}); + ctx.state.chat_follow_tail = true; + return; + } + + // 无参 → 列表(picker 占位) + if (flag.empty() && name.empty()) { + render_model_picker(ctx); + return; + } + + auto entry = lookup_entry_by_name(ctx.config, name); + if (!entry.has_value()) { + report_unknown_name(ctx, name); + return; + } + + // 切换前保护:provider_handle / provider_mu 缺失则退化为旧行为。理论 + // 上启动期 main.cpp 总会传入这俩,但 init_command 的测试桩不会。 + if (ctx.provider_handle && ctx.provider_mu) { + swap_provider_if_needed(*ctx.provider_handle, *ctx.provider_mu, *entry, ctx.config); + } else { + ctx.provider.set_model(entry->model); + ctx.config.context_window = resolve_model_context_window( + ctx.config, ctx.provider.name(), ctx.provider.model(), + ctx.config.context_window); + } + + std::string scope_note; + if (flag == "--cwd") { + save_cwd_model_override(ctx.cwd, name); + scope_note = "[persisted to cwd]"; + } else if (flag == "--default") { + ctx.config.default_model_name = name; + save_config(ctx.config); + scope_note = "[persisted as default]"; + } + announce_switch(ctx, *entry, scope_note); +} + +} // namespace + +void register_model_command(CommandRegistry& registry) { + registry.register_command({"model", "Show or switch current model", cmd_model}); +} + +} // namespace acecode diff --git a/src/commands/model_command.hpp b/src/commands/model_command.hpp new file mode 100644 index 000000000..024cd0447 --- /dev/null +++ b/src/commands/model_command.hpp @@ -0,0 +1,15 @@ +// /model slash command —— display the picker or switch the active model entry. +// Sub-actions: +// /model — list saved_models + (legacy), mark current with '*' +// /model — in-memory switch +// /model --cwd — switch + persist to /model_override.json +// /model --default — switch + persist to config.json default_model_name +#pragma once + +#include "command_registry.hpp" + +namespace acecode { + +void register_model_command(CommandRegistry& registry); + +} // namespace acecode diff --git a/src/config/config.cpp b/src/config/config.cpp index 4bea3ddcf..d631d1dbb 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -33,81 +33,6 @@ std::string trim_ascii_copy(const std::string& s) { } // namespace -std::string expand_path(const std::string& raw) { - if (raw.empty()) return raw; - - std::string result; - result.reserve(raw.size()); - size_t i = 0; - - // ${VAR} substitution first so later ~ expansion sees final text. - while (i < raw.size()) { - if (raw[i] == '$' && i + 1 < raw.size() && raw[i + 1] == '{') { - size_t end = raw.find('}', i + 2); - if (end == std::string::npos) { - result.push_back(raw[i++]); - continue; - } - std::string var_name = raw.substr(i + 2, end - (i + 2)); - const char* val = std::getenv(var_name.c_str()); - if (val) { - result.append(val); - } else { - result.append(raw, i, end + 1 - i); // leave ${VAR} untouched - } - i = end + 1; - } else { - result.push_back(raw[i++]); - } - } - - if (!result.empty() && result.front() == '~') { -#ifdef _WIN32 - const char* home = std::getenv("USERPROFILE"); -#else - const char* home = std::getenv("HOME"); -#endif - if (home && *home) { - result = std::string(home) + result.substr(1); - } - } - - return result; -} - -std::vector get_project_dirs_up_to_home(const std::string& cwd) { - std::vector dirs; - if (cwd.empty()) return dirs; - - std::error_code ec; - fs::path abs = fs::weakly_canonical(fs::path(cwd), ec); - if (ec || abs.empty()) abs = fs::path(cwd); - - fs::path home_path; -#ifdef _WIN32 - const char* home_env = std::getenv("USERPROFILE"); -#else - const char* home_env = std::getenv("HOME"); -#endif - if (home_env && *home_env) { - std::error_code hec; - home_path = fs::weakly_canonical(fs::path(home_env), hec); - if (hec) home_path = fs::path(home_env); - } - - // Walk up from cwd; stop at/above HOME (the user-global root is added - // separately) or once we hit a filesystem root. Deepest first. - fs::path cur = abs; - while (true) { - if (!home_path.empty() && cur == home_path) break; - dirs.push_back(cur.string()); - fs::path parent = cur.parent_path(); - if (parent == cur) break; - cur = parent; - } - return dirs; -} - std::string normalize_upgrade_base_url(std::string raw) { raw = trim_ascii_copy(raw); if (!raw.empty() && raw.back() != '/') { diff --git a/src/config/config.hpp b/src/config/config.hpp index 54d0adc29..e7fce100d 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -225,17 +225,6 @@ struct AppConfig { std::string default_model_name; }; -// Expand ~ and ${ENV} style variables in a path string. Returns the expanded -// form; missing env vars are left as-is (per hermes convention). -std::string expand_path(const std::string& raw); - -// Collect project-level directories from cwd up to (but not including) the -// user's home directory. Returned deepest-first so cwd-level skills take -// precedence over ancestor-level skills when scanned in order. HOME itself is -// excluded because the user-global skills root (`~/.acecode/skills`) is -// registered separately. -std::vector get_project_dirs_up_to_home(const std::string& cwd); - // Normalize upgrade.base_url by trimming surrounding whitespace and adding a // trailing slash when non-empty. std::string normalize_upgrade_base_url(std::string raw); diff --git a/src/daemon/worker.cpp b/src/daemon/worker.cpp index ffbb8b5f2..8c966ee5b 100644 --- a/src/daemon/worker.cpp +++ b/src/daemon/worker.cpp @@ -30,6 +30,7 @@ #include "../tool/web_search/web_search_tool.hpp" #include "../network/proxy_resolver.hpp" #include "../utils/logger.hpp" +#include "../utils/paths.hpp" #include "../utils/token.hpp" #include "../web/auth.hpp" #include "../web/server.hpp" diff --git a/src/desktop/dpi_win.cpp b/src/desktop/dpi_win.cpp new file mode 100644 index 000000000..8559c2313 --- /dev/null +++ b/src/desktop/dpi_win.cpp @@ -0,0 +1,82 @@ +#include "dpi_win.hpp" + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include + +namespace acecode::desktop { + +bool enable_desktop_dpi_awareness() { + // The desktop shell owns the Win32 parent window that hosts WebView2. + // webview/webview enables DPI awareness only for windows it creates itself, + // so the custom parent path must set this before any HWND is created. + HMODULE user32 = ::LoadLibraryW(L"user32.dll"); + if (user32) { + using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(HANDLE); + auto set_context = reinterpret_cast( + ::GetProcAddress(user32, "SetProcessDpiAwarenessContext")); + if (set_context) { + constexpr INT_PTR kPerMonitorAware = -3; + constexpr INT_PTR kPerMonitorAwareV2 = -4; + if (set_context(reinterpret_cast(kPerMonitorAwareV2))) { + ::FreeLibrary(user32); + return true; + } + if (::GetLastError() == ERROR_ACCESS_DENIED) { + ::FreeLibrary(user32); + return true; + } + if (set_context(reinterpret_cast(kPerMonitorAware))) { + ::FreeLibrary(user32); + return true; + } + if (::GetLastError() == ERROR_ACCESS_DENIED) { + ::FreeLibrary(user32); + return true; + } + } + + ::FreeLibrary(user32); + } + + HMODULE shcore = ::LoadLibraryW(L"shcore.dll"); + if (shcore) { + using SetProcessDpiAwarenessFn = HRESULT(WINAPI*)(int); + auto set_awareness = reinterpret_cast( + ::GetProcAddress(shcore, "SetProcessDpiAwareness")); + if (set_awareness) { + constexpr int kProcessPerMonitorDpiAware = 2; + HRESULT hr = set_awareness(kProcessPerMonitorDpiAware); + if (SUCCEEDED(hr) || hr == E_ACCESSDENIED) { + ::FreeLibrary(shcore); + return true; + } + } + ::FreeLibrary(shcore); + } + + user32 = ::LoadLibraryW(L"user32.dll"); + if (user32) { + using SetProcessDpiAwareFn = BOOL(WINAPI*)(); + auto set_system_aware = reinterpret_cast( + ::GetProcAddress(user32, "SetProcessDPIAware")); + if (set_system_aware) { + if (set_system_aware() || ::GetLastError() == ERROR_ACCESS_DENIED) { + ::FreeLibrary(user32); + return true; + } + } + ::FreeLibrary(user32); + } + + return false; +} + +} // namespace acecode::desktop + +#endif // _WIN32 diff --git a/src/desktop/dpi_win.hpp b/src/desktop/dpi_win.hpp new file mode 100644 index 000000000..bf38a2396 --- /dev/null +++ b/src/desktop/dpi_win.hpp @@ -0,0 +1,26 @@ +#pragma once + +// Windows 桌面壳 DPI 感知初始化。 +// +// 必须在任何 HWND 创建之前调一次:webview/webview 只对自己创建的窗口设 +// DPI awareness,而我们使用自定义的 Win32 父窗口承载 WebView2,所以得自己 +// 调用 SetProcessDpiAwarenessContext / SetProcessDpiAwareness / SetProcessDPIAware +// 之中能用的那个。 +// +// 三层回退顺序(失败则尝试下一层): +// 1. user32!SetProcessDpiAwarenessContext (Win10 1703+,PerMonitorAwareV2) +// 2. shcore!SetProcessDpiAwareness (Win8.1+,PerMonitorAware) +// 3. user32!SetProcessDPIAware (Vista+,SystemAware) +// +// ERROR_ACCESS_DENIED 视为成功 — 表示 manifest 里已经声明过,系统不接受重复设置。 +// +// 仅在 Windows 编译时定义。 + +namespace acecode::desktop { + +// 返回 true 表示成功设置(包括 ERROR_ACCESS_DENIED 的"已设置"情况), +// false 表示三层回退全部失败。失败不致命,主流程继续 — DPI 渲染将以 +// 默认的 system-aware 行为运行。 +bool enable_desktop_dpi_awareness(); + +} // namespace acecode::desktop diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index b7dbe1f7f..2bb311c1e 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -14,6 +14,7 @@ // daemon 端通过 workspace-aware API 在同一进程内服务多个 workspace。 #include "daemon_pool.hpp" +#include "dpi_win.hpp" #include "folder_picker.hpp" #include "notifications_win.hpp" #include "open_in_explorer.hpp" @@ -72,71 +73,6 @@ void show_error(const std::string& msg) { ::MessageBoxW(nullptr, w.c_str(), L"ACECode Desktop", MB_ICONERROR | MB_OK); } -bool enable_desktop_dpi_awareness() { - // The desktop shell owns the Win32 parent window that hosts WebView2. - // webview/webview enables DPI awareness only for windows it creates itself, - // so the custom parent path must set this before any HWND is created. - HMODULE user32 = ::LoadLibraryW(L"user32.dll"); - if (user32) { - using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(HANDLE); - auto set_context = reinterpret_cast( - ::GetProcAddress(user32, "SetProcessDpiAwarenessContext")); - if (set_context) { - constexpr INT_PTR kPerMonitorAware = -3; - constexpr INT_PTR kPerMonitorAwareV2 = -4; - if (set_context(reinterpret_cast(kPerMonitorAwareV2))) { - ::FreeLibrary(user32); - return true; - } - if (::GetLastError() == ERROR_ACCESS_DENIED) { - ::FreeLibrary(user32); - return true; - } - if (set_context(reinterpret_cast(kPerMonitorAware))) { - ::FreeLibrary(user32); - return true; - } - if (::GetLastError() == ERROR_ACCESS_DENIED) { - ::FreeLibrary(user32); - return true; - } - } - - ::FreeLibrary(user32); - } - - HMODULE shcore = ::LoadLibraryW(L"shcore.dll"); - if (shcore) { - using SetProcessDpiAwarenessFn = HRESULT(WINAPI*)(int); - auto set_awareness = reinterpret_cast( - ::GetProcAddress(shcore, "SetProcessDpiAwareness")); - if (set_awareness) { - constexpr int kProcessPerMonitorDpiAware = 2; - HRESULT hr = set_awareness(kProcessPerMonitorDpiAware); - if (SUCCEEDED(hr) || hr == E_ACCESSDENIED) { - ::FreeLibrary(shcore); - return true; - } - } - ::FreeLibrary(shcore); - } - - user32 = ::LoadLibraryW(L"user32.dll"); - if (user32) { - using SetProcessDpiAwareFn = BOOL(WINAPI*)(); - auto set_system_aware = reinterpret_cast( - ::GetProcAddress(user32, "SetProcessDPIAware")); - if (set_system_aware) { - if (set_system_aware() || ::GetLastError() == ERROR_ACCESS_DENIED) { - ::FreeLibrary(user32); - return true; - } - } - ::FreeLibrary(user32); - } - - return false; -} #endif fs::path path_from_utf8(const std::string& path) { @@ -356,7 +292,7 @@ int main(int, char**) { LOG_INFO("[desktop] starting acecode-desktop"); #ifdef _WIN32 LOG_INFO(std::string("[desktop] DPI awareness ") + - (enable_desktop_dpi_awareness() ? "enabled" : "not enabled")); + (acecode::desktop::enable_desktop_dpi_awareness() ? "enabled" : "not enabled")); #endif SplashScreen splash; diff --git a/src/skills/skill_init.cpp b/src/skills/skill_init.cpp index 998f8e665..4af0b6852 100644 --- a/src/skills/skill_init.cpp +++ b/src/skills/skill_init.cpp @@ -2,6 +2,7 @@ #include "skill_registry.hpp" #include "../config/config.hpp" +#include "../utils/paths.hpp" #include #include diff --git a/src/utils/paths.cpp b/src/utils/paths.cpp index 99cdf59d2..89be82c16 100644 --- a/src/utils/paths.cpp +++ b/src/utils/paths.cpp @@ -7,9 +7,87 @@ #include #include #include +#include +#include namespace acecode { +std::string expand_path(const std::string& raw) { + if (raw.empty()) return raw; + + std::string result; + result.reserve(raw.size()); + size_t i = 0; + + // ${VAR} substitution first so later ~ expansion sees final text. + while (i < raw.size()) { + if (raw[i] == '$' && i + 1 < raw.size() && raw[i + 1] == '{') { + size_t end = raw.find('}', i + 2); + if (end == std::string::npos) { + result.push_back(raw[i++]); + continue; + } + std::string var_name = raw.substr(i + 2, end - (i + 2)); + const char* val = std::getenv(var_name.c_str()); + if (val) { + result.append(val); + } else { + result.append(raw, i, end + 1 - i); // leave ${VAR} untouched + } + i = end + 1; + } else { + result.push_back(raw[i++]); + } + } + + if (!result.empty() && result.front() == '~') { +#ifdef _WIN32 + const char* home = std::getenv("USERPROFILE"); +#else + const char* home = std::getenv("HOME"); +#endif + if (home && *home) { + result = std::string(home) + result.substr(1); + } + } + + return result; +} + +std::vector get_project_dirs_up_to_home(const std::string& cwd) { + namespace fs = std::filesystem; + std::vector dirs; + if (cwd.empty()) return dirs; + + std::error_code ec; + fs::path abs = fs::weakly_canonical(fs::path(cwd), ec); + if (ec || abs.empty()) abs = fs::path(cwd); + + fs::path home_path; +#ifdef _WIN32 + const char* home_env = std::getenv("USERPROFILE"); +#else + const char* home_env = std::getenv("HOME"); +#endif + if (home_env && *home_env) { + std::error_code hec; + home_path = fs::weakly_canonical(fs::path(home_env), hec); + if (hec) home_path = fs::path(home_env); + } + + // Walk up from cwd; stop at/above HOME (the user-global root is added + // separately) or once we hit a filesystem root. Deepest first. + fs::path cur = abs; + while (true) { + if (!home_path.empty() && cur == home_path) break; + dirs.push_back(cur.string()); + fs::path parent = cur.parent_path(); + if (parent == cur) break; + cur = parent; + } + return dirs; +} + namespace { // 进程级单例。RunMode 用 atomic 是因为 set_run_mode 可能在 ServiceMain 早期由 diff --git a/src/utils/paths.hpp b/src/utils/paths.hpp index 097885ffa..b118dd44f 100644 --- a/src/utils/paths.hpp +++ b/src/utils/paths.hpp @@ -13,9 +13,21 @@ // 绕过 once 保护,fixture TearDown 应调 reset_run_mode_for_test() 清场。 #include +#include namespace acecode { +// Expand ~ and ${ENV} style variables in a path string. Returns the expanded +// form; missing env vars are left as-is (per hermes convention). +std::string expand_path(const std::string& raw); + +// Collect project-level directories from cwd up to (but not including) the +// user's home directory. Returned deepest-first so cwd-level skills take +// precedence over ancestor-level skills when scanned in order. HOME itself is +// excluded because the user-global skills root (`~/.acecode/skills`) is +// registered separately. +std::vector get_project_dirs_up_to_home(const std::string& cwd); + enum class RunMode { User = 0, // 默认 — TUI / standalone daemon / `acecode daemon --foreground` Service = 1, // SCM ServiceMain 拉起的 worker diff --git a/src/web/origin.cpp b/src/web/origin.cpp new file mode 100644 index 000000000..7f5344361 --- /dev/null +++ b/src/web/origin.cpp @@ -0,0 +1,62 @@ +#include "origin.hpp" + +#include +#include + +namespace acecode::web { + +namespace { + +std::string ascii_lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; +} + +} // namespace + +OriginParts split_host_port(std::string hostport) { + OriginParts out; + hostport = ascii_lower(std::move(hostport)); + if (!hostport.empty() && hostport.front() == '[') { + auto end = hostport.find(']'); + if (end != std::string::npos) { + out.host = hostport.substr(1, end - 1); + if (end + 1 < hostport.size() && hostport[end + 1] == ':') { + out.port = hostport.substr(end + 2); + } + return out; + } + } + + auto colon = hostport.rfind(':'); + if (colon != std::string::npos && + hostport.find(':') == colon) { + out.host = hostport.substr(0, colon); + out.port = hostport.substr(colon + 1); + } else { + out.host = hostport; + } + return out; +} + +OriginParts parse_origin(const std::string& origin) { + OriginParts out; + auto scheme_pos = origin.find("://"); + if (scheme_pos == std::string::npos) return out; + out.scheme = ascii_lower(origin.substr(0, scheme_pos)); + auto rest = origin.substr(scheme_pos + 3); + auto slash = rest.find('/'); + if (slash != std::string::npos) rest = rest.substr(0, slash); + OriginParts hp = split_host_port(rest); + out.host = std::move(hp.host); + out.port = std::move(hp.port); + if (out.port.empty()) { + if (out.scheme == "http") out.port = "80"; + else if (out.scheme == "https") out.port = "443"; + } + return out; +} + +} // namespace acecode::web diff --git a/src/web/origin.hpp b/src/web/origin.hpp new file mode 100644 index 000000000..ab4c5a88a --- /dev/null +++ b/src/web/origin.hpp @@ -0,0 +1,24 @@ +#pragma once + +// HTTP Origin 头解析(纯字符串处理,无 Crow / 无 daemon 状态)。 +// 给 src/web/server.cpp 的同源 / loopback 判定用 — 拆出来便于单测覆盖。 + +#include + +namespace acecode::web { + +struct OriginParts { + std::string scheme; + std::string host; + std::string port; +}; + +// 拆 "host[:port]" 或 "[ipv6]:port"。空 port 留空 — 调用方按 scheme 决定默认。 +// host / port 都被 lowercase。 +OriginParts split_host_port(std::string hostport); + +// 解析完整 origin "scheme://host[:port][/...]"。无 "://" 返回空 OriginParts。 +// scheme + host 都 lowercase。port 缺省时按 scheme 填:http→80,https→443。 +OriginParts parse_origin(const std::string& origin); + +} // namespace acecode::web diff --git a/src/web/server.cpp b/src/web/server.cpp index 75202e1d6..7826f6823 100644 --- a/src/web/server.cpp +++ b/src/web/server.cpp @@ -1,6 +1,7 @@ #include "server.hpp" #include "auth.hpp" +#include "origin.hpp" #include "static_assets.hpp" #include "../config/config.hpp" #include "../desktop/workspace_registry.hpp" @@ -147,55 +148,6 @@ std::string ascii_lower(std::string s) { return s; } -struct OriginParts { - std::string scheme; - std::string host; - std::string port; -}; - -OriginParts split_host_port(std::string hostport) { - OriginParts out; - hostport = ascii_lower(std::move(hostport)); - if (!hostport.empty() && hostport.front() == '[') { - auto end = hostport.find(']'); - if (end != std::string::npos) { - out.host = hostport.substr(1, end - 1); - if (end + 1 < hostport.size() && hostport[end + 1] == ':') { - out.port = hostport.substr(end + 2); - } - return out; - } - } - - auto colon = hostport.rfind(':'); - if (colon != std::string::npos && - hostport.find(':') == colon) { - out.host = hostport.substr(0, colon); - out.port = hostport.substr(colon + 1); - } else { - out.host = hostport; - } - return out; -} - -OriginParts parse_origin(const std::string& origin) { - OriginParts out; - auto scheme_pos = origin.find("://"); - if (scheme_pos == std::string::npos) return out; - out.scheme = ascii_lower(origin.substr(0, scheme_pos)); - auto rest = origin.substr(scheme_pos + 3); - auto slash = rest.find('/'); - if (slash != std::string::npos) rest = rest.substr(0, slash); - OriginParts hp = split_host_port(rest); - out.host = std::move(hp.host); - out.port = std::move(hp.port); - if (out.port.empty()) { - if (out.scheme == "http") out.port = "80"; - else if (out.scheme == "https") out.port = "443"; - } - return out; -} - bool is_loopback_host(const std::string& host) { auto h = ascii_lower(host); return h == "localhost" || h == "::1" || diff --git a/tests/web/origin_test.cpp b/tests/web/origin_test.cpp new file mode 100644 index 000000000..caf18cf81 --- /dev/null +++ b/tests/web/origin_test.cpp @@ -0,0 +1,142 @@ +// 覆盖 src/web/origin.{hpp,cpp}。这是 daemon 的 HTTP Origin 头解析层 —— +// 同源 / loopback 判定的输入,任何回归都会让 server.cpp 的 CSRF / cross-origin +// 防护误判,直接放行恶意 origin 或反过来打断本机正常请求。 +// +// 这些测试是纯函数级别 —— 不动 Crow,不动磁盘,毫秒级跑完。 +// 与现有 tests/web/auth_test.cpp 的风格一致。 + +#include + +#include "web/origin.hpp" + +using namespace acecode::web; + +// ============================================================ +// parse_origin: scheme + host + port 拆分 +// ============================================================ + +// 场景: 标准 http origin,显式端口。最常见的 daemon 自身请求形态。 +TEST(ParseOrigin, HttpExplicitPort) { + auto o = parse_origin("http://localhost:28080"); + EXPECT_EQ(o.scheme, "http"); + EXPECT_EQ(o.host, "localhost"); + EXPECT_EQ(o.port, "28080"); +} + +// 场景: https origin 显式端口。处理路径与 http 完全相同,只是默认端口不同。 +TEST(ParseOrigin, HttpsExplicitPort) { + auto o = parse_origin("https://example.com:8443"); + EXPECT_EQ(o.scheme, "https"); + EXPECT_EQ(o.host, "example.com"); + EXPECT_EQ(o.port, "8443"); +} + +// 场景: 缺省端口由 scheme 推断 —— http→80,https→443。 +// is_same_request_origin 需要 port 永远非空,这里是兜底。 +TEST(ParseOrigin, DefaultPortForHttp) { + auto o = parse_origin("http://example.com"); + EXPECT_EQ(o.scheme, "http"); + EXPECT_EQ(o.host, "example.com"); + EXPECT_EQ(o.port, "80"); +} + +TEST(ParseOrigin, DefaultPortForHttps) { + auto o = parse_origin("https://example.com"); + EXPECT_EQ(o.scheme, "https"); + EXPECT_EQ(o.host, "example.com"); + EXPECT_EQ(o.port, "443"); +} + +// 场景: scheme 与 host 都被 lowercase。Origin header 大小写不敏感,但下游 +// 比较是逐字符的 —— 不归一化会让 "HTTP://Foo" 与 "http://foo" 被判为不同源。 +TEST(ParseOrigin, LowercasesSchemeAndHost) { + auto o = parse_origin("HTTP://EXAMPLE.COM:8080"); + EXPECT_EQ(o.scheme, "http"); + EXPECT_EQ(o.host, "example.com"); + EXPECT_EQ(o.port, "8080"); +} + +// 场景: IPv6 字面量包在方括号里。crow / 浏览器都按 RFC 3986 发, +// "http://[::1]:28080" 必须正确拆出 host="::1" port="28080"。 +TEST(ParseOrigin, IPv6BracketedHost) { + auto o = parse_origin("http://[::1]:28080"); + EXPECT_EQ(o.scheme, "http"); + EXPECT_EQ(o.host, "::1"); + EXPECT_EQ(o.port, "28080"); +} + +// 场景: IPv6 字面量缺省端口 —— 仍需按 scheme 兜底端口。 +TEST(ParseOrigin, IPv6BracketedDefaultPort) { + auto o = parse_origin("https://[::1]"); + EXPECT_EQ(o.scheme, "https"); + EXPECT_EQ(o.host, "::1"); + EXPECT_EQ(o.port, "443"); +} + +// 场景: origin 带尾部路径 —— 浏览器一般只发 scheme://host[:port],但有些 +// 客户端会带 "/"。path 部分必须被忽略,只保留 authority。 +TEST(ParseOrigin, TrailingSlashPathIgnored) { + auto o = parse_origin("http://example.com:1234/some/path"); + EXPECT_EQ(o.scheme, "http"); + EXPECT_EQ(o.host, "example.com"); + EXPECT_EQ(o.port, "1234"); +} + +// 场景: 缺 "://" 必须返回空 OriginParts。Origin 头没这个分隔符就是不合法, +// 不应该当成 host 名硬塞 — 否则 is_same_request_origin 会拿空 scheme 比对。 +TEST(ParseOrigin, MissingSchemeReturnsEmpty) { + auto o = parse_origin("example.com:8080"); + EXPECT_TRUE(o.scheme.empty()); + EXPECT_TRUE(o.host.empty()); + EXPECT_TRUE(o.port.empty()); +} + +// 场景: 完全空字符串 —— 与缺 "://" 同样路径,返回空。 +TEST(ParseOrigin, EmptyInputReturnsEmpty) { + auto o = parse_origin(""); + EXPECT_TRUE(o.scheme.empty()); + EXPECT_TRUE(o.host.empty()); + EXPECT_TRUE(o.port.empty()); +} + +// ============================================================ +// split_host_port: Host 头(无 scheme)的纯 authority 拆分 +// ============================================================ + +// 场景: 标准 "host:port"。Host header 在 HTTP/1.1 永远带 port(虽然规范上可省)。 +TEST(SplitHostPort, StandardHostPort) { + auto hp = split_host_port("example.com:1234"); + EXPECT_EQ(hp.host, "example.com"); + EXPECT_EQ(hp.port, "1234"); +} + +// 场景: 只 host 不带 port —— port 留空,调用方按 scheme 决定默认。 +TEST(SplitHostPort, HostOnlyEmptyPort) { + auto hp = split_host_port("example.com"); + EXPECT_EQ(hp.host, "example.com"); + EXPECT_TRUE(hp.port.empty()); +} + +// 场景: IPv6 方括号 + 端口。判定方括号要早于"找最后一个冒号"逻辑, +// 否则 "[::1]:28080" 的最后一个冒号会落在 "1]" 后面,host 就拆错了。 +TEST(SplitHostPort, IPv6BracketedWithPort) { + auto hp = split_host_port("[::1]:28080"); + EXPECT_EQ(hp.host, "::1"); + EXPECT_EQ(hp.port, "28080"); +} + +// 场景: 裸 IPv6 含多个冒号且没方括号。代码用 "rfind == find" 启发式判定 +// 是否只有一个冒号,多冒号且没方括号视为 IPv6 host 整段保留 — 不拆 port。 +// 这条测覆盖该启发式分支。 +TEST(SplitHostPort, BareIPv6MultipleColonsKeepWhole) { + auto hp = split_host_port("fe80::1"); + EXPECT_EQ(hp.host, "fe80::1"); + EXPECT_TRUE(hp.port.empty()); +} + +// 场景: host 与 port 都被 lowercase。host 大小写不敏感,与 parse_origin 行为一致。 +TEST(SplitHostPort, LowercasesHost) { + auto hp = split_host_port("EXAMPLE.com:80"); + EXPECT_EQ(hp.host, "example.com"); + EXPECT_EQ(hp.port, "80"); +}