fix: retry macOS robot hotspot scans
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# Task: Diagnose Robot hotspot missing from in-app scan
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260817-robot-hotspot-scan-missing-4c7e91
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260817-robot-hotspot-scan-missing-4c7e91-robot-hotspot-scan-missing
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-robot-hotspot-scan-missing-4c7e91
|
||||
- Base commit: 7e8d9e38114158992c03e589de32535274796d04
|
||||
- Owner: codex-root
|
||||
- Status: Complete — bounded macOS SSID-visibility repair implemented and automated; signed-device smoke remains a release follow-up
|
||||
|
||||
## Scope
|
||||
|
||||
- Reproduce the reported gap where the operating-system Wi-Fi surface lists a Robot provisioning hotspot but Makelore's guided binding scan does not.
|
||||
- Trace the full local path from the Windows/macOS native adapter through Robot Hotspot Module filtering, Host projection, and Renderer state without exposing SSIDs or native diagnostics to Renderer/logs.
|
||||
- If a client defect is confirmed, add the narrowest regression test and repair the Makelore scan path while preserving the current firmware, fixed Portal, six-digit Binding, manual system-Wi-Fi fallback, and exact environment rollback.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Use a tight deterministic feedback loop that can fail for the same native candidate shape before changing production code.
|
||||
- Do not edit `D:\Datas\HardwareProjects\xiaozhi-esp32-firmware`; the user prefers firmware-zero-change and the current accepted ADR preserves that boundary.
|
||||
- Do not broaden Makelore into a general Wi-Fi manager or expose BSSID, interface/profile details, location data, credentials, or raw native errors beyond Electron Main.
|
||||
- Preserve explicit user selection, bounded operations, opaque short-lived candidate IDs, exact-current-SSID verification, and the system Wi-Fi fallback.
|
||||
- Treat physical adapter behavior and the exact shipped macOS package as unverified until observed; do not infer hardware acceptance from unit tests. The user has now confirmed macOS and a case-correct `Xiaozhi-` hotspot visible in the system Wi-Fi surface.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: `20260817-robot-hotspot-scan-missing-4c7e91`
|
||||
- Mode: Feature
|
||||
- Branch: `codex/20260817-robot-hotspot-scan-missing-4c7e91-robot-hotspot-scan-missing`
|
||||
- Worktree: `D:\Datas\OthersProjects\makelore-robot-hotspot-scan-missing-4c7e91`
|
||||
- Base commit: `7e8d9e38114158992c03e589de32535274796d04`
|
||||
- Other active local tasks: the long-running `main` integration owner and all feature owners remain in separate registered worktrees. Planning-status peer records were read; the only Robot-hotspot implementation task is already integrated/complete, while the active multi-chat, Canvas-error, account-lifecycle, and compaction scopes do not overlap this task.
|
||||
- Overlap or semantic-conflict assessment: no peer owns this worktree. This task follows ADR-003 and investigates an unverified physical-device release gate rather than changing its product direction. The old integration task still owns `main`, so this feature remains isolated.
|
||||
|
||||
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`
|
||||
- this active task record
|
||||
- `.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-003-robot-in-app-hotspot-connection.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/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 peer task records for the `main` integration and Robot hotspot implementation, plus all other planning-status owners
|
||||
|
||||
Relevant understanding:
|
||||
- Project goal: keep Robot as one cross-platform Electron module whose native/system and cloud boundaries are owned by Main.
|
||||
- Current integrated focus: Guided Hotspot Binding is default-on and should list nearby eligible open `Xiaozhi-*` hotspots in-page, with system Wi-Fi as fallback.
|
||||
- Active task scope: reproduce and correct a system-visible but application-missing Robot hotspot without changing firmware or Binding contracts.
|
||||
- Active constraints: Main-only native access, no Wi-Fi credentials or native identifiers in Renderer, explicit selection, bounded scan/connect, safe fallback, exact `=0` rollback.
|
||||
- Decisions affecting this task: ADR-003 accepts Windows WLAN/macOS CoreWLAN page-owned scan and requires open/connectable/printable `Xiaozhi-*` candidates; changing that contract requires evidence, not assumption.
|
||||
- Evidence, reflections, or commitments affecting this task: existing automation passed, but Windows physical Robot, signed macOS, exact shipped firmware, and real Host/native E2E remain pending; this report is physical evidence that the current discovery path may be incomplete.
|
||||
- Files or modules likely involved: `electron/robot-hotspot/{index,windows,macos,adapter}.ts`, `electron/api/routes/ai-hardware.ts`, `src/lib/ai-hardware.ts`, `src/pages/AiHardware/index.tsx`, and focused Robot hotspot/API/page tests.
|
||||
- Unknowns, stale docs, or conflicts: the physical CoreWLAN result, installed package signature/Info.plist, macOS version, and current Location authorization state are not directly observable on this Windows host; `project-positioning.md` remains a canonical placeholder; no accepted decision conflicts with diagnosing or repairing this gap.
|
||||
|
||||
Gate result:
|
||||
- Passed.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Reproduced the reported class of failure on the Windows development machine through the real `wlanapi.dll` Adapter followed by the production Robot Hotspot Module: an Adapter scan returned one open/connectable candidate while the Module projected zero candidates because none matched the exact `Xiaozhi-` filter. The diagnostic emitted aggregate counts only and was removed after use.
|
||||
- Confirmed Host, Renderer facade, and page do not silently transform a valid non-empty candidate response into an empty list. Empty UI state originates at the native/Module candidate boundary unless a deliberately superseded Renderer session ignores a late result.
|
||||
- Confirmed current firmware source at `0449e51` configures a visible open `Xiaozhi-XXXX` SoftAP through `WifiBoard` and therefore matches the accepted filter. This source fact does not prove which image is installed on the user's device.
|
||||
- The user subsequently confirmed the affected computer is macOS and the system-visible hotspot uses the exact case-sensitive `Xiaozhi-` prefix. This rules out the Windows scan path and the known prefix mismatch class for this incident.
|
||||
- Confirmed the macOS worker currently converts a non-empty CoreWLAN result whose `CWNetwork.ssid` values are all unavailable into a normal empty candidate list. On current macOS releases SSID access is gated by the app's Location privilege, and Apple DTS has documented a first-read-after-authorization case where SSID is initially unavailable but a later read succeeds. The current behavior therefore conflates an authorization/visibility condition with a genuine empty scan.
|
||||
- Confirmed a second Windows-specific gap remains plausible: `WlanScan` is asynchronous, the implementation waits a fixed four seconds and reads the available-network list once, while Microsoft documents that the driver may flush the prior list and recommends scan-completion notification or timeout. The physical report is consistent with stale/late scan data but does not distinguish it from SSID/security/connectability filtering.
|
||||
- Applied a narrow macOS-only repair. The CoreWLAN worker now performs exactly one delayed 250 ms rescan when the raw result is empty or every returned network has an unavailable SSID. A recovered open candidate continues through the unchanged Module filter; two genuinely empty scans still return an empty list; and a second non-empty scan with no readable SSIDs returns the existing fixed `permission_denied` result instead of the misleading empty state.
|
||||
- The fix keeps CoreWLAN/Objective-C work on the existing worker thread and preserves the open-only, printable `Xiaozhi-*`, opaque-candidate, exact-current-SSID, Host DTO, Renderer, manual-fallback, and firmware-zero-change contracts. No SSID or native diagnostic was added to logs or error payloads.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm vitest run tests/unit/robot-hotspot-module.test.ts`: PASS, 1 file / 10 tests.
|
||||
- Temporary real-Windows aggregate probe through `createWindowsRobotHotspotAdapter()` and `createRobotHotspotModule('windows', adapter)`: PASS as a diagnostic, observing `openConnectableCount=1`, exact/case-insensitive `Xiaozhi-` count `0`, and projected count `0`; no SSID/BSSID/native identifier was printed. The temporary test file was deleted.
|
||||
- Read-only firmware inspection: HEAD `0449e51`; `WifiBoard` sets `ssid_prefix="Xiaozhi"`, the pinned component source generates `Xiaozhi-` plus four uppercase hex digits and configures `WIFI_AUTH_OPEN`; no repository file was modified.
|
||||
- Microsoft `WlanScan` and Apple CoreWLAN security semantics were checked against official documentation. macOS `CWSecurity.none` / value `0` correctly means open-system authentication.
|
||||
- The same focused baseline was rerun after the macOS/prefix clarification and remained PASS, 1 file / 10 tests, before the new regression was introduced.
|
||||
- TDD RED: the four new macOS projection/retry cases initially failed while the existing 10 cases passed.
|
||||
- `pnpm exec vitest run tests/unit/robot-hotspot-module.test.ts --reporter=dot`: PASS, 1 file / 14 tests.
|
||||
- `pnpm run typecheck`: PASS.
|
||||
- `pnpm exec eslint electron/robot-hotspot/macos.ts tests/unit/robot-hotspot-module.test.ts`: PASS.
|
||||
- `pnpm run build:vite`: PASS for Renderer, Main, and Preload; only the existing mixed-import and chunk-size warnings remained. The emitted macOS Main chunk retains the worker branch, one 250 ms `Atomics.wait`, bounded retry, and fixed `permission_denied` mapping.
|
||||
- `git diff --check`: PASS.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Validate on a signed macOS package with Location access granted: revoke/regrant Location permission, enter Guided Binding, and confirm the physical `Xiaozhi-*` hotspot is listed and connects. This Windows host cannot execute CoreWLAN or prove physical discovery/association.
|
||||
- If the signed package still reports `permission_denied`, inspect the actual built app's Info.plist/TCC authorization and signing entitlements on macOS before adding capabilities. The repository already supplies both macOS location usage-description keys; Apple documentation does not establish the iOS-oriented Wi-Fi Information entitlement as a CoreWLAN scan requirement, so this task does not add speculative signing capabilities.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. The current result is task-scoped diagnostic evidence; no product behavior, accepted decision, or canonical integrated fact changed.
|
||||
@@ -10,6 +10,7 @@ const LOCATION_AUTHORIZED_ALWAYS = 3;
|
||||
const LOCATION_AUTHORIZED_WHEN_IN_USE = 4;
|
||||
const CORE_WLAN_PERMISSION_DENIED = -3930;
|
||||
const CORE_WLAN_UNSUPPORTED = -3903;
|
||||
const SCAN_RETRY_DELAY_MS = 250;
|
||||
|
||||
type StableReason = 'unsupported' | 'permission_denied' | 'scan_failed' | 'connect_failed';
|
||||
type ObjcId = unknown;
|
||||
@@ -21,6 +22,36 @@ interface WorkerCandidate {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface MacosScanNetwork {
|
||||
ssid: string | null;
|
||||
rssi: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function scanMacosCandidatesWithRetry(
|
||||
readNetworks: () => MacosScanNetwork[],
|
||||
currentSsid: string | null,
|
||||
waitBeforeRetry: () => void,
|
||||
): WorkerCandidate[] {
|
||||
let networks = readNetworks();
|
||||
if (networks.length === 0 || networks.every((network) => !network.ssid)) {
|
||||
waitBeforeRetry();
|
||||
networks = readNetworks();
|
||||
}
|
||||
if (networks.length > 0 && networks.every((network) => !network.ssid)) {
|
||||
throw new RobotHotspotAdapterError('permission_denied');
|
||||
}
|
||||
return networks.flatMap((network): WorkerCandidate[] => {
|
||||
if (!network.open || !network.ssid) return [];
|
||||
return [{
|
||||
ssid: network.ssid,
|
||||
signalPercent: Math.max(0, Math.min(100, Math.round((network.rssi + 100) * 2))),
|
||||
connected: currentSsid === network.ssid,
|
||||
open: true,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -219,13 +250,16 @@ async function runCoreWlanWorker(): Promise<void> {
|
||||
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 }];
|
||||
});
|
||||
const readNetworks = (): MacosScanNetwork[] => scanNetworks('scan').map((network) => ({
|
||||
ssid: stringValue(sendId(network, selectors.ssid)),
|
||||
rssi: Number(sendLong(network, selectors.rssiValue)),
|
||||
open: sendSupportsSecurity(network, selectors.supportsSecurity, 0),
|
||||
}));
|
||||
return scanMacosCandidatesWithRetry(
|
||||
readNetworks,
|
||||
current,
|
||||
() => { Atomics.wait(pollSleep, 0, 0, SCAN_RETRY_DELAY_MS); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { MacosWorkerController, requestMacosWorker, scanMacosCandidatesWithRetry } from '@electron/robot-hotspot/macos';
|
||||
import type { MacosWorkerLike } from '@electron/robot-hotspot/macos';
|
||||
|
||||
function candidate(ssid: string, signalPercent: number, overrides: Partial<RobotHotspotAdapterCandidate> = {}): RobotHotspotAdapterCandidate {
|
||||
@@ -22,6 +22,62 @@ async function expectCode(promise: Promise<unknown>, code: string): Promise<void
|
||||
}
|
||||
|
||||
describe('Robot hotspot module', () => {
|
||||
it('retries once when the first macOS scan hides every SSID and returns the recovered open hotspot', () => {
|
||||
const readNetworks = vi.fn()
|
||||
.mockReturnValueOnce([
|
||||
{ ssid: null, rssi: -80, open: true },
|
||||
{ ssid: '', rssi: -60, open: true },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ ssid: 'Xiaozhi-7A2B', rssi: -58, open: true },
|
||||
{ ssid: 'Private-Network', rssi: -42, open: false },
|
||||
]);
|
||||
const waitBeforeRetry = vi.fn();
|
||||
|
||||
expect(scanMacosCandidatesWithRetry(readNetworks, 'Xiaozhi-7A2B', waitBeforeRetry)).toEqual([
|
||||
{ ssid: 'Xiaozhi-7A2B', signalPercent: 84, connected: true, open: true },
|
||||
]);
|
||||
expect(readNetworks).toHaveBeenCalledTimes(2);
|
||||
expect(waitBeforeRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports permission denied when both macOS scans find networks but expose no SSIDs', () => {
|
||||
const readNetworks = vi.fn()
|
||||
.mockReturnValueOnce([{ ssid: null, rssi: -70, open: true }])
|
||||
.mockReturnValueOnce([{ ssid: '', rssi: -65, open: true }]);
|
||||
|
||||
expect(() => scanMacosCandidatesWithRetry(readNetworks, null, vi.fn())).toThrowError(
|
||||
expect.objectContaining({ reason: 'permission_denied' }),
|
||||
);
|
||||
expect(readNetworks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bounds an empty macOS scan retry and keeps an empty result empty', () => {
|
||||
const readNetworks = vi.fn().mockReturnValue([]);
|
||||
const waitBeforeRetry = vi.fn();
|
||||
|
||||
expect(scanMacosCandidatesWithRetry(readNetworks, null, waitBeforeRetry)).toEqual([]);
|
||||
expect(readNetworks).toHaveBeenCalledTimes(2);
|
||||
expect(waitBeforeRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not retry a readable macOS scan and filters secured networks while clamping RSSI', () => {
|
||||
const readNetworks = vi.fn().mockReturnValue([
|
||||
{ ssid: 'Xiaozhi-weak', rssi: -120, open: true },
|
||||
{ ssid: 'Xiaozhi-strong', rssi: -20, open: true },
|
||||
{ ssid: 'Xiaozhi-secured', rssi: -50, open: false },
|
||||
{ ssid: null, rssi: -40, open: true },
|
||||
]);
|
||||
const waitBeforeRetry = vi.fn();
|
||||
|
||||
expect(scanMacosCandidatesWithRetry(readNetworks, 'Xiaozhi-strong', waitBeforeRetry)).toEqual([
|
||||
{ ssid: 'Xiaozhi-weak', signalPercent: 0, connected: false, open: true },
|
||||
{ ssid: 'Xiaozhi-strong', signalPercent: 100, connected: true, open: true },
|
||||
]);
|
||||
expect(readNetworks).toHaveBeenCalledOnce();
|
||||
expect(waitBeforeRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('terminates a hanging macOS native worker when the request is aborted', async () => {
|
||||
const worker = {
|
||||
postMessage: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user