diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 743dd7a63..6fd3a0168 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -36,17 +36,22 @@ jobs: executable: acecode.exe archive_extension: zip msvc_arch: amd64 + build_desktop: true + cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON - id: macos-x64 os: macos-15 triplet: x64-osx executable: acecode archive_extension: tar.gz - cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=x86_64 + build_desktop: true + cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=x86_64 -DACECODE_BUILD_DESKTOP=ON - id: macos-arm64 os: macos-15 triplet: arm64-osx executable: acecode archive_extension: tar.gz + build_desktop: true + cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON steps: - name: Checkout @@ -63,6 +68,23 @@ jobs: - name: Run vcpkg uses: lukka/run-vcpkg@v11 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: '9' + + - name: Build web UI + shell: bash + run: | + cd web + pnpm install --frozen-lockfile + pnpm build + - name: Configure shell: bash run: | @@ -77,6 +99,11 @@ jobs: shell: bash run: cmake --build build --config Release --target acecode + - name: Build desktop + if: matrix.build_desktop + shell: bash + run: cmake --build build --config Release --target acecode-desktop + - name: Extract debug symbols (Linux) if: runner.os == 'Linux' shell: bash @@ -100,6 +127,9 @@ jobs: mkdir -p "$package_dir" cp "build/${{ matrix.executable }}" "$package_dir/" cp README.md README_CN.md "$package_dir/" + if [ "${{ matrix.build_desktop }}" = "true" ] && [ -d "build/ACECode.app" ]; then + cp -r "build/ACECode.app" "$package_dir/" + fi tar -C dist -czf "acecode-${{ matrix.id }}.tar.gz" "acecode-${{ matrix.id }}" - name: Package (Windows) @@ -110,6 +140,9 @@ jobs: New-Item -ItemType Directory -Force -Path $packageDir | Out-Null Copy-Item "build/${{ matrix.executable }}" "$packageDir/" Copy-Item "README.md","README_CN.md" "$packageDir/" + if ("${{ matrix.build_desktop }}" -eq "true" -and (Test-Path "build/Release/acecode-desktop.exe")) { + Copy-Item "build/Release/acecode-desktop.exe" "$packageDir/" + } Compress-Archive -Path "$packageDir/*" -DestinationPath "acecode-${{ matrix.id }}.zip" -Force - name: Upload artifact @@ -127,6 +160,14 @@ jobs: path: build/**/acecode.pdb if-no-files-found: warn + - name: Upload desktop PDB + if: runner.os == 'Windows' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-pdb + path: build/**/acecode-desktop.pdb + if-no-files-found: warn + - name: Upload debug symbols (Linux) if: runner.os == 'Linux' uses: actions/upload-artifact@v4 diff --git a/CMakeLists.txt b/CMakeLists.txt index d832e7ad4..faf61d471 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -167,6 +167,14 @@ add_library(acecode_testable OBJECT ${CMAKE_BINARY_DIR}/generated/version.hpp ${CMAKE_BINARY_DIR}/generated/static_assets_data.cpp ) +if(APPLE) + # folder_picker_mac.mm 是 Objective-C++,不在 *.cpp GLOB 里。 + # acecode_testable 里的 worker.cpp 会调用 pick_folder,所以需在此层提供实现。 + target_sources(acecode_testable PRIVATE + ${CMAKE_SOURCE_DIR}/src/desktop/folder_picker_mac.mm + ) + target_link_libraries(acecode_testable PUBLIC "-framework AppKit") +endif() target_include_directories(acecode_testable PUBLIC ${CMAKE_SOURCE_DIR}/src ${CMAKE_BINARY_DIR}/generated diff --git a/src/desktop/daemon_pool.cpp b/src/desktop/daemon_pool.cpp index fbb2d3174..3c2172f97 100644 --- a/src/desktop/daemon_pool.cpp +++ b/src/desktop/daemon_pool.cpp @@ -2,8 +2,25 @@ #include "../utils/logger.hpp" +#include +#include + namespace acecode::desktop { +namespace { + +// 读 run_dir 下的单个文本文件,去除尾部换行。失败返回空字符串。 +std::string read_run_file(const std::string& dir, const char* fname) { + namespace fs = std::filesystem; + std::ifstream f(fs::path(dir) / fname, std::ios::binary); + if (!f.is_open()) return ""; + std::string s((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back(); + return s; +} + +} // namespace + DaemonPool::~DaemonPool() { // 不主动 stop_all — 让调用方控制(quit 时显式调,析构时机可能在异常路径上)。 } @@ -107,6 +124,29 @@ ActivateResult DaemonPool::activate(const ActivateRequest& req, } } + // POSIX/macOS: daemon 没有类似 Windows Job Object 的父死子死机制。 + // 若 desktop 上次正常退出但 daemon 以孤儿进程形式继续运行,下次 spawn + // 新 daemon 时会因"already running"而立刻退出。 + // 处理:spawn 失败后检查 run_dir 内的 port/token 文件,若原有 daemon 仍 + // 在监听则直接复用(adopt),跳过重新 spawn。 + if (!ok && !req.run_dir.empty()) { + std::string rport_s = read_run_file(req.run_dir, "daemon.port"); + std::string rtoken = read_run_file(req.run_dir, "token"); + if (!rport_s.empty() && !rtoken.empty()) { + try { + int rport = std::stoi(rport_s); + if (probe_loopback_port(rport)) { + port = rport; + token = rtoken; + ok = true; + err.clear(); + LOG_INFO("[daemon_pool] reclaimed existing daemon at port=" + + rport_s + " for hash=" + req.hash); + } + } catch (...) {} + } + } + sl.lock(); if (ok) { slot->port = port; diff --git a/src/desktop/daemon_supervisor.cpp b/src/desktop/daemon_supervisor.cpp index ba3a703a5..ae1356f48 100644 --- a/src/desktop/daemon_supervisor.cpp +++ b/src/desktop/daemon_supervisor.cpp @@ -579,4 +579,8 @@ std::string make_auth_token() { return acecode::generate_auth_token(); } +bool probe_loopback_port(int port) { + return tcp_probe(port); +} + } // namespace acecode::desktop diff --git a/src/desktop/daemon_supervisor.hpp b/src/desktop/daemon_supervisor.hpp index db61705d2..4229d1aac 100644 --- a/src/desktop/daemon_supervisor.hpp +++ b/src/desktop/daemon_supervisor.hpp @@ -75,6 +75,10 @@ class DaemonSupervisor : public IDaemonSupervisor { Impl* impl_; }; +// 工具: 对 loopback:port 做一次 TCP connect 探测。端口可连接返回 true。 +// 用于在不持有 DaemonSupervisor 的情况下探测外部已有 daemon 是否存活。 +bool probe_loopback_port(int port); + // 工具: 选一个 loopback 空闲端口。失败返回 0。用 ::bind(:0) + getsockname // 拿系统分配的端口,然后立刻关闭 socket。 // 注意: TOCTOU — 拿到端口和 daemon bind 之间窗口期里端口可能被别人抢, diff --git a/src/desktop/folder_picker_mac.mm b/src/desktop/folder_picker_mac.mm new file mode 100644 index 000000000..081442dfa --- /dev/null +++ b/src/desktop/folder_picker_mac.mm @@ -0,0 +1,58 @@ +#ifdef __APPLE__ + +// macOS 目录选择器 — 用 NSOpenPanel (Cocoa) 弹出原生文件夹选择对话框。 +// 必须编译为 Objective-C++ (.mm),仅 Apple 平台编译。 +// CMakeLists.txt 通过显式列举把此文件加入 acecode_testable。 + +#include "folder_picker.hpp" + +#include +#include + +#import + +namespace acecode::desktop { + +std::optional pick_folder(void* /* parent_window */) { + // NSOpenPanel 必须在主线程上运行。 + // - desktop bridge 回调: 由 webview/webview 的 bind 机制 dispatch_async 到 + // 主线程,所以直接调用 runModal 启动嵌套 run loop,安全。 + // - daemon HTTP handler(--native-folder-picker 路径): 运行在 Crow 线程池, + // 必须 dispatch_sync 到主线程。 + // + // 注意: 不使用 beginSheetModalForWindow + dispatch_semaphore_wait —— 该 + // 组合在主线程上会死锁:semaphore wait 阻塞主线程,completion handler 也需 + // 要主线程来执行,导致永久等待。 + + __block std::optional result; + + dispatch_block_t work = ^{ + NSOpenPanel* panel = [NSOpenPanel openPanel]; + panel.canChooseDirectories = YES; + panel.canChooseFiles = NO; + panel.allowsMultipleSelection = NO; + panel.canCreateDirectories = YES; + panel.title = @"选择项目文件夹"; + panel.prompt = @"选择"; + + if ([panel runModal] == NSModalResponseOK) { + NSURL* url = panel.URL; + if (url) { + const char* path = url.fileSystemRepresentation; + if (path) result = std::string(path); + } + } + }; + + if ([NSThread isMainThread]) { + work(); + } else { + dispatch_sync(dispatch_get_main_queue(), work); + } + + return result; +} + +} // namespace acecode::desktop + +#endif // __APPLE__ diff --git a/src/desktop/folder_picker_win.cpp b/src/desktop/folder_picker_win.cpp index de37a48b9..e2336f00a 100644 --- a/src/desktop/folder_picker_win.cpp +++ b/src/desktop/folder_picker_win.cpp @@ -84,7 +84,7 @@ std::optional pick_folder(void* parent_hwnd) { } // namespace acecode::desktop -#else // POSIX stub +#elif !defined(__APPLE__) // POSIX stub (Linux etc.) #include #include @@ -92,7 +92,7 @@ std::optional pick_folder(void* parent_hwnd) { namespace acecode::desktop { std::optional pick_folder(void* /*parent*/) { - // POSIX 版本未实现 — 后续 PR 接 GTK FileChooser / NSOpenPanel。 + // Linux 版本未实现 — 后续 PR 接 GTK FileChooser。 return std::nullopt; } diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index 46aeda0ca..c820469b3 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -98,6 +98,13 @@ std::string desktop_exe_dir() { return acecode::wide_to_utf8(fs::path(wpath).parent_path().wstring()); } #elif defined(__APPLE__) +bool desktop_webapp_requested(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (argv[i] && is_webapp_arg(argv[i])) return true; + } + return false; +} + std::string desktop_exe_dir() { uint32_t size = 1024; std::vector buf(size); diff --git a/test_final.cpp b/test_final.cpp deleted file mode 100644 index 66e5e5815..000000000 --- a/test_final.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include -#include -#include - -int main() { - std::wstring wpath = L"N:\\Users\\shao\\acedesktoptest"; - HANDLE h = ::CreateFileW( - wpath.c_str(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - nullptr); - if (h == INVALID_HANDLE_VALUE) { - std::cout << "CreateFileW failed " << GetLastError() << "\n"; - return 1; - } - std::wstring buffer(1024, L'\0'); - DWORD written = ::GetFinalPathNameByHandleW( - h, buffer.data(), 1024, - FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); - ::CloseHandle(h); - if (written == 0) { - std::cout << "GetFinalPathNameByHandleW failed " << GetLastError() << "\n"; - return 1; - } - buffer.resize(written); - std::wcout << L"Final path: " << buffer << L"\n"; - return 0; -} diff --git a/test_perf.cpp b/test_perf.cpp deleted file mode 100644 index 17f83b44d..000000000 --- a/test_perf.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include -#include -#include - -int main() { - auto start = std::chrono::high_resolution_clock::now(); - std::error_code ec; - auto w = std::filesystem::weakly_canonical("N:\\Users\\shao\\acedesktoptest", ec); - auto end = std::chrono::high_resolution_clock::now(); - std::cout << "weakly_canonical: " << std::chrono::duration_cast(end - start).count() << "ms\n"; - return 0; -} diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index fe5c495b8..6c4ccc290 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -103,13 +103,20 @@ function ActivityIndicator({ activity }) { return () => window.clearInterval(id); }, [activity?.startedAtMs, activity?.phase, activity?.toolCallId, activity?.toolIndex]); + const isPermWaiting = activity?.phase === 'permission_waiting'; const label = activity?.label || '正在处理请求'; const detail = activity?.detail || ''; const elapsed = formatElapsedSeconds(activity?.startedAtMs, nowMs); return (
-
A
-
+
A
+
{label} {elapsed && {elapsed}} @@ -119,7 +126,7 @@ function ActivityIndicator({ activity }) { {[0, 1, 2].map((i) => ( ))} @@ -1174,7 +1181,7 @@ export function ChatView({ sessionRef, sessionId, onSessionPromoted, onCommandWo ); })} - {busy && streamingId == null && !hasActiveTool && ( + {busy && streamingId == null && (!hasActiveTool || activity?.phase === 'permission_waiting') && ( )}
diff --git a/web/src/components/PermissionModal.jsx b/web/src/components/PermissionModal.jsx index da1f355a4..128f7efdf 100644 --- a/web/src/components/PermissionModal.jsx +++ b/web/src/components/PermissionModal.jsx @@ -1,7 +1,7 @@ // permission_request 弹框。Allow / AllowAlways / Deny 三按钮。 // 多 request 排队由 App 层管(本组件每次只显示一个)。 -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { connection } from '../lib/connection.js'; import { Modal } from './Modal.jsx'; import { VsIcon } from './Icon.jsx'; @@ -57,15 +57,26 @@ function formatArgsPreview(args) { export function PermissionModal({ request, onResolve }) { const preview = useMemo(() => formatArgsPreview(request?.args), [request?.args]); + const resolvedRef = useRef(false); const respond = (choice, close) => { + resolvedRef.current = true; connection.sendDecision(request.request_id, choice, request.session_id); close(); setTimeout(() => onResolve?.(), 220); }; + // Escape key or backdrop click: send deny immediately so the backend is + // unblocked instead of waiting up to 5 minutes for the timeout. + const handleModalClose = () => { + if (!resolvedRef.current) { + connection.sendDecision(request.request_id, 'deny', request.session_id); + } + onResolve?.(); + }; + return ( - + {({ close }) => ( <>