diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md index 4d5f22c..71581ef 100644 --- a/.project-docs/00-brief/success-criteria.md +++ b/.project-docs/00-brief/success-criteria.md @@ -10,14 +10,14 @@ - Electron 双视口预检必须检查与最终 `built_archive` 相同的内存文件字节;预检失败不得上传,预检成功不得被表述为可信审核凭据。 - 上传协议必须同时携带源码归档、构建归档和严格版本化 artifact contract;服务端独立重算摘要、校验合同并固化不可变 Release。 - Renderer 不得获得发布凭据、归档、临时目录、构建 origin 或任意本地路径;旧客户端和旧 sandbox/browser 任务必须提示升级后重新构建提交。 -- Robot Guided Hotspot Binding 必须保持固件零改动、Main-owned default-on capability、精确环境值 `0` 回滚、固定系统浏览器 portal 和现有六位 Binding facade;Makelore 不得接触 Wi-Fi 凭据,也不得把 Binding 成功等同于在线。 +- Robot Guided Hotspot Binding 必须保持固件零改动、Main-owned default-on capability、精确环境值 `0` 回滚、固定系统浏览器 Portal 和现有六位 Binding facade。Windows/macOS 页面内只能扫描开放 `Xiaozhi-*` 短效候选并连接用户明确选择的项;Makelore 不得接触家庭 Wi-Fi 凭据,也不得把热点发现或 Binding 成功等同于可信身份/在线。 ## Quality Checks - 发布安装包前运行 `pnpm verify:publish-runtime`,并对最终 Windows 产物运行 `pnpm verify:artifact:win`;固定 npm 闭包缺失或版本不符时 fail closed。 - 至少覆盖 release builder/静态产物服务/发布路由/Renderer 状态的聚焦测试、typecheck、scoped lint、Vite 构建和真实 Electron 双视口 production-seam E2E。 - 真实生产发布仍需成组验证服务端合同、不可变 Release、运营审核、CDN/App 播放;客户端本地验证不能替代该整链验收。 -- Robot 默认引导路径必须通过聚焦 Renderer/Main 测试并保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚。真实 Host API Electron E2E、指定固件镜像、六位码发行契约与真机 smoke 未完成前,不得宣称完整硬件兼容或端到端配网已经验收。 +- Robot 默认引导路径必须通过聚焦 Renderer/Main/native 测试并保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚。真实 Host API/native seam Electron E2E、Windows Robot 真机、签名 macOS x64/arm64、指定固件镜像与六位码发行契约未完成前,不得宣称完整双平台硬件兼容或端到端配网已经验收。 ## Last Reviewed diff --git a/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md b/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md index 17f9688..6d30f82 100644 --- a/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md +++ b/.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md @@ -14,10 +14,12 @@ Implementation status: implemented and enabled by default. Exact environment val - For V1 delivery only, this decision supersedes the proposed Security 2 / automatic-claim onboarding path in task `20260816-device-provisioning-flow-a4d91c`. - Authenticated BLE provisioning and automatic claim remain a deferred production-security direction, not a current implementation contract. +- ADR-003 supersedes only this decision's manual operating-system hotspot-selection step; all other firmware, Portal, credential, Binding, rollback, and readiness boundaries remain active. ## Related - Source proposal commit `14afe4a`: `.project-docs/10-decisions/proposals/20260816-minimal-firmware-onboarding-c3e8b7__guided-hotspot-binding-v1.md` +- Cross-platform page-owned hotspot connection: ADR-003, `adr-003-robot-in-app-hotspot-connection.md` - Current Robot binding implementation: `src/pages/AiHardware/index.tsx`, `src/lib/ai-hardware.ts`, `electron/api/routes/ai-hardware.ts` - Firmware audit target: `D:\Datas\HardwareProjects\xiaozhi-esp32-firmware` at audited commit `0449e51` @@ -33,7 +35,7 @@ V1 is a Renderer-guided, Main-gated workflow: `choose_path -> prepare_robot -> connect_device_ap -> configure_wifi -> reconnect_internet -> enter_activation_code -> binding -> bound` -- Makelore explains how to place the Robot in provisioning mode and connect the computer to the Robot's existing Wi-Fi hotspot through the operating system. +- Makelore explains how to place the Robot in provisioning mode, scans eligible open `Xiaozhi-*` hotspots through the Main-owned ADR-003 Module, and connects only the candidate the user explicitly selects. System Wi-Fi remains the recovery fallback. - Electron Main alone may open the fixed system-browser portal `http://192.168.4.1/`; Renderer never supplies or receives an arbitrary portal URL. - After Wi-Fi provisioning, Makelore instructs the user to reconnect the computer to the internet and obtain the freshly issued six-digit activation code from the Robot. - Binding continues to use the existing `bindAiHardwareDevice(activationCode, agentId, { operationId })` cloud contract. @@ -63,12 +65,12 @@ The user explicitly chose default-on on 2026-08-16 while retaining the current f 1. The exact shipped Robot component/image uses the audited Hotspot portal flow and fixed portal address. 2. The deployed activation issuer emits exactly six ASCII digits accepted by the existing Works Binding validator, with documented freshness and consumption behavior. -3. Electron E2E through the real Host API/native-opener seam and a physical-device smoke pass; focused Renderer/Main route tests already cover default-on, exact `0` rollback, fixed portal ownership, local-before-cloud behavior, and error redaction. +3. Electron E2E through the real Host API/native opener and native hotspot seams plus Windows/macOS physical-device smoke pass; focused Renderer/Main/native tests already cover default-on, exact `0` rollback, fixed portal ownership, candidate constraints, local-before-cloud behavior, cancellation, and error redaction. 4. Release/support instructions retain the exact `=0` rollback and do not describe V1 as authenticated discovery, automatic Wi-Fi delivery, automatic claim, or proof of online readiness. ## Consequences - The first implementation is desktop-only and requires no firmware, BLE, manufacturing, or cloud-contract change. -- V1 cannot truthfully advertise automatic nearby-device discovery or automatic device claim. -- The UI becomes a single coherent onboarding journey while system Wi-Fi selection and firmware portal entry remain explicit user actions. +- V1 may advertise nearby provisioning-hotspot discovery and explicit user-selected connection as an unauthenticated convenience, but not trusted device discovery or automatic device claim. +- The UI becomes a single coherent onboarding journey while system Wi-Fi remains a recovery path and firmware Portal entry remains an explicit user action. - A later authenticated BLE/automatic-claim design requires a new accepted ADR and must not silently widen this V1 interface. diff --git a/.project-docs/10-decisions/adr-003-robot-in-app-hotspot-connection.md b/.project-docs/10-decisions/adr-003-robot-in-app-hotspot-connection.md new file mode 100644 index 0000000..db8714d --- /dev/null +++ b/.project-docs/10-decisions/adr-003-robot-in-app-hotspot-connection.md @@ -0,0 +1,72 @@ +# ADR-003: Robot 配网页内连接 Windows 与 macOS 热点 + +## Status + +Accepted + +Implementation status: implemented and enabled with Guided Hotspot Binding. Exact environment value `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` disables the complete guided path and preserves direct six-digit Binding. + +## Date + +2026-08-16 + +## Supersedes + +- Supersedes only ADR-002's requirement that the user leave Makelore and select the Robot hotspot in the operating-system Wi-Fi panel. +- Preserves ADR-002's firmware-zero-change, fixed Portal, Wi-Fi credential isolation, Activation, six-digit Binding, `bound` semantics, and exact `=0` rollback. + +## Context + +ADR-002 delivered a coherent firmware-zero-change journey, but its `connect_device_ap` step remained manual. The user explicitly requested that Makelore show connectable Robot hotspots and connect the selected hotspot inside the binding dialog on both Windows and macOS. + +The current firmware exposes an open `Xiaozhi-*` provisioning hotspot. Its SSID prefix is only a convenience filter: it does not authenticate Robot identity, ownership, account Binding, or online readiness. Exact shipped-image and physical-device behavior remain release evidence rather than assumptions. + +## Decision + +Electron Main owns a Robot Hotspot Module with platform Adapters: + +- Windows uses the native WLAN API for open-network scanning, temporary discovery connection, and exact current-SSID verification. It does not parse `netsh` output or persist a Wi-Fi profile. +- macOS uses CoreWLAN for scanning, association, and exact current-SSID verification. Main owns CoreLocation authorization; CoreWLAN work runs in an abortable worker that owns its Objective-C objects. +- Unsupported platforms fail safely to the existing system-Wi-Fi/manual fallback. + +Renderer accesses only two typed Host operations: + +- `POST /api/works/ai-hardware/provisioning-hotspots/scan` accepts exact body `{}` and returns bounded `{ candidate_id, ssid, signal_percent, connected }` projections. +- `POST /api/works/ai-hardware/provisioning-hotspots/connect` accepts exact body `{ candidate_id }` and succeeds only after the current SSID exactly matches the selected candidate. + +Both routes authenticate through the normal loopback Host boundary, check the Main-owned capability, and finish locally before Works credentials or upstream access. Native diagnostics, BSSID, interface names, profiles, and location data never cross into Renderer. + +Entering `connect_device_ap` starts a scan. Makelore displays nearby eligible hotspots and requires the user to choose one explicitly; it never auto-connects the strongest candidate. A verified connection advances to the existing fixed-Portal step. Permission denial, no candidates, timeout, unsupported platform, or connection failure retains a safe retry and manual system-Wi-Fi path. + +## Candidate And Operation Rules + +- Only explicitly open, connectable, printable `Xiaozhi-*` SSIDs no longer than 32 UTF-8 bytes are eligible. +- Duplicate SSIDs collapse to the strongest candidate. BSSID never leaves Main. +- Candidate IDs are unpredictable and valid only for the latest bounded Main snapshot; rescan, clear, process restart, or the 60-second TTL invalidates them. +- Scan and connect are mutually exclusive and bounded. Abort or dialog-session replacement prevents stale permission, worker, or UI continuation. +- Discovery and association are unauthenticated convenience signals. They must not be presented as trusted physical identity, automatic claim, or protocol-online proof. + +## Security And Privacy + +- Main is the only native-network owner. Renderer cannot submit an arbitrary SSID, BSSID, interface, profile, command, or URL. +- Makelore handles no Robot-hotspot password and no home-network credential. Home Wi-Fi SSID/password remain inside the firmware Portal. +- Logs and safe errors exclude BSSID, interface/profile details, native diagnostics, Wi-Fi credentials, and location data. +- The current open SoftAP/plain-HTTP risk and public-environment warning remain. Default-on does not make the channel authenticated. + +## Verification And Release Gates + +The implementation passed focused Renderer/Main/native tests, the full unit suite, typecheck, lint, production build, Windows Electron/Koffi/WLAN definition loading, a Windows permission-denied native probe, and independent Standards/Spec review. + +Before claiming complete two-platform hardware acceptance, release evidence must still include: + +1. A Windows physical Robot scan, selection, connection, exact-SSID verification, Portal, and Binding smoke with location/Wi-Fi access enabled. +2. Signed packaged macOS x64 and arm64 permission, scan, association, worker/ASAR/Koffi loading, current-SSID, Portal, and Binding smoke. +3. Electron E2E through the real Host API and native platform seam for success and recovery outcomes. +4. Exact shipped-firmware hotspot naming/open-network behavior, fixed Portal, and six-digit issuer/validator compatibility. + +## Consequences + +- Users can select and connect a Robot provisioning hotspot without first leaving Makelore, while the manual system path remains available. +- The firmware, fixed Portal, cloud Binding contract, and credential boundary remain unchanged. +- Packaging now includes a native Koffi dependency and macOS location usage descriptions, expanding signing and platform acceptance work. +- Trusted discovery, automatic claim, or new firmware provisioning still requires a separate authenticated protocol and accepted ADR. diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index 845b8c7..56c0ded 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -6,6 +6,7 @@ |---|---|---|---|---|---| | ADR-001 | AI 绘画采用 Workspace / Conversation / Task 分层状态与服务端持久 Conversation Session | Accepted | 2026-08-11 | AI 绘画客户端、Main 适配器、Works Square API | `adr-001-ai-design-conversation-ownership.md` | | ADR-002 | Robot V1 采用 Main 门控的引导式热点配网并衔接现有六位 Binding | Accepted / implemented, default on | 2026-08-16 | Robot Renderer、Host API、Electron Main、现有固件热点入口 | `adr-002-robot-guided-hotspot-binding-v1.md` | +| ADR-003 | Robot 配网页内扫描并连接 Windows/macOS 热点 | Accepted / implemented with physical release gates pending | 2026-08-16 | Robot Renderer、Host API、Electron Main、Windows WLAN、macOS CoreWLAN/CoreLocation | `adr-003-robot-in-app-hotspot-connection.md` | ## Superseded Decisions diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 7d9e999..6421d46 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -19,7 +19,7 @@ | 设计消息与确认 | 当前 Conversation | Main ↔ 持久 Agent Gateway Session WebSocket → Conversation 快照 | `command.submit`、Run 与设计事件共用连接;传输失败才以同一幂等 ID 回退 REST;结构化业务错误不重试且未知文本由 Main 脱敏;请求和流式结果同时绑定 Workspace + Conversation | | 设计单图来源选择 | 当前 Workspace 已完成图片 / 本地图片 | 现有 Asset 上传或选择 → `attachmentAssetIds` → 当前 Conversation Turn | 图片 Brief 用作图生图参考图;视频 Brief 用作首帧;只提交一个真实 Workspace Asset ID | | 设计任务同步 | 任一 Conversation 的事件流 / Quote REST 对账 | Renderer Workspace 任务列表 | Task 和 Asset 按 Workspace 归并;任务已落库但 Run 失败时恢复可见性,内部对账失败不覆盖新会话错误,切换 Conversation 后仍同步任务但不回写旧会话 | -| Robot 引导式热点配网 V1(已实现、默认开启) | Robot Binding 页面 | 用户选择引导配网 → 进入固件配网模式 → 操作系统连接 Robot 热点 → Main 在系统浏览器打开固定 portal → 用户在 portal 配置 Wi-Fi → 电脑恢复互联网 → 现有六位 Binding | 精确环境值 `0` 或 capability 读取失败回退直接六位码;Makelore 不扫描设备、不收集 Wi-Fi 密码、不修改固件,`bound` 不等于 online/ready | +| Robot 引导式热点配网 V1(已实现、默认开启) | Robot Binding 页面 | 用户选择引导配网 → 进入固件配网模式 → Renderer 经 Host API 请求 Main 扫描 → 用户选择短效候选 → Windows/macOS Adapter 连接并核验当前 SSID → Main 打开固定 Portal → 用户在 Portal 配置 Wi-Fi → 电脑恢复互联网 → 现有六位 Binding | 精确环境值 `0` 或 capability 读取失败回退直接六位码;系统 Wi-Fi 保留兜底,Makelore 不收集 Wi-Fi 密码、不修改固件,热点发现/`bound` 都不等于可信身份或 online/ready | ## State Ownership @@ -37,6 +37,7 @@ - Main Host AI proxy 可为固定 OpenCode 重试契约做窄化的内部状态投影:配额耗尽投影为 `402`,明确上游分组饱和的 `429` 投影为终止 `400`,其他 `429` 原样保留;升级 OpenCode 时必须重新验证该契约。 - Main AppUpdater 持有 feed、原始异常、下载和安装状态;设置页只消费状态投影。一次 electron-updater `error` 事件覆盖的并发检查不会在 Renderer 重复发错,但独立的后续检查仍有自己的报告生命周期。 - Robot V1 引导状态只在 Renderer 当前进程内保存,不持久化 Wi-Fi 凭据、activation code 或 Binding operation ID。相同进程内的模糊 Binding 重试复用 operation ID;无效码或重启后必须取得新码并使用新 operation ID。 +- Robot Hotspot Module 只在 Main 内保存最近一次扫描的短效、不透明候选快照。新的扫描、clear、60 秒过期或进程重启使旧 candidate ID 失效;Renderer 关闭/重开向导以 generation 防止旧扫描/连接结果回写。 ## External Interfaces @@ -46,7 +47,7 @@ - Main-owned electron-updater IPC 与 Works Square 平台/架构稳定 feed;正式安装产物发布不由 Renderer 控制。 - 服务端安全投影后的公共 `play_url`;只接受同源 HTTPS、精确 App 路径和可信版本状态。 - Works Square Workspace/Conversation API、每个 Conversation 的持久 Agent Gateway Session、单次 WebSocket ticket、双向命令/事件帧与幂等 REST 传输回退。 -- 已实现的本机 Robot provisioning capability 与固定 portal-open Host API。它们是本地 Main 操作,不读取 Works access token、不调用上游,也不接受任意 URL。 +- 已实现的本机 Robot provisioning capability、固定 portal-open 与 hotspot scan/connect Host API。它们是本地 Main 操作,不读取 Works access token、不调用上游,也不接受任意 URL/SSID/BSSID/interface/profile。 ## Last Updated diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index ee89630..d2c11de 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -30,8 +30,12 @@ | `src/pages/ImageCanvas/index.tsx` | Conversation 对话、Quote 确认、统一任务列表、新会话入口与单图来源选择器 | 图片 Brief 选择/上传图生图参考图;视频 Brief 绑定首帧;均提交一个 Workspace Asset ID | | `src/components/layout/ImageWorkspaceSidebar.tsx` | Workspace 与近期 Conversation 切换/创建 | 切换会话不清空项目级任务 | | `src/pages/AiHardware/index.tsx` | Robot 管理、现有六位 Binding,以及已实现的 default-on 引导式热点配网状态机 | 只编排非敏感步骤;不接收 Wi-Fi 密码,不把 `bound` 展示为在线证明 | -| `src/lib/ai-hardware.ts` | Renderer 侧 Robot Host API 类型、安全错误映射和稳定 Binding facade | 读取 Main-owned capability 并调用无参数固定 portal-open;不添加任意 URL 或 Renderer IPC | -| `electron/api/routes/ai-hardware.ts` | Main-owned Robot 云端代理,以及已实现的本地配网 capability/portal action | 默认开启、精确环境值 `0` 回滚;本地操作必须在 Works token/上游访问前返回,系统浏览器只能打开固定 `http://192.168.4.1/` | +| `src/lib/ai-hardware.ts` | Renderer 侧 Robot Host API 类型、安全错误映射和稳定 Binding/hotspot facade | 读取 Main-owned capability,调用固定 portal-open,并只传递短效 hotspot candidate ID;不添加任意 URL、SSID 或 Renderer IPC | +| `electron/api/routes/ai-hardware.ts` | Main-owned Robot 云端代理,以及本地 capability/portal/hotspot actions | 默认开启、精确环境值 `0` 回滚;所有本地操作必须在 Works token/上游访问前返回,且只投影稳定安全错误 | +| `electron/robot-hotspot/index.ts` | Robot hotspot 深模块:候选过滤/去重/TTL、操作互斥、超时和精确 SSID 核验 | 只接受 Adapter 输出与不透明 candidate ID;Renderer 不能选择任意 SSID | +| `electron/robot-hotspot/windows.ts` | Windows 原生 WLAN 扫描、临时开放网络连接和当前 SSID 查询 | 懒加载 `wlanapi.dll`;不使用 `netsh`、不保存 profile、不主动断开 | +| `electron/robot-hotspot/macos.ts` | macOS CoreLocation 授权与 worker-owned CoreWLAN 扫描/关联/核验 | Objective-C 对象不跨线程;取消/超时终止 worker,旧终止屏障阻止迟到权限/native continuation | +| `electron/robot-hotspot/adapter.ts` | 平台 Adapter 的最小内部契约与稳定错误分类 | 平台细节不进入 Host/Renderer 公共 DTO | ## Dependency Direction @@ -39,7 +43,7 @@ - AI 编程 Renderer → Host API → Main OpenCode routes/runtime;OpenCode provider 请求再经 Main AI proxy 访问模型上游,Renderer 不直接持有上游凭据或本地 runtime URL。 - Project configuration 决定产品分流;Main release builder 生成 source/built/contract,服务端独立重算和校验决定发布安全,本地 `ProjectType` 不是授权结论。 - Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。 -- Robot Renderer → typed AI hardware API → Main Host route。云端 Binding 仍由 Main 代理;V1 配网只增加 Main-owned capability 与固定系统浏览器 action,Wi-Fi 选择和凭据输入留在操作系统/固件 portal。 +- Robot Renderer → typed AI hardware API → Main Host route → Robot Hotspot Module → Windows/macOS Adapter。云端 Binding 仍由 Main 代理;热点选择/连接移入页面,但家庭 Wi-Fi 凭据输入仍只留在固件 Portal,系统 Wi-Fi 保留为兜底。 ## Risky Or Sensitive Areas @@ -55,7 +59,7 @@ - Gateway 命令的 REST fallback 只处理 WebSocket 发送、断连和 ACK 超时,必须复用 `client_command_id`;业务错误回退会造成重复提交。Quote 任务恢复只更新 Workspace 所有的任务,不能覆盖当前 Conversation。 - `closeEventSessions` 只负责本地流和缓存生命周期;远端 Conversation Session 是服务端持久资源。 - 单图来源选择器当前仍由精确中文 quick reply 触发,并以 Brief medium 判断图生图或视频首帧用途;扩展更多输入用途前应先把消息协议升级为结构化 action/purpose,避免展示文案与行为继续耦合。 -- Guided Hotspot Binding 已由产品决策默认开启,但仍依赖当前固件的开放热点和明文 HTTP portal。未完成指定固件镜像核对、六位码发行契约、原生 opener E2E 和真机 smoke 前不得宣称完整兼容;现场异常使用精确环境值 `0` 回滚。 +- Guided Hotspot Binding 已由产品决策默认开启,并通过 native dependency 执行未经认证的热点扫描/连接。未完成指定固件镜像核对、六位码发行契约、签名 macOS x64/arm64 worker/ASAR/Koffi 验证、Windows 真机和完整 Electron/Robot smoke 前不得宣称完整兼容;现场异常使用精确环境值 `0` 回滚。 ## Last Updated diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 73aad82..cfca72f 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -24,7 +24,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections. Configuration choices come from the USER-scoped safe catalog; unavailable current values remain editable without exposing provider credentials or configuration internals. | | AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, error redaction, and the fixed no-store configuration-catalog proxy. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. | -| Guided Hotspot Binding V1 | Implemented, default-on Renderer journey over the current firmware Hotspot portal and six-digit Binding | Exact environment value `0` disables the journey. System Wi-Fi selection and portal credential entry remain user/firmware-owned; no BLE, automatic claim, or firmware change is part of V1. | +| Robot Hotspot Module | Main-owned bounded scan/connect/verify interface with Windows WLAN and macOS CoreWLAN/CoreLocation adapters | Accepts only short-lived opaque candidates from the latest scan, exposes no BSSID/interface/profile/native diagnostic, and retains the system-Wi-Fi fallback. | +| Guided Hotspot Binding V1 | Implemented, default-on Renderer journey over in-page hotspot selection, the current firmware Portal, and six-digit Binding | Exact environment value `0` disables the journey. Hotspot discovery is unauthenticated convenience; Portal credential entry remains firmware-owned, and no BLE, automatic claim, or firmware change is part of V1. | ## Important Boundaries @@ -34,6 +35,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - One local account maps to one server-side Xiaozhi account binding. Agents and devices are resources beneath that account binding, not separate Xiaozhi users. - Robot/Canvas/module-selection routes must not initialize AI Programming projects or providers. - Guided Hotspot Binding is implemented behind a Main-owned capability that is true by default; exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` disables it, while capability-read failure falls back to direct six-digit Binding. The guided state is process-local, opener failures expose only the same fixed address for manual copy, and Binding conflicts refresh the safe account overview. +- Robot hotspot scanning and connection are local Main operations that return before Works credentials/upstream access. Renderer may submit only an opaque candidate ID from the latest bounded scan; Main alone filters open printable `Xiaozhi-*` SSIDs, performs platform association, and verifies the exact current SSID. +- Hotspot discovery and connection do not authenticate a Robot. BSSID, interface/profile details, native diagnostics, location data, and Wi-Fi credentials never cross the Main boundary; permission or platform failure keeps the system-settings/manual path available. - The fixed portal action may open only `http://192.168.4.1/` in the system browser and must not acquire cloud credentials or call Works Square. Renderer never supplies a portal URL and never handles Wi-Fi credentials. - A successful Binding means account ownership was established; it is not evidence that the Robot is currently online or protocol-ready. diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index c4ca966..20ff2c1 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- `c1326a2`: Guided Hotspot Binding now scans bounded open `Xiaozhi-*` candidates and connects the user-selected hotspot inside the page through Main-owned Windows WLAN and macOS CoreWLAN/CoreLocation adapters; system Wi-Fi remains fallback, exact `=0` rollback and firmware/cloud contracts are unchanged. - `b78fc07`: Guided Hotspot Binding is enabled by default in Electron Main, with exact environment value `0` as rollback and direct six-digit fallback on capability-read failure; firmware and Host/cloud contracts are unchanged. - `b7a1590` / `14afe4a`: initial firmware-zero-change Guided Hotspot Binding V1 implementation and decision used a Main-owned default-off capability, fixed portal action, in-memory Renderer journey, and existing six-digit Binding contract; `b78fc07` above supersedes only that default. - `ea75b06`: Robot configuration reads accept canonical weak numeric response ETags introduced by public response compression only when the numeric revision exactly matches the strictly projected DTO; configuration and assignment writes continue to emit strong `If-Match`. @@ -33,10 +34,11 @@ AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗 Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。 -Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选择。引导路径只编排现有固件 Hotspot、固定 `http://192.168.4.1/` 系统浏览器页面、恢复互联网和现有六位 Binding;精确环境值 `0` 可回滚为直接六位码。Makelore 不扫描附近设备、不接收 Wi-Fi 密码、不修改固件,也不把 Binding 成功等同于在线。指定固件/发行契约、原生 opener 和真机整链仍未完成验证。 +Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选择。引导路径在页面内扫描附近符合条件的开放 `Xiaozhi-*` 热点,要求用户明确选择后由 Main-owned Windows/macOS 原生适配器连接并核验当前 SSID;失败时保留系统 Wi-Fi 兜底。后续继续使用固定 `http://192.168.4.1/` 系统浏览器页面、恢复互联网和现有六位 Binding;精确环境值 `0` 可回滚为直接六位码。Makelore 不接收 Wi-Fi 密码、不修改固件,也不把热点发现或 Binding 成功等同于可信身份/在线。签名 macOS、Windows 真机、指定固件/发行契约和完整整链仍未完成验证。 ## Recently Completed +- 2026-08-16: Integrated Windows/macOS in-page Robot hotspot discovery, explicit selection, connection, and exact-current-SSID verification behind the existing default-on guided capability. Candidate IDs are bounded and short-lived, native diagnostics stay in Main, system settings remain fallback, and firmware/Portal/Binding contracts are unchanged. - 2026-08-16: Enabled the existing Guided Hotspot Binding journey by default after explicit product confirmation. Exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` remains the operational rollback; fixed portal ownership, direct-code fallback, security warnings, firmware-zero-change, and Binding-without-online semantics are preserved. - 2026-08-16: Initially implemented ADR-002's Robot onboarding V1 without changing firmware, behind a default-off Main capability and fixed portal opener. The later `b78fc07` decision above changes only the default; the same firmware/issuer/native-opener/physical evidence remains outstanding. - 2026-08-15: Corrected the deployed Robot configuration-read contract after the compressed public Works response was observed with `ETag: W/\"0\"` and matching numeric `config_revision: 0`. Electron Main now accepts only canonical strong or weak numeric response tags, still requires exact DTO revision equality, and always sends strong `If-Match` for mutations. No production client rollout is claimed. @@ -61,7 +63,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Next Recommended Steps -1. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐原生 opener Electron E2E 和真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。 +1. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐 Windows 真机热点连接、签名 macOS x64/arm64 CoreWLAN/CoreLocation/worker 打包验证、真实 Host API/native seam Electron E2E 和完整真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。 2. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。 3. 配置真实生产环境,分别执行“小游戏/小程序创建 → 客户端本地构建与同字节预检 → 提交 → 服务端合同/摘要校验与不可变 Release 固化 → 运营批准 → App 播放”。 @@ -71,7 +73,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Risky Areas -- Guided Hotspot Binding 默认开启,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、原生 opener 与真机整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得宣称自动发现、自动认领或在线证明。 +- Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。 - 一键提交已成功但本机 submission binding 落盘失败时必须保持提交成功、显示固定 `binding_warning` 并继续轮询,避免用户误判上传失败。 - 公共 `play_url` 必须满足 Works Square 同源 HTTPS、无 userinfo/loopback、精确 `/apps/{app_id}/` 路径、无 query/fragment、版本非空且上游标记可播放。 - `/api/works/projects/publish-source` 必须在读取凭据和项目文件前校验 Renderer capability;Host token/base 不能替代该 UI 边界。 diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index d86e190..d86e3ca 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -12,6 +12,7 @@ ## Scope +- On 2026-08-16, resume the existing Integration owner to merge reviewed cross-platform in-app Robot hotspot connection source `c1326a2` into local `main`, supersede only ADR-002's manual operating-system hotspot-selection step, preserve firmware/credential/Binding boundaries, and keep remote push outside this resumption. - On 2026-08-16, resume the existing Integration owner to merge reviewed default-on Guided Hotspot Binding source `b78fc07` into local `main`, accept the user's explicit reversal of the prior default-off policy, preserve exact environment value `0` as rollback, and keep firmware edits and remote push outside this resumption. - On 2026-08-16, resume the existing Integration owner to merge reviewed Robot Guided Hotspot Binding implementation commit `b7a1590` into local `main`, reconcile canonical memory from planned to implemented/default-off, and keep firmware edits, capability enablement, and remote push outside this resumption. - On 2026-08-16, resume the existing Integration owner to accept reviewed Robot Guided Hotspot Binding V1 design commit `14afe4a`, promote only its confirmed minimal-firmware decision into canonical memory, and keep product implementation, firmware changes, and remote push outside this integration step. @@ -48,6 +49,7 @@ - The 2026-08-16 Robot onboarding confirmation accepts the current-firmware Hotspot + six-digit Binding V1. It does not authorize firmware edits, claim automatic nearby discovery, enable the pilot capability by default, or revive the unready Security 2/automatic-claim proposal as a V1 contract. - The reviewed implementation may move canonical truth from planned to present, but release guidance must retain the exact shipped-firmware, issuer/validator, gate-on Electron, and physical-device smoke prerequisites. The source task record remains read-only and must stay on its feature history. - The user's latest instruction explicitly authorizes default-on and supersedes only the earlier default-off/capability-not-enabled constraint. It does not authorize firmware changes, automatic discovery/claim claims, arbitrary portal URLs, Wi-Fi credential handling, or remote push. Missing installed-Electron/physical-device evidence remains an explicit residual release risk, not completed evidence. +- The user has now explicitly authorized page-owned selection and connection of nearby open `Xiaozhi-*` provisioning hotspots on both Windows and macOS. This supersedes only manual operating-system hotspot selection; discovery remains unauthenticated convenience, Main remains the sole native-network owner, and signed macOS plus physical-Robot smoke remain release gates. ## Project Context Loaded @@ -95,6 +97,14 @@ Relevant understanding: Gate result: - Passed. +### 2026-08-16 Cross-Platform Robot Hotspot Integration Resume + +- Reused the existing Integration owner because it exclusively owns clean local `main` at `abecd5f34485ab467e5f032c618083d88e34b74d`; `task_context.py touch` refreshed the reservation and the registry reports this exact `main` worktree/branch owner. +- Formed reviewed source commit `c1326a298026a697181c822cdd3062cc96c3aa2d`; it is exactly one commit ahead of current `main`, its feature task is `ready_for_integration`, its worktree is clean, and final read-only Sol review returned PASS on Standards and Spec with no P0-P3 findings. +- Read the source task outcome, verification, follow-ups, and proposal against ADR-002, current Robot architecture/domain memory, release commitments, and relevant peer task scopes. The user-confirmed Windows/macOS page connection decision resolves the only semantic conflict with ADR-002's manual operating-system hotspot-selection step. +- No peer owns `main`. Related Robot tasks are isolated and either support this direction or remain historical/deferred: authenticated automatic claim is not revived, discovery does not prove identity, and source task records remain read-only. +- Gate result: Passed for a local no-ff merge and canonical reconciliation. Firmware edits, remote push, signed macOS x64/arm64 acceptance, and physical-Robot acceptance remain outside this resumption. + ### 2026-08-16 Guided Hotspot Default-On Integration Resume - Reused the existing Integration owner because it still exclusively owns clean local `main` at `971865c256c2ddfe1f8ab4f8f4731a1f608599c0`; `task_context.py touch` refreshed the reservation and the registry reports this exact `main` worktree/branch owner. @@ -147,6 +157,13 @@ Gate result: ## Plan +### 2026-08-16 Cross-Platform Robot Hotspot Integration Plan + +1. Merge reviewed source `c1326a2` into local `main` with a normal no-ff merge while preserving source history; exclude the source-owned task record and proposal from the integrated tree. +2. Accept the proposal as a new ADR that supersedes only ADR-002's manual OS hotspot-selection step, and reconcile current state, decision/architecture/domain/evidence/commitment memory without overclaiming platform or physical-device validation. +3. Run the 132-test Robot hotspot selection, full unit suite, typecheck, lint, production build, task-aware document gates, whitespace/unmerged-entry checks, and exact topology checks on the staged merge. +4. Obtain an independent read-only Sol PASS/FAIL integration review, fix any blocking findings, create the local no-ff merge commit, and leave firmware and remote push untouched. + ### 2026-08-16 Guided Hotspot Default-On Integration Plan 1. Merge reviewed source `b78fc07` into local `main` with a normal no-ff merge, preserve feature history, and exclude the source-owned task record from the integrated tree. @@ -205,6 +222,10 @@ Gate result: ## Outcome +- On 2026-08-16, formed reviewed in-app Robot hotspot source commit `c1326a298026a697181c822cdd3062cc96c3aa2d` as the exact direct child of local `main` at `abecd5f34485ab467e5f032c618083d88e34b74d`, then started a normal `--no-ff --no-commit` merge. Git reported no textual conflicts. +- The staged main tree now owns Windows WLAN and macOS CoreWLAN/CoreLocation scan/connect/verify adapters behind one bounded Main Module and typed Host seam. Renderer exposes explicit candidate selection, safe recovery, system-Wi-Fi fallback, and the unchanged fixed-Portal/six-digit Binding continuation. +- Excluded the source-owned task record and proposal from the integrated tree while preserving both on source commit/branch `c1326a2`. Accepted ADR-003 and reconciled ADR-002, current state, architecture, domain rules, evidence, and release commitments without claiming signed macOS or physical-Robot acceptance. +- This resumption is a local `main` merge only. It changes no firmware and performs no remote push; exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` remains rollback. - On 2026-08-16, started a normal `--no-ff --no-commit` merge of reviewed default-on source `b78fc07`; Git reported no textual conflicts. The feature task record remains reachable on the source commit/branch and is excluded from the integrated `main` tree. - Integrated only the Main default change and focused route regressions: unset environment reports guided capability true; exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` and dependency-injected false report disabled. Renderer, Host wire shape, fixed portal, cloud Binding contract, credentials, and firmware are unchanged. - Reconciled ADR-002, decision index, success criteria, current state, Robot architecture/domain/glossary, README, and commitments to default-on with exact `=0` rollback. The documents explicitly preserve unverified shipped-firmware, activation-issuer, native-opener, and physical-device evidence instead of claiming those checks passed. @@ -304,6 +325,14 @@ Gate result: ## Verification +- 2026-08-16 cross-platform Robot hotspot source final Sol review — `PASS` on Standards and Spec with no P0-P3 findings; macOS termination/cancellation races, Renderer post-connect state, default-on rollback, security boundaries, and firmware-zero-change were confirmed. +- Staged merged-main Robot hotspot selection — 4 files / 132 tests passed. +- Staged merged-main `pnpm test` — 157 files / 1796 tests passed. +- Staged merged-main `pnpm run typecheck` and `pnpm run lint:check` — passed; lint retained 0 errors and the same 6 warnings outside the merge paths. +- Staged merged-main `pnpm run build:vite` — Renderer, Electron Main, and Preload passed; emitted Windows and macOS Main chunks, with only existing mixed-import/chunk-size warnings. +- Staged merged-main Electron module-navigation E2E — 2/2 passed after rebuilding the production Renderer/Main/Preload tree. The fixture does not simulate a physical Robot or signed macOS association and is not claimed as native acceptance. +- Integration task-aware document drift, project-document structure, staged/unstaged whitespace, source-record exclusion, and unmerged-entry checks passed before independent integration review. +- Independent final cross-platform Robot hotspot integration review — `PASS` on Standards and Spec with no P0-P3 findings. It confirmed source-code identity, exact merge topology, source-record exclusion, Main-owned native/security boundaries, ADR-003's narrow supersession, canonical consistency, and honest residual release gates. - 2026-08-16 default-on feature final Sol review — `PASS`, no P0-P3 findings; independently confirmed default-on, exact environment opt-out, dependency-injection opt-out, fixed portal ownership, local-before-Works-token behavior, error redaction, unchanged Binding contract, and zero firmware changes. - Merged-main Robot selection — 3 files / 94 tests passed, including fresh-module exact `=0` rollback coverage. - Merged-main `pnpm run typecheck` and scoped ESLint on the two source files — passed. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index 6d49e0e..8428127 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -29,8 +29,10 @@ - 图片生成支持无参考图的文生图和单参考图图生图;参考图必须来自当前 Workspace 的已完成图片 Asset,或先通过现有 Workspace 上传接口把本地图片转成 Asset,再随当前 Conversation Turn 提交唯一 Asset ID。 - 视频首帧与图生图参考图复用单图选择器,但用途由当前 Brief medium 决定:只有明确 `image` 使用图生图语义,`video`、null 或缺失值保持视频首帧兼容语义。选择作品或上传成功后应关闭弹窗。 - 当前 quick reply 文案匹配只是兼容契约;新增更多素材输入用途前,应改为结构化 action/purpose,不得继续依赖本地化展示字符串推断行为。 -- Robot V1 把现有固件 Hotspot 配网指导放进现有 Binding 体验,但不宣称自动发现附近设备、自动下发 Wi-Fi、自动认领设备或自动确认在线;这些能力需要另行接受的协议与 ADR。 +- Robot V1 在现有 Binding 体验内扫描符合条件的开放 `Xiaozhi-*` 配网热点,并只连接用户明确选择的短效候选;该便利信号不得宣称为可信设备发现、自动下发家庭 Wi-Fi、自动认领或自动确认在线。 - Guided Hotspot Binding capability 由 Electron Main 持有且默认开启。精确 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 关闭引导;关闭或 capability 读取失败时保留现有六位码 Binding,Renderer 可以读取但不能覆盖它。 +- Robot hotspot scan/connect 必须由 Electron Main 持有并在读取 Works 凭据前本地完成。Renderer 只能提交最近扫描生成的短效不透明 candidate ID,不能提交任意 SSID、BSSID、接口、profile 或命令。 +- 只有开放、可连接、可打印、UTF-8 不超过 32 bytes 且以 `Xiaozhi-` 开头的候选可进入页面。连接只有在当前 SSID 与候选完全一致时成功;权限、平台、扫描或连接失败必须保留系统 Wi-Fi 兜底。 - Robot portal 必须由 Main 以系统浏览器打开固定 `http://192.168.4.1/`。Renderer 不得提交任意 URL;该本地动作不得读取 Works 凭据或访问云端。 - Wi-Fi SSID/密码只在现有固件 portal 内输入。Makelore 不收集、不代理、不日志记录、不持久化 Wi-Fi 凭据。 - 产品已明确选择在保留当前固件行为时 default-on;开放 SoftAP 与明文 HTTP portal 仍是已知残余风险,不能据此推断安全风险已被单独验收。界面必须保留不在不可信公共环境操作的警告。未核对精确固件镜像、六位码发行/消费契约、原生 opener E2E 与真机 smoke 前,不得宣称完整兼容或端到端验收;现场异常必须可用精确环境值 `0` 回滚。 @@ -47,7 +49,7 @@ - 图生图随客户端发布前,需确认相匹配的服务端 `image_to_image` Brief/Quote/Task 冻结、私有源图复核与 Bailian edit 执行链已部署,并使用真实 Workspace Asset 完成生产 smoke。 - Updater 生产恢复仍需对齐权威版本、发布正式签名/公证的平台产物,并从旧安装版本执行发现、下载、重启和安装 smoke;源码提示修复本身不构成发布链恢复。 - AI Canvas 双向 Gateway 与 Quote 任务恢复仍需真实账号执行一次生产确认 smoke,核对 WebSocket 不产生 `/runs/{run_id}` 轮询、任务按 Quote 出现在 Workspace 列表,并区分真正的 `agent_runtime_unavailable` worker 故障。 -- Robot Guided Hotspot Binding default-on 发布仍需确认指定硬件/固件确实提供被审计的 Hotspot portal、部署端签发严格六位 ASCII 数字码且与 Works validator 的时效/消费语义一致,并完成真实设备端到端 smoke。 +- Robot Guided Hotspot Binding default-on 发布仍需确认指定硬件/固件确实提供被审计的开放 Hotspot/Portal、部署端签发严格六位 ASCII 数字码且与 Works validator 的时效/消费语义一致,并完成 Windows 真机、签名 macOS x64/arm64 native worker/association 与真实设备端到端 smoke。 ## Last Reviewed diff --git a/.project-docs/40-domain/glossary.md b/.project-docs/40-domain/glossary.md index c4c3b7e..ed35a14 100644 --- a/.project-docs/40-domain/glossary.md +++ b/.project-docs/40-domain/glossary.md @@ -19,5 +19,6 @@ | Robot Provisioning | 让 Robot 获得目标 Wi-Fi 凭据并尝试联网的阶段 | 与云端 Activation、账号 Binding、协议在线是不同阶段;V1 由现有固件 Hotspot portal 完成 | | Robot Activation | Robot 联网后向既有服务获取六位激活码的阶段 | 激活码由设备展示/播报给用户;Makelore 不生成该码 | | Robot Binding | 用户把六位激活码和 Agent 提交到 Works Square,建立账号侧设备关系 | `bound` 不等于设备当前 online 或 protocol-ready | -| Guided Hotspot Binding | Makelore 已实现的 default-on 引导流程 | 串联系统热点连接、固定 portal、恢复互联网和现有 Binding;不包含自动发现、Wi-Fi 代填或固件修改 | +| Guided Hotspot Binding | Makelore 已实现的 default-on 引导流程 | 串联页面内未认证热点扫描/显式连接、固定 Portal、恢复互联网和现有 Binding;不包含可信设备发现、家庭 Wi-Fi 代填、自动 claim 或固件修改 | | `guidedHotspotBinding` | Electron Main 所有的本地 capability | Renderer 只读;默认 true,精确环境值 `0` 回滚为 false,读取失败时 Renderer 回退直接六位码 | +| Robot Hotspot Candidate | Main 最近一次扫描中符合开放 `Xiaozhi-*` 规则的短效连接候选 | Renderer 只见不透明 ID、SSID、信号和 connected;不是设备身份,重扫/clear/过期/重启后失效 | diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index e7d21ce..818f709 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | |---|---|---|---|---| +| 2026-08-16 | Robot Windows/macOS 配网页内热点连接 | 实现与本地自动化验证通过;双平台实机发布证据待完成 | 源提交 `c1326a2`、ADR-003、集成任务 `20260813-sync-push-main-9c2f71` | 4 files / 132 focused tests、157 files / 1796 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Standards/Spec review 通过;Electron 40.10.6 加载 Koffi/wlanapi 成功,Windows 权限拒绝安全投影通过。签名 macOS x64/arm64、Windows Robot 真机和真实 Host/native Electron E2E 未完成,不得据此宣称双平台硬件验收 | | 2026-08-11 | AI 设计多会话客户端集成 | 本地功能验证通过;仓库基线仍有既有失败 | `30-worklog/tasks/20260811-merge-all-code-a7c91e.md`、`4980894`、`03dae62` | 8 files / 116 focused tests、typecheck、changed-file ESLint、production build 与新增 Electron E2E 通过;生产 migration 0033/API 尚待验收,全量 lint/unit/E2E 的既有失败已单独记录 | | 2026-08-10 | 客户端静态发布唯一链路 | 本地验证通过 | `30-worklog/tasks/20260810-static-release-only-a91c.md`、`4df0477`、`8dd99c1` | 227 项聚焦回归、typecheck、Vite/Electron build、实际 Electron E2E 与 13 files / 21 tests 收集通过;不等同于真实生产部署验收 | | 2026-08-12 | Makelore 内置浏览器发布前预检 | 本地功能验证通过;不构成可信发布证明 | `30-worklog/tasks/20260812-client-browser-preflight-c8e2.md`、`926056a` | 121 项聚焦回归、typecheck、scoped lint、三端 build 与真实 Electron 双视口 E2E 通过;loopback UX 预检无 receipt/provenance/opaque-origin parity,服务端门禁仍需生产整链验收 | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index 20178bc..5dd7267 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -4,7 +4,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | Date | Commitment | Trigger / Due | Owner | Status | Next Action | |---|---|---|---|---|---| -| 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的真实设备链路 | 下一份包含 default-on 行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 portal、六位码发行/消费语义,执行真实 Host API/native opener Electron smoke 和真机端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚,并记录支持矩阵 | +| 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke,以及真实 Host/native Electron 端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 | | 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对;如需不可绕过 runtime gate,另行设计可信 verifier | | 2026-08-12 | Windows 发布流水线保留固定 npm 运行时产物门禁 | 每次生成正式 Windows 安装器时 | 客户端发布 | Pending | 运行 `pnpm verify:publish-runtime` 和 `pnpm verify:artifact:win`;当前 223,547,912-byte / SHA-256 `08A0BB7BC66EE4AD8B120E8955B149951CD86AF53CC966C7973AD4D77A5815C1` 安装器仅为本地证据,尚未发布 | | 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 | diff --git a/README.md b/README.md index cd24cd4..3e5ee86 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。 -- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;绑定设备时默认先选择“引导配网”或“已有激活码”,引导路径复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 +- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未接入;入口保持可见,点击暂不进入工作区。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页,已登录用户可直接进入对应工作区。 diff --git a/electron-builder.yml b/electron-builder.yml index aeef5aa..0915626 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -82,6 +82,8 @@ mac: notarize: true extendInfo: NSMicrophoneUsageDescription: Makelore requires microphone access for voice features + NSLocationUsageDescription: Makelore uses location access to find and connect to nearby Robot setup hotspots + NSLocationWhenInUseUsageDescription: Makelore uses location access to find and connect to nearby Robot setup hotspots dmg: # Explicit volume size prevents dmg-builder@1.2.0 auto-calculation from diff --git a/electron/api/routes/ai-hardware.ts b/electron/api/routes/ai-hardware.ts index ae86963..daf7aec 100644 --- a/electron/api/routes/ai-hardware.ts +++ b/electron/api/routes/ai-hardware.ts @@ -6,6 +6,7 @@ import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; +import type { RobotHotspotModule } from '../../robot-hotspot'; const MAX_REQUEST_BYTES = 64 * 1024; const MAX_RESPONSE_BYTES = 256 * 1024; @@ -16,6 +17,7 @@ const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/; const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/; const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/; const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const HOTSPOT_CANDIDATE_ID = /^[A-Za-z0-9_-]{1,128}$/; const MAX_RETRY_AFTER_SECONDS = 2; const PROVISIONING_PORTAL_URL = 'http://192.168.4.1/'; const GUIDED_HOTSPOT_BINDING_ENABLED = process.env.NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING !== '0'; @@ -30,6 +32,7 @@ export type AiHardwareRouteDependencies = { timeoutMs?: number; guidedHotspotBinding?: boolean; openExternal?: (url: string) => Promise; + robotHotspot?: RobotHotspotModule; }; class SafeRouteError extends Error { @@ -291,6 +294,52 @@ function requireRevision(body: Record): number { return revision; } +function requireHotspotCandidateId(value: unknown): string { + if (typeof value !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value)) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + return value; +} + +const HOTSPOT_ERRORS: Record = { + unsupported: { + status: 501, + code: 'AI_HARDWARE_HOTSPOT_UNSUPPORTED', + error: 'Robot hotspot connection is not supported on this platform', + }, + permission_denied: { + status: 403, + code: 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED', + error: 'Permission to access nearby Robot hotspots was denied', + }, + busy: { + status: 409, + code: 'AI_HARDWARE_HOTSPOT_BUSY', + error: 'Another Robot hotspot operation is in progress', + }, + candidate_expired: { + status: 409, + code: 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED', + error: 'The Robot hotspot selection expired; scan again', + }, + scan_failed: { + status: 502, + code: 'AI_HARDWARE_HOTSPOT_SCAN_FAILED', + error: 'Robot hotspots could not be scanned', + }, + connect_failed: { + status: 502, + code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED', + error: 'The Robot hotspot could not be connected', + }, +}; + +function asHotspotRouteError(error: unknown, fallback: 'scan_failed' | 'connect_failed'): SafeRouteError { + const nativeCode = isRecord(error) && typeof error.code === 'string' ? error.code : fallback; + const safe = HOTSPOT_ERRORS[nativeCode] ?? HOTSPOT_ERRORS[fallback]; + return new SafeRouteError(safe.status, safe.code, safe.error); +} + function takeOperationId(body: Record, createUuid: () => string): string { const candidate = body.client_operation_id ?? createUuid(); if (typeof candidate !== 'string' || !OPERATION_ID.test(candidate)) { @@ -442,6 +491,9 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS; const guidedHotspotBinding = dependencies.guidedHotspotBinding ?? GUIDED_HOTSPOT_BINDING_ENABLED; const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url)); + const getRobotHotspot = dependencies.robotHotspot + ? async () => dependencies.robotHotspot as RobotHotspotModule + : async () => (await import('../../robot-hotspot')).robotHotspotModule; return async function handleAiHardwareRoutes( req: IncomingMessage, @@ -493,6 +545,67 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend sendJson(res, 200, { success: true, data: { opened: true } }); return true; } + if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/scan`) { + if (url.search) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set()); + if (!guidedHotspotBinding) { + throw new SafeRouteError( + 403, + 'AI_HARDWARE_PROVISIONING_DISABLED', + 'AI hardware guided provisioning is not enabled', + ); + } + try { + const result = await (await getRobotHotspot()).scan(); + sendJson(res, 200, { + success: true, + data: { + platform: result.platform, + hotspots: result.hotspots.map((hotspot) => ({ + candidate_id: hotspot.candidateId, + ssid: hotspot.ssid, + signal_percent: hotspot.signalPercent, + connected: hotspot.connected, + })), + }, + }); + } catch (error) { + throw asHotspotRouteError(error, 'scan_failed'); + } + return true; + } + if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/connect`) { + if (url.search) { + throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request'); + } + const input = await readBoundedJson(req); + ensureExactKeys(input, new Set(['candidate_id'])); + const candidateId = requireHotspotCandidateId(input.candidate_id); + if (!guidedHotspotBinding) { + throw new SafeRouteError( + 403, + 'AI_HARDWARE_PROVISIONING_DISABLED', + 'AI hardware guided provisioning is not enabled', + ); + } + try { + const result = await (await getRobotHotspot()).connect(candidateId); + sendJson(res, 200, { + success: true, + data: { + connected: result.connected, + candidate_id: result.candidateId, + ssid: result.ssid, + }, + }); + } catch (error) { + throw asHotspotRouteError(error, 'connect_failed'); + } + return true; + } let upstreamPath: string; let body: Record | undefined; diff --git a/electron/robot-hotspot/adapter.ts b/electron/robot-hotspot/adapter.ts new file mode 100644 index 0000000..10d1412 --- /dev/null +++ b/electron/robot-hotspot/adapter.ts @@ -0,0 +1,36 @@ +export interface RobotHotspotAdapterCandidate { + ssid: string; + signalPercent: number; + connected: boolean; + open: boolean; + nativeKey: unknown; +} + +export interface RobotHotspotAdapter { + scan(signal: AbortSignal): Promise; + connect(candidate: RobotHotspotAdapterCandidate, signal: AbortSignal): Promise; + currentSsid(signal: AbortSignal): Promise; + clear(): void; +} + +export class RobotHotspotAdapterError extends Error { + constructor(public readonly reason: 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed') { + super(reason); + this.name = 'RobotHotspotAdapterError'; + } +} + +export function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} + +export function abortableDelay(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, milliseconds); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); +} diff --git a/electron/robot-hotspot/index.ts b/electron/robot-hotspot/index.ts new file mode 100644 index 0000000..1441e0a --- /dev/null +++ b/electron/robot-hotspot/index.ts @@ -0,0 +1,193 @@ +import { randomUUID } from 'node:crypto'; +import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter'; +import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter'; + +export type RobotHotspotPlatform = 'windows' | 'macos'; + +export interface RobotHotspotCandidate { + candidateId: string; + ssid: string; + signalPercent: number; + connected: boolean; +} + +export interface RobotHotspotScanResult { + platform: RobotHotspotPlatform; + hotspots: RobotHotspotCandidate[]; +} + +export interface RobotHotspotConnection { + connected: true; + candidateId: string; + ssid: string; +} + +export interface RobotHotspotModule { + scan(): Promise; + connect(candidateId: string): Promise; + clear(): void; +} + +export type RobotHotspotErrorCode = 'unsupported' | 'permission_denied' | 'busy' | 'scan_failed' | 'candidate_expired' | 'connect_failed'; + +export class RobotHotspotError extends Error { + constructor(public readonly code: RobotHotspotErrorCode) { + super(code); + this.name = 'RobotHotspotError'; + } +} + +export interface RobotHotspotModuleOptions { + candidateTtlMs?: number; + scanTimeoutMs?: number; + connectTimeoutMs?: number; + verificationIntervalMs?: number; + now?: () => number; + createId?: () => string; +} + +interface SnapshotEntry { + candidateId: string; + candidate: RobotHotspotAdapterCandidate; +} + +const utf8 = new TextEncoder(); +const MAX_PROJECTED_HOTSPOTS = 64; +const forbiddenSsidCharacters = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u; + +function isAllowedSsid(ssid: string): boolean { + return ssid.startsWith('Xiaozhi-') + && ssid.length > 'Xiaozhi-'.length + && utf8.encode(ssid).byteLength <= 32 + && !forbiddenSsidCharacters.test(ssid) + && !ssid.includes('\ufffd'); +} + +function stableError(error: unknown, fallback: 'scan_failed' | 'connect_failed'): RobotHotspotError { + if (error instanceof RobotHotspotError) return error; + if (error instanceof RobotHotspotAdapterError) return new RobotHotspotError(error.reason); + return new RobotHotspotError(fallback); +} + +export function createRobotHotspotModule(platform: RobotHotspotPlatform, adapter: RobotHotspotAdapter, options: RobotHotspotModuleOptions = {}): RobotHotspotModule { + const candidateTtlMs = options.candidateTtlMs ?? 60_000; + const scanTimeoutMs = options.scanTimeoutMs ?? 8_000; + const connectTimeoutMs = options.connectTimeoutMs ?? 12_000; + const verificationIntervalMs = options.verificationIntervalMs ?? 250; + const now = options.now ?? Date.now; + const createId = options.createId ?? randomUUID; + let snapshot = new Map(); + let expiresAt = 0; + let active = false; + + function clear(): void { + snapshot = new Map(); + expiresAt = 0; + adapter.clear(); + } + + async function runExclusive(timeoutMs: number, timeoutCode: 'scan_failed' | 'connect_failed', work: (signal: AbortSignal) => Promise): Promise { + if (active) throw new RobotHotspotError('busy'); + active = true; + const controller = new AbortController(); + let timer: ReturnType | undefined; + const operation = work(controller.signal).finally(() => { + if (timer) clearTimeout(timer); + active = false; + }); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new RobotHotspotError(timeoutCode); + controller.abort(error); + reject(error); + }, timeoutMs); + }); + return Promise.race([operation, timeout]); + } + + return { + async scan() { + if (active) throw new RobotHotspotError('busy'); + clear(); + try { + return await runExclusive(scanTimeoutMs, 'scan_failed', async (signal) => { + const discovered = await adapter.scan(signal); + throwIfAborted(signal); + const strongest = new Map(); + for (const candidate of discovered) { + if (!candidate.open || !isAllowedSsid(candidate.ssid)) continue; + const normalized = { ...candidate, signalPercent: Math.max(0, Math.min(100, Math.round(candidate.signalPercent))) }; + const previous = strongest.get(candidate.ssid); + if (!previous || normalized.signalPercent > previous.signalPercent) strongest.set(candidate.ssid, normalized); + } + const next = new Map(); + const hotspots = [...strongest.values()] + .sort((left, right) => Number(right.connected) - Number(left.connected) || right.signalPercent - left.signalPercent || left.ssid.localeCompare(right.ssid)) + .slice(0, MAX_PROJECTED_HOTSPOTS) + .map((candidate) => { + const candidateId = createId(); + next.set(candidateId, { candidateId, candidate }); + return { candidateId, ssid: candidate.ssid, signalPercent: candidate.signalPercent, connected: candidate.connected }; + }); + snapshot = next; + expiresAt = now() + candidateTtlMs; + return { platform, hotspots }; + }); + } catch (error) { + throw stableError(error, 'scan_failed'); + } + }, + + async connect(candidateId) { + if (active) throw new RobotHotspotError('busy'); + const entry = snapshot.get(candidateId); + if (!entry || now() >= expiresAt) { + clear(); + throw new RobotHotspotError('candidate_expired'); + } + try { + return await runExclusive(connectTimeoutMs, 'connect_failed', async (signal) => { + await adapter.connect(entry.candidate, signal); + for (;;) { + throwIfAborted(signal); + if (await adapter.currentSsid(signal) === entry.candidate.ssid) { + return { connected: true as const, candidateId: entry.candidateId, ssid: entry.candidate.ssid }; + } + await abortableDelay(verificationIntervalMs, signal); + } + }); + } catch (error) { + throw stableError(error, 'connect_failed'); + } + }, + clear, + }; +} + +let defaultModule: Promise | undefined; + +async function loadDefaultModule(): Promise { + const platform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : null; + if (!platform) throw new RobotHotspotError('unsupported'); + if (!defaultModule) { + defaultModule = (platform === 'windows' + ? import('./windows').then(({ createWindowsRobotHotspotAdapter }) => createWindowsRobotHotspotAdapter()) + : import('./macos').then(({ createMacosRobotHotspotAdapter }) => createMacosRobotHotspotAdapter())) + .then((adapter) => createRobotHotspotModule(platform, adapter)); + } + return defaultModule; +} + +export const robotHotspotModule: RobotHotspotModule = { + async scan() { + try { return await (await loadDefaultModule()).scan(); } + catch (error) { throw stableError(error, 'scan_failed'); } + }, + async connect(candidateId) { + try { return await (await loadDefaultModule()).connect(candidateId); } + catch (error) { throw stableError(error, 'connect_failed'); } + }, + clear() { void defaultModule?.then((module) => module.clear()); }, +}; + +export default robotHotspotModule; diff --git a/electron/robot-hotspot/macos.ts b/electron/robot-hotspot/macos.ts new file mode 100644 index 0000000..1419cb4 --- /dev/null +++ b/electron/robot-hotspot/macos.ts @@ -0,0 +1,332 @@ +import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; +import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter'; +import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter'; + +const WORKER_KIND = 'makelore-robot-hotspot-macos'; +const LOCATION_NOT_DETERMINED = 0; +const LOCATION_RESTRICTED = 1; +const LOCATION_DENIED = 2; +const LOCATION_AUTHORIZED_ALWAYS = 3; +const LOCATION_AUTHORIZED_WHEN_IN_USE = 4; +const CORE_WLAN_PERMISSION_DENIED = -3930; +const CORE_WLAN_UNSUPPORTED = -3903; + +type StableReason = 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed'; +type ObjcId = unknown; + +interface WorkerCandidate { + ssid: string; + signalPercent: number; + connected: boolean; + open: boolean; +} + +type WorkerRequest = { id: number; operation: 'scan' } | { id: number; operation: 'connect'; ssid: string }; +type WorkerResponse = { id: number; ok: true; result: WorkerCandidate[] | { ssid: string } } | { id: number; ok: false; reason: StableReason }; + +export interface MacosWorkerLike { + postMessage(value: WorkerRequest): void; + once(event: 'message', listener: (value: WorkerResponse) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number) => void): this; + off(event: 'message', listener: (value: WorkerResponse) => void): this; + off(event: 'error', listener: (error: Error) => void): this; + off(event: 'exit', listener: (code: number) => void): this; + terminate(): Promise; +} + +interface WorkerLifecycle { + terminate?: () => Promise; + exited?: () => void; +} + +export function requestMacosWorker(worker: MacosWorkerLike, request: WorkerRequest, signal: AbortSignal, lifecycle: WorkerLifecycle = {}): Promise { + const terminate = lifecycle.terminate ?? (() => worker.terminate()); + if (signal.aborted) { + void terminate(); + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const cleanup = () => { + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + signal.removeEventListener('abort', onAbort); + }; + const onMessage = (response: WorkerResponse) => { + if (response.id !== request.id) return; + cleanup(); + if (response.ok) resolve(response.result as T); + else reject(new RobotHotspotAdapterError(response.reason)); + }; + const onError = () => { + cleanup(); + void terminate(); + reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed')); + }; + const onExit = () => { + cleanup(); + lifecycle.exited?.(); + reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed')); + }; + const onAbort = () => { + cleanup(); + void terminate(); + reject(signal.reason); + }; + worker.once('message', onMessage); + worker.once('error', onError); + worker.once('exit', onExit); + signal.addEventListener('abort', onAbort, { once: true }); + try { + worker.postMessage(request); + } catch { + cleanup(); + void terminate(); + reject(new RobotHotspotAdapterError(request.operation === 'scan' ? 'scan_failed' : 'connect_failed')); + } + }); +} + +type WorkerRequestInput = { operation: 'scan' } | { operation: 'connect'; ssid: string }; + +export class MacosWorkerController { + private worker: MacosWorkerLike | undefined; + private requestId = 0; + private terminationBarrier: Promise = Promise.resolve(); + private readonly terminations = new WeakMap>(); + + constructor(private readonly createWorker: () => MacosWorkerLike) {} + + private retire(target: MacosWorkerLike): Promise { + let termination = this.terminations.get(target as object); + if (!termination) { + termination = target.terminate(); + this.terminations.set(target as object, termination); + const previousBarrier = this.terminationBarrier; + const settled = termination.then(() => undefined, () => undefined); + this.terminationBarrier = Promise.all([previousBarrier, settled]).then(() => undefined); + } + if (this.worker === target) this.worker = undefined; + return termination; + } + + private markExited(target: MacosWorkerLike): void { + if (this.worker === target) this.worker = undefined; + } + + private getWorker(): MacosWorkerLike { + if (this.worker) return this.worker; + const target = this.createWorker(); + target.once('error', () => { void this.retire(target); }); + target.once('exit', () => { this.markExited(target); }); + this.worker = target; + return target; + } + + async request(value: WorkerRequestInput, signal: AbortSignal): Promise { + await this.terminationBarrier; + throwIfAborted(signal); + const target = this.getWorker(); + return requestMacosWorker(target, { ...value, id: ++this.requestId } as WorkerRequest, signal, { + terminate: () => this.retire(target), + exited: () => this.markExited(target), + }); + } + + async ready(signal: AbortSignal): Promise { + await this.terminationBarrier; + throwIfAborted(signal); + } + + clear(): void { + if (this.worker) void this.retire(this.worker); + } +} + +async function runCoreWlanWorker(): Promise { + if (!parentPort) return; + const koffiModule = await import('koffi'); + const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default; + const objc = koffi.load('/usr/lib/libobjc.A.dylib'); + koffi.load('/System/Library/Frameworks/Foundation.framework/Foundation', { global: true }); + koffi.load('/System/Library/Frameworks/CoreWLAN.framework/CoreWLAN', { global: true }); + + const objcGetClass = objc.func('objc_getClass', 'void *', ['str']); + const selRegisterName = objc.func('sel_registerName', 'void *', ['str']); + const sendId = objc.func('objc_msgSend', 'void *', ['void *', 'void *']); + const sendBool = objc.func('objc_msgSend', 'bool', ['void *', 'void *']); + const sendLong = objc.func('objc_msgSend', 'long', ['void *', 'void *']); + const sendVoid = objc.func('objc_msgSend', 'void', ['void *', 'void *']); + const sendObjectAtIndex = objc.func('objc_msgSend', 'void *', ['void *', 'void *', 'size_t']); + const sendSupportsSecurity = objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'long']); + const sendObjectError = objc.func('objc_msgSend', 'void *', ['void *', 'void *', 'void *', koffi.out(koffi.pointer('void *'))]); + const sendAssociate = objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'void *', 'void *', koffi.out(koffi.pointer('void *'))]); + const sendUtf8 = objc.func('objc_msgSend', 'str', ['void *', 'void *']); + const selector = (name: string): ObjcId => selRegisterName(name); + const cls = (name: string): ObjcId => objcGetClass(name); + const selectors = { + alloc: selector('alloc'), init: selector('init'), drain: selector('drain'), + sharedWiFiClient: selector('sharedWiFiClient'), interface: selector('interface'), + powerOn: selector('powerOn'), serviceActive: selector('serviceActive'), + scan: selector('scanForNetworksWithName:error:'), associate: selector('associateToNetwork:password:error:'), + ssid: selector('ssid'), rssiValue: selector('rssiValue'), supportsSecurity: selector('supportsSecurity:'), + allObjects: selector('allObjects'), count: selector('count'), objectAtIndex: selector('objectAtIndex:'), + UTF8String: selector('UTF8String'), code: selector('code'), + }; + const pollSleep = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); + + function withPool(body: () => T): T { + const pool = sendId(sendId(cls('NSAutoreleasePool'), selectors.alloc), selectors.init); + try { return body(); } + finally { if (pool) sendVoid(pool, selectors.drain); } + } + + function fail(error: ObjcId, operation: 'scan' | 'connect'): never { + const code = error ? Number(sendLong(error, selectors.code)) : null; + if (code === CORE_WLAN_PERMISSION_DENIED) throw new RobotHotspotAdapterError('permission_denied'); + if (code === CORE_WLAN_UNSUPPORTED) throw new RobotHotspotAdapterError('unsupported'); + throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed'); + } + + function stringValue(value: ObjcId): string | null { + if (!value) return null; + const result = sendUtf8(value, selectors.UTF8String); + return typeof result === 'string' ? result : null; + } + + function wifiInterface(operation: 'scan' | 'connect'): ObjcId { + const client = sendId(cls('CWWiFiClient'), selectors.sharedWiFiClient); + const wifi = client && sendId(client, selectors.interface); + if (!wifi) throw new RobotHotspotAdapterError('unsupported'); + if (!sendBool(wifi, selectors.powerOn) || !sendBool(wifi, selectors.serviceActive)) { + throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed'); + } + return wifi; + } + + function scanNetworks(operation: 'scan' | 'connect'): ObjcId[] { + const wifi = wifiInterface(operation); + const error = [null] as unknown[]; + const set = sendObjectError(wifi, selectors.scan, null, error); + if (!set) fail(error[0], operation); + const array = sendId(set, selectors.allObjects); + const count = Math.min(Number(sendLong(array, selectors.count)), 512); + return Array.from({ length: count }, (_, index) => sendObjectAtIndex(array, selectors.objectAtIndex, index)); + } + + function scan(): WorkerCandidate[] { + return withPool(() => { + const wifi = wifiInterface('scan'); + const current = stringValue(sendId(wifi, selectors.ssid)); + return scanNetworks('scan').flatMap((network): WorkerCandidate[] => { + if (!sendSupportsSecurity(network, selectors.supportsSecurity, 0)) return []; + const ssid = stringValue(sendId(network, selectors.ssid)); + if (!ssid) return []; + const rssi = Number(sendLong(network, selectors.rssiValue)); + return [{ ssid, signalPercent: Math.max(0, Math.min(100, Math.round((rssi + 100) * 2))), connected: current === ssid, open: true }]; + }); + }); + } + + function connect(ssid: string): { ssid: string } { + return withPool(() => { + const wifi = wifiInterface('connect'); + const network = scanNetworks('connect').find((item) => stringValue(sendId(item, selectors.ssid)) === ssid && sendSupportsSecurity(item, selectors.supportsSecurity, 0)); + if (!network) throw new RobotHotspotAdapterError('connect_failed'); + const error = [null] as unknown[]; + if (!sendAssociate(wifi, selectors.associate, network, null, error)) fail(error[0], 'connect'); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (stringValue(sendId(wifi, selectors.ssid)) === ssid) return { ssid }; + Atomics.wait(pollSleep, 0, 0, 150); + } + throw new RobotHotspotAdapterError('connect_failed'); + }); + } + + parentPort.on('message', (request: WorkerRequest) => { + try { + const result = request.operation === 'scan' ? scan() : connect(request.ssid); + parentPort.postMessage({ id: request.id, ok: true, result } satisfies WorkerResponse); + } catch (error) { + const reason = error instanceof RobotHotspotAdapterError ? error.reason : request.operation === 'scan' ? 'scan_failed' : 'connect_failed'; + parentPort.postMessage({ id: request.id, ok: false, reason } satisfies WorkerResponse); + } + }); +} + +if (!isMainThread && (workerData as { kind?: string } | null)?.kind === WORKER_KIND) { + void runCoreWlanWorker().catch(() => { process.exitCode = 1; }); +} + +export async function createMacosRobotHotspotAdapter(): Promise { + if (process.platform !== 'darwin') throw new RobotHotspotAdapterError('unsupported'); + const koffiModule = await import('koffi'); + const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default; + const objc = koffi.load('/usr/lib/libobjc.A.dylib'); + koffi.load('/System/Library/Frameworks/Foundation.framework/Foundation', { global: true }); + koffi.load('/System/Library/Frameworks/CoreLocation.framework/CoreLocation', { global: true }); + const objcGetClass = objc.func('objc_getClass', 'void *', ['str']); + const selRegisterName = objc.func('sel_registerName', 'void *', ['str']); + const sendId = objc.func('objc_msgSend', 'void *', ['void *', 'void *']); + const sendLong = objc.func('objc_msgSend', 'long', ['void *', 'void *']); + const sendVoid = objc.func('objc_msgSend', 'void', ['void *', 'void *']); + const selector = (name: string): ObjcId => selRegisterName(name); + const cls = (name: string): ObjcId => objcGetClass(name); + const selectors = { + alloc: selector('alloc'), init: selector('init'), release: selector('release'), + authorizationStatus: selector('authorizationStatus'), + requestWhenInUseAuthorization: selector('requestWhenInUseAuthorization'), + }; + let verifiedSsid: string | null = null; + const workerController = new MacosWorkerController(() => new Worker(__filename, { workerData: { kind: WORKER_KIND } })); + + async function ensureLocationAuthorization(signal: AbortSignal): Promise { + const manager = sendId(sendId(cls('CLLocationManager'), selectors.alloc), selectors.init); + if (!manager) throw new RobotHotspotAdapterError('permission_denied'); + try { + let status = Number(sendLong(manager, selectors.authorizationStatus)); + if (status === LOCATION_NOT_DETERMINED) { + sendVoid(manager, selectors.requestWhenInUseAuthorization); + const deadline = Date.now() + 8_000; + while (status === LOCATION_NOT_DETERMINED && Date.now() < deadline) { + await abortableDelay(100, signal); + status = Number(sendLong(manager, selectors.authorizationStatus)); + } + } + if (status === LOCATION_RESTRICTED || status === LOCATION_DENIED || status === LOCATION_NOT_DETERMINED) { + throw new RobotHotspotAdapterError('permission_denied'); + } + if (status !== LOCATION_AUTHORIZED_ALWAYS && status !== LOCATION_AUTHORIZED_WHEN_IN_USE) { + throw new RobotHotspotAdapterError('permission_denied'); + } + } finally { sendVoid(manager, selectors.release); } + } + + return { + async scan(signal) { + await workerController.ready(signal); + await ensureLocationAuthorization(signal); + throwIfAborted(signal); + verifiedSsid = null; + const candidates = await workerController.request({ operation: 'scan' }, signal); + return candidates.map((candidate): RobotHotspotAdapterCandidate => ({ ...candidate, nativeKey: candidate.ssid })); + }, + async connect(candidate, signal) { + await workerController.ready(signal); + await ensureLocationAuthorization(signal); + throwIfAborted(signal); + const result = await workerController.request<{ ssid: string }>({ operation: 'connect', ssid: candidate.ssid }, signal); + verifiedSsid = result.ssid; + }, + async currentSsid(signal) { + throwIfAborted(signal); + return verifiedSsid; + }, + clear() { + verifiedSsid = null; + workerController.clear(); + }, + }; +} diff --git a/electron/robot-hotspot/windows.ts b/electron/robot-hotspot/windows.ts new file mode 100644 index 0000000..12dcc99 --- /dev/null +++ b/electron/robot-hotspot/windows.ts @@ -0,0 +1,203 @@ +import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from './adapter'; +import { RobotHotspotAdapterError, abortableDelay, throwIfAborted } from './adapter'; + +const ERROR_SUCCESS = 0; +const ERROR_ACCESS_DENIED = 5; +const WLAN_CONNECTION_MODE_DISCOVERY_UNSECURE = 3; +const DOT11_BSS_TYPE_INFRASTRUCTURE = 1; +const WLAN_INTERFACE_STATE_CONNECTED = 1; +const WLAN_INTF_OPCODE_CURRENT_CONNECTION = 7; + +interface WindowsNativeKey { + interfaceGuid: Record; + ssid: { uSSIDLength: number; ucSSID: Uint8Array | number[] }; +} + +function nativeFailure(result: number, operation: 'scan' | 'connect'): never { + if (result === ERROR_ACCESS_DENIED) throw new RobotHotspotAdapterError('permission_denied'); + throw new RobotHotspotAdapterError(operation === 'scan' ? 'scan_failed' : 'connect_failed'); +} + +function bytesToSsid(value: { uSSIDLength: number; ucSSID: Uint8Array | number[] }): string { + return Buffer.from(value.ucSSID).subarray(0, value.uSSIDLength).toString('utf8'); +} + +function guidKey(value: Record): string { + return JSON.stringify(value); +} + +export async function createWindowsRobotHotspotAdapter(): Promise { + if (process.platform !== 'win32') throw new RobotHotspotAdapterError('scan_failed'); + const koffiModule = await import('koffi'); + const koffi = (koffiModule as typeof koffiModule & { default: typeof koffiModule }).default; + const wlan = koffi.load('wlanapi.dll'); + const GUID = koffi.struct({ + Data1: 'uint32_t', Data2: 'uint16_t', Data3: 'uint16_t', Data4: koffi.array('uint8_t', 8), + }); + const DOT11_SSID = koffi.struct({ uSSIDLength: 'uint32_t', ucSSID: koffi.array('uint8_t', 32) }); + const WLAN_INTERFACE_INFO = koffi.struct({ + InterfaceGuid: GUID, + strInterfaceDescription: koffi.array('char16_t', 256, 'String'), + isState: 'int32_t', + }); + const WLAN_AVAILABLE_NETWORK = koffi.struct({ + strProfileName: koffi.array('char16_t', 256, 'String'), + dot11Ssid: DOT11_SSID, + dot11BssType: 'int32_t', + uNumberOfBssids: 'uint32_t', + bNetworkConnectable: 'int32_t', + wlanNotConnectableReason: 'uint32_t', + uNumberOfPhyTypes: 'uint32_t', + dot11PhyTypes: koffi.array('int32_t', 8), + bMorePhyTypes: 'int32_t', + wlanSignalQuality: 'uint32_t', + bSecurityEnabled: 'int32_t', + dot11DefaultAuthAlgorithm: 'uint32_t', + dot11DefaultCipherAlgorithm: 'uint32_t', + dwFlags: 'uint32_t', + dwReserved: 'uint32_t', + }); + const WLAN_ASSOCIATION_ATTRIBUTES = koffi.struct({ + dot11Ssid: DOT11_SSID, + dot11BssType: 'int32_t', + dot11Bssid: koffi.array('uint8_t', 6), + dot11PhyType: 'int32_t', + uDot11PhyIndex: 'uint32_t', + wlanSignalQuality: 'uint32_t', + ulRxRate: 'uint32_t', + ulTxRate: 'uint32_t', + }); + const WLAN_SECURITY_ATTRIBUTES = koffi.struct({ + bSecurityEnabled: 'int32_t', bOneXEnabled: 'int32_t', + dot11AuthAlgorithm: 'uint32_t', dot11CipherAlgorithm: 'uint32_t', + }); + const WLAN_CONNECTION_ATTRIBUTES = koffi.struct({ + isState: 'int32_t', wlanConnectionMode: 'int32_t', + strProfileName: koffi.array('char16_t', 256, 'String'), + wlanAssociationAttributes: WLAN_ASSOCIATION_ATTRIBUTES, + wlanSecurityAttributes: WLAN_SECURITY_ATTRIBUTES, + }); + const WLAN_CONNECTION_PARAMETERS = koffi.struct({ + wlanConnectionMode: 'int32_t', strProfile: 'str16', + pDot11Ssid: koffi.pointer(DOT11_SSID), pDesiredBssidList: 'void *', + dot11BssType: 'int32_t', dwFlags: 'uint32_t', + }); + + const WlanOpenHandle = wlan.func('WlanOpenHandle', 'uint32_t', ['uint32_t', 'void *', koffi.out(koffi.pointer('uint32_t')), koffi.out(koffi.pointer('void *'))]); + const WlanCloseHandle = wlan.func('WlanCloseHandle', 'uint32_t', ['void *', 'void *']); + const WlanEnumInterfaces = wlan.func('WlanEnumInterfaces', 'uint32_t', ['void *', 'void *', koffi.out(koffi.pointer('void *'))]); + const WlanScan = wlan.func('WlanScan', 'uint32_t', ['void *', koffi.pointer(GUID), 'void *', 'void *', 'void *']); + const WlanGetAvailableNetworkList = wlan.func('WlanGetAvailableNetworkList', 'uint32_t', ['void *', koffi.pointer(GUID), 'uint32_t', 'void *', koffi.out(koffi.pointer('void *'))]); + const WlanConnect = wlan.func('WlanConnect', 'uint32_t', ['void *', koffi.pointer(GUID), koffi.pointer(WLAN_CONNECTION_PARAMETERS), 'void *']); + const WlanQueryInterface = wlan.func('WlanQueryInterface', 'uint32_t', ['void *', koffi.pointer(GUID), 'int32_t', 'void *', koffi.out(koffi.pointer('uint32_t')), koffi.out(koffi.pointer('void *')), koffi.out(koffi.pointer('int32_t'))]); + const WlanFreeMemory = wlan.func('WlanFreeMemory', 'void', ['void *']); + let connectingInterfaceGuid: Record | null = null; + + async function withHandle(operation: 'scan' | 'connect', body: (handle: unknown) => Promise): Promise { + const negotiated = [0]; + const handle = [null] as unknown[]; + const result = WlanOpenHandle(2, null, negotiated, handle) as number; + if (result !== ERROR_SUCCESS || !handle[0]) nativeFailure(result, operation); + try { return await body(handle[0]); } + finally { WlanCloseHandle(handle[0], null); } + } + + function interfaces(handle: unknown, operation: 'scan' | 'connect'): Array> { + const output = [null] as unknown[]; + const result = WlanEnumInterfaces(handle, null, output) as number; + if (result !== ERROR_SUCCESS || !output[0]) nativeFailure(result, operation); + try { + const count = koffi.decode(output[0], 'uint32_t') as number; + const offset = 8; + return Array.from({ length: Math.min(count, 64) }, (_, index) => koffi.decode(output[0], offset + index * koffi.sizeof(WLAN_INTERFACE_INFO), WLAN_INTERFACE_INFO) as Record); + } finally { WlanFreeMemory(output[0]); } + } + + function currentOnInterface(handle: unknown, interfaceGuid: Record, operation: 'scan' | 'connect'): string | null { + const size = [0]; + const data = [null] as unknown[]; + const valueType = [0]; + const result = WlanQueryInterface(handle, interfaceGuid, WLAN_INTF_OPCODE_CURRENT_CONNECTION, null, size, data, valueType) as number; + if (result !== ERROR_SUCCESS || !data[0]) { + if (result === ERROR_ACCESS_DENIED) nativeFailure(result, operation); + return null; + } + try { + const connection = koffi.decode(data[0], WLAN_CONNECTION_ATTRIBUTES) as { isState: number; wlanAssociationAttributes: { dot11Ssid: WindowsNativeKey['ssid'] } }; + return connection.isState === WLAN_INTERFACE_STATE_CONNECTED ? bytesToSsid(connection.wlanAssociationAttributes.dot11Ssid) : null; + } finally { WlanFreeMemory(data[0]); } + } + + return { + async scan(signal) { + return withHandle('scan', async (handle) => { + const foundInterfaces = interfaces(handle, 'scan'); + for (const item of foundInterfaces) { + throwIfAborted(signal); + const result = WlanScan(handle, item.InterfaceGuid, null, null, null) as number; + if (result !== ERROR_SUCCESS) nativeFailure(result, 'scan'); + } + // WlanScan is asynchronous and usually needs about four seconds before the + // available-network cache represents the requested scan. + await abortableDelay(4_000, signal); + const candidates: RobotHotspotAdapterCandidate[] = []; + for (const item of foundInterfaces) { + throwIfAborted(signal); + const output = [null] as unknown[]; + const result = WlanGetAvailableNetworkList(handle, item.InterfaceGuid, 0, null, output) as number; + if (result !== ERROR_SUCCESS || !output[0]) nativeFailure(result, 'scan'); + try { + const count = koffi.decode(output[0], 'uint32_t') as number; + const connectedSsid = currentOnInterface(handle, item.InterfaceGuid as Record, 'scan'); + for (let index = 0; index < Math.min(count, 512); index += 1) { + const network = koffi.decode(output[0], 8 + index * koffi.sizeof(WLAN_AVAILABLE_NETWORK), WLAN_AVAILABLE_NETWORK) as { + dot11Ssid: WindowsNativeKey['ssid']; dot11BssType: number; wlanSignalQuality: number; bSecurityEnabled: number; bNetworkConnectable: number; + }; + if (network.dot11BssType !== DOT11_BSS_TYPE_INFRASTRUCTURE || network.bSecurityEnabled || !network.bNetworkConnectable) continue; + const ssid = bytesToSsid(network.dot11Ssid); + candidates.push({ + ssid, signalPercent: network.wlanSignalQuality, connected: connectedSsid === ssid, open: true, + nativeKey: { interfaceGuid: item.InterfaceGuid, ssid: network.dot11Ssid } satisfies WindowsNativeKey, + }); + } + } finally { WlanFreeMemory(output[0]); } + } + return candidates; + }); + }, + async connect(candidate, signal) { + throwIfAborted(signal); + const key = candidate.nativeKey as WindowsNativeKey; + await withHandle('connect', async (handle) => { + const match = interfaces(handle, 'connect').find((item) => guidKey(item.InterfaceGuid as Record) === guidKey(key.interfaceGuid)); + if (!match) throw new RobotHotspotAdapterError('connect_failed'); + const parameters = { + wlanConnectionMode: WLAN_CONNECTION_MODE_DISCOVERY_UNSECURE, + strProfile: null, + pDot11Ssid: key.ssid, + pDesiredBssidList: null, + dot11BssType: DOT11_BSS_TYPE_INFRASTRUCTURE, + dwFlags: 0, + }; + const result = WlanConnect(handle, match.InterfaceGuid, parameters, null) as number; + if (result !== ERROR_SUCCESS) nativeFailure(result, 'connect'); + connectingInterfaceGuid = match.InterfaceGuid as Record; + }); + }, + async currentSsid(signal) { + throwIfAborted(signal); + return withHandle('connect', async (handle) => { + const foundInterfaces = interfaces(handle, 'connect'); + const orderedInterfaces = connectingInterfaceGuid + ? [...foundInterfaces].sort((left, right) => Number(guidKey(right.InterfaceGuid as Record) === guidKey(connectingInterfaceGuid as Record)) - Number(guidKey(left.InterfaceGuid as Record) === guidKey(connectingInterfaceGuid as Record))) + : foundInterfaces; + for (const item of orderedInterfaces) { + const ssid = currentOnInterface(handle, item.InterfaceGuid as Record, 'connect'); + if (ssid) return ssid; + } + return null; + }); + }, + clear() { connectingInterfaceGuid = null; }, + }; +} diff --git a/package.json b/package.json index f32230a..b019396 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "electron-updater": "^6.8.3", "gsap": "^3.15.0", "katex": "^0.16.45", + "koffi": "2.16.3", "lru-cache": "^11.2.6", "monaco-editor": "^0.55.1", "ms": "^2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73383c4..54a54be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: katex: specifier: ^0.16.45 version: 0.16.45 + koffi: + specifier: 2.16.3 + version: 2.16.3 lru-cache: specifier: ^11.2.6 version: 11.2.7 @@ -3453,6 +3456,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + koffi@2.16.3: + resolution: {integrity: sha512-E9y1AsgYGlaxMhcZzHr8y96QF2U5XzA12GGVAfbWqIubTwPNMXQarfBzePNXHe0xtIEtNd6ifAv3GAKYGUeBAQ==} + kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} @@ -8486,6 +8492,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + koffi@2.16.3: {} + kubernetes-types@1.30.0: {} lazy-val@1.0.5: {} diff --git a/src/lib/ai-hardware.ts b/src/lib/ai-hardware.ts index 3ab52bd..9bdd12b 100644 --- a/src/lib/ai-hardware.ts +++ b/src/lib/ai-hardware.ts @@ -35,8 +35,15 @@ const SAFE_ERROR_MESSAGES: Record = { AI_HARDWARE_INVALID_RESPONSE: 'AI hardware service returned an invalid response', AI_HARDWARE_PROVISIONING_DISABLED: 'AI hardware guided provisioning is not enabled', AI_HARDWARE_PORTAL_OPEN_FAILED: 'AI hardware provisioning portal could not be opened', + AI_HARDWARE_HOTSPOT_UNSUPPORTED: 'Robot hotspot connection is not supported on this platform', + AI_HARDWARE_HOTSPOT_PERMISSION_DENIED: 'Permission to access nearby Robot hotspots was denied', + AI_HARDWARE_HOTSPOT_BUSY: 'Another Robot hotspot operation is in progress', + AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED: 'The Robot hotspot selection expired; scan again', + AI_HARDWARE_HOTSPOT_SCAN_FAILED: 'Robot hotspots could not be scanned', + AI_HARDWARE_HOTSPOT_CONNECT_FAILED: 'The Robot hotspot could not be connected', }; const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const HOTSPOT_CANDIDATE_ID = /^[A-Za-z0-9_-]{1,128}$/; export type AiHardwareStatus = | 'unprovisioned' @@ -67,6 +74,24 @@ export type AiHardwareProvisioningCapabilities = { guidedHotspotBinding: boolean; }; +export type AiHardwareProvisioningHotspot = { + candidateId: string; + ssid: string; + signalPercent: number; + connected: boolean; +}; + +export type AiHardwareProvisioningHotspotScan = { + platform: 'windows' | 'macos'; + hotspots: AiHardwareProvisioningHotspot[]; +}; + +export type AiHardwareProvisioningHotspotConnection = { + connected: true; + candidateId: string; + ssid: string; +}; + export type AiHardwareAgentConfiguration = AiHardwareAgent & { system_prompt: string | null; lang_code: string | null; @@ -360,6 +385,41 @@ function readPortalOpened(value: unknown): { opened: true } { return { opened: true }; } +function readProvisioningHotspot(value: unknown): AiHardwareProvisioningHotspot { + if (!isRecord(value) + || !hasOnlyKeys(value, ['candidate_id', 'ssid', 'signal_percent', 'connected']) + || typeof value.candidate_id !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value.candidate_id) + || typeof value.ssid !== 'string' || value.ssid.length < 1 || value.ssid.length > 32 + || !isInteger(value.signal_percent, 0, 100) + || typeof value.connected !== 'boolean') invalidPayload(); + return { + candidateId: value.candidate_id, + ssid: value.ssid, + signalPercent: value.signal_percent, + connected: value.connected, + }; +} + +function readProvisioningHotspotScan(value: unknown): AiHardwareProvisioningHotspotScan { + if (!isRecord(value) + || !hasOnlyKeys(value, ['platform', 'hotspots']) + || (value.platform !== 'windows' && value.platform !== 'macos') + || !Array.isArray(value.hotspots) || value.hotspots.length > 64) invalidPayload(); + return { + platform: value.platform, + hotspots: value.hotspots.map(readProvisioningHotspot), + }; +} + +function readProvisioningHotspotConnection(value: unknown): AiHardwareProvisioningHotspotConnection { + if (!isRecord(value) + || !hasOnlyKeys(value, ['connected', 'candidate_id', 'ssid']) + || value.connected !== true + || typeof value.candidate_id !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value.candidate_id) + || typeof value.ssid !== 'string' || value.ssid.length < 1 || value.ssid.length > 32) invalidPayload(); + return { connected: true, candidateId: value.candidate_id, ssid: value.ssid }; +} + function throwEnvelopeError(envelope: MainEnvelope): never { const status = isInteger(envelope.status, 400, 599) ? envelope.status : 502; const code = typeof envelope.code === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(envelope.code) @@ -536,6 +596,25 @@ export async function openAiHardwareProvisioningPortal(): Promise<{ opened: true ) as Promise<{ opened: true }>; } +export async function scanAiHardwareProvisioningHotspots(): Promise { + return request( + '/provisioning-hotspots/scan', + jsonInit('POST', {}), + readProvisioningHotspotScan, + ) as Promise; +} + +export async function connectAiHardwareProvisioningHotspot( + candidateId: string, +): Promise { + if (!HOTSPOT_CANDIDATE_ID.test(candidateId)) throw new TypeError('candidateId is invalid'); + return request( + '/provisioning-hotspots/connect', + jsonInit('POST', { candidate_id: candidateId }), + readProvisioningHotspotConnection, + ) as Promise; +} + export async function getAiHardwareConfigurationCatalog( ttsModelId?: string, ): Promise { diff --git a/src/pages/AiHardware/index.tsx b/src/pages/AiHardware/index.tsx index b15f8be..d5347a9 100644 --- a/src/pages/AiHardware/index.tsx +++ b/src/pages/AiHardware/index.tsx @@ -32,8 +32,10 @@ import { getAiHardwareAssignment, getAiHardwareOverview, getAiHardwareProvisioningCapabilities, + connectAiHardwareProvisioningHotspot, openAiHardwareProvisioningPortal, recoverAiHardwareCredential, + scanAiHardwareProvisioningHotspots, updateAiHardwareAgentConfiguration, updateAiHardwareAssignment, type AiHardwareAgent, @@ -58,6 +60,13 @@ type BindStep = | 'binding' | 'bound'; type BindRetryIntent = { agentId: string; fingerprint: ArrayBuffer; operationId: string }; +type ProvisioningHotspot = { + candidateId: string; + ssid: string; + signalPercent: number; + connected: boolean; +}; +type HotspotState = 'idle' | 'scanning' | 'ready' | 'connecting' | 'error'; type ConfigDraft = { agent_name: string; system_prompt: string; language: string; lang_code: string; asr_model_id: string; vad_model_id: string; llm_model_id: string; slm_model_id: string; @@ -114,6 +123,18 @@ function safeMessage(error: unknown): string { } return messages[error.code] ?? (error.retryable ? '服务暂时不可用,请稍后重试。' : '操作没有完成,请稍后重试。'); } +function safeHotspotMessage(error: unknown): string { + if (!(error instanceof AiHardwareApiError)) return '暂时无法检查设备热点,请重新扫描或手动连接。'; + const messages: Record = { + AI_HARDWARE_HOTSPOT_UNSUPPORTED: '当前系统不支持在 Makelore 内连接热点,请使用系统 Wi-Fi 设置手动连接。', + AI_HARDWARE_HOTSPOT_PERMISSION_DENIED: 'Makelore 没有检查附近热点所需的系统权限,请授权后重新扫描,或手动连接。', + AI_HARDWARE_HOTSPOT_BUSY: '系统正在处理其他 Wi-Fi 操作,请稍后重新扫描。', + AI_HARDWARE_HOTSPOT_SCAN_FAILED: '暂时无法检查设备热点,请重新扫描或手动连接。', + AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED: '这个热点候选已失效,请重新扫描后再连接。', + AI_HARDWARE_HOTSPOT_CONNECT_FAILED: '没有成功连接并验证这个设备热点,请重试或手动连接。', + }; + return messages[error.code] ?? '暂时无法检查设备热点,请重新扫描或手动连接。'; +} const retryOperationId = (error: unknown): string | null => error instanceof AiHardwareApiError && (error.retryable || error.code === 'ai_hardware_operation_in_progress') ? error.operationId : null; @@ -208,6 +229,10 @@ export function AiHardware() { const [portalOpened, setPortalOpened] = useState(false); const [portalOpenFailed, setPortalOpenFailed] = useState(false); const [portalAddressCopied, setPortalAddressCopied] = useState(false); + const [hotspotState, setHotspotState] = useState('idle'); + const [hotspots, setHotspots] = useState([]); + const [selectedHotspotId, setSelectedHotspotId] = useState(null); + const [hotspotError, setHotspotError] = useState(null); const [configOpen, setConfigOpen] = useState(false); const [catalog, setCatalog] = useState(null); const [catalogLoading, setCatalogLoading] = useState(false); @@ -231,6 +256,7 @@ export function AiHardware() { const bindFingerprintKeyRef = useRef(null); const bindRetryIntentRef = useRef(null); const catalogRequestRef = useRef(0); + const hotspotSessionRef = useRef(0); const clearCreateOperation = () => { setCreateOperationId(null); setCreateOperationBody(null); }; const clearBindOperation = () => { @@ -241,6 +267,55 @@ export function AiHardware() { const clearConfigOperation = () => { setConfigOperationId(null); setConfigOperationBody(null); }; const clearAssignmentOperation = () => { setAssignmentOperationId(null); setAssignmentOperationBody(null); }; + const clearHotspotSession = () => { + hotspotSessionRef.current += 1; + setHotspotState('idle'); + setHotspots([]); + setSelectedHotspotId(null); + setHotspotError(null); + }; + + const scanProvisioningHotspots = useCallback(async () => { + const session = hotspotSessionRef.current + 1; + hotspotSessionRef.current = session; + setHotspotState('scanning'); + setHotspots([]); + setSelectedHotspotId(null); + setHotspotError(null); + try { + const result = await scanAiHardwareProvisioningHotspots(); + if (hotspotSessionRef.current !== session) return; + setHotspots(result.hotspots); + setHotspotState('ready'); + } catch (error) { + if (hotspotSessionRef.current !== session) return; + setHotspotState('error'); + setHotspotError(safeHotspotMessage(error)); + } + }, []); + + const connectProvisioningHotspot = async () => { + if (!selectedHotspotId || hotspotState === 'scanning' || hotspotState === 'connecting') return; + const session = hotspotSessionRef.current; + setHotspotState('connecting'); + setHotspotError(null); + try { + await connectAiHardwareProvisioningHotspot(selectedHotspotId); + if (hotspotSessionRef.current !== session) return; + clearHotspotSession(); + setBindStep('configure_wifi'); + } catch (error) { + if (hotspotSessionRef.current !== session) return; + setHotspotState('error'); + setHotspotError(safeHotspotMessage(error)); + } + }; + + const continueAfterManualHotspotConnection = () => { + clearHotspotSession(); + setBindStep('configure_wifi'); + }; + const loadConfigurationCatalog = useCallback(async (ttsModelId?: string) => { const requestId = catalogRequestRef.current + 1; catalogRequestRef.current = requestId; @@ -299,6 +374,10 @@ export function AiHardware() { }, []); useEffect(() => { void loadOverview(); }, [loadOverview]); + useEffect(() => { + if (bindOpen && bindStep === 'connect_device_ap') void scanProvisioningHotspots(); + }, [bindOpen, bindStep, scanProvisioningHotspots]); + useEffect(() => () => { hotspotSessionRef.current += 1; }, []); useEffect(() => { if (!selectedAgentId) { setConfig(null); @@ -333,6 +412,7 @@ export function AiHardware() { const resetDialog = () => { setDialogError(null); setBusy(false); clearCreateOperation(); clearBindOperation(); clearConfigOperation(); clearAssignmentOperation(); + clearHotspotSession(); setBindPath('direct'); setBindStep(guidedHotspotBinding ? 'choose_path' : 'enter_activation_code'); setPortalOpening(false); @@ -350,7 +430,7 @@ export function AiHardware() { }; const closeBindingDialog = () => { - if (busy || portalOpening) return; + if (busy || portalOpening || hotspotState === 'connecting') return; setBindOpen(false); resetDialog(); }; @@ -384,10 +464,10 @@ export function AiHardware() { }; const backBindingStep = () => { - if (busy || bindStep === 'binding' || bindStep === 'bound') return; + if (busy || hotspotState === 'connecting' || bindStep === 'binding' || bindStep === 'bound') return; setDialogError(null); if (bindStep === 'prepare_robot') setBindStep('choose_path'); - else if (bindStep === 'connect_device_ap') setBindStep('prepare_robot'); + else if (bindStep === 'connect_device_ap') { clearHotspotSession(); setBindStep('prepare_robot'); } else if (bindStep === 'configure_wifi') setBindStep('connect_device_ap'); else if (bindStep === 'reconnect_internet') setBindStep('configure_wifi'); else if (bindStep === 'enter_activation_code' && bindPath === 'guided') setBindStep('reconnect_internet'); @@ -536,7 +616,7 @@ export function AiHardware() { binding: '正在绑定设备', bound: '绑定成功', }; - const canGoBack = !busy && !['choose_path', 'binding', 'bound'].includes(bindStep) + const canGoBack = !busy && hotspotState !== 'connecting' && !['choose_path', 'binding', 'bound'].includes(bindStep) && !(bindStep === 'enter_activation_code' && !guidedHotspotBinding); const bindingContent = (() => { if (bindStep === 'choose_path') { @@ -578,9 +658,52 @@ export function AiHardware() { } if (bindStep === 'connect_device_ap') { return ( -
-

