Skip to content
Merged

dev #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
set(ACECODE_WEB_DIST "${CMAKE_SOURCE_DIR}/web/dist")
set(ACECODE_WEB_EMBED_DIR "${ACECODE_WEB_DIST}")
if(NOT EXISTS "${ACECODE_WEB_DIST}")
message(WARNING

Check warning on line 68 in CMakeLists.txt

View workflow job for this annotation

GitHub Actions / unit-tests (linux-x64)

[acecode] web/dist/ not found — embedding a minimal fallback page. Run
"[acecode] web/dist/ not found — embedding a minimal fallback page. "
"Run `pnpm install && pnpm build` inside web/ before re-configuring for the full UI.")
set(ACECODE_WEB_EMBED_DIR "${CMAKE_BINARY_DIR}/generated/web-dist-fallback")
Expand Down Expand Up @@ -167,6 +167,14 @@
${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
Expand Down
40 changes: 40 additions & 0 deletions src/desktop/daemon_pool.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,25 @@

#include "../utils/logger.hpp"

#include <filesystem>
#include <fstream>

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<char>(f)), std::istreambuf_iterator<char>());
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back();
return s;
}

} // namespace

DaemonPool::~DaemonPool() {
// 不主动 stop_all — 让调用方控制(quit 时显式调,析构时机可能在异常路径上)。
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/desktop/daemon_supervisor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/desktop/daemon_supervisor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 之间窗口期里端口可能被别人抢,
Expand Down
58 changes: 58 additions & 0 deletions src/desktop/folder_picker_mac.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#ifdef __APPLE__

// macOS 目录选择器 — 用 NSOpenPanel (Cocoa) 弹出原生文件夹选择对话框。
// 必须编译为 Objective-C++ (.mm),仅 Apple 平台编译。
// CMakeLists.txt 通过显式列举把此文件加入 acecode_testable。

#include "folder_picker.hpp"

#include <optional>
#include <string>

#import <AppKit/AppKit.h>

namespace acecode::desktop {

std::optional<std::string> 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<std::string> 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__
4 changes: 2 additions & 2 deletions src/desktop/folder_picker_win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ std::optional<std::string> pick_folder(void* parent_hwnd) {

} // namespace acecode::desktop

#else // POSIX stub
#elif !defined(__APPLE__) // POSIX stub (Linux etc.)

#include <optional>
#include <string>

namespace acecode::desktop {

std::optional<std::string> pick_folder(void* /*parent*/) {
// POSIX 版本未实现 — 后续 PR 接 GTK FileChooser / NSOpenPanel。
// Linux 版本未实现 — 后续 PR 接 GTK FileChooser。
return std::nullopt;
}

Expand Down
7 changes: 7 additions & 0 deletions src/desktop/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> buf(size);
Expand Down
31 changes: 0 additions & 31 deletions test_final.cpp

This file was deleted.

12 changes: 0 additions & 12 deletions test_perf.cpp

This file was deleted.

15 changes: 11 additions & 4 deletions web/src/components/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex gap-2 max-w-[85%]">
<div className="w-6 h-6 rounded-full bg-ok text-white text-[11px] font-bold flex items-center justify-center mt-[2px]">A</div>
<div className="rounded-2xl border border-border bg-surface-hi px-3 py-2 text-[12px] text-fg shadow-sm min-w-[180px]">
<div className={clsx(
'w-6 h-6 rounded-full text-white text-[11px] font-bold flex items-center justify-center mt-[2px]',
isPermWaiting ? 'bg-warn' : 'bg-ok',
)}>A</div>
<div className={clsx(
'rounded-2xl border px-3 py-2 text-[12px] text-fg shadow-sm min-w-[180px]',
isPermWaiting ? 'border-warn/50 bg-warn/10' : 'border-border bg-surface-hi',
)}>
<div className="flex items-center gap-2">
<span className="font-medium">{label}</span>
{elapsed && <span className="text-fg-mute tabular-nums">{elapsed}</span>}
Expand All @@ -119,7 +126,7 @@ function ActivityIndicator({ activity }) {
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-fg-mute"
className={clsx('w-1.5 h-1.5 rounded-full', isPermWaiting ? 'bg-warn' : 'bg-fg-mute')}
style={{ animation: `ace-pulse 1.2s ease-in-out ${i * 0.2}s infinite` }}
/>
))}
Expand Down Expand Up @@ -1174,7 +1181,7 @@ export function ChatView({ sessionRef, sessionId, onSessionPromoted, onCommandWo
</Fragment>
);
})}
{busy && streamingId == null && !hasActiveTool && (
{busy && streamingId == null && (!hasActiveTool || activity?.phase === 'permission_waiting') && (
<ActivityIndicator activity={activity} />
)}
</div>
Expand Down
15 changes: 13 additions & 2 deletions web/src/components/PermissionModal.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<Modal width={520} onClose={onResolve}>
<Modal width={520} onClose={handleModalClose}>
{({ close }) => (
<>
<div className="px-4.5 py-3 bg-warn/10 border-b border-border flex items-center gap-2">
Expand Down
Loading