diff --git a/src/desktop/chromium_app_launcher.cpp b/src/desktop/chromium_app_launcher.cpp new file mode 100644 index 00000000..939b850f --- /dev/null +++ b/src/desktop/chromium_app_launcher.cpp @@ -0,0 +1,158 @@ +// chromium_app_launcher 实现。设计 + 调用时机见 chromium_app_launcher.hpp。 + +#include "chromium_app_launcher.hpp" + +#include "../utils/encoding.hpp" +#include "../utils/logger.hpp" + +#include +#include +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include +#endif + +namespace fs = std::filesystem; + +namespace acecode::desktop { + +namespace { + +// 候选 Chromium 系浏览器,(/, display_name)。Edge +// 优先 — 同事场景里 Edge 几乎必装,且 Edge UI 跟我们想要的 "原生 app" 体感 +// 最接近。Chrome 次之。其它二线 Chromium(360/QQ/Brave/Vivaldi)有的不兼容 +// --app= 或参数改了名,本期不试,留给 ShellExecuteW 兜底默认浏览器。 +constexpr std::array, 2> kBrowserCandidates = {{ + {"Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"}, + {"Google/Chrome/Application/chrome.exe", "Google Chrome"}, +}}; + +bool path_exists_file(const fs::path& p) { + std::error_code ec; + return fs::is_regular_file(p, ec) && !ec; +} + +} // namespace + +std::optional find_chromium_app_browser_in( + const std::vector& roots) { + // 先按候选浏览器顺序遍历,再按 roots 顺序;这样 Edge 在任一 root 命中 + // 都优先于 Chrome。 + for (const auto& [rel_path, display_name] : kBrowserCandidates) { + for (const auto& root : roots) { + if (root.empty()) continue; + fs::path candidate = root / rel_path; + if (path_exists_file(candidate)) { + return ChromiumBrowser{candidate, display_name}; + } + } + } + return std::nullopt; +} + +#ifdef _WIN32 + +namespace { + +fs::path known_folder_path(REFKNOWNFOLDERID id) { + PWSTR raw = nullptr; + HRESULT hr = ::SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, nullptr, &raw); + if (FAILED(hr) || !raw) { + if (raw) ::CoTaskMemFree(raw); + return {}; + } + fs::path result(raw); + ::CoTaskMemFree(raw); + return result; +} + +} // namespace + +std::optional find_chromium_app_browser() { + std::vector roots; + if (auto pf = known_folder_path(FOLDERID_ProgramFiles); !pf.empty()) { + roots.push_back(std::move(pf)); + } + if (auto pfx86 = known_folder_path(FOLDERID_ProgramFilesX86); !pfx86.empty()) { + roots.push_back(std::move(pfx86)); + } + if (roots.empty()) { + LOG_WARN("[chromium_app] SHGetKnownFolderPath returned no ProgramFiles paths"); + return std::nullopt; + } + return find_chromium_app_browser_in(roots); +} + +bool launch_chromium_app_mode(const fs::path& exe, + const std::string& url, + std::string& error) { + // 拼 command line:CreateProcessW 的 lpCommandLine 必须可写,且第一个 + // 实参约定是程序名(被 argv[0] 拿)。带空格的路径要加引号。 + // "" --app="" + std::wstring cmd; + cmd.reserve(exe.wstring().size() + url.size() + 16); + cmd.push_back(L'"'); + cmd += exe.wstring(); + cmd.push_back(L'"'); + cmd += L" --app=\""; + cmd += acecode::utf8_to_wide(url); + cmd.push_back(L'"'); + + // lpCommandLine 在 CreateProcessW 路径下可能被内部修改(strtok 风格), + // 必须传非 const 缓冲区。std::wstring::data() 自 C++17 起返回可写指针。 + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + + // 不传 lpApplicationName 而是让 CreateProcessW 从 cmd 第一个 token 解析, + // 这样 Chrome / Edge 自己也能依赖 argv[0] 正确推断安装目录。 + // 不带 CREATE_SUSPENDED / CREATE_BREAKAWAY_FROM_JOB —— 浏览器进程加入 + // desktop 的 Job 也无所谓(浏览器自己创建子进程时会用 + // PROCESS_BREAKAWAY,绕开 KILL_ON_JOB_CLOSE);desktop 退出时浏览器跟着 + // kill 也是预期(用户从托盘 quit 时,浏览器开着没意义)。 + BOOL ok = ::CreateProcessW( + /*lpApplicationName=*/nullptr, + /*lpCommandLine=*/cmd.data(), + /*lpProcessAttributes=*/nullptr, + /*lpThreadAttributes=*/nullptr, + /*bInheritHandles=*/FALSE, + /*dwCreationFlags=*/0, + /*lpEnvironment=*/nullptr, + /*lpCurrentDirectory=*/nullptr, + &si, &pi); + if (!ok) { + const DWORD gle = ::GetLastError(); + error = "CreateProcessW failed, gle=" + std::to_string(gle); + return false; + } + // 父进程不等待子进程结束,直接关掉句柄。 + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + return true; +} + +#else // _WIN32 + +std::optional find_chromium_app_browser() { + return std::nullopt; +} + +bool launch_chromium_app_mode(const fs::path& /*exe*/, + const std::string& /*url*/, + std::string& error) { + error = "platform not supported"; + return false; +} + +#endif // _WIN32 + +} // namespace acecode::desktop diff --git a/src/desktop/chromium_app_launcher.hpp b/src/desktop/chromium_app_launcher.hpp new file mode 100644 index 00000000..9d41046c --- /dev/null +++ b/src/desktop/chromium_app_launcher.hpp @@ -0,0 +1,54 @@ +#pragma once + +// "Chromium app 模式" 浏览器启动器 — 把已经在跑的 daemon URL 喂给一个无地址 +// 栏 / 无标签栏 / 无书签栏的浏览器窗口,让用户体感是一个独立 native app, +// 而不是"被丢回浏览器"。所有 Chromium 系浏览器(Edge / Chrome / Brave 等) +// 都支持 `--app=` 参数。 +// +// 使用场景:WebView2 不可用(企业 / 信创机器,Edge >=126 不允许第三方借用) +// 时,acecode-desktop 进入"浏览器降级"流程,优先尝试 app 模式 — 用户感知最 +// 接近原生 webview;失败再 fallback 到 ShellExecuteW 系统默认浏览器(可能 +// 是 IE / Firefox 等非 Chromium,只能用普通 tab)。 +// +// 平台:Windows-only。POSIX 上头文件保持可见但实现是 stub(macOS / Linux +// 桌面壳本期未对 WebView2 / WKWebView 失败做浏览器降级)。 + +#include +#include +#include +#include + +namespace acecode::desktop { + +struct ChromiumBrowser { + std::filesystem::path exe; // 浏览器主进程的绝对路径 + std::string display_name; // 用于日志的可读名(Edge / Chrome / ...) +}; + +// 在给定根目录列表中查找 Chromium 系浏览器主可执行。纯函数,unit test +// 喂临时目录覆盖。返回首个命中(按 candidates_per_root 的优先级顺序)。 +// +// 期望路径形态:///Application/。我们只列业内 +// 主流的 Edge + Chrome 两条 — 二线 Chromium 系(360/QQ/Brave/...)有的不 +// 兼容 --app= 或参数被改名,贸然走会出现"浏览器开了但啥也没显示"的诡异 +// 体验,不如直接 fallback 给 ShellExecuteW 让用户看到原始默认浏览器。 +std::optional find_chromium_app_browser_in( + const std::vector& roots); + +// Windows 系统调用版:SHGetKnownFolderPath 拿 ProgramFiles / ProgramFiles(x86), +// 调上面纯函数。POSIX stub。 +std::optional find_chromium_app_browser(); + +// 启动 browser.exe 用 --app=url 把窗口拉起来,detached 模式(不阻塞、不 +// 加入 desktop 的 Job Object — 浏览器是用户进程,desktop 退出时不该跟着 +// kill,虽然 daemon 会因 Job 关闭被 kill 让浏览器加载失败,但这是用户从 +// 托盘 quit 的预期行为)。 +// +// 返回 true = CreateProcessW 成功(进程已 spawn,不保证窗口已渲染); +// false + error 字符串 = CreateProcessW 失败,调用方应当 fallback。POSIX +// 上始终返 false / "platform not supported"。 +bool launch_chromium_app_mode(const std::filesystem::path& exe, + const std::string& url, + std::string& error); + +} // namespace acecode::desktop diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index ef98bc3d..7bac16ee 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 "chromium_app_launcher.hpp" #include "dpi_win.hpp" #include "folder_picker.hpp" #include "notifications_win.hpp" @@ -496,6 +497,11 @@ int main(int, char**) { // 父窗口在屏幕外保持可见状态完成 WebView2 渲染,页面 ready 后再移回 // 当前屏幕中央。这样用户启动时只看到透明 icon,不会看到白屏。 const bool desktop_debug = is_desktop_debug_mode(); + // WebHost 构造可能抛 webview::exception(WebView2 Runtime 缺失等)。Impl + // ctor 内部已经做两层 fallback(默认 Loader → Edge 浏览器目录),都失败时 + // rethrow 由这里接管 — 询问用户是否在系统默认浏览器中继续运行,daemon + // 保留后台,所有业务逻辑走 daemon HTTP(P1-P3 已下沉)。 + try { WebHost host(/*debug=*/desktop_debug, WebHost::StartupWindowMode::OffscreenUntilReady); host.set_title("ACECode"); host.set_size(kDefaultDesktopWindowWidth, kDefaultDesktopWindowHeight); @@ -1079,6 +1085,123 @@ int main(int, char**) { auto failures = pool.stop_all(); return failures.empty() ? 0 : 100; // 部分失败返回非零便于诊断 + } catch (...) { + // webview/webview 的 webview::exception 是否派生自 std::exception 我们 + // 不直接 include webview.h(为了让 main.cpp 跟 webview 解耦),所以用 + // catch (...) 兜底而非 catch (std::exception&)。webview_ex_what() 内 + // 部对 std::exception 取 what(),其它情形 fallback 到固定字符串。 + const std::string what_str = []() -> std::string { + try { throw; } + catch (const std::exception& se) { return se.what(); } + catch (...) { return "(unknown WebView2 init exception)"; } + }(); + // WebHost 构造失败 — 进入"系统默认浏览器"降级流程。所有 daemon + // / pool 状态在 try 外定义,可在 catch 内继续操作;host / 托盘 / + // 通知尚未初始化(异常发生在 host ctor 内),不需要 shutdown。 + // + // 选择 daemon 后台保留 + ShellExecuteW 打开 URL 的设计原因: + // 1) daemon 已由 desktop 进程 spawn 在 Job Object 内,且 supervisor + // 设了 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — desktop 退出 Job 关 + // daemon 跟着死。要让 daemon 继续服务浏览器,desktop 进程必须 + // "挂着"(简单 message loop 等系统/用户结束)。 + // 2) 业务逻辑在 P1-P3 已全部下沉到 daemon HTTP,浏览器里 UI 完整。 + // Tier A 的窗口 chrome / 托盘 / 系统通知降级缺失,前端 detect + // bridge 不存在自动隐藏对应 UI。 + LOG_ERROR(std::string("[desktop] WebView2 initialization failed: ") + + what_str); + +#ifdef _WIN32 + // daemon URL 必须就绪;onboarding daemon 启动失败时仍是 about:blank 占 + // 位符,降级也没意义 — 这种情况直接弹错退出避免用户看到一个空白浏览 + // 器窗口。 + if (url.empty() || url == onboarding_url()) { + ::MessageBoxW(nullptr, + L"daemon 未就绪,无法启动浏览器模式。\n" + L"请查看 %USERPROFILE%\\.acecode\\logs\\desktop-*.log 后重试。", + L"ACECode 启动失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + pool.stop_all(); + return 1; + } + + // 静默降级:优先 Chromium 系浏览器 --app= 假装独立 native 窗口 + // (无地址栏 / 无标签栏 / 独立 taskbar 图标),用户体感最接近原生 + // webview。找不到 Chromium 时 fallback ShellExecuteW 走系统默认浏览 + // 器(可能是 Firefox / 其它,只能用普通 tab,UX 退化但功能完整)。 + auto open_app_window = [&url]() -> bool { + auto found = acecode::desktop::find_chromium_app_browser(); + if (found.has_value()) { + std::string err; + if (acecode::desktop::launch_chromium_app_mode(found->exe, url, err)) { + LOG_INFO("[desktop] launched browser app-mode via " + + found->display_name + ": " + found->exe.string() + + " --app=" + url); + return true; + } + LOG_WARN("[desktop] " + found->display_name + + " --app= launch failed: " + err + + " — falling back to ShellExecuteW"); + } else { + LOG_INFO("[desktop] no Chromium-based browser found for app-mode; " + "using ShellExecuteW default browser"); + } + const std::wstring wurl = acecode::utf8_to_wide(url); + HINSTANCE sh = ::ShellExecuteW(nullptr, L"open", wurl.c_str(), + nullptr, nullptr, SW_SHOWNORMAL); + if (reinterpret_cast(sh) > 32) { + LOG_INFO("[desktop] launched default browser tab: " + url); + return true; + } + LOG_ERROR("[desktop] ShellExecuteW failed gle=" + + std::to_string(::GetLastError()) + " url=" + url); + return false; + }; + + if (!open_app_window()) { + const std::wstring fallback_msg = + L"无法启动浏览器。请手动复制下面 URL 在浏览器打开:\n\n" + + acecode::utf8_to_wide(url); + ::MessageBoxW(nullptr, fallback_msg.c_str(), + L"ACECode 浏览器降级失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + // 不立即退出 — 用户拷贝 URL 后可以自行打开,desktop 进程仍保持 + // 让 daemon 活,下面跌入 message loop。 + } + + // 浏览器降级模式下的托盘 — 给用户一个看得见的"退出"出口,否则 + // desktop 进程只能任务管理器结束(daemon 被 Job Object 绑生死)。 + // on_show 重新拉起 app 窗口(用户关闭浏览器窗口想再开时用)。 + // on_quit 走 PostQuitMessage 退 message loop,后续走 stop_all。 + void* tray_message_hwnd_fallback = nullptr; + bool tray_ok_fallback = init_tray_icon( + /*on_show=*/[&open_app_window]() { open_app_window(); }, + /*on_quit=*/[]() { ::PostQuitMessage(0); }, + &tray_message_hwnd_fallback); + if (!tray_ok_fallback) { + LOG_WARN("[desktop] browser-fallback tray init failed; user must end " + "process via Task Manager to stop daemon"); + } + + // Message loop — daemon 由 Job Object 跟 desktop 绑生死,所以这里必 + // 须让 desktop 挂着。Windows session logoff 时 WM_QUIT 广播让 loop + // 自然退出;托盘 "退出" 也走 PostQuitMessage(0) 同样路径。 + MSG msg; + while (::GetMessageW(&msg, nullptr, 0, 0) > 0) { + ::TranslateMessage(&msg); + ::DispatchMessageW(&msg); + } + LOG_INFO("[desktop] browser-fallback message loop exited"); + if (tray_ok_fallback) shutdown_tray_icon(); + pool.stop_all(); + return 0; +#else + // POSIX 上 webview/webview 走 WKWebView / WebKitGTK,异常路径跟 + // WebView2 完全不同,这里不构造浏览器降级 UI,直接 rethrow 给外层 + // 顶层 catch(MessageBox 在 POSIX 上没意义,顶层 catch 用 fprintf)。 + (void)what_str; + throw; +#endif + } }; // end of run lambda try { diff --git a/src/desktop/web_host.cpp b/src/desktop/web_host.cpp index 5dfd167e..eedf3798 100644 --- a/src/desktop/web_host.cpp +++ b/src/desktop/web_host.cpp @@ -416,9 +416,9 @@ struct WebHost::Impl { auto edge_folder = find_edge_browser_folder(); if (!edge_folder.has_value()) { LOG_ERROR("[desktop] no Microsoft Edge browser folder found to " - "fall back to; aborting startup"); - show_webview2_failure_message_box(e1.what()); - ::ExitProcess(1); + "fall back to; rethrowing for wWinMain browser-fallback " + "branch"); + throw; // wWinMain 的 catch 决定弹 MessageBox 询问浏览器降级 } const std::wstring folder_w = edge_folder->wstring(); LOG_INFO(std::string("[desktop] retrying WebView2 with Edge browser " @@ -436,8 +436,7 @@ struct WebHost::Impl { } catch (const webview::exception& e2) { LOG_ERROR(std::string("[desktop] WebView2 Edge browser folder " "fallback also failed: ") + e2.what()); - show_webview2_failure_message_box(e2.what()); - ::ExitProcess(1); + throw; // 同上 — 让 wWinMain 接管降级 / 用户确认 } } if (custom_window) { diff --git a/src/web/server.cpp b/src/web/server.cpp index 674bf6e5..498ffa69 100644 --- a/src/web/server.cpp +++ b/src/web/server.cpp @@ -5,6 +5,8 @@ #include "static_assets.hpp" #include "../config/config.hpp" #include "../config/saved_models_editor.hpp" +#include "../desktop/folder_picker.hpp" +#include "../desktop/open_in_explorer.hpp" #include "../desktop/workspace_registry.hpp" #include "../provider/llm_provider.hpp" #include "../session/ask_user_question_prompter.hpp" @@ -758,6 +760,7 @@ struct WebServer::Impl { register_skills(); register_commands(); register_mcp(); + register_system(); register_websocket(); register_static(); } @@ -767,6 +770,10 @@ struct WebServer::Impl { ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { + return cors_preflight(req); + }); CROW_ROUTE(app, "/api/workspaces//sessions").methods(crow::HTTPMethod::Options) ([this](const crow::request& req, const std::string&) { return cors_preflight(req); @@ -942,6 +949,90 @@ struct WebServer::Impl { r.add_header("Content-Type", "application/json"); return with_cors(req, std::move(r)); }); + + // PATCH /api/workspaces/:hash:行内重命名,body {"name": "<新名>"}。 + // 等价于原 desktop bridge aceDesktop_renameWorkspace 的下沉版本 —— + // 浏览器降级模式下前端也能改名,而不必走 webview JS-bridge。 + // 失败语义: + // - workspace_registry 未挂载 → 503 + // - body 不是合法 JSON → 400 + // - 缺 name / name 为空字符串 → 400 + // - hash 未知 / set_name 返 false → 404 + // 成功返回 200 + 与 GET /api/workspaces 列表项同构的 workspace JSON, + // 方便前端拿到 hash 后立即覆盖本地缓存。 + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::PATCH) + ([this](const crow::request& req, const std::string& hash) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (!deps.workspace_registry) { + crow::response r(503); + r.body = R"({"error":"workspace registry unavailable"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + std::string name; + try { + auto j = json::parse(req.body); + name = j.value("name", std::string{}); + } catch (const std::exception& e) { + crow::response r(400); + r.body = json{{"error", std::string("bad json: ") + e.what()}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (name.empty()) { + crow::response r(400); + r.body = R"({"error":"name required"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (!deps.workspace_registry->set_name(projects_dir(), hash, name)) { + crow::response r(404); + r.body = R"({"error":"workspace not found"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + auto m = deps.workspace_registry->get(hash); + if (!m.has_value()) { + // 写盘成功但内存被并发 scan 清掉 — 罕见但要兜底,客户端拿 + // 200 + 空体当作"再 GET 一次列表"。 + crow::response r(200); + r.body = R"({"hash":"")" + hash + R"(""})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] workspace renamed hash=" + hash + " name=" + name); + crow::response r(200); + r.body = workspace_to_json(*m).dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + + // DELETE /api/workspaces/:hash:从 Desktop 项目列表隐藏 workspace。 + // 等价于原 desktop bridge aceDesktop_removeWorkspace。语义跟 registry::hide + // 完全一致 ── 写 desktop_visible=false,不删 hash 目录、session、用户文件。 + // 失败: + // - workspace_registry 未挂载 → 503 + // - hash 未知 / 隐藏失败 → 404 + // 成功 → 204 No Content。 + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::DELETE) + ([this](const crow::request& req, const std::string& hash) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (!deps.workspace_registry) { + crow::response r(503); + r.body = R"({"error":"workspace registry unavailable"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (!deps.workspace_registry->hide(projects_dir(), hash)) { + crow::response r(404); + r.body = R"({"error":"workspace not found"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] workspace hidden hash=" + hash); + crow::response r(204); + return with_cors(req, std::move(r)); + }); } void register_static() { @@ -2448,6 +2539,136 @@ struct WebServer::Impl { }); } + // ----------------------------------------------------------------- + // /api/system/* 原 desktop JS-bridge 下沉端点。 + // + // 设计动机:WebView2 不可用时(企业内网常见,Edge >=126 不许第三方借用), + // 前端要能直接跑在系统浏览器里访问 daemon。但 "在资源管理器里打开目录"、 + // "弹文件夹选择" 这两个动作浏览器物理上做不了 —— 浏览器只是 UI 层, + // daemon 跟 webview 同一台机器、同一 session、同一用户权限,完全可以代 + // 执行 native 操作。把这两个动作下沉到 daemon HTTP 后,webview 模式与 + // 浏览器降级模式 UI 路径完全一致。 + // + // 安全:loopback-only + token 认证已经保证只有本机有效用户能命中端点。 + // 路径白名单(workspace cwd 列表)在 open-in-explorer 里再加一道,避免 + // 浏览器里的恶意 web page 让 daemon 打开 C:\Windows\System32 之类。 + // pick-folder 是用户主动触发,无白名单意义(用户本身就在选目录)。 + // ----------------------------------------------------------------- + void register_system() { + CROW_ROUTE(app, "/api/system/open-in-explorer").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { + return cors_preflight(req); + }); + CROW_ROUTE(app, "/api/system/pick-folder").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { + return cors_preflight(req); + }); + + // POST /api/system/open-in-explorer body {"path": ""} + // 在系统文件管理器里打开 path 目录。等价于原 aceDesktop_openInExplorer。 + // 路径必须是已注册 workspace cwd 或其子目录(白名单)。 + // - 非法 JSON / 缺 path → 400 + // - 路径不是绝对路径 / 不存在 → 404 (validate_open_directory_request) + // - 不在白名单内 → 403 + // - launcher 失败 → 500 + // 成功 → 200 {"ok": true} + CROW_ROUTE(app, "/api/system/open-in-explorer").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + + std::string path; + try { + auto j = json::parse(req.body); + path = j.value("path", std::string{}); + } catch (const std::exception& e) { + crow::response r(400); + r.body = json{{"error", std::string("bad json: ") + e.what()}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (path.empty()) { + crow::response r(400); + r.body = R"({"error":"path required"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + + // 白名单 = 当前所有已注册 workspace 的 cwd。is_under_allowed_root + // 内部做大小写归一(Windows) + 子目录前缀匹配。 + std::vector allowed_roots; + if (deps.workspace_registry) { + deps.workspace_registry->scan(projects_dir()); + for (const auto& m : deps.workspace_registry->list()) { + if (!m.cwd.empty()) allowed_roots.push_back(m.cwd); + } + } + + auto result = acecode::desktop::open_directory_in_file_manager( + path, allowed_roots); + if (!result.ok) { + // 错误信息粒度:不存在 → 404;白名单拒绝 → 403;其它(launcher + // 失败 / fork 失败 / ShellExecute 失败)→ 500。validate_* + // 已经返回字符串,这里按 prefix 分类。 + int status = 500; + if (result.error.find("not an existing directory") != std::string::npos || + result.error == "path required" || + result.error == "failed to resolve path") { + status = 404; + } else if (result.error.find("outside registered workspaces") != std::string::npos) { + status = 403; + } else if (result.error == "path must be absolute") { + status = 400; + } + LOG_INFO("[web] open-in-explorer rejected path=" + path + + " status=" + std::to_string(status) + + " error=" + result.error); + crow::response r(status); + r.body = json{{"error", result.error}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] open-in-explorer ok path=" + path); + crow::response r(200); + r.body = R"({"ok":true})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + + // POST /api/system/pick-folder + // body 可选 {} (后续可扩 title / initial_dir,本期 MVP 不传)。 + // 调起 native folder picker (Windows: IFileOpenDialog;POSIX MVP 阶段 + // 直接 503 - "platform not supported")。同步阻塞直到用户选定 / 取消。 + // 用户选了 → 200 {"ok": true, "path": ""} + // 用户取消 → 200 {"ok": false, "canceled": true} + // 平台不支持 → 503 + // + // 阻塞行为:Crow 的 thread pool 一个 handler thread 阻塞不影响其他 + // 请求。folder picker 的 owner 是 nullptr(daemon 没主窗口),Windows + // 上可能弹在任务栏闪烁需要用户点亮;前端调用方应当通过 UI 文案提示 + // "请在任务栏切换到文件夹选择对话框"。 + CROW_ROUTE(app, "/api/system/pick-folder").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + auto picked = acecode::desktop::pick_folder(nullptr); + if (!picked.has_value()) { + // pick_folder 在 POSIX MVP 阶段直接返 nullopt 不区分"取消" vs + // "平台不支持";Windows 上 nullopt = 用户取消 / 失败。最稳的 + // 客户端语义:把 nullopt 一律当作 "canceled",前端如果重试 + // 多次仍 canceled 再提示用户手输路径(降级路径已在 P3 规划)。 + LOG_INFO("[web] pick-folder returned no selection"); + crow::response r(200); + r.body = R"({"ok":false,"canceled":true})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] pick-folder selected path=" + *picked); + crow::response r(200); + r.body = json{{"ok", true}, {"path", *picked}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + } + // ----------------------------------------------------------------- // WebSocket: /ws/sessions/:id (spec Section 10) // ----------------------------------------------------------------- diff --git a/tests/desktop/chromium_app_launcher_test.cpp b/tests/desktop/chromium_app_launcher_test.cpp new file mode 100644 index 00000000..41c3feeb --- /dev/null +++ b/tests/desktop/chromium_app_launcher_test.cpp @@ -0,0 +1,132 @@ +// 覆盖 src/desktop/chromium_app_launcher.cpp 中的纯函数 find_chromium_app_browser_in。 +// 系统调用版 find_chromium_app_browser() 依赖 SHGetKnownFolderPath,unit test +// 直接喂临时目录列表更直观。launch_chromium_app_mode() 启 CreateProcessW +// 无法在 headless CI 跑,留作手动 e2e。 + +#include + +#include "desktop/chromium_app_launcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::desktop::find_chromium_app_browser_in; + +namespace { + +class TempDir { +public: + TempDir() { + std::random_device rd; + std::mt19937 rng(rd()); + std::uniform_int_distribution dist(0, 0x7FFFFFFF); + for (int attempt = 0; attempt < 8; ++attempt) { + const std::string name = "acecode_chromium_app_" + + std::to_string(dist(rng)); + fs::path candidate = fs::temp_directory_path() / name; + std::error_code ec; + if (fs::create_directories(candidate, ec) && !ec) { + path_ = candidate; + return; + } + } + ADD_FAILURE() << "could not create unique temp dir"; + } + + ~TempDir() { + std::error_code ec; + fs::remove_all(path_, ec); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + const fs::path& path() const { return path_; } + +private: + fs::path path_; +}; + +fs::path make_browser_exe(const fs::path& root, const std::string& rel) { + fs::path full = root / rel; + std::error_code ec; + fs::create_directories(full.parent_path(), ec); + EXPECT_FALSE(ec) << ec.message(); + std::ofstream(full) << "stub"; + return full; +} + +} // namespace + +TEST(ChromiumAppLauncher, EmptyRootsReturnsNullopt) { + EXPECT_FALSE(find_chromium_app_browser_in({}).has_value()); +} + +TEST(ChromiumAppLauncher, RootWithoutAnyBrowserReturnsNullopt) { + TempDir d; + EXPECT_FALSE(find_chromium_app_browser_in({d.path()}).has_value()); +} + +// 单 root 装了 Edge → 命中 Edge,display_name 标记正确。 +TEST(ChromiumAppLauncher, FindsEdgeInProgramFiles) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); + EXPECT_EQ(r->display_name, "Microsoft Edge"); +} + +// 单 root 装了 Chrome → 命中 Chrome。 +TEST(ChromiumAppLauncher, FindsChromeInProgramFiles) { + TempDir d; + fs::path chrome = make_browser_exe(d.path(), "Google/Chrome/Application/chrome.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), chrome.lexically_normal()); + EXPECT_EQ(r->display_name, "Google Chrome"); +} + +// 同一 root 同时装 Edge + Chrome → Edge 优先(用户体感更接近 native app)。 +TEST(ChromiumAppLauncher, EdgePreferredOverChromeWhenBothPresent) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + make_browser_exe(d.path(), "Google/Chrome/Application/chrome.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); + EXPECT_EQ(r->display_name, "Microsoft Edge"); +} + +// 两个 root,Edge 在第二个,Chrome 在第一个 → 仍然 Edge 优先(按候选浏览器 +// 顺序而不是 root 顺序,避免 PFx86 只装 Chrome 时漏掉 PF 的 Edge)。 +TEST(ChromiumAppLauncher, EdgePreferredAcrossRoots) { + TempDir pf, pfx86; + make_browser_exe(pfx86.path(), "Google/Chrome/Application/chrome.exe"); + fs::path edge = make_browser_exe(pf.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({pf.path(), pfx86.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); +} + +// 空字符串 root 元素静默跳过(防御性 — SHGetKnownFolderPath 失败可能塞空)。 +TEST(ChromiumAppLauncher, EmptyRootEntryIgnored) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({fs::path(), d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); +} + +// 路径上是目录而非文件 → 不算命中。 +TEST(ChromiumAppLauncher, DirectoryAtExePathIsNotAMatch) { + TempDir d; + std::error_code ec; + fs::create_directories(d.path() / "Microsoft/Edge/Application/msedge.exe", ec); + EXPECT_FALSE(find_chromium_app_browser_in({d.path()}).has_value()); +} diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 3f1d04da..75d6eb78 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -410,6 +410,142 @@ TEST(WebServerHttp, WorkspaceListRefreshesExternalRename) { EXPECT_EQ(ws_list[0]["name"], "renamed-from-desktop"); } +// 场景: PATCH /api/workspaces/:hash 改 name 成功路径。 +// 返回 200 + 与列表项同构 JSON,且后续 GET 列表里 name 已更新。 +TEST(WebServerHttp, WorkspaceRenameViaHttpUpdatesName) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto patch = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", "renamed-via-http"}}.dump()}); + ASSERT_EQ(patch.status_code, 200) << patch.text; + auto updated = json::parse(patch.text); + EXPECT_EQ(updated["hash"], hash); + EXPECT_EQ(updated["name"], "renamed-via-http"); + + auto get_ws = cpr::Get(cpr::Url{fx.url("/api/workspaces")}); + ASSERT_EQ(get_ws.status_code, 200) << get_ws.text; + auto ws_list = json::parse(get_ws.text); + ASSERT_TRUE(ws_list.is_array()); + ASSERT_FALSE(ws_list.empty()); + EXPECT_EQ(ws_list[0]["hash"], hash); + EXPECT_EQ(ws_list[0]["name"], "renamed-via-http"); +} + +// 场景: PATCH 不带 name 字段(或 name 为空字符串)→ 400,内存 cache 不动。 +TEST(WebServerHttp, WorkspaceRenameRejectsMissingOrEmptyName) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto no_name = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + EXPECT_EQ(no_name.status_code, 400) << no_name.text; + + auto empty_name = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", ""}}.dump()}); + EXPECT_EQ(empty_name.status_code, 400) << empty_name.text; + + auto bad_json = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"not json"}); + EXPECT_EQ(bad_json.status_code, 400) << bad_json.text; +} + +// 场景: PATCH 指向未知 hash → 404,不创建新 entry。 +TEST(WebServerHttp, WorkspaceRenameRejectsUnknownHash) { + WebServerFixture fx; + auto patch = cpr::Patch(cpr::Url{fx.url("/api/workspaces/0000000000000000")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", "ghost"}}.dump()}); + EXPECT_EQ(patch.status_code, 404) << patch.text; +} + +// 场景: DELETE /api/workspaces/:hash 成功 → 204,后续 GET 列表里它消失。 +// 不动 hash 目录或 session 文件 — registry.hide 只翻 desktop_visible 标志。 +TEST(WebServerHttp, WorkspaceDeleteHidesFromList) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto del = cpr::Delete(cpr::Url{fx.url("/api/workspaces/" + hash)}); + EXPECT_EQ(del.status_code, 204) << del.text; + + auto get_ws = cpr::Get(cpr::Url{fx.url("/api/workspaces")}); + ASSERT_EQ(get_ws.status_code, 200) << get_ws.text; + auto ws_list = json::parse(get_ws.text); + ASSERT_TRUE(ws_list.is_array()); + for (const auto& w : ws_list) { + EXPECT_NE(w.value("hash", std::string{}), hash); + } +} + +// 场景: DELETE 指向未知 hash → 404,不影响其他 workspace。 +TEST(WebServerHttp, WorkspaceDeleteRejectsUnknownHash) { + WebServerFixture fx; + auto del = cpr::Delete(cpr::Url{fx.url("/api/workspaces/0000000000000000")}); + EXPECT_EQ(del.status_code, 404) << del.text; +} + +// /api/system/open-in-explorer 入参校验 — 我们不验证 ShellExecute 真的拉起 +// Explorer(那是 e2e + 平台相关行为),只验证 daemon 做了 (1) 路径白名单 +// 拒绝越界,(2) 路径不存在 404,(3) 缺参 400。**真正打开 Explorer 的 launch +// 路径无法在 headless 测试机里跑**,所以这条路径在 e2e 阶段手动验证。 +TEST(WebServerHttp, SystemOpenInExplorerRejectsPathOutsideWorkspace) { + WebServerFixture fx; + + auto outside_dir = fx.tmp_dir / "outside"; + std::filesystem::create_directories(outside_dir); + + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"path", outside_dir.string()}}.dump()}); + EXPECT_EQ(post.status_code, 403) << post.text; +} + +TEST(WebServerHttp, SystemOpenInExplorerRejectsMissingPath) { + WebServerFixture fx; + + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + EXPECT_EQ(post.status_code, 400) << post.text; + + auto bad_json = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"not json"}); + EXPECT_EQ(bad_json.status_code, 400) << bad_json.text; +} + +TEST(WebServerHttp, SystemOpenInExplorerRejectsNonexistentPath) { + WebServerFixture fx; + + auto ghost = (fx.cwd_dir / "this-subdir-does-not-exist").string(); + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"path", ghost}}.dump()}); + EXPECT_EQ(post.status_code, 404) << post.text; +} + +// pick-folder 端点本身在 POSIX CI 上 pick_folder() 返 nullopt +// (MVP 没接 GTK/Cocoa),Windows GUI 上 IFileOpenDialog 同步阻塞需要用户 +// 交互。两种环境下都不适合 e2e 测真"弹窗+选目录"。这里只验证 endpoint 存活 +// + 返回结构稳定(POSIX 上必返 {ok:false,canceled:true}),POST 不带 body 也 +// 不报 500。Windows 上跑这条会卡住等用户,所以 Windows CI 跳过。 +#ifndef _WIN32 +TEST(WebServerHttp, SystemPickFolderReturnsCanceledOnPosixStub) { + WebServerFixture fx; + auto post = cpr::Post(cpr::Url{fx.url("/api/system/pick-folder")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + ASSERT_EQ(post.status_code, 200) << post.text; + auto body = json::parse(post.text); + EXPECT_EQ(body["ok"], false); + EXPECT_EQ(body["canceled"], true); +} +#endif + // 场景:在非 daemon 启动 cwd 的 workspace 里 fork,新 session 必须用源 // workspace cwd 装回 registry;否则前端切到新 fork 时 WS 会报 unknown session。 TEST(WebServerHttp, ForkWorkspaceSessionResumesInSourceWorkspace) { diff --git a/web/src/components/DesktopContextMenu.jsx b/web/src/components/DesktopContextMenu.jsx index 087058ac..660ab7a0 100644 --- a/web/src/components/DesktopContextMenu.jsx +++ b/web/src/components/DesktopContextMenu.jsx @@ -8,8 +8,13 @@ import { openInExplorerTargetFromElement, sessionPinTargetFromElement, } from '../lib/desktopContextMenu.js'; +import { createApi } from '../lib/api.js'; import { toast } from './Toast.jsx'; +// 单例 default api client — createApi(null) 内部从全局 _baseOrigin / _baseToken +// 取值(由 setBase 在 App 启动时配好),所以这里不需要 prop drill / context。 +const apiClient = createApi(); + const MENU_WIDTH = 176; const MENU_ROW_HEIGHT = 30; const MENU_PADDING = 8; @@ -36,7 +41,14 @@ const TEXT_INPUT_TYPES = new Set([ ]); function isDesktopShell() { - return !!(window.__ACECODE_DESKTOP_SHELL__ || window.aceDesktop_openDevTools || window.aceDesktop_openInExplorer); + // 原 detection 只看 native bridge,浏览器降级模式会整个禁用右键菜单(连 + // 复制/粘贴/全选都灰)。把"在资源管理器中打开"下沉到 daemon HTTP 之后, + // 浏览器走 loopback 也能享受这条菜单 — 把 loopback host 也认作"可用"。 + if (window.__ACECODE_DESKTOP_SHELL__) return true; + if (typeof window.aceDesktop_openDevTools === 'function') return true; + if (typeof window.aceDesktop_openInExplorer === 'function') return true; + const host = window.location?.hostname || ''; + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; } function parseDesktopResult(value) { @@ -139,19 +151,20 @@ async function pasteIntoTarget(target) { } async function openTargetInExplorer(openTarget) { - if (!openTarget?.path || typeof window.aceDesktop_openInExplorer !== 'function') { - toast({ kind: 'err', text: '无法打开:desktop bridge 不可用' }); + if (!openTarget?.path) { + toast({ kind: 'err', text: '无法打开:缺少路径' }); return; } + // HTTP-first:daemon 跟前端同一台机器同一 session,POST /api/system/open- + // in-explorer 走 ShellExecuteW / open / xdg-open。这条路径浏览器降级模式也 + // 一样跑得通,webview 层不再持有业务逻辑。 try { - const result = parseDesktopResult(await window.aceDesktop_openInExplorer(openTarget.path)); - if (!result?.ok) { - toast({ kind: 'err', text: '打开失败:' + (result?.error || '') }); - return; - } + await apiClient.openInExplorer(openTarget.path); toast({ kind: 'ok', text: '已在资源管理器中打开' }); } catch (e) { - toast({ kind: 'err', text: '打开异常:' + (e?.message || '') }); + // daemon 端 403 = 白名单拒绝(不在已注册 workspace cwd 内) + const msg = e?.body?.error || e?.message || ''; + toast({ kind: 'err', text: '打开失败:' + msg }); } } diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index eaaeee50..64a20c53 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -555,31 +555,39 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome }; const onRename = async (hash, name) => { - if (!hasDesktopBridge()) throw new Error('not in desktop mode'); - const r = parseDesktopResult(await window.aceDesktop_renameWorkspace(hash, name)); - if (!r.ok) throw new Error(r.error || 'rename failed'); + // 主路径走 daemon HTTP — 浏览器降级模式也能用。webview 模式下 daemon 同源 + // 同进程组,延迟可忽略;同时为了不丢 native 副作用(webview 在 sidebar + // 视图态外可能有其它缓存),保留 bridge 调用作为 fire-and-forget,失败 + // 不影响业务结果。 + await api.renameWorkspace(hash, name); + if (typeof window.aceDesktop_renameWorkspace === 'function') { + try { await window.aceDesktop_renameWorkspace(hash, name); } catch { /* best-effort */ } + } setWorkspaces((prev) => prev.map((w) => w.hash === hash ? { ...w, name } : w)); await refresh(hash); }; const removeWorkspace = async (ws) => { if (!ws?.hash) return; - if (!hasDesktopRemoveWorkspace()) { - toast({ kind: 'info', text: '需在 desktop shell 中使用' }); - return; - } const ok = window.confirm( `从桌面项目列表移除“${ws.name || ws.hash}”?\n\n不会删除项目文件、会话或 .acecode 数据。之后可通过“添加项目”重新显示。`, ); if (!ok) return; try { - const r = parseDesktopResult(await window.aceDesktop_removeWorkspace(ws.hash)); - if (!r?.ok) throw new Error(r?.error || 'remove failed'); + // HTTP-first;原 bridge 路径返 r.active_workspace_hash 这种 native + // 建议下一活跃 workspace,daemon 不算这个,客户端按"还剩第一个或保 + // 持现状"算等价结果(下文 fallback)。bridge 仍 fire-and-forget 调 + // 用以清理 native 托盘菜单缓存等副作用。 + await api.removeWorkspace(ws.hash); + if (typeof window.aceDesktop_removeWorkspace === 'function') { + try { await window.aceDesktop_removeWorkspace(ws.hash); } catch { /* best-effort */ } + } const remaining = workspaces.filter((w) => w.hash !== ws.hash); - const nextHash = r.active_workspace_hash - || ((ws.active || activeWorkspaceHash === ws.hash) ? (remaining[0]?.hash || '') : activeWorkspaceHash); + const nextHash = (ws.active || activeWorkspaceHash === ws.hash) + ? (remaining[0]?.hash || '') + : activeWorkspaceHash; setWorkspaces(remaining.map((w) => ({ ...w, active: w.hash === nextHash }))); setSessions((prev) => prev.filter((s) => (s.workspace_hash || s.workspaceHash || '') !== ws.hash)); @@ -658,16 +666,52 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome } }; - const onAddWorkspace = async () => { - if (!hasDesktopBridge()) { - toast({ kind: 'info', text: '需在 desktop shell 中使用' }); - return; + // pick + register 工作流: + // 1) webview 模式:优先调 native bridge,一次拿到 cwd + register 全套 + // (跟原行为完全一致,避免 webview 用户体验回归) + // 2) Chromium 系浏览器:试 showDirectoryPicker (File System Access API) + // 取目录 name,但拿不到绝对路径(API 安全限制) → 退到方案 3 + // 3) 其它浏览器(Firefox / 旧 Edge 已不可能) / 方案 2 拿不到 cwd: + // 调 daemon POST /api/system/pick-folder 让 daemon 弹 native folder + // picker,拿到绝对路径。daemon 跟用户同一 session、同一权限,在 + // Windows 上走 IFileOpenDialog,POSIX MVP 阶段 daemon 返 canceled。 + const pickWorkspaceCwd = async () => { + if (typeof window.aceDesktop_addWorkspace === 'function') { + const ws = parseDesktopResult(await window.aceDesktop_addWorkspace()); + if (ws == null) return null; + if (!ws || !ws.hash) return null; + return { cwd: ws.cwd, prefilled: ws }; } + // 浏览器降级模式:File System Access API 拿不到绝对路径(handle 不暴露 + // 真实 cwd),所以直接走 daemon picker。除非以后接入 OPFS / DirectoryHandle + // 的 cwd 推断,不然这条更可靠。 try { - const ws = parseDesktopResult(await window.aceDesktop_addWorkspace()); - if (ws == null) return; + const r = await api.pickFolder(); + if (r && r.ok && r.path) return { cwd: r.path }; + return null; // 用户取消 / 平台不支持 + } catch (e) { + throw e; + } + }; + + const onAddWorkspace = async () => { + try { + const picked = await pickWorkspaceCwd(); + if (!picked) return; + let ws = picked.prefilled || null; + if (!ws || !ws.hash) { + try { + ws = await api.registerWorkspace(picked.cwd); + } catch (e) { + toast({ kind: 'err', text: '添加项目失败:' + (e.message || '') }); + return; + } + } else { + // bridge 已经在 desktop 端 register 过;再 POST 一次幂等(daemon 端 + // register_new 已存在时直接返回已有 meta,不重写 workspace.json)。 + try { await api.registerWorkspace(ws.cwd); } catch { /* 幂等容错 */ } + } if (!ws || !ws.hash) return; - try { await api.registerWorkspace(ws.cwd); } catch { /* daemon 可能已入册;忽略 */ } onActivate(ws); } catch (e) { toast({ kind: 'err', text: '添加项目失败:' + (e.message || '') }); @@ -740,7 +784,7 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome onRename={onRename} onActivate={onActivate} onNewSession={createSessionInWorkspace} - onRemove={hasDesktopRemoveWorkspace() ? removeWorkspace : undefined} + onRemove={removeWorkspace} onTogglePin={togglePinnedSession} /> ); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 3c7e77b4..78be7b41 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -72,6 +72,12 @@ export function createApi(base = null) { health: () => request('GET', '/api/health', undefined, base), listWorkspaces: () => request('GET', '/api/workspaces', undefined, base), registerWorkspace:(cwd) => request('POST', '/api/workspaces', {cwd}, base), + renameWorkspace: (hash, name) => request('PATCH', `/api/workspaces/${encodeURIComponent(hash)}`, {name}, base), + removeWorkspace: (hash) => request('DELETE', `/api/workspaces/${encodeURIComponent(hash)}`, undefined, base), + // 系统层操作的 daemon-side 实现 — webview 不可用 / 浏览器降级时业务也能跑。 + // 见 src/web/server.cpp::register_system 与 openspec/decisions/desktop-down-sink。 + openInExplorer: (path) => request('POST', '/api/system/open-in-explorer', {path}, base), + pickFolder: () => request('POST', '/api/system/pick-folder', {}, base), listSessions: () => request('GET', '/api/sessions', undefined, base), createSession: (opts={}) => request('POST', '/api/sessions', opts, base), resumeSession: (id) => request('POST', `/api/sessions/${encodeURIComponent(id)}/resume`, {}, base),