From 8d3b026508f20a55d51a25185b1a1fe7500e37ec Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 19 Oct 2026 08:10:07 +0800 Subject: [PATCH] Handle desktop microphone permission without web prompts Grant microphone access only to the Desktop host's current loopback origin, preserve browser and OS privacy prompts, and cover the policy and production Windows WebHost with focused tests. --- cmake/acecode_source_paths.cmake | 4 + docs/speech-recognition.md | 4 + .../changes/add-speech-recognition/design.md | 9 + .../specs/speech-recognition/spec.md | 8 + .../changes/add-speech-recognition/tasks.md | 4 + src/apps/desktop/microphone_permission.hpp | 53 +++++ src/apps/desktop/web_host.cpp | 189 ++++++------------ src/apps/desktop/web_host_browser_win.hpp | 180 +++++++++++++++++ tests/CMakeLists.txt | 11 + .../helpers/microphone_permission_smoke.cpp | 51 +++++ tests/desktop/microphone_permission_smoke.py | 54 +++++ tests/desktop/microphone_permission_test.cpp | 51 +++++ 12 files changed, 488 insertions(+), 130 deletions(-) create mode 100644 src/apps/desktop/microphone_permission.hpp create mode 100644 src/apps/desktop/web_host_browser_win.hpp create mode 100644 tests/desktop/helpers/microphone_permission_smoke.cpp create mode 100644 tests/desktop/microphone_permission_smoke.py create mode 100644 tests/desktop/microphone_permission_test.cpp 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)); +}