打开电脑的系统 Wi-Fi 设置,连接机器人显示的 Xiaozhi-* 热点。

-

热点名称只用于寻找候选设备,不代表设备身份认证。连接后电脑暂时无法访问互联网是正常现象。

+
+
+ + 设备热点未加密;连接后电脑可能暂时无法访问互联网。热点名称不是设备身份认证。 +
+ {hotspotState === 'scanning' ? ( +
+ 正在检查附近的设备热点… +
+ ) : null} + {hotspotState === 'ready' && hotspots.length === 0 ? ( +
+

没有发现设备热点

+

请确认机器人仍在配网模式,然后重新扫描。

+
+ ) : null} + {hotspots.length > 0 ? ( +
+ {hotspots.map((hotspot) => ( + + ))} +
+ ) : null} + {hotspotError ?

{hotspotError}

: null} +
+ + +
); } @@ -650,7 +773,7 @@ export function AiHardware() { const bindingPrimaryAction = (() => { if (bindStep === 'prepare_robot') return ; - if (bindStep === 'connect_device_ap') return ; + if (bindStep === 'connect_device_ap') return ; if (bindStep === 'configure_wifi' && !portalOpened) return ; if (bindStep === 'configure_wifi') return ; if (bindStep === 'reconnect_internet') return ; @@ -683,7 +806,7 @@ export function AiHardware() { )} { if (!busy) { setCreateOpen(open); if (!open) { setAgentName(''); resetDialog(); } } }}>创建智能体输入一个容易识别的名称,稍后仍可修改。
{ setAgentName(e.target.value); clearCreateOperation(); }} />
{dialogError ?

{dialogError}

: null}
- { if (!open) closeBindingDialog(); }}>{bindingTitles[bindStep]}{bindPath === 'guided' ? '按步骤完成机器人联网与账号绑定。' : '使用机器人播报的激活码绑定到智能体。'}{bindingContent}{dialogError ?

{dialogError}

: null}{canGoBack ? : null}{!['binding', 'bound'].includes(bindStep) ? : null}{bindingPrimaryAction}
+ { if (!open) closeBindingDialog(); }}>{bindingTitles[bindStep]}{bindPath === 'guided' ? '按步骤完成机器人联网与账号绑定。' : '使用机器人播报的激活码绑定到智能体。'}{bindingContent}{dialogError ?

{dialogError}

: null}{canGoBack ? : null}{!['binding', 'bound'].includes(bindStep) ? : null}{bindingPrimaryAction}
{ if (!busy) { setConfigOpen(open); if (!open) { resetDialog(); setCatalog(null); setCatalogError(null); } } }}>编辑智能体配置模型、语言和音色来自小智当前可用目录;选择“使用服务默认值”可清除可选字段。{draft ? { setDraft(next); clearConfigOperation(); }} onTtsModelChange={(value, next) => { setDraft(next); clearConfigOperation(); void loadConfigurationCatalog(value || undefined); }} onRetryCatalog={() => void loadConfigurationCatalog(draft.tts_model_id || undefined)} /> : null}{dialogError ?

{dialogError}

: null}
{ if (!open && !busy) { setAssignmentDevice(null); resetDialog(); } }}>重新指派设备选择接收这台设备的智能体。系统会检查最新修订,避免覆盖其他更改。 { setDialogAgentId(value); clearAssignmentOperation(); }} />{dialogError ?

{dialogError}

: null}
diff --git a/tests/unit/ai-hardware-api.test.ts b/tests/unit/ai-hardware-api.test.ts index 20de035..30477d8 100644 --- a/tests/unit/ai-hardware-api.test.ts +++ b/tests/unit/ai-hardware-api.test.ts @@ -13,6 +13,8 @@ import { createAiHardwareOperationId, recoverAiHardwareCredential, openAiHardwareProvisioningPortal, + scanAiHardwareProvisioningHotspots, + connectAiHardwareProvisioningHotspot, } from '@/lib/ai-hardware'; const hostApiFetchMock = vi.hoisted(() => vi.fn()); @@ -80,6 +82,99 @@ describe('AI hardware renderer API', () => { ); }); + it('strictly projects Robot hotspot scan and connection DTOs', async () => { + hostApiFetchMock + .mockResolvedValueOnce({ + success: true, + data: { + platform: 'macos', + hotspots: [{ + candidate_id: 'candidate_123', + ssid: 'Xiaozhi-Desk', + signal_percent: 72, + connected: false, + }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' }, + }); + + await expect(scanAiHardwareProvisioningHotspots()).resolves.toEqual({ + platform: 'macos', + hotspots: [{ + candidateId: 'candidate_123', + ssid: 'Xiaozhi-Desk', + signalPercent: 72, + connected: false, + }], + }); + await expect(connectAiHardwareProvisioningHotspot('candidate_123')).resolves.toEqual({ + connected: true, + candidateId: 'candidate_123', + ssid: 'Xiaozhi-Desk', + }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith( + 1, + '/api/works/ai-hardware/provisioning-hotspots/scan', + { method: 'POST', body: '{}' }, + ); + expect(hostApiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/works/ai-hardware/provisioning-hotspots/connect', + { method: 'POST', body: JSON.stringify({ candidate_id: 'candidate_123' }) }, + ); + }); + + it.each([ + { platform: 'linux', hotspots: [] }, + { platform: 'windows', hotspots: [], native_interface: 'secret' }, + { + platform: 'windows', + hotspots: [{ + candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk', signal_percent: 101, + connected: false, + }], + }, + { + platform: 'windows', + hotspots: [{ + candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk', signal_percent: 72, + connected: false, bssid: 'secret', + }], + }, + ])('rejects malformed or expanded hotspot scan DTO %#', async (data) => { + hostApiFetchMock.mockResolvedValueOnce({ success: true, data }); + await expect(scanAiHardwareProvisioningHotspots()).rejects.toMatchObject({ + code: 'AI_HARDWARE_INVALID_RESPONSE', + }); + }); + + it.each([ + { connected: false, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' }, + { connected: true, candidate_id: '../ssid', ssid: 'Xiaozhi-Desk' }, + { + connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk', + native_profile: 'secret', + }, + ])('rejects malformed or expanded hotspot connection DTO %#', async (data) => { + hostApiFetchMock.mockResolvedValueOnce({ success: true, data }); + await expect(connectAiHardwareProvisioningHotspot('candidate_123')).rejects.toMatchObject({ + code: 'AI_HARDWARE_INVALID_RESPONSE', + }); + }); + + it('rejects invalid hotspot candidate IDs before calling Host API', async () => { + await expect(connectAiHardwareProvisioningHotspot('../ssid')).rejects.toThrow( + 'candidateId is invalid', + ); + await expect(connectAiHardwareProvisioningHotspot('a'.repeat(129))).rejects.toThrow( + 'candidateId is invalid', + ); + expect(hostApiFetchMock).not.toHaveBeenCalled(); + }); + it.each([ { guided_hotspot_binding: 'true' }, { guided_hotspot_binding: false, portal_url: 'http://unsafe.example' }, diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index 1552484..fdf4007 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -11,6 +11,8 @@ import { const api = vi.hoisted(() => ({ getAiHardwareOverview: vi.fn(), getAiHardwareProvisioningCapabilities: vi.fn(), + scanAiHardwareProvisioningHotspots: vi.fn(), + connectAiHardwareProvisioningHotspot: vi.fn(), openAiHardwareProvisioningPortal: vi.fn(), recoverAiHardwareCredential: vi.fn(), createAiHardwareAgent: vi.fn(), @@ -87,10 +89,19 @@ async function openConfigurationEditor(): Promise { await screen.findByRole('heading', { name: '编辑智能体配置' }); } +async function openGuidedHotspotStep(): Promise { + fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' })); + fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' })); + fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); + await screen.findByRole('heading', { name: '连接设备热点' }); +} + describe('AI hardware page', () => { beforeEach(() => { api.getAiHardwareOverview.mockResolvedValue(activeOverview); api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false }); + api.scanAiHardwareProvisioningHotspots.mockResolvedValue({ platform: 'windows', hotspots: [] }); + api.connectAiHardwareProvisioningHotspot.mockResolvedValue({ connected: true, candidateId: 'candidate-a', ssid: 'Xiaozhi-A' }); api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true }); api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] }); api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 }); @@ -176,6 +187,142 @@ describe('AI hardware page', () => { expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument(); expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled(); + expect(api.scanAiHardwareProvisioningHotspots).not.toHaveBeenCalled(); + }); + + it('automatically scans, lists candidates, and advances only after an explicit verified connection', async () => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({ + platform: 'windows', + hotspots: [ + { candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 82, connected: false }, + { candidateId: 'candidate-b', ssid: 'Xiaozhi-B', signalPercent: 47, connected: true }, + ], + }); + api.connectAiHardwareProvisioningHotspot.mockResolvedValueOnce({ + connected: true, candidateId: 'candidate-b', ssid: 'Xiaozhi-B', + }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + await openGuidedHotspotStep(); + + await waitFor(() => expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledOnce()); + expect(await screen.findByRole('button', { name: '选择 Xiaozhi-A' })).toHaveTextContent('信号 82%'); + expect(screen.getByRole('button', { name: '选择 Xiaozhi-B' })).toHaveTextContent('已连接'); + expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled(); + expect(api.connectAiHardwareProvisioningHotspot).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '选择 Xiaozhi-B' })); + const connectButton = screen.getByRole('button', { name: '连接所选热点' }); + fireEvent.click(connectButton); + fireEvent.click(connectButton); + + await waitFor(() => expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledOnce()); + expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledWith('candidate-b'); + expect(await screen.findByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument(); + const cancelButton = screen.getByRole('button', { name: '取消' }); + expect(cancelButton).toBeEnabled(); + fireEvent.click(cancelButton); + await waitFor(() => expect(screen.queryByRole('heading', { name: '配置机器人 Wi-Fi' })).not.toBeInTheDocument()); + }); + + it('shows an empty scan result and supports rescanning without selecting a hotspot', async () => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.scanAiHardwareProvisioningHotspots + .mockResolvedValueOnce({ platform: 'windows', hotspots: [] }) + .mockResolvedValueOnce({ + platform: 'windows', + hotspots: [{ candidateId: 'candidate-new', ssid: 'Xiaozhi-New', signalPercent: 61, connected: false }], + }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + await openGuidedHotspotStep(); + + expect(await screen.findByText('没有发现设备热点')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '重新扫描' })); + expect(await screen.findByRole('button', { name: '选择 Xiaozhi-New' })).toHaveTextContent('信号 61%'); + expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['AI_HARDWARE_HOTSPOT_PERMISSION_DENIED', '没有检查附近热点所需的系统权限'], + ['AI_HARDWARE_HOTSPOT_UNSUPPORTED', '当前系统不支持在 Makelore 内连接热点'], + ['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'], + ['AI_HARDWARE_HOTSPOT_SCAN_FAILED', '暂时无法检查设备热点'], + ])('shows a safe %s scan error and preserves the manual fallback', async (code, expected) => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.scanAiHardwareProvisioningHotspots.mockRejectedValueOnce(new AiHardwareApiError({ + status: 200, code, message: 'native adapter secret path', + })); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + await openGuidedHotspotStep(); + + expect(await screen.findByRole('alert')).toHaveTextContent(expected); + expect(screen.queryByText(/native adapter secret path/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' })); + expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument(); + }); + + it.each([ + ['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'], + ['AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED', '这个热点候选已失效'], + ['AI_HARDWARE_HOTSPOT_CONNECT_FAILED', '没有成功连接并验证'], + ])('shows a safe %s connection error and allows retrying the selected candidate', async (code, expected) => { + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({ + platform: 'macos', + hotspots: [{ candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 70, connected: false }], + }); + api.connectAiHardwareProvisioningHotspot.mockRejectedValueOnce(new AiHardwareApiError({ + status: 200, + code, + message: 'native command secret', + })); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + await openGuidedHotspotStep(); + fireEvent.click(await screen.findByRole('button', { name: '选择 Xiaozhi-A' })); + fireEvent.click(screen.getByRole('button', { name: '连接所选热点' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(expected); + expect(screen.queryByText(/native command secret/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '连接所选热点' })).toBeEnabled(); + }); + + it('ignores a late scan after dismiss and reopen', async () => { + const staleScan = deferred<{ + platform: 'windows'; + hotspots: Array<{ candidateId: string; ssid: string; signalPercent: number; connected: boolean }>; + }>(); + api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true }); + api.scanAiHardwareProvisioningHotspots + .mockReturnValueOnce(staleScan.promise) + .mockResolvedValueOnce({ + platform: 'windows', + hotspots: [{ candidateId: 'fresh', ssid: 'Xiaozhi-Fresh', signalPercent: 75, connected: false }], + }); + render(); + await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled()); + + await openGuidedHotspotStep(); + expect(screen.getByText(/正在检查附近的设备热点/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + await openGuidedHotspotStep(); + expect(await screen.findByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument(); + staleScan.resolve({ + platform: 'windows', + hotspots: [{ candidateId: 'stale', ssid: 'Xiaozhi-Stale', signalPercent: 99, connected: false }], + }); + + await waitFor(() => expect(screen.queryByRole('button', { name: '选择 Xiaozhi-Stale' })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument(); }); it('shows and binds devices for the currently selected agent', async () => { @@ -221,7 +368,7 @@ describe('AI hardware page', () => { fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' })); expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); - expect(screen.getByText(/Xiaozhi-\*/)).toBeInTheDocument(); + expect(screen.getByText(/设备热点未加密/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '上一步' })); expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' })); diff --git a/tests/unit/ai-hardware-routes.test.ts b/tests/unit/ai-hardware-routes.test.ts index 34b2941..edb2410 100644 --- a/tests/unit/ai-hardware-routes.test.ts +++ b/tests/unit/ai-hardware-routes.test.ts @@ -44,6 +44,23 @@ function setup( options: { guidedHotspotBinding?: boolean; openExternal?: (url: string) => Promise; + robotHotspot?: { + scan: () => Promise<{ + platform: 'windows' | 'macos'; + hotspots: Array<{ + candidateId: string; + ssid: string; + signalPercent: number; + connected: boolean; + }>; + }>; + connect: (candidateId: string) => Promise<{ + connected: true; + candidateId: string; + ssid: string; + }>; + clear: () => void; + }; } = {}, ) { const getAccessToken = vi.fn().mockResolvedValue('secret-token'); @@ -263,6 +280,183 @@ describe('AI hardware Host API route', () => { expect(getAccessToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it('scans and connects Robot hotspots locally before credentials or upstream access', async () => { + const robotHotspot = { + scan: vi.fn().mockResolvedValue({ + platform: 'windows' as const, + hotspots: [{ + candidateId: 'candidate_123', + ssid: 'Xiaozhi-Desk', + signalPercent: 78, + connected: false, + nativeInterface: 'must-not-pass', + }], + }), + connect: vi.fn().mockResolvedValue({ + connected: true as const, + candidateId: 'candidate_123', + ssid: 'Xiaozhi-Desk', + profile: 'must-not-pass', + }), + clear: vi.fn(), + }; + const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot }); + + const scanned = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/provisioning-hotspots/scan', + {}, + ); + const connected = await invoke( + handler, + 'POST', + '/api/works/ai-hardware/provisioning-hotspots/connect', + { candidate_id: 'candidate_123' }, + ); + + expect(scanned.payload).toEqual({ + success: true, + data: { + platform: 'windows', + hotspots: [{ + candidate_id: 'candidate_123', + ssid: 'Xiaozhi-Desk', + signal_percent: 78, + connected: false, + }], + }, + }); + expect(connected.payload).toEqual({ + success: true, + data: { connected: true, candidate_id: 'candidate_123', ssid: 'Xiaozhi-Desk' }, + }); + expect(robotHotspot.connect).toHaveBeenCalledWith('candidate_123'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects hotspot queries, missing or expanded bodies, and invalid candidate IDs locally', async () => { + const robotHotspot = { + scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }), + connect: vi.fn(), + clear: vi.fn(), + }; + const { handler, fetchImpl, getAccessToken } = setup(undefined, { robotHotspot }); + const requests = [ + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan?refresh=1', {}), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan'), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', { extra: true }), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect?retry=1', { candidate_id: 'candidate_123' }), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', {}), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { candidate_id: '../ssid' }), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { + candidate_id: 'a'.repeat(129), + }), + invoke(handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { + candidate_id: 'candidate_123', ssid: 'Xiaozhi-Evil', + }), + ]; + + for (const result of await Promise.all(requests)) { + expect(result.payload).toMatchObject({ + success: false, + status: 400, + code: 'AI_HARDWARE_INVALID_REQUEST', + }); + } + expect(robotHotspot.scan).not.toHaveBeenCalled(); + expect(robotHotspot.connect).not.toHaveBeenCalled(); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('disables both local hotspot operations with the guided provisioning capability', async () => { + const robotHotspot = { + scan: vi.fn().mockResolvedValue({ platform: 'windows' as const, hotspots: [] }), + connect: vi.fn(), + clear: vi.fn(), + }; + const disabled = setup(undefined, { guidedHotspotBinding: false, robotHotspot }); + + for (const result of await Promise.all([ + invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/scan', {}), + invoke(disabled.handler, 'POST', '/api/works/ai-hardware/provisioning-hotspots/connect', { + candidate_id: 'candidate_123', + }), + ])) { + expect(result.payload).toMatchObject({ + success: false, + status: 403, + code: 'AI_HARDWARE_PROVISIONING_DISABLED', + }); + } + expect(robotHotspot.scan).not.toHaveBeenCalled(); + expect(robotHotspot.connect).not.toHaveBeenCalled(); + expect(disabled.getAccessToken).not.toHaveBeenCalled(); + expect(disabled.fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([ + ['unsupported', 501, 'AI_HARDWARE_HOTSPOT_UNSUPPORTED'], + ['permission_denied', 403, 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED'], + ['busy', 409, 'AI_HARDWARE_HOTSPOT_BUSY'], + ['candidate_expired', 409, 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED'], + ['scan_failed', 502, 'AI_HARDWARE_HOTSPOT_SCAN_FAILED'], + ])('projects native hotspot scan error %s safely', async (nativeCode, status, code) => { + const nativeError = Object.assign(new Error('native interface and location secret'), { + code: nativeCode, + }); + const robotHotspot = { + scan: vi.fn().mockRejectedValue(nativeError), + connect: vi.fn(), + clear: vi.fn(), + }; + const local = setup(undefined, { robotHotspot }); + const result = await invoke( + local.handler, + 'POST', + '/api/works/ai-hardware/provisioning-hotspots/scan', + {}, + ); + + expect(result.status).toBe(200); + expect(result.payload).toMatchObject({ success: false, status, code, retryable: false }); + expect(JSON.stringify(result.payload)).not.toContain('native interface'); + expect(local.getAccessToken).not.toHaveBeenCalled(); + expect(local.fetchImpl).not.toHaveBeenCalled(); + }); + + it('projects native connect failures safely', async () => { + const robotHotspot = { + scan: vi.fn().mockResolvedValue({ platform: 'macos' as const, hotspots: [] }), + connect: vi.fn().mockRejectedValue(Object.assign(new Error('native profile secret'), { + code: 'connect_failed', + })), + clear: vi.fn(), + }; + const local = setup(undefined, { robotHotspot }); + const result = await invoke( + local.handler, + 'POST', + '/api/works/ai-hardware/provisioning-hotspots/connect', + { candidate_id: 'candidate_123' }, + ); + + expect(result.status).toBe(200); + expect(result.payload).toEqual({ + success: false, + status: 502, + code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED', + error: 'The Robot hotspot could not be connected', + retryable: false, + }); + expect(JSON.stringify(result.payload)).not.toContain('native profile secret'); + expect(local.getAccessToken).not.toHaveBeenCalled(); + expect(local.fetchImpl).not.toHaveBeenCalled(); + }); + it('proxies only the fixed catalog query and projects its safe DTO', async () => { const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ schema_version: 1, diff --git a/tests/unit/robot-hotspot-module.test.ts b/tests/unit/robot-hotspot-module.test.ts new file mode 100644 index 0000000..ce70786 --- /dev/null +++ b/tests/unit/robot-hotspot-module.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RobotHotspotAdapter, RobotHotspotAdapterCandidate } from '@electron/robot-hotspot/adapter'; +import { RobotHotspotError, createRobotHotspotModule } from '@electron/robot-hotspot'; +import { MacosWorkerController, requestMacosWorker } from '@electron/robot-hotspot/macos'; +import type { MacosWorkerLike } from '@electron/robot-hotspot/macos'; + +function candidate(ssid: string, signalPercent: number, overrides: Partial = {}): RobotHotspotAdapterCandidate { + return { ssid, signalPercent, connected: false, open: true, nativeKey: ssid, ...overrides }; +} + +function fakeAdapter(hotspots: RobotHotspotAdapterCandidate[]): RobotHotspotAdapter { + return { + scan: vi.fn(async () => hotspots), + connect: vi.fn(async () => {}), + currentSsid: vi.fn(async () => hotspots[0]?.ssid ?? null), + clear: vi.fn(), + }; +} + +async function expectCode(promise: Promise, code: string): Promise { + await expect(promise).rejects.toMatchObject({ name: 'RobotHotspotError', code }); +} + +describe('Robot hotspot module', () => { + it('terminates a hanging macOS native worker when the request is aborted', async () => { + const worker = { + postMessage: vi.fn(), + once: vi.fn(function (this: MacosWorkerLike) { return this; }), + off: vi.fn(function (this: MacosWorkerLike) { return this; }), + terminate: vi.fn(async () => 1), + } as unknown as MacosWorkerLike; + const controller = new AbortController(); + const request = requestMacosWorker(worker, { id: 1, operation: 'scan' }, controller.signal); + const timeout = new RobotHotspotError('scan_failed'); + + controller.abort(timeout); + + await expect(request).rejects.toBe(timeout); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); + + it('does not create or post to a replacement macOS worker until prior termination settles', async () => { + let finishTermination!: (code: number) => void; + const firstTermination = new Promise((resolve) => { finishTermination = resolve; }); + const createFakeWorker = (termination: Promise): MacosWorkerLike => ({ + postMessage: vi.fn(), + once: vi.fn(function (this: MacosWorkerLike) { return this; }), + off: vi.fn(function (this: MacosWorkerLike) { return this; }), + terminate: vi.fn(() => termination), + }); + const firstWorker = createFakeWorker(firstTermination); + const secondWorker = createFakeWorker(Promise.resolve(1)); + const factory = vi.fn() + .mockReturnValueOnce(firstWorker) + .mockReturnValueOnce(secondWorker); + const controller = new MacosWorkerController(factory); + const firstAbort = new AbortController(); + const firstRequest = controller.request({ operation: 'scan' }, firstAbort.signal); + await vi.waitFor(() => expect(firstWorker.postMessage).toHaveBeenCalledOnce()); + firstAbort.abort(new RobotHotspotError('scan_failed')); + await expect(firstRequest).rejects.toMatchObject({ code: 'scan_failed' }); + + const secondAbort = new AbortController(); + const secondRequest = controller.request({ operation: 'scan' }, secondAbort.signal); + await Promise.resolve(); + expect(factory).toHaveBeenCalledTimes(1); + expect(secondWorker.postMessage).not.toHaveBeenCalled(); + + finishTermination(1); + await vi.waitFor(() => expect(secondWorker.postMessage).toHaveBeenCalledOnce()); + expect(factory).toHaveBeenCalledTimes(2); + secondAbort.abort(new RobotHotspotError('scan_failed')); + await expect(secondRequest).rejects.toMatchObject({ code: 'scan_failed' }); + expect(firstWorker.terminate).toHaveBeenCalledOnce(); + }); + + it('does not continue to permission or native work when aborted behind the termination barrier', async () => { + let finishTermination!: (code: number) => void; + const firstTermination = new Promise((resolve) => { finishTermination = resolve; }); + const firstWorker = { + postMessage: vi.fn(), + once: vi.fn(function (this: MacosWorkerLike) { return this; }), + off: vi.fn(function (this: MacosWorkerLike) { return this; }), + terminate: vi.fn(() => firstTermination), + } as unknown as MacosWorkerLike; + const replacementWorker = { + postMessage: vi.fn(), + once: vi.fn(function (this: MacosWorkerLike) { return this; }), + off: vi.fn(function (this: MacosWorkerLike) { return this; }), + terminate: vi.fn(async () => 1), + } as unknown as MacosWorkerLike; + const factory = vi.fn() + .mockReturnValueOnce(firstWorker) + .mockReturnValueOnce(replacementWorker); + const controller = new MacosWorkerController(factory); + const firstAbort = new AbortController(); + const firstRequest = controller.request({ operation: 'scan' }, firstAbort.signal); + await vi.waitFor(() => expect(firstWorker.postMessage).toHaveBeenCalledOnce()); + firstAbort.abort(new RobotHotspotError('scan_failed')); + await expect(firstRequest).rejects.toMatchObject({ code: 'scan_failed' }); + + const permissionOrNativeContinuation = vi.fn(); + const blockedAbort = new AbortController(); + const blockedFlow = (async () => { + await controller.ready(blockedAbort.signal); + permissionOrNativeContinuation(); + await controller.request({ operation: 'scan' }, blockedAbort.signal); + })(); + blockedAbort.abort(new RobotHotspotError('scan_failed')); + finishTermination(1); + + await expect(blockedFlow).rejects.toMatchObject({ code: 'scan_failed' }); + expect(permissionOrNativeContinuation).not.toHaveBeenCalled(); + expect(factory).toHaveBeenCalledTimes(1); + expect(replacementWorker.postMessage).not.toHaveBeenCalled(); + }); + + it.runIf(process.platform === 'win32')('loads the Windows WLAN adapter definitions', async () => { + const { createWindowsRobotHotspotAdapter } = await import('@electron/robot-hotspot/windows'); + await expect(createWindowsRobotHotspotAdapter()).resolves.toMatchObject({ + scan: expect.any(Function), connect: expect.any(Function), currentSsid: expect.any(Function), + }); + }); + + it('projects only open printable Xiaozhi SSIDs and merges duplicates using the strongest signal', async () => { + const adapter = fakeAdapter([ + candidate('Other-1', 100), + candidate('Xiaozhi-secured', 90, { open: false }), + candidate('Xiaozhi-bad\nname', 90), + candidate('Xiaozhi-bidi\u202eoverride', 90), + candidate('Xiaozhi-zero\u200bwidth', 90), + candidate('Xiaozhi-line\u2028separator', 90), + candidate('Xiaozhi-lone\ud800surrogate', 90), + candidate(`Xiaozhi-${'界'.repeat(9)}`, 80), + candidate('Xiaozhi-device', 20), + candidate('Xiaozhi-device', 81, { connected: true }), + ]); + const module = createRobotHotspotModule('windows', adapter, { createId: () => 'opaque-id' }); + + await expect(module.scan()).resolves.toEqual({ + platform: 'windows', + hotspots: [{ candidateId: 'opaque-id', ssid: 'Xiaozhi-device', signalPercent: 81, connected: true }], + }); + }); + + it('bounds the projected candidate list', async () => { + const adapter = fakeAdapter(Array.from( + { length: 70 }, + (_, index) => candidate(`Xiaozhi-${String(index).padStart(2, '0')}`, 70 - index), + )); + let id = 0; + const module = createRobotHotspotModule('windows', adapter, { createId: () => `id-${id += 1}` }); + + const result = await module.scan(); + + expect(result.hotspots).toHaveLength(64); + expect(result.hotspots.at(0)?.ssid).toBe('Xiaozhi-00'); + expect(result.hotspots.at(-1)?.ssid).toBe('Xiaozhi-63'); + }); + + it('expires candidates after the short snapshot TTL and when clear is called', async () => { + let now = 10; + const adapter = fakeAdapter([candidate('Xiaozhi-one', 50)]); + const module = createRobotHotspotModule('macos', adapter, { now: () => now, candidateTtlMs: 100, createId: () => 'id-1' }); + await module.scan(); + now = 110; + await expectCode(module.connect('id-1'), 'candidate_expired'); + + now = 10; + await module.scan(); + module.clear(); + await expectCode(module.connect('id-1'), 'candidate_expired'); + }); + + it('rejects overlapping native work as busy', async () => { + let finishConnect!: () => void; + const adapter = fakeAdapter([candidate('Xiaozhi-one', 50)]); + adapter.connect = vi.fn(() => new Promise((resolve) => { finishConnect = resolve; })); + adapter.currentSsid = vi.fn(async () => 'Xiaozhi-one'); + const module = createRobotHotspotModule('windows', adapter, { createId: () => 'id-1' }); + await module.scan(); + const connecting = module.connect('id-1'); + await Promise.resolve(); + + await expectCode(module.scan(), 'busy'); + finishConnect(); + await expect(connecting).resolves.toMatchObject({ connected: true, ssid: 'Xiaozhi-one' }); + }); + + it('does not report success until the exact current SSID is verified', async () => { + const adapter = fakeAdapter([candidate('Xiaozhi-target', 50)]); + adapter.currentSsid = vi.fn() + .mockResolvedValueOnce('Xiaozhi-other') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('Xiaozhi-target'); + const module = createRobotHotspotModule('windows', adapter, { + createId: () => 'id-1', verificationIntervalMs: 1, connectTimeoutMs: 100, + }); + await module.scan(); + + await expect(module.connect('id-1')).resolves.toEqual({ connected: true, candidateId: 'id-1', ssid: 'Xiaozhi-target' }); + expect(adapter.currentSsid).toHaveBeenCalledTimes(3); + }); + + it('keeps stable errors free of native diagnostics', async () => { + const adapter = fakeAdapter([]); + adapter.scan = vi.fn(async () => { throw new Error('native secret diagnostic'); }); + const module = createRobotHotspotModule('windows', adapter); + + const error = await module.scan().catch((caught: unknown) => caught); + expect(error).toEqual(new RobotHotspotError('scan_failed')); + expect(String(error)).not.toContain('native secret diagnostic'); + }); +});