feat: 点击蓝牙设备行连接/断开 + 面板列表打磨 / device row actions and list polish - #62
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要 / 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.
docs/superpowers/specs/2026-09-22-bluetooth-device-actions-design.mddocs/superpowers/plans/2026-09-22-bluetooth-device-actions.mddocs/bluetooth-status.md变更 / What changed
设备动作
BluetoothDeviceActionPerformer(新):IOBluetoothDevice.openConnection()/closeConnection()是同步调用、设备不在范围内可能阻塞到 page timeout,因此只在私有队列上调用;设备按规范化地址匹配(IOBluetooth 仍会报改名前的缓存名,名字无法与报告对齐)。BluetoothDeviceAction/BluetoothDeviceActionState/BluetoothDeviceRowStatus/BluetoothDeviceActionPolicy(新):动作、状态与判定规则的纯函数。BluetoothDeviceController:按地址维护动作状态 + 单调递增的请求令牌(只有当前请求能决定结果)、10 秒超时、约 4 秒可见失败、失败可重试、deactivate()清理并作废未完成请求。BluetoothDeviceRow:输入驱动的可点行(进行中 / 失败 / 行内确认三态)。确认态的归属(真机验证后修正)
SystemStatusStore.setPopoverVisible(false)在关闭面板时清除它。原因:弹层关闭时StatusBarController会刻意保留内容视图 controller 一分钟以便快速重开,视图从未离开层级,所以视图自己的onDisappear根本不会触发——最初基于该钩子的实现会在重开时仍挂着确认提示。这条规则现在被单测钉住。行的呈现(照音量输出列表的选中态)
BluetoothDeviceRowStatus.drawsText钉住。权限被拒时可直接前往授权
bluetoothPermissionSettingsURLs),不是齿轮那条「蓝牙设置」(那条是开关蓝牙)。文案改为动作句(12 语言),键名相应改为bluetooth.action.openPermissionSettings。restricted(MDM/家长控制)保持不可点的陈述句——用户自己也授不了权,给按钮是空头承诺。详情页移除、长列表有界
>提示去掉,详情页独有的手动刷新移到齿轮旁、电量读取失败提示移到列表下方。验证 / Verification
swift test→ 743 XCTest(6 跳过,0 失败)+ 180 Swift Testing / 28 suites 全绿swift build -c release→ Build complete;scripts/check-forbidden-patterns.sh→ cleanVERSION=1.3.0 BUILD=27 PUBLISH=false bash scripts/validate-appcast-notes.sh→12 titles and 12 descriptions, en firstpublish=false)通过kIOReturnSuccess、约 0.5s 生效、系统不自动回连、连接/断开通知正常审查 / Review
每任务一次独立审查(spec 合规 + 质量)后才进入下一个,最后整支审查 + 唯一一次修复波 + scoped 重审(7/7 ADDRESSED)。真机验证后又修掉三处只有实机能暴露的问题:
finishDeviceAction同时清掉令牌,且完成回调与超时一样要求动作仍在进行中。已知限制 / Known limitations
NSScrollView):失败方向是响亮可诊断的;「滚动视图是否真的溢出」在离屏夹具里观测不到,测试钉的是可观测的部分(滚动视图是否存在、是否停在上限、面板总高是否在预算内),并在注释里写明。IOBluetooth路径只在 macOS 27 本机验证过(最低支持 macOS 15);失败会如实显示为失败,这是设计要求。