From c1326a298026a697181c822cdd3062cc96c3aa2d Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 16 Aug 2026 20:18:28 +0800 Subject: [PATCH] feat(robot): connect provisioning hotspots in app --- ...cross-platform-robot-hotspot-connection.md | 87 +++++ .../20260816-robot-hotspot-connect-5e7a91.md | 112 ++++++ README.md | 2 +- electron-builder.yml | 2 + electron/api/routes/ai-hardware.ts | 113 ++++++ electron/robot-hotspot/adapter.ts | 36 ++ electron/robot-hotspot/index.ts | 193 ++++++++++ electron/robot-hotspot/macos.ts | 332 ++++++++++++++++++ electron/robot-hotspot/windows.ts | 203 +++++++++++ package.json | 1 + pnpm-lock.yaml | 8 + src/lib/ai-hardware.ts | 79 +++++ src/pages/AiHardware/index.tsx | 141 +++++++- tests/unit/ai-hardware-api.test.ts | 95 +++++ tests/unit/ai-hardware-page.test.tsx | 149 +++++++- tests/unit/ai-hardware-routes.test.ts | 194 ++++++++++ tests/unit/robot-hotspot-module.test.ts | 214 +++++++++++ 17 files changed, 1950 insertions(+), 11 deletions(-) create mode 100644 .project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md create mode 100644 .project-docs/30-worklog/tasks/20260816-robot-hotspot-connect-5e7a91.md create mode 100644 electron/robot-hotspot/adapter.ts create mode 100644 electron/robot-hotspot/index.ts create mode 100644 electron/robot-hotspot/macos.ts create mode 100644 electron/robot-hotspot/windows.ts create mode 100644 tests/unit/robot-hotspot-module.test.ts diff --git a/.project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md b/.project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md new file mode 100644 index 0000000..076a316 --- /dev/null +++ b/.project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md @@ -0,0 +1,87 @@ +# Proposal: Robot 配网页内连接 Windows 与 macOS 热点 + +## Status + +Proposed for promotion. Implementation and automated verification are complete; signed macOS and physical-Robot platform verification remain open acceptance gates. + +## Date + +2026-08-16 + +## Supersedes + +- 仅替换 ADR-002 中 `connect_device_ap` 依赖用户前往操作系统 Wi-Fi 面板手动选网的步骤。 +- 不改变 ADR-002 的固件零改动、固定 Portal、Wi-Fi 凭据隔离、六位码 Binding、`bound` 语义和精确 `=0` 回滚。 + +## Context + +当前引导在用户确认机器人进入配网模式后,只说明如何前往系统 Wi-Fi 设置连接 `Xiaozhi-*` 热点。用户希望在 Makelore 弹窗内直接看到附近可连接的机器人热点并完成连接,而且 Windows 与 macOS 必须同时交付。 + +已审计固件源码使用 `Xiaozhi` 前缀生成配网热点名称,但确切出货镜像仍是发布门禁。现有固件热点为开放网络;发现热点不能证明设备身份、所有权、账号 Binding 或在线状态。 + +## Decision + +Makelore 在现有 `connect_device_ap` 步骤中加入一个 Main-owned Robot Hotspot Module,并通过两个平台 Adapter 实现 Windows 与 macOS 原生扫描、连接和当前 SSID 核验。 + +Module 对 Host route 暴露两个小操作: + +- `scan()` 返回本次扫描内有效的 Robot 热点候选。 +- `connect(candidateId)` 只接受本次扫描生成的短期不透明候选 ID,连接并核验当前 SSID 后返回成功。 + +Renderer 只通过 `src/lib/ai-hardware.ts` 的 typed Host seam 使用该 Module。Renderer 不接触原生网络接口、BSSID、接口名、系统 profile、命令行、任意 SSID 或任意 URL。 + +Host API surface: + +- `POST /api/works/ai-hardware/provisioning-hotspots/scan`:无 query,exact body `{}`;成功 envelope 的 `data` 为 `{ platform, hotspots }`,其中热点只有 `{ candidate_id, ssid, signal_percent, connected }`。 +- `POST /api/works/ai-hardware/provisioning-hotspots/connect`:无 query,exact body `{ candidate_id }`;成功 envelope 的 `data` 为 `{ connected: true, candidate_id, ssid }`。 +- 两个操作都必须先检查 Main-owned `guidedHotspotBinding` capability,并在获取 Works token 或访问 upstream 前本地返回。 +- 逻辑失败沿用 outer HTTP 200 的标准 Host error envelope;稳定错误码覆盖 unsupported、permission denied、busy、scan failed、candidate expired 和 connect failed。原生错误文本不得投影到 Renderer。 + +平台实现: + +- Windows Adapter 直接使用系统 WLAN API 扫描开放网络、以临时 discovery connection 连接,并轮询当前 connection 验证 SSID;不得解析本地化的 `netsh` 输出,也不得保存永久 profile。 +- macOS Adapter 直接使用 CoreWLAN 扫描和关联,并由 Main 侧 CoreLocation 权限流程满足现代 macOS 的 SSID 访问要求;应用包声明用途文本。不得依赖已弃用或系统版本不稳定的 `airport` CLI。 +- 两个平台 Adapter 隔离在 Module 后;不支持的平台安全返回 unsupported。 + +## Candidate And Operation Rules + +- Main 只保留 SSID 以 `Xiaozhi-` 开头、名称可打印且长度不超过 32 bytes、明确为开放网络的候选。 +- 多个同名 BSSID 在 Renderer 合并为一个 SSID 候选,使用最强信号项;BSSID 永不跨越 Main 边界。 +- 候选 ID 使用不可预测随机值,下一次扫描、显式 clear、进程重启或短期过期后失效。 +- 同一 Module 同时只运行一个 native operation;扫描和连接都有硬超时。 +- 不自动连接最强热点。用户必须明确选择并触发连接。 +- `connect` 只有在当前 SSID 与候选 SSID 完全一致时才成功;原生关联请求已返回但未验证时不得推进流程。 + +## UX And Recovery + +进入 `connect_device_ap` 时自动扫描,页面展示候选、信号强度、重新扫描和明确连接操作。连接中锁定会话状态;成功核验后进入固定 Portal 步骤。 + +权限拒绝、没有候选、超时或平台调用失败时,页面提供安全、可行动的说明,并保留“我已在系统中连接”的人工兜底。取消/关闭/重新打开向导会使旧的异步结果失效,不能污染新的向导会话。 + +产品文案继续提示开放热点与临时断网风险;Makelore 不收集热点密码,也不收集、代理或保存家庭 Wi-Fi 密码。家庭 Wi-Fi 凭据仍只在固件 Portal 内输入。 + +## Security And Privacy + +- 系统级网络访问只存在于 Electron Main;Host bearer authentication 仍先于 route dispatch。 +- 仅允许连接最近一次扫描获得的开放 `Xiaozhi-*` 候选,客户端不能提交任意 SSID。 +- 日志不得包含 BSSID、系统接口、profile、家庭 Wi-Fi 凭据、原生错误或位置数据。 +- 热点发现是未经认证的便利功能。恶意热点可冒充前缀;默认开启不等于安全身份发现。 + +## Acceptance Gates + +在声称双平台硬件流程完成前,必须同时满足: + +1. Windows 聚焦测试、打包加载检查和真实系统扫描/连接 Robot 热点 smoke。 +2. macOS x64/arm64 打包加载、位置权限、扫描、连接和当前 SSID 核验 smoke。 +3. Renderer -> Host API -> Main Module 的 Electron E2E,覆盖成功与权限/无热点/连接失败兜底。 +4. 确切出货固件热点命名、开放网络属性、Portal 地址和六位码发行契约核验。 +5. exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 仍关闭整个引导路径,直接六位码 Binding 不回归。 + +Windows 开发机上的自动化测试不能替代 macOS 实机证明;在 macOS smoke 完成前,代码可以达到实现完成,但发布证据必须明确标记为未完成。 + +## Consequences + +- 用户无需先离开 Makelore 即可选择并连接机器人热点,失败时仍能回到系统设置完成操作。 +- 固件、云端 Binding 和家庭 Wi-Fi 密码边界保持不变。 +- 应用新增跨平台 native dependency 与 macOS 位置用途声明,安装包和签名验证范围扩大。 +- 未来若需要可信设备身份或自动 claim,仍必须另立经过认证的 BLE/制造身份/云契约方案;本决策不能被扩展解释为已解决该问题。 diff --git a/.project-docs/30-worklog/tasks/20260816-robot-hotspot-connect-5e7a91.md b/.project-docs/30-worklog/tasks/20260816-robot-hotspot-connect-5e7a91.md new file mode 100644 index 0000000..8129929 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-robot-hotspot-connect-5e7a91.md @@ -0,0 +1,112 @@ +# Task: Implement in-app Robot hotspot scan and connection on Windows and macOS + +## Identity + +- Task ID: 20260816-robot-hotspot-connect-5e7a91 +- Mode: Feature +- Branch: codex/20260816-robot-hotspot-connect-5e7a91-robot-hotspot-connect +- Worktree: D:\Datas\OthersProjects\makelore-robot-hotspot-connect-5e7a91 +- Base commit: abecd5f34485ab467e5f032c618083d88e34b74d +- Owner: codex-root +- Status: Complete + +## Scope + +- Replace the manual `connect_device_ap` step with in-dialog discovery, explicit selection, connection, and verification of nearby open `Xiaozhi-*` Robot hotspots on both Windows and macOS. +- Add one Main-owned Robot hotspot Module with Windows and macOS Adapters, a strict typed Host interface, safe Renderer projection, permission/error fallbacks, and focused tests. +- Keep the current firmware, fixed portal, Wi-Fi credential entry, Activation, six-digit Binding, and exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` rollback unchanged. +- Add task-scoped decision/evidence records and promotion candidates; do not write accepted ADRs or canonical shared memory from feature mode. + +## Intent And Constraints + +- The user explicitly confirmed simultaneous Windows and macOS delivery, superseding only ADR-002's manual operating-system hotspot-selection step. +- Renderer uses the existing typed Host seam and never invokes native networking, arbitrary IPC, shell commands, interface names, SSIDs, BSSIDs, profiles, or URLs directly. +- Renderer receives bounded `Xiaozhi-*` candidate projections with short-lived opaque candidate IDs. Dialog reset immediately drops all Renderer references; Main invalidates the snapshot on a later scan/explicit clear, after the 60-second TTL, or on process restart. +- Only open Robot hotspots may be connected. Makelore does not accept or retain hotspot/home-network passwords and does not become a general Wi-Fi manager. +- Discovery and connection are convenience signals, not authenticated Robot identity, account claim, Binding, or online readiness. +- Native operations are bounded, non-overlapping, locally handled before Works credentials/upstream access, and fail safely to the existing system-settings/manual path. +- Windows and macOS use separate platform Adapters behind the same deep Module interface; tests replace those Adapters rather than exposing platform details through the Renderer contract. + +## Project Context Loaded + +Task context: +- Task ID: `20260816-robot-hotspot-connect-5e7a91` +- Mode: Feature +- Branch: `codex/20260816-robot-hotspot-connect-5e7a91-robot-hotspot-connect` +- Worktree: `D:\Datas\OthersProjects\makelore-robot-hotspot-connect-5e7a91` +- Base commit: `abecd5f34485ab467e5f032c618083d88e34b74d` +- Other active local tasks: all owners returned by `task_context.py status --json` were checked through their task-record Scope, Intent And Constraints, and Promotion Candidates only. +- Overlap or semantic-conflict assessment: completed Robot onboarding/default-on tasks overlap semantically but do not own this worktree or an in-app Wi-Fi Module. ADR-002 conflicts with automatic discovery, but the user has explicitly confirmed the superseding Windows+macOS product decision. No unresolved peer decision blocks implementation. + +Read: +- `.project-docs/05-agent-entry/read-before-planning.md` +- `.project-docs/05-agent-entry/planning-gate.md` +- `.project-docs/05-agent-entry/memory-index.md` +- `.project-docs/30-worklog/tasks/20260816-robot-hotspot-connect-5e7a91.md` +- `.project-docs/00-brief/project-positioning.md` +- `.project-docs/00-brief/success-criteria.md` +- `.project-docs/30-worklog/current-state.md` +- `.project-docs/10-decisions/decision-index.md` +- `.project-docs/10-decisions/adr-002-robot-guided-hotspot-binding-v1.md` +- `.project-docs/20-architecture/system-overview.md` +- `.project-docs/20-architecture/module-map.md` +- `.project-docs/20-architecture/data-flow.md` +- `.project-docs/40-domain/business-rules.md` +- `.project-docs/40-domain/glossary.md` +- `.project-docs/50-evidence/evidence-index.md` +- `.project-docs/60-reflection/reflection-index.md` +- `.project-docs/80-commitments/commitments.md` +- `.project-docs/90-maintenance/stale-items.md` + +Relevant understanding: +- Project goal: cross-platform Electron product with Robot as the single hardware module and Main ownership of native/system integration. +- Current integrated focus: Guided Hotspot Binding is default-on, firmware-zero-change, manual at the OS hotspot step, then fixed portal and six-digit Binding. +- Active task scope: move only Robot hotspot discovery/selection/connection into the dialog for Windows and macOS. +- Active constraints: no firmware or cloud-contract change; no Wi-Fi credentials; no arbitrary native input; preserve safe fallback and exact rollback. +- Decisions affecting this task: ADR-002 is accepted and must be superseded only at `connect_device_ap`; a new decision proposal is required because public workflow and architecture change. +- Evidence, reflections, or commitments affecting this task: exact shipped firmware, physical-device flow, native opener, and full end-to-end evidence remain release gates; local tests cannot claim physical compatibility. +- Files or modules likely involved: `electron/services/robot-hotspot-*`, `electron/api/routes/ai-hardware.ts`, `src/lib/ai-hardware.ts`, `src/pages/AiHardware/index.tsx`, focused Robot tests, packaging/native assets, task-scoped proposal/evidence. +- Unknowns, stale docs, or conflicts: project-positioning remains a placeholder; Windows and macOS physical permission/association behavior cannot be fully verified from the Windows development host; accepted canonical docs still describe manual OS selection until Integration Gate. + +Gate result: +- Passed. User confirmation resolves the decision change; implementation remains isolated and preserves all unaffected ADR-002 rules. + +## Outcome + +- Decision proposal created at `.project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md`. +- Added a Main-owned Robot Hotspot Module with bounded opaque candidate snapshots, stable safe errors, exclusive native operations, open `Xiaozhi-*` filtering, strongest-SSID deduplication, and exact current-SSID verification. +- Added real Windows `wlanapi.dll` and macOS CoreWLAN/CoreLocation Adapters through lazily loaded Koffi 2.16.3; neither Adapter uses a shell, CLI, persistent Windows Wi-Fi profile, arbitrary Renderer SSID, or firmware change. +- macOS CoreWLAN scan, association, and exact-SSID verification run in a dedicated abortable worker that owns all Objective-C objects. Foreground CoreLocation authorization remains in Main; an 8/12-second Module deadline can terminate a hung worker without freezing Electron Main. A deduplicated termination barrier prevents a replacement operation from creating or messaging another worker until the previous worker has fully exited. +- Added strict local Host scan/connect routes and typed Renderer projections. Both routes remain behind Host authentication and the existing `guidedHotspotBinding` capability, and finish before Works token/upstream access. +- Replaced the manual-only hotspot step with automatic in-dialog scan, explicit selection/connection, signal and connected-state display, safe recovery messages, rescan, async-session guards, and a retained system-Wi-Fi/manual fallback. +- Added macOS location usage descriptions and synchronized `README.md` with the Windows/macOS product behavior. +- Integration runtime smoke found and fixed CommonJS dynamic-import interop: Electron exposes Koffi through the `default` export. The corrected adapters now load under Electron 40.10.6. + +## Verification + +- `pnpm vitest run tests/unit/robot-hotspot-module.test.ts tests/unit/ai-hardware-routes.test.ts tests/unit/ai-hardware-api.test.ts tests/unit/ai-hardware-page.test.tsx`: PASS, 4 files / 132 tests. +- `pnpm test`: PASS on the latest tree, 157 files / 1796 tests. +- `pnpm run typecheck`: PASS. +- `pnpm run lint:check`: PASS with 6 pre-existing warnings and no errors. +- `pnpm run build:vite`: PASS for Renderer, Electron Main, and Preload; existing chunk-size warnings only. +- Electron 40.10.6 runtime probe: Koffi 2.16.3 loaded, `wlanapi.dll` loaded, and the WLAN function definition resolved. +- Windows native scan probe: `WlanOpenHandle` and interface enumeration succeeded; this machine's Windows location setting denied `WlanScan` with native code 5, which the public Module correctly projected as `permission_denied`. No SSIDs were printed or retained by the probe. +- Firmware repository `D:\Datas\HardwareProjects\xiaozhi-esp32-firmware` remains clean at `0449e51dec7e0fad3d4567a7bcfcd354efbe1a87`. +- `git diff --check`: PASS; line-ending notices only. +- Final read-only Sol review: PASS on both Standards and Spec axes with no P0-P3 findings. + +## Follow-ups + +- Run signed packaged macOS x64 and arm64 smoke for location authorization, CoreWLAN scan, open-hotspot association, current SSID verification, and Koffi load. This cannot be proven from the Windows development host. +- Run a Windows physical Robot smoke with location/Wi-Fi access enabled, including multiple Robot candidates and verified connection to the fixed Portal flow. +- Confirm on signed packaged macOS that the bundled `Worker(__filename)` dynamic Main chunk loads from ASAR and Koffi is worker-thread compatible on both x64 and arm64; the build preserves the worker self-entry, but the Windows host cannot execute it. +- Windows waits a bounded four seconds for scan refresh rather than registering WLAN notifications; revisit notification-based completion only if physical evidence shows stale/slow scan results. + +## Promotion Candidates + +- Target canonical documents: `10-decisions/decision-index.md`, a new accepted ADR following ADR-002, `20-architecture/system-overview.md`, `20-architecture/module-map.md`, `20-architecture/data-flow.md`, `30-worklog/current-state.md`, `40-domain/business-rules.md`, `40-domain/glossary.md`, and `50-evidence/evidence-index.md` after platform evidence exists. +- Proposal: promote `.project-docs/10-decisions/proposals/20260816-robot-hotspot-connect-5e7a91__cross-platform-robot-hotspot-connection.md`; it supersedes only ADR-002's manual OS hotspot-selection step and preserves the rest of ADR-002. +- Evidence: implementation/test/build results in this task record; Windows Electron/Koffi/WLAN permission-path smoke; macOS and physical Robot evidence remain outstanding and must not be inferred. +- Future impact: support, security copy, packaging, notarization, and hardware acceptance must distinguish unauthenticated hotspot convenience from Robot identity/Binding/online readiness. +- Semantic conflict: accepted ADR-002 still states OS-level manual hotspot selection and must be updated/superseded during Integration Gate. No other accepted decision is intentionally reversed. +- Human confirmation: not required for this product-direction promotion because the user explicitly confirmed in this task that both Windows and macOS should connect from the page. Human or release-owner confirmation is still required before treating incomplete macOS/physical smoke as accepted release evidence. 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'); + }); +});