diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index b732cf46..9fcfe8f8 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -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} @@ -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} diff --git a/docs/speech-recognition.md b/docs/speech-recognition.md index b6a5ed6c..43162a7e 100644 --- a/docs/speech-recognition.md +++ b/docs/speech-recognition.md @@ -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)。 ## 输入框操作 @@ -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 用户目录和模拟音频设备,不启用自动接受权限提示的测试开关,验证首次及再次采集。 diff --git a/openspec/changes/add-speech-recognition/design.md b/openspec/changes/add-speech-recognition/design.md index 75b3b870..73276391 100644 --- a/openspec/changes/add-speech-recognition/design.md +++ b/openspec/changes/add-speech-recognition/design.md @@ -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`,在完整依赖构建前检查系统头文件兼容性。原有仅本地页面、仅音频的权限规则保持;此编译检查不证明真实录音或系统权限交互已通过。 @@ -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)。 diff --git a/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md index 74bd95f9..a64cf5f7 100644 --- a/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md +++ b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md @@ -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 使用所选设备。 diff --git a/openspec/changes/add-speech-recognition/tasks.md b/openspec/changes/add-speech-recognition/tasks.md index edb65ab6..d1d45d14 100644 --- a/openspec/changes/add-speech-recognition/tasks.md +++ b/openspec/changes/add-speech-recognition/tasks.md @@ -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 定向编译/验证并记录未执行的平台真机验证。 diff --git a/src/apps/desktop/microphone_permission.hpp b/src/apps/desktop/microphone_permission.hpp new file mode 100644 index 00000000..d559c50b --- /dev/null +++ b/src/apps/desktop/microphone_permission.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +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(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(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 diff --git a/src/apps/desktop/web_host.cpp b/src/apps/desktop/web_host.cpp index 5df1abd1..72f2c924 100644 --- a/src/apps/desktop/web_host.cpp +++ b/src/apps/desktop/web_host.cpp @@ -3,6 +3,7 @@ #include "application_icon.hpp" #include "external_url.hpp" #include "linux_webview_scale_policy.hpp" +#include "microphone_permission.hpp" #include "taskbar_badge_win.hpp" #include "tray_icon_win.hpp" #include "web_host_close_policy.hpp" @@ -45,6 +46,7 @@ #ifdef _WIN32 # include # include // Microsoft::WRL::Callback,挂 WebView2 事件 handler +# include "web_host_browser_win.hpp" #endif #if !defined(_WIN32) && !defined(__APPLE__) # include "linux_desktop.hpp" @@ -58,6 +60,7 @@ #ifdef __APPLE__ # include # import +# import # include #endif @@ -89,8 +92,50 @@ WebHost::FileDropHandler g_file_drop_handler; std::function g_existing_instance_focus_handler; using detail::window_focus_handler; +#ifdef _WIN32 +using detail::log_webview_setting_failure; +using detail::open_dev_tools_for_core; +using detail::configure_browser_defaults; +#endif #ifdef __APPLE__ +const char kMicrophoneOriginKey = 0; + +void mac_request_microphone_permission(id, SEL, WKWebView* view, + WKSecurityOrigin* origin, WKFrameInfo* frame, + WKMediaCaptureType type, + void (^decide)(WKPermissionDecision)) API_AVAILABLE(macos(12.0)) { + NSString* trusted = objc_getAssociatedObject(view, &kMicrophoneOriginKey); + NSString* host = origin.host; + if ([host containsString:@":"] && ![host hasPrefix:@"["]) { + host = [NSString stringWithFormat:@"[%@]", host]; + } + NSString* request = origin.port > 0 + ? [NSString stringWithFormat:@"%@://%@:%ld", origin.protocol, host, static_cast(origin.port)] + : [NSString stringWithFormat:@"%@://%@", origin.protocol, host]; + const bool allow = type == WKMediaCaptureTypeMicrophone && frame.isMainFrame && + trusted && may_grant_desktop_microphone(trusted.UTF8String ?: "", + view.URL.absoluteString.UTF8String ?: "", request.UTF8String ?: ""); + decide(allow ? WKPermissionDecisionGrant : WKPermissionDecisionPrompt); +} + +void install_mac_microphone_permission(webview::webview& host) { + if (@available(macOS 12.0, *)) { + auto widget = host.widget(); + if (!widget.ok()) return; + auto* view = static_cast(widget.value()); + id delegate = view.UIDelegate; + if (!delegate) return; + const SEL selector = @selector(webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:); + const auto method = protocol_getMethodDescription(@protocol(WKUIDelegate), selector, NO, YES); + // Extend the existing delegate; retain its open-panel and window behavior. + if (!class_getInstanceMethod(object_getClass(delegate), selector)) { + class_addMethod(object_getClass(delegate), selector, + reinterpret_cast(mac_request_microphone_permission), method.types); + } + } +} + std::function g_mac_window_state_handler; bool g_mac_last_known_maximized = false; std::function g_mac_window_fullscreen_handler; @@ -950,135 +995,6 @@ void refresh_non_client_frame(HWND hwnd) { SWP_NOSIZE); } -void log_webview_setting_failure(const char* operation, HRESULT hr) { - LOG_WARN(std::string("[desktop] ") + operation + - " failed, hr=" + std::to_string(static_cast(hr))); -} - -bool set_dev_tools_enabled(ICoreWebView2* core, BOOL enabled) { - if (!core) return false; - ICoreWebView2Settings* settings = nullptr; - HRESULT hr = core->get_Settings(&settings); - if (FAILED(hr) || !settings) { - log_webview_setting_failure("get_Settings for DevTools", hr); - return false; - } - hr = settings->put_AreDevToolsEnabled(enabled); - settings->Release(); - if (FAILED(hr)) { - log_webview_setting_failure("put_AreDevToolsEnabled", hr); - return false; - } - return true; -} - -bool open_dev_tools_for_core(ICoreWebView2* core) { - if (!core) return false; - // webview/webview disables DevTools when constructed with debug=false. - // Re-enable it only for ACECode's explicit F11/bridge entry point. - if (!set_dev_tools_enabled(core, TRUE)) return false; - HRESULT hr = core->OpenDevToolsWindow(); - if (FAILED(hr)) { - log_webview_setting_failure("OpenDevToolsWindow", hr); - return false; - } - return true; -} - -void install_dev_tools_shortcut(ICoreWebView2Controller* controller) { - if (!controller) return; - using Microsoft::WRL::Callback; - EventRegistrationToken token{}; - HRESULT hr = controller->add_AcceleratorKeyPressed( - Callback( - [](ICoreWebView2Controller* sender, - ICoreWebView2AcceleratorKeyPressedEventArgs* args) -> HRESULT { - if (!sender || !args) return S_OK; - - COREWEBVIEW2_KEY_EVENT_KIND kind{}; - UINT virtual_key = 0; - if (FAILED(args->get_KeyEventKind(&kind)) || - FAILED(args->get_VirtualKey(&virtual_key))) return S_OK; - if (virtual_key != VK_F11 || - (kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN && - kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN)) return S_OK; - // Modified F11 belongs to the application; bare F11 opens DevTools. - if ((GetKeyState(VK_CONTROL) | GetKeyState(VK_MENU) | GetKeyState(VK_SHIFT) | - GetKeyState(VK_LWIN) | GetKeyState(VK_RWIN)) & 0x8000) return S_OK; - - args->put_Handled(TRUE); - ICoreWebView2* core = nullptr; - if (SUCCEEDED(sender->get_CoreWebView2(&core)) && core) { - open_dev_tools_for_core(core); - core->Release(); - } - return S_OK; - }) - .Get(), - &token); - if (FAILED(hr)) { - log_webview_setting_failure("add_AcceleratorKeyPressed for F11 DevTools", hr); - } -} - -void configure_browser_defaults(webview::webview& host) { - auto controller_result = host.browser_controller(); - if (!controller_result.ok()) { - LOG_WARN("[desktop] browser_controller unavailable; browser defaults not configured"); - return; - } - auto* controller = static_cast(controller_result.value()); - if (!controller) return; - - HRESULT hr = controller->put_ZoomFactor(1.0); - if (FAILED(hr)) { - log_webview_setting_failure("put_ZoomFactor", hr); - } - - ICoreWebView2* core = nullptr; - hr = controller->get_CoreWebView2(&core); - if (FAILED(hr) || !core) { - log_webview_setting_failure("get_CoreWebView2", hr); - return; - } - install_dev_tools_shortcut(controller); - - ICoreWebView2Settings* settings = nullptr; - hr = core->get_Settings(&settings); - core->Release(); - if (FAILED(hr) || !settings) { - log_webview_setting_failure("get_Settings", hr); - return; - } - - hr = settings->put_IsZoomControlEnabled(FALSE); - if (FAILED(hr)) { - log_webview_setting_failure("put_IsZoomControlEnabled", hr); - } - - ICoreWebView2Settings5* settings5 = nullptr; - hr = settings->QueryInterface(IID_PPV_ARGS(&settings5)); - if (SUCCEEDED(hr) && settings5) { - const HRESULT pinch_hr = settings5->put_IsPinchZoomEnabled(FALSE); - if (FAILED(pinch_hr)) { - log_webview_setting_failure("put_IsPinchZoomEnabled", pinch_hr); - } - settings5->Release(); - } - - ICoreWebView2Settings6* settings6 = nullptr; - hr = settings->QueryInterface(IID_PPV_ARGS(&settings6)); - if (SUCCEEDED(hr) && settings6) { - const HRESULT swipe_hr = settings6->put_IsSwipeNavigationEnabled(FALSE); - if (FAILED(swipe_hr)) { - log_webview_setting_failure("put_IsSwipeNavigationEnabled", swipe_hr); - } - settings6->Release(); - } - - settings->Release(); -} - // ── 窗口打底色(快速 resize 防黑边)──────────────────────────────────── // 窗口树三层(host HWND → webview_widget → WebView2 合成器)没有任何一层 // 自带背景 — 两个窗口类的 hbrBackground 都是 NULL,放大窗口时新暴露区域 @@ -2247,7 +2163,7 @@ struct WebHost::Impl { // Also cover the WebView-owned fallback window, whose class has no icon. apply_host_window_icons(hwnd()); taskbar_badge = std::make_unique(hwnd()); - configure_browser_defaults(*w); + configure_browser_defaults(*w, microphone_origin); // 三层打底(host 类刷 / widget 类刷 / WebView2 合成器)统一走默认色。 // offscreen 路径的 host 类注册时已带刷,这里等价换新;降级路径 // (webview 库自建 `webview` 类窗口)的 NULL 刷靠这里补上。 @@ -2261,6 +2177,7 @@ struct WebHost::Impl { w = std::make_unique(debug, nullptr); configure_mac_window_chrome(*w); install_mac_edit_menu(*w); + install_mac_microphone_permission(*w); mac_focus_observer = install_mac_focus_existing_observer(*w); mac_window_focus = std::make_unique(*w); mac_enter_fullscreen_observer = install_mac_window_fullscreen_observer( @@ -2350,6 +2267,8 @@ struct WebHost::Impl { bool center_on_first_show = false; ComApartment com{false}; std::unique_ptr taskbar_badge; + // Shared with the WebView-owned callback; both access it on the GUI thread. + std::shared_ptr microphone_origin = std::make_shared(); HWND hwnd() const { if (custom_window) return custom_window; @@ -2423,6 +2342,16 @@ void WebHost::set_size(int width, int height) { #endif } void WebHost::navigate(const std::string& url) { +#ifdef _WIN32 + *impl_->microphone_origin = desktop_microphone_origin(url); +#elif defined(__APPLE__) + auto widget = impl_->w->widget(); + if (widget.ok()) { + const auto origin = desktop_microphone_origin(url); + objc_setAssociatedObject(static_cast(widget.value()), &kMicrophoneOriginKey, + [NSString stringWithUTF8String:origin.c_str()], OBJC_ASSOCIATION_COPY_NONATOMIC); + } +#endif impl_->w->navigate(url); } void WebHost::set_visible(bool visible) { diff --git a/src/apps/desktop/web_host_browser_win.hpp b/src/apps/desktop/web_host_browser_win.hpp new file mode 100644 index 00000000..93aade59 --- /dev/null +++ b/src/apps/desktop/web_host_browser_win.hpp @@ -0,0 +1,180 @@ +#pragma once + +#ifdef _WIN32 +#include "microphone_permission.hpp" +#include "utils/encoding.hpp" +#include "utils/logger.hpp" +#include + +#include +#include +#include + +namespace acecode::desktop::detail { + +inline HRESULT install_microphone_permission_handler( + ICoreWebView2* core, const std::shared_ptr& trusted_origin) { + EventRegistrationToken token{}; + // The WebHost and its WebView callback share this GUI-thread-only origin. + // Capture no host or COM owner; destroying the WebView releases the callback. + return core->add_PermissionRequested( + Microsoft::WRL::Callback( + [trusted_origin](ICoreWebView2* sender, + ICoreWebView2PermissionRequestedEventArgs* args) -> HRESULT { + COREWEBVIEW2_PERMISSION_KIND kind{}; + if (FAILED(args->get_PermissionKind(&kind)) || + kind != COREWEBVIEW2_PERMISSION_KIND_MICROPHONE) return S_OK; + LPWSTR request = nullptr; + LPWSTR current = nullptr; + const HRESULT request_result = args->get_Uri(&request); + const HRESULT current_result = sender->get_Source(¤t); + const std::unique_ptr + request_owner(request, ::CoTaskMemFree), current_owner(current, ::CoTaskMemFree); + const bool allow = SUCCEEDED(request_result) && SUCCEEDED(current_result) && + request && current && may_grant_desktop_microphone( + *trusted_origin, wide_to_utf8(current), wide_to_utf8(request)); + if (!allow) return S_OK; + + Microsoft::WRL::ComPtr args3; + if (SUCCEEDED(args->QueryInterface(IID_PPV_ARGS(&args3)))) { + const HRESULT result = args3->put_SavesInProfile(FALSE); + if (FAILED(result)) return result; + } + return args->put_State(COREWEBVIEW2_PERMISSION_STATE_ALLOW); + }).Get(), &token); +} + +inline void log_webview_setting_failure(const char* operation, HRESULT hr) { + LOG_WARN(std::string("[desktop] ") + operation + + " failed, hr=" + std::to_string(static_cast(hr))); +} + +inline bool set_dev_tools_enabled(ICoreWebView2* core, BOOL enabled) { + if (!core) return false; + ICoreWebView2Settings* settings = nullptr; + HRESULT hr = core->get_Settings(&settings); + if (FAILED(hr) || !settings) { + log_webview_setting_failure("get_Settings for DevTools", hr); + return false; + } + hr = settings->put_AreDevToolsEnabled(enabled); + settings->Release(); + if (FAILED(hr)) { + log_webview_setting_failure("put_AreDevToolsEnabled", hr); + return false; + } + return true; +} + +inline bool open_dev_tools_for_core(ICoreWebView2* core) { + if (!core) return false; + // webview/webview disables DevTools when constructed with debug=false. + // Re-enable it only for ACECode's explicit F11/bridge entry point. + if (!set_dev_tools_enabled(core, TRUE)) return false; + HRESULT hr = core->OpenDevToolsWindow(); + if (FAILED(hr)) { + log_webview_setting_failure("OpenDevToolsWindow", hr); + return false; + } + return true; +} + +inline void install_dev_tools_shortcut(ICoreWebView2Controller* controller) { + if (!controller) return; + using Microsoft::WRL::Callback; + EventRegistrationToken token{}; + HRESULT hr = controller->add_AcceleratorKeyPressed( + Callback( + [](ICoreWebView2Controller* sender, + ICoreWebView2AcceleratorKeyPressedEventArgs* args) -> HRESULT { + if (!sender || !args) return S_OK; + + COREWEBVIEW2_KEY_EVENT_KIND kind{}; + UINT virtual_key = 0; + if (FAILED(args->get_KeyEventKind(&kind)) || + FAILED(args->get_VirtualKey(&virtual_key))) return S_OK; + if (virtual_key != VK_F11 || + (kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN && + kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN)) return S_OK; + // Modified F11 belongs to the application; bare F11 opens DevTools. + if ((GetKeyState(VK_CONTROL) | GetKeyState(VK_MENU) | GetKeyState(VK_SHIFT) | + GetKeyState(VK_LWIN) | GetKeyState(VK_RWIN)) & 0x8000) return S_OK; + + args->put_Handled(TRUE); + ICoreWebView2* core = nullptr; + if (SUCCEEDED(sender->get_CoreWebView2(&core)) && core) { + open_dev_tools_for_core(core); + core->Release(); + } + return S_OK; + }) + .Get(), + &token); + if (FAILED(hr)) { + log_webview_setting_failure("add_AcceleratorKeyPressed for F11 DevTools", hr); + } +} + +inline void configure_browser_defaults(webview::webview& host, + const std::shared_ptr& microphone_origin) { + auto controller_result = host.browser_controller(); + if (!controller_result.ok()) { + LOG_WARN("[desktop] browser_controller unavailable; browser defaults not configured"); + return; + } + auto* controller = static_cast(controller_result.value()); + if (!controller) return; + + HRESULT hr = controller->put_ZoomFactor(1.0); + if (FAILED(hr)) { + log_webview_setting_failure("put_ZoomFactor", hr); + } + + ICoreWebView2* core = nullptr; + hr = controller->get_CoreWebView2(&core); + if (FAILED(hr) || !core) { + log_webview_setting_failure("get_CoreWebView2", hr); + return; + } + install_dev_tools_shortcut(controller); + hr = install_microphone_permission_handler(core, microphone_origin); + if (FAILED(hr)) log_webview_setting_failure("add_PermissionRequested for microphone", hr); + + ICoreWebView2Settings* settings = nullptr; + hr = core->get_Settings(&settings); + core->Release(); + if (FAILED(hr) || !settings) { + log_webview_setting_failure("get_Settings", hr); + return; + } + + hr = settings->put_IsZoomControlEnabled(FALSE); + if (FAILED(hr)) { + log_webview_setting_failure("put_IsZoomControlEnabled", hr); + } + + ICoreWebView2Settings5* settings5 = nullptr; + hr = settings->QueryInterface(IID_PPV_ARGS(&settings5)); + if (SUCCEEDED(hr) && settings5) { + const HRESULT pinch_hr = settings5->put_IsPinchZoomEnabled(FALSE); + if (FAILED(pinch_hr)) { + log_webview_setting_failure("put_IsPinchZoomEnabled", pinch_hr); + } + settings5->Release(); + } + + ICoreWebView2Settings6* settings6 = nullptr; + hr = settings->QueryInterface(IID_PPV_ARGS(&settings6)); + if (SUCCEEDED(hr) && settings6) { + const HRESULT swipe_hr = settings6->put_IsSwipeNavigationEnabled(FALSE); + if (FAILED(swipe_hr)) { + log_webview_setting_failure("put_IsSwipeNavigationEnabled", swipe_hr); + } + settings6->Release(); + } + + settings->Release(); +} + +} // namespace acecode::desktop::detail +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 838c0ff8..00f047ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -171,6 +171,17 @@ endif() # proves that the native Browser host proxies CDP to the exact visible WebView2 # page without an external debug port or ACECode host bindings in web content. if(WIN32 AND ACECODE_BUILD_DESKTOP) + add_executable(microphone_permission_smoke EXCLUDE_FROM_ALL + ${ACECODE_MICROPHONE_PERMISSION_SMOKE_SOURCES}) + target_link_libraries(microphone_permission_smoke PRIVATE + acecode_include_roots acecode_desktop_support acecode_base_core + webview::core nlohmann_json::nlohmann_json + ole32 shell32 user32 gdi32 dwmapi) + if(MSVC) + set_target_properties(microphone_permission_smoke PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + endif() + add_executable(agent_browser_host_smoke EXCLUDE_FROM_ALL ${ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES} ) diff --git a/tests/desktop/helpers/microphone_permission_smoke.cpp b/tests/desktop/helpers/microphone_permission_smoke.cpp new file mode 100644 index 00000000..1023e349 --- /dev/null +++ b/tests/desktop/helpers/microphone_permission_smoke.cpp @@ -0,0 +1,51 @@ +// Uses the production Desktop WebHost and a fresh WebView2 profile. The driver +// supplies fake audio hardware, but deliberately does not auto-accept prompts. +#include "desktop/web_host.hpp" +#include "utils/scope_exit.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 2) return 2; + using acecode::desktop::WebHost; + WebHost host(false, WebHost::StartupWindowMode::OffscreenUntilReady); + // Shared by this runner and the host-owned reporting callback. + auto result = std::make_shared(1); + host.bind("aceMicrophoneTestDone", [result](const std::string& message) { + const auto report = nlohmann::json::parse(message).at(0); + std::cout << report.dump() << std::endl; + *result = report.value("ok", false) ? 0 : 1; + ::PostQuitMessage(0); + return std::string("null"); + }); + const auto timer = ::SetTimer(nullptr, 0, 15000, + +[](HWND, UINT, UINT_PTR, DWORD) { ::PostQuitMessage(1); }); + if (!timer) return 3; + acecode::ScopeExit stop_timer([timer] { ::KillTimer(nullptr, timer); }); + host.init_script(R"JS( + addEventListener('DOMContentLoaded', async () => { + const states = []; + try { + for (let attempt = 0; attempt < 2; attempt++) { + const before = (await navigator.permissions.query({ name: 'microphone' })).state; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + const live = stream.getAudioTracks().some(track => track.readyState === 'live'); + stream.getTracks().forEach(track => track.stop()); + const after = (await navigator.permissions.query({ name: 'microphone' })).state; + states.push({ before, after, live }); + } + // Chromium can retain a temporary grant within the current page. + // A fresh host using the same profile must still start at prompt. + aceMicrophoneTestDone({ ok: states[0].before === 'prompt' && states.every(s => s.live), states }); + } catch (error) { + aceMicrophoneTestDone({ ok: false, error: error.name, states }); + } + }, { once: true }); + )JS"); + host.navigate(argv[1]); + host.run(); + return *result; +} diff --git a/tests/desktop/microphone_permission_smoke.py b/tests/desktop/microphone_permission_smoke.py new file mode 100644 index 00000000..01b43190 --- /dev/null +++ b/tests/desktop/microphone_permission_smoke.py @@ -0,0 +1,54 @@ +"""Run the opt-in Windows Desktop test without using the real microphone.""" +import http.server +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + page = b"ACECode microphone permission test" + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(page))) + self.end_headers() + self.wfile.write(page) + + def log_message(self, *_args): + pass + + +def main(): + binary = Path(sys.argv[1]).resolve(strict=True) + with tempfile.TemporaryDirectory(prefix="ace-microphone-permission-", ignore_cleanup_errors=True) as folder: + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever) + worker.start() + try: + env = dict(os.environ) + env["WEBVIEW2_USER_DATA_FOLDER"] = str(Path(folder) / "profile") + # Fake hardware only: adding --use-fake-ui-for-media-stream would + # hide the exact permission regression this smoke must catch. + env["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = "--use-fake-device-for-media-stream" + for _ in range(2): + result = subprocess.run( + [str(binary), f"http://127.0.0.1:{server.server_port}/"], + env=env, capture_output=True, text=True, timeout=30, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + if result.returncode: + return result.returncode + return 0 + finally: + server.shutdown() + worker.join() + server.server_close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/desktop/microphone_permission_test.cpp b/tests/desktop/microphone_permission_test.cpp new file mode 100644 index 00000000..d460f581 --- /dev/null +++ b/tests/desktop/microphone_permission_test.cpp @@ -0,0 +1,51 @@ +#include + +#include "desktop/microphone_permission.hpp" + +using acecode::desktop::desktop_microphone_origin; +using acecode::desktop::may_grant_desktop_microphone; + +TEST(DesktopMicrophonePermission, NormalizesLoopbackOriginsWithoutKeepingTokens) { + EXPECT_EQ(desktop_microphone_origin("http://127.0.0.1:28080/?token=private#chat"), + "http://127.0.0.1:28080"); + EXPECT_EQ(desktop_microphone_origin("HTTP://LOCALHOST:028080/chat"), "http://localhost:28080"); + EXPECT_EQ(desktop_microphone_origin("http://[::1]:28080/"), "http://[::1]:28080"); + EXPECT_EQ(desktop_microphone_origin("http://localhost/"), "http://localhost:80"); + EXPECT_EQ(desktop_microphone_origin("https://localhost"), "https://localhost:443"); +} + +TEST(DesktopMicrophonePermission, RejectsExternalAndMalformedOrigins) { + for (const auto* url : {"", "http://", "https://", "https://example.com/", + "http://192.168.1.2:8080/", "http://localhost.evil.test:8080/", + "http://localhost@evil.test:8080/", "http://user@localhost:8080/", + "http://localhost:8080@evil.test/", "http://localhost:0/", + "http://localhost:65536/", "http://localhost:/", "http://localhost:12x/", + "http://localhost:999999999999999999/", "http://[::1", "file:///tmp/page.html", + "http://localhost:8080\\@evil.test/", "http://localhost:8080/\n"}) { + EXPECT_TRUE(desktop_microphone_origin(url).empty()) << url; + } +} + +TEST(DesktopMicrophonePermission, GrantsOnlyTheConfiguredOriginAndCurrentPage) { + const auto trusted = desktop_microphone_origin("http://127.0.0.1:28080/?token=private"); + EXPECT_TRUE(may_grant_desktop_microphone(trusted, + "http://127.0.0.1:28080/chat", "http://127.0.0.1:28080/")); + for (const auto* other : {"http://127.0.0.1:28081/", "http://localhost:28080/", + "https://127.0.0.1:28080/", "https://example.com/", "about:blank"}) { + EXPECT_FALSE(may_grant_desktop_microphone(trusted, other, trusted)) << other; + EXPECT_FALSE(may_grant_desktop_microphone(trusted, trusted, other)) << other; + } + EXPECT_FALSE(may_grant_desktop_microphone("", trusted, trusted)); + EXPECT_FALSE(may_grant_desktop_microphone("https://example.com:443", + "https://example.com/", "https://example.com/")); +} + +TEST(DesktopMicrophonePermission, NativeNavigationReplacesOrRevokesTheGrant) { + auto trusted = desktop_microphone_origin("http://127.0.0.1:28080/"); + const auto previous = trusted; + trusted = desktop_microphone_origin("http://127.0.0.1:28081/"); + EXPECT_FALSE(may_grant_desktop_microphone(trusted, previous, previous)); + EXPECT_TRUE(may_grant_desktop_microphone(trusted, trusted, trusted)); + trusted = desktop_microphone_origin("https://example.com/"); + EXPECT_FALSE(may_grant_desktop_microphone(trusted, previous, previous)); +}