Skip to content
Merged
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
4 changes: 4 additions & 0 deletions cmake/acecode_source_paths.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ set(ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES
${CMAKE_SOURCE_DIR}/tests/desktop/helpers/agent_browser_host_smoke.cpp
${ACECODE_AGENT_BROWSER_HOST_SOURCE}
${ACECODE_BROWSER_CDP_SOURCE})
set(ACECODE_MICROPHONE_PERMISSION_SMOKE_SOURCES
${CMAKE_SOURCE_DIR}/tests/desktop/helpers/microphone_permission_smoke.cpp
${ACECODE_DESKTOP_WEB_HOST_SOURCE})
set(ACECODE_AGENT_BROWSER_POINTER_DEMO_SOURCES
${CMAKE_SOURCE_DIR}/tests/desktop/helpers/agent_browser_pointer_demo.cpp
${ACECODE_AGENT_BROWSER_HOST_SOURCE}
Expand Down Expand Up @@ -138,6 +141,7 @@ if(BUILD_TESTING)
${ACECODE_COMPUTER_USE_NATIVE_SMOKE_SOURCES}
${ACECODE_COMPUTER_USE_NATIVE_MAC_SMOKE_SOURCES}
${ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES}
${ACECODE_MICROPHONE_PERMISSION_SMOKE_SOURCES}
${ACECODE_AGENT_BROWSER_POINTER_DEMO_SOURCES}
${ACECODE_AGENT_BROWSER_HOST_MAC_SMOKE_SOURCES}
${ACECODE_MACOS_NOTIFICATION_SMOKE_SOURCES}
Expand Down
4 changes: 4 additions & 0 deletions docs/speech-recognition.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ API Key 可选,填写后通过 `Authorization: Bearer …` 发送。高级设

**麦克风权限取决于打开 ACECode 的页面环境。** Desktop 的本机页面、浏览器的 `http://localhost:端口` 或 HTTPS 页面可以请求麦克风;直接在浏览器打开另一台电脑的 `http://内网IP:端口` 通常无法录音。没有证书时可在各电脑使用 ACECode Desktop 或本机 Web,再连接同一个 HTTP 识别服务。系统和浏览器仍需允许麦克风权限。

普通浏览器首次使用时由浏览器弹出原生麦克风授权。原生 Desktop 对 ACECode 自身页面直接处理网页麦克风权限,不额外弹网页确认;操作系统的隐私授权仍需允许(例如 macOS 首次运行的系统提示)。外部网页、摄像头等权限保持原有行为,Edge app 降级模式仍走浏览器授权。

浏览器限制参见 [MDN getUserMedia 安全上下文说明](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security);whisper.cpp 的服务端参数和接口见[官方服务文档](https://github.com/ggml-org/whisper.cpp/blob/master/examples/server/README.md)。

## 输入框操作
Expand All @@ -46,3 +48,5 @@ API Key 可选,填写后通过 `Authorization: Bearer …` 发送。高级设
## 验证边界

浏览器自动化使用模拟麦克风验证真实 WebAudio 采集、WAV 编码和输入框交互;不能代替真实设备授权验证。macOS 的麦克风用途声明和 Linux WebKit 的本机音频权限处理已接入,仍需在对应系统进行真实设备验收。

Windows 原生权限回归:构建 `microphone_permission_smoke`,运行 `python tests/desktop/microphone_permission_smoke.py build/tests/Release/microphone_permission_smoke.exe`。该测试使用生产 WebHost、独立 WebView2 用户目录和模拟音频设备,不启用自动接受权限提示的测试开关,验证首次及再次采集。
9 changes: 9 additions & 0 deletions openspec/changes/add-speech-recognition/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
5. 输入框以会话身份和请求代次约束录音与结果,复用 Slate 异步插入位置;开始录音时保留草稿和标签,取消不回滚用户自行编辑的内容。停止不提交,发送成功后走现有提交/排队逻辑,识别失败和空结果不提交。
6. 同时只允许一份客户端录音;后端限制并发转写避免堆积。取消接口以请求 ID 标识活动调用。外部识别服务是否真正中止推理由服务自己决定,ACECode 不接受已取消结果。
7. Mac Desktop 添加麦克风用途说明;检查各 Desktop WebView 权限入口,保持仅主页面用户触发采集。Windows 本地编译和浏览器验证作为当前可执行验收,不将其宣称为 Mac/Linux 真机通过。
8. 普通浏览器继续由 getUserMedia 触发原生授权,不增加前端确认框。Windows WebView2 和 macOS WKWebView 在宿主层处理麦克风权限:仅允许宿主导航设置的 loopback origin,核对当前页面与请求来源及端口,不扩大到 Agent 浏览器、外部网页或摄像头。WebView2 不持久化这次自动授权;macOS 保留现有 UIDelegate 的文件选择等能力。Linux 保留已有本地音频授权。系统级麦克风隐私授权不由网页层绕过,Edge app 降级模式仍属于普通浏览器权限路径。

2026-10-19 Linux 编译补验:v1.0.10 打包暴露了 WebKitGTK 音视频权限函数名错误。按官方接口更正为 `webkit_user_media_permission_is_for_audio_device` 和 `webkit_user_media_permission_is_for_video_device`,并用独立 CI 直接编译 `linux_desktop.cpp`,在完整依赖构建前检查系统头文件兼容性。原有仅本地页面、仅音频的权限规则保持;此编译检查不证明真实录音或系统权限交互已通过。

Expand Down Expand Up @@ -70,3 +71,11 @@
- 麦克风通过现有委托事件按按钮实例认领;打开时关闭通用菜单和待打开任务,保留无全局组件时的按钮处理。
- 带真实全局菜单的浏览器回归通过:图标和按钮空白区、明暗主题、已有编辑菜单切换、无 pointerdown 的 contextmenu 事件、编辑器粘贴/全选;原设备选择、快捷键编辑、按住/松开、迟到授权、布局稳定和录音完整流程仍通过。
- `pnpm test`、`pnpm build`、OpenSpec 严格校验及差异检查通过。本轮验证使用 Chromium 模拟音频设备和受控识别 API;未重新构建或替换正在运行的 Desktop。

### Desktop 麦克风授权验证(2026-10-19)

- Windows 接入 WebView2 PermissionRequested,仅对宿主设置且仍在使用的本机 origin 放行麦克风,并设置 SavesInProfile=false。macOS 为现有 WKUIDelegate 添加媒体授权方法,仅对相同来源的主页面麦克风请求返回 Grant;保留文件选择等已有代理方法。普通浏览器和 Edge app 模式继续原生授权,OS 隐私授权保持有效。
- `microphone_permission_smoke` 编译通过并使用生产 `web_host.cpp` 验证:独立 WebView2 配置目录,仅模拟音频设备,不使用 fake-ui 自动确认;首次和再次采集均获得 live 音轨,同一配置目录重新启动后仍从 prompt 状态开始并再次成功。当前页面内部临时状态会变为 granted,这是 Chromium 的正常状态,不代表永久保存。
- `DesktopMicrophonePermission` 4 项用例以 MSVC + 仓库 GTest 库单独编译并全部通过,覆盖来源规范化、无 token 保存、伪造 host、端口/协议不匹配和宿主导航后的撤销。总单测目标触发大量无关重编译后已主动停止,不将其记录为全量通过。
- 为满足现有 web_host.cpp 文件行数限制,将 Windows WebView 设置函数原样提取到 `web_host_browser_win.hpp`,麦克风处理集中在同一宿主适配文件;分层严格检查与 OpenSpec 严格校验通过。
- 本轮未替换运行中的 Desktop,未执行 macOS/Linux 编译及系统级首次麦克风授权实测。macOS 原生媒体权限方法要求 macOS 12+;系统提示的限制参见 [Apple 媒体采集授权文档](https://developer.apple.com/documentation/bundleresources/requesting-authorization-for-media-capture-on-macos),WebView2 授权持久化行为参见 [SavesInProfile 文档](https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2permissionrequestedeventargs3)。
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@
- **WHEN** 浏览器因 ACECode 页面不处于安全上下文而拒绝麦克风
- **THEN** 提示使用 Desktop、localhost 或安全页面入口;服务端 HTTP 地址无需更改

#### Scenario: 浏览器首次麦克风授权
- **WHEN** 用户在普通浏览器首次使用语音且尚未授权
- **THEN** 由浏览器显示原生权限提示,ACECode 不额外弹确认框

#### Scenario: Desktop 不额外确认
- **WHEN** 用户在原生 Desktop 的 ACECode 主页面使用语音
- **THEN** 桌面宿主直接处理自身来源的麦克风网页权限,不显示额外网页授权确认;系统隐私授权仍有效,其他来源、摄像头和其他权限不自动放行

### Requirement: 麦克风设备菜单与录音快捷键
系统 SHALL 在麦克风按钮 hover 时提示“右键配置麦克风”,右键打开使用实际浏览器音频输入设备的菜单,包含设备选中标记和“按住录音”开关,不包含语言项。设备和按住偏好 SHALL 即时保存在本机,采集 SHALL 使用所选设备。

Expand Down
4 changes: 4 additions & 0 deletions openspec/changes/add-speech-recognition/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@
- [x] 8.2 增加独立 Linux 原生 API 编译检查并取得 CI 通过结果;该检查使用系统 WebKitGTK 头文件编译生产代码,不代替真实麦克风交互验收。

验证:GitHub Actions run `39152273460` 的 `linux-desktop-api` job `117748175419` 已通过,实际编译生产 `linux_desktop.cpp` 与系统 WebKitGTK 4.1 头文件。静态检查及 Web 测试/构建亦通过;完整原生回归与发布包检查继续由后续 CI 执行。

## 9. Desktop 麦克风授权
- [x] 9.1 普通浏览器保留原生麦克风授权;Windows/macOS Desktop 对自身页面的麦克风请求直接授权,不增加网页权限确认,保留系统隐私限制和其他权限行为。
- [x] 9.2 覆盖来源、端口和页面变化的权限策略测试,执行 Windows 定向编译/验证并记录未执行的平台真机验证。
53 changes: 53 additions & 0 deletions src/apps/desktop/microphone_permission.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#pragma once

#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>

namespace acecode::desktop {

// Only native navigation can establish trust. Normalize the origin, never keep
// the daemon token/query, and compare ports as well as the loopback hostname.
inline std::string desktop_microphone_origin(std::string_view url) {
for (unsigned char ch : url) {
if (ch <= 0x20 || ch == 0x7f || ch == '\\') return {};
}
const auto separator = url.find("://");
if (separator == std::string_view::npos) return {};
const auto end = url.find_first_of("/?#", separator + 3);
std::string origin(url.substr(0, end));
std::transform(origin.begin(), origin.end(), origin.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
const bool https = origin.rfind("https://", 0) == 0;
if (!https && origin.rfind("http://", 0) != 0) return {};
const auto scheme_end = https ? 8u : 7u;
const std::string_view authority(origin.data() + scheme_end, origin.size() - scheme_end);
if (authority.empty()) return {};
const auto host_end = authority.front() == '[' ? authority.find(']') + 1 : authority.find(':');
const auto host = authority.substr(0, host_end);
if (host != "127.0.0.1" && host != "localhost" && host != "[::1]") return {};
unsigned int port = https ? 443 : 80;
if (host.size() < authority.size()) {
const auto suffix = authority.substr(host.size());
if (suffix.size() < 2 || suffix.front() != ':') return {};
port = 0;
for (const char ch : suffix.substr(1)) {
if (ch < '0' || ch > '9') return {};
port = port * 10 + static_cast<unsigned int>(ch - '0');
if (port > 65535) return {};
}
if (!port) return {};
}
return origin.substr(0, scheme_end) + std::string(host) + ':' + std::to_string(port);
}

inline bool may_grant_desktop_microphone(const std::string& trusted_origin,
std::string_view current_url,
std::string_view request_url) {
return !trusted_origin.empty() &&
desktop_microphone_origin(current_url) == trusted_origin &&
desktop_microphone_origin(request_url) == trusted_origin;
}

} // namespace acecode::desktop
Loading
Loading