Skip to content

feat: 点击蓝牙设备行连接/断开 + 面板列表打磨 / device row actions and list polish - #62

Merged
lingyired merged 19 commits into
mainfrom
feat/bluetooth-device-actions
Sep 22, 2026
Merged

lingyired merged 19 commits into
mainfrom
feat/bluetooth-device-actions

Conversation

@lingyired

@lingyired lingyired commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

概要 / Summary

状态面板(以及设置里的同一套行)里的蓝牙行现在可点击:点未连接的设备即连接,点已连接的设备即断开。键鼠类设备(键盘/鼠标/触控板/手柄)的断开会先在该行内确认——面板是 transient 的,弹窗会把面板关掉,所以确认做在行内。

不做乐观翻转:指令的返回值只表示系统是否接受,行的连接状态仍然来自设备报告;只有报告变成目标状态才算动作完成。被拒绝的指令、以及 10 秒内报告仍未变化的指令,都会显示为可见的失败(约 4 秒后清除),失败的行可以再点一次重试。

Tapping a paired device in the Bluetooth list now connects it; tapping a connected one disconnects it. Disconnecting an input device is confirmed in the row itself, because the panel is transient and a modal would close it. Nothing flips optimistically: the request only decides whether the system accepted the command, the row's state still comes from the device report, and an action settles only when that report changes. A refused request, or one not visible within ten seconds, becomes a visible failure for about four seconds; a failed row can be tapped again to retry.

  • 设计 / Design: docs/superpowers/specs/2026-09-22-bluetooth-device-actions-design.md
  • 计划 / Plan: docs/superpowers/plans/2026-09-22-bluetooth-device-actions.md
  • 行为文档 / Behaviour: docs/bluetooth-status.md

变更 / What changed

设备动作

  • BluetoothDeviceActionPerformer(新):IOBluetoothDevice.openConnection() / closeConnection() 是同步调用、设备不在范围内可能阻塞到 page timeout,因此只在私有队列上调用;设备按规范化地址匹配(IOBluetooth 仍会报改名前的缓存名,名字无法与报告对齐)。
  • BluetoothDeviceAction / BluetoothDeviceActionState / BluetoothDeviceRowStatus / BluetoothDeviceActionPolicy(新):动作、状态与判定规则的纯函数。
  • BluetoothDeviceController:按地址维护动作状态 + 单调递增的请求令牌(只有当前请求能决定结果)、10 秒超时、约 4 秒可见失败、失败可重试、deactivate() 清理并作废未完成请求。
  • BluetoothDeviceRow:输入驱动的可点行(进行中 / 失败 / 行内确认三态)。

确认态的归属(真机验证后修正)

  • 确认态由 controller 持有,SystemStatusStore.setPopoverVisible(false) 在关闭面板时清除它。原因:弹层关闭时 StatusBarController 会刻意保留内容视图 controller 一分钟以便快速重开,视图从未离开层级,所以视图自己的 onDisappear 根本不会触发——最初基于该钩子的实现会在重开时仍挂着确认提示。这条规则现在被单测钉住。

行的呈现(照音量输出列表的选中态)

  • 已连接=图标加强调色圆圈底、名称半粗体、右侧对勾;不再写出「已连接/未连接」文字,状态改由行的无障碍 value 承载(颜色与对勾都不是唯一指示);进行中/失败仍写成文字,由纯函数 BluetoothDeviceRowStatus.drawsText 钉住。

权限被拒时可直接前往授权

  • 权限被拒时整行变成按钮,打开 系统设置 › 隐私与安全性 › 蓝牙(bluetoothPermissionSettingsURLs),不是齿轮那条「蓝牙设置」(那条是开关蓝牙)。文案改为动作句(12 语言),键名相应改为 bluetooth.action.openPermissionSettings。
  • 与 Wi‑Fi 有一处故意不同:restricted(MDM/家长控制)保持不可点的陈述句——用户自己也授不了权,给按钮是空头承诺。

详情页移除、长列表有界

  • 蓝牙详情页已删除(面板内联列表 + 「展开」已覆盖同一信息):摘要行不再是导航按钮,> 提示去掉,详情页独有的手动刷新移到齿轮旁、电量读取失败提示移到列表下方。
  • 设备行超过上限时在面板内有界滚动(330pt,与 Wi‑Fi 列表同值);放得下时完全不创建 ScrollView(否则展开/收起动画会闪一下滚动条)。展开/收起按钮在滚动区之外。

验证 / Verification

  • swift test → 743 XCTest(6 跳过,0 失败)+ 180 Swift Testing / 28 suites 全绿
  • swift build -c release → Build complete;scripts/check-forbidden-patterns.sh → clean
  • VERSION=1.3.0 BUILD=27 PUBLISH=false bash scripts/validate-appcast-notes.sh → 12 titles and 12 descriptions, en first
  • 非发布 release 预检(publish=false)通过
  • 真机探针(一次性、产物已弃):两个方向都返回 kIOReturnSuccess、约 0.5s 生效、系统不自动回连、连接/断开通知正常
  • 真机手动验证:连接 / 断开 / 键鼠确认与取消 / 关闭面板后确认态消失 / 失败可见与重试 / 长列表滚动与收起动画

审查 / Review

每任务一次独立审查(spec 合规 + 质量)后才进入下一个,最后整支审查 + 唯一一次修复波 + scoped 重审(7/7 ADDRESSED)。真机验证后又修掉三处只有实机能暴露的问题:

  1. 确认态随面板关闭存活(弹层保留内容视图)→ 改由 controller 持有并在关闭时清除,并补上此前缺失的自动化测试。
  2. 已结束动作的迟到回执画出 4 秒假失败 → finishDeviceAction 同时清掉令牌,且完成回调与超时一样要求动作仍在进行中。
  3. 收起时短暂闪出滚动条 → 未超过上限时不再创建 ScrollView。

已知限制 / Known limitations

  • 渲染/滚动断言部分依赖 SwiftUI 与 AppKit 的内部结构(focus-ring 宿主类、NSScrollView):失败方向是响亮可诊断的;「滚动视图是否真的溢出」在离屏夹具里观测不到,测试钉的是可观测的部分(滚动视图是否存在、是否停在上限、面板总高是否在预算内),并在注释里写明。
  • IOBluetooth 路径只在 macOS 27 本机验证过(最低支持 macOS 15);失败会如实显示为失败,这是设计要求。
  • 整机蓝牙 radio 开关只有私有 API,因此不做。

The popover keeps its content view controller after a close so a reopen is
cheap, so the device rows and their `confirmingAddress` @State survive the
close. Both parents now clear the confirmation in `onDisappear`, the way the
Bluetooth surfaces already release their claims there, so closing the panel
cancels the prompt instead of leaving it armed for the next open.

The confirming flag is also keyed on the action it offers, not the bare
address: a device whose state changes while the prompt is open no longer
shows a red Disconnect button that would run a connect.

The row layout tests can no longer pass for the wrong reason. They count the
rendered focus-ring hosts (the confirming row's Disconnect and Cancel versus
the plain row's one button), assert the phrase's ink is drawn left of
Disconnect, and count the in-flight row's NSProgressIndicator. The
accessibility tree carries nothing in this harness, so the checks go to the
AppKit views SwiftUI does create. The two dead width assertions are gone:
the helper applies `.frame(width: 330)` itself.
Apply the whole-branch review findings:

- Drop the action token when an action settles, and require the action to
  still be in flight before a refusal can fail it. A device that reconnects
  on its own can otherwise have its redundant command answer "refused"
  after the report settled, painting a false "Could not connect".
- Shorten the German in-flight labels to "Verbinden…"/"Trennen…" so they
  fit the row, matching that file's compact state phrasing.
- Use the Arabic file's own "اتصال" for the connect action and the 1.3.0
  release note, including the section heading.
- Align the zh-Hans release-note heading with "状态面板".
- Justify `@unchecked Sendable` on the IOBluetooth action performer.
- Document the performer's serial queue, and record the two accepted spec
  deviations (eight keys, nameless confirm prompt) in the design spec.

Adds a regression test for the late-refusal-after-settle race.
The confirmation lived in each surface's own `@State`, and the fix for the
retained popover content was a view `onDisappear`. That hook never runs when the
panel is closed from the summary: `StatusBarController` keeps the content view
controller alive for a minute so a reopen is cheap, so the view never leaves its
hierarchy and the unanswered prompt was still open on the next open. Reproduced
on the real app, and now by
`testPopoverClosingCancelsAPendingBluetoothConfirmation`.

`BluetoothDeviceController` owns the pending confirmation, and
`SystemStatusStore.setPopoverVisible(false)` cancels it — the same place the app
already stops the battery page and releases the surface claim. Both surfaces read
it instead of holding it, so the question cannot outlive the panel that asked it.
The behaviour doc and the spec's 后续修订 record the change and why a
view-lifecycle hook could not work.
A connected device is now drawn the way the volume output list draws the device
in use: its glyph ringed in the accent colour in the section's own 24-point icon
column, its name in semibold, and a checkmark after it. The words "Connected" and
"Not connected" are gone from the row, so its trailing space belongs to the
battery level and to what an action is doing — and the state was not dropped from
the accessibility tree with the word: the row's accessibility value still carries
it, and the checkmark is not the only indicator.

`BluetoothDeviceRowStatus.drawsText` states the rule the row follows (a resting
state is told by appearance, an action in flight or a failure is written out) so
it is pinned by a test rather than by a view body.

The Bluetooth summary row also loses its disclosure chevron: the panel's inline
list already shows what the detail page showed, so the chevron advertised a
second surface that no longer adds anything. The row still opens it.
The panel's list shows the same paired devices the detail page showed, and its
expansion control covers the ones the limit hides, so the page only repeated it.
Removing it also removes the chevron that advertised it.

What the page offered besides does not go with it:

- its manual refresh button moves into the row, beside the gear;
- its one-line report of a level read that failed moves under the list, where the
  levels are, so a failed read is still never silent.

`SystemStatusStore` loses `isBluetoothDetailsOpen`, `openBluetoothDetails()` and
`closeBluetoothDetails()`, and `StatusPopoverView` its `.bluetooth` panel case;
`hasOpenPopoverPanel` now covers only the Wi-Fi and battery pages. The row is no
longer a button, so `BluetoothStatusView` loses `onOpenDetails` and
`BluetoothDeviceListView` becomes `BluetoothStatusView`, which is what the file
now holds.

`BluetoothBatteryLevelHandoffTests` keeps its regression by standing a
second surface in for the page: what it pins is that one surface's release cannot
drop a claim another is holding, which outlives the page that used to exercise it.

The behaviour doc, the README sentence that listed the Bluetooth page among the
rows that open one, and the 1.3.0 note that called the list a "device page" all
follow. The spec's 后续修订 records the removal and the two UI decisions taken
after it was written.
The other rows end on their disclosure chevron itself, so its right edge is what
sits ten points before the gear. The refresh button reserved a 24-point box and
centred its glyph in it, which put the glyph about seven points to the left of
that column and made the panel's right edge look ragged. Trailing-aligning the
glyph inside its own box lines the two up (measured: the glyph now lands within
half a point of where a chevron's ink ends) while the whole box stays clickable.
The other rows end on a `.tertiary` chevron with the `.secondary` gear after it;
the refresh button carried `.secondary`, so it read a shade darker than the
column it now shares (measured ink: 0.490 against the gear's 0.478 before, 0.714
after). The trailing column of the panel is now one thing top to bottom.
…s it back

The row used to state that the permission was denied and stop there. It now
carries the same action the Wi-Fi row carries for location: the row is a button
while its state has somewhere to send the user, and for a refused grant that is
Privacy & Security › Bluetooth — deliberately not the pane the gear opens, which
turns the radio on and off.

Which states are tappable is a pure rule (`BluetoothSummary.rowAction`), because
the row now renders either a button or a plain block from it, and both the
subtitle's affordance and the row's own action follow it. A *restricted* grant is
left out on purpose: a managed Mac or parental controls leave the user nothing to
change, so offering a way there would promise something the system will not keep.

The copy follows each language's Wi-Fi location action, and
`bluetooth.authorizationDenied` is renamed to
`bluetooth.action.openPermissionSettings`, so the key says what it holds rather
than reading as a statement of fact. The behaviour doc's state table and
permission section record the destination, and the 1.3.0 notes gain a line in all
12 languages.
The summary popover has no scroll view of its own: it sizes to its content. With
the list expanded, or with the limit raised to 20, a Mac with many paired devices
would keep growing the popover past the screen, where a popover cannot scroll
itself out of trouble.

The rows now stop at 330 points and scroll beyond that — the bound the Wi-Fi list
already uses — so the panel's height no longer depends on how many devices the
report carries: 30 rendered rows measure 390 points against 236 for 3, which is
the bound plus the row's own chrome rather than 27 more rows. The expansion
control sits outside the scroll region on purpose, so collapsing a long list
never needs a scroll to the bottom first, and the panel's existing scroll-wheel
handling already leaves a pointer over an `NSScrollView` to that view instead of
adjusting the volume.

Whether the scroll view overflows cannot be seen from the render harness (it
hosts with no window, so the content rect it reports is already clipped), so the
test pins what is visible from there: a scroll view exists, it stops at the
bound, and the panel's total height stays inside its budget.
The bound from the previous commit put every list inside a scroll view, and on a
Mac with six devices the scroller flashed whenever an expansion animated: the
animated frame shrinks through the moment the content is still taller than it, so
a list that never needed scrolling briefly looked scrollable.

The scroll view now exists only past the bound. One row is exactly 26 points tall
— the badge sets the height, the name is a single line — so the list can tell
whether it fits by division rather than by measuring: twelve rows are inside the
bound and are laid out directly, thirteen scroll. The behaviour doc records both
halves, and the tests render 5, 6 and 12 rows and assert there is no scroll view
at all, then 13 and 30 and assert there is one, still stopping at the bound.
@lingyired lingyired changed the title feat: 点击蓝牙设备行连接/断开 / connect or disconnect a device from its row feat: 点击蓝牙设备行连接/断开 + 面板列表打磨 / device row actions and list polish Sep 22, 2026
@lingyired
lingyired merged commit e73411c into main Sep 22, 2026
1 check passed
@lingyired
lingyired deleted the feat/bluetooth-device-actions branch September 22, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant