feat: Sidecar portrait mode + fix blank menu bar panel on macOS 26 - #9
Open
kimberlake wants to merge 2 commits into
Open
feat: Sidecar portrait mode + fix blank menu bar panel on macOS 26#9kimberlake wants to merge 2 commits into
kimberlake wants to merge 2 commits into
Conversation
现象:菜单栏图标正常显示,点开后面板是空的,什么都不渲染。 原因:面板内容是一个 ScrollView,而它唯一的高度约束是 `.frame(maxHeight: 700)` —— 那是「最大值」,不是高度。`MenuBarExtra(.window)` 提出的是不确定尺寸, 未加约束的 ScrollView 拿到不确定提议后会塌成 0 高,于是整块内容都不见了。 修复:给滚动内容一个确定的宽度并加 `.fixedSize(vertical:)`,让它报出真实高度; 给 ScrollView 一个 `minHeight/maxHeight` 区间;外层 VStack 的 `.frame(maxHeight:)` 改为 `.fixedSize(vertical:)`,让面板按内容撑开。 除了面板能正常渲染之外没有其他行为变化:宽度不变,内容仍可滚动, 640 的上限保证显示器很多时不会撑出屏幕。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sidecar 显示器本身无法旋转:`CGDisplayRotation` 只读,系统设置里没有 Rotation 选项, 旧的 `IOFBTransform` 方案在 Apple Silicon 上也已失效(M4 Pro 上 `IODisplayConnect` 服务数为 0,显示器改由 DCP 驱动)。物理转动 iPad 时 iPadOS 自行旋转、而 macOS 仍输出 横向 framebuffer,画面会被裁掉 —— 这是 iPadOS 的老问题。 所以不旋转 Sidecar 屏,改为:建一块宽高对调的虚拟屏 → ScreenCaptureKit 抓取 → CoreImage 旋转 90° → 渲染到钉在 Sidecar 屏上的无边框全屏窗口。iPad 全程锁横屏, 只显示已经转好的画面;用户把窗口拖到那块虚拟竖屏上。方案同 BetterDisplay: https://github.com/waydabber/BetterDisplay/wiki/Rotated-Sidecar 几个关键坑(代码里有详细注释): - macOS 会持久化镜像配置,新建的虚拟屏会被自动并入旧的镜像组,变成 online 但不 active,`SCShareableContent` 里看不到 → 启动时显式解除镜像 - Sidecar 的 displayID 每次重连都变 → 监听 `didChangeScreenParametersNotification` - 光标会跑到只显示输出窗口的物理 Sidecar 屏上 → 把它停在只共享一个「角」的位置 (macOS 仅允许光标穿过共享「边」的显示器) - `arrangeExternalAboveBuiltin()` 会在 500ms 后把上述停放覆盖掉 → 运行期间排除本 功能管理的两块屏 - 刚建好的虚拟屏不会立刻出现在 `SCShareableContent` 里 → 轮询 开关、方向、停放前的原始位置都会持久化,退出重开自动恢复;自动恢复只尝试一次, 失败即停手并在界面报错。需要屏幕录制权限,界面上以清单列出前置条件。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Two commits: one fixes the menu bar panel rendering empty on macOS 26, one adds Sidecar
portrait mode. They're independent — the fix can be cherry-picked on its own.
1.
fix:menu bar panel is completely blank on macOS 26On macOS 26 the menu bar icon appears, but clicking it opens an empty panel — the app is
effectively unusable.
Cause: the panel content is a
ScrollViewwhose only height constraint is.frame(maxHeight: 700)— a maximum, not a height.MenuBarExtra(.window)proposes anindefinite size, and an unconstrained
ScrollViewgiven an indefinite proposal collapsesto zero height.
One file,
MenuBarView.swift, +6 −1: give the scroll content a definite width plus.fixedSize(vertical:), give theScrollViewa min/max height range, and change the outer.frame(maxHeight:)to.fixedSize(vertical:). No behaviour change beyond the panelactually rendering.
If you only want this fix, that commit stands alone.
2.
feat:Sidecar portrait modeUse an iPad as a vertical display.
A Sidecar display can't be rotated:
CGDisplayRotationis read-only, System Settings showsno Rotation control for Sidecar, and the old
IOFBTransformapproach is dead on AppleSilicon (zero
IODisplayConnectservices on an M4 Pro — displays are driven through DCP).Physically turning the iPad makes iPadOS reorient while macOS keeps sending a landscape
framebuffer, so the picture gets cropped — a known iPadOS issue, which BetterDisplay's
author describes the same way.
So this doesn't rotate the Sidecar display. Instead:
The iPad stays locked in landscape and just shows already-rotated content. You drag windows
onto the virtual portrait display; the physical Sidecar display becomes a passive output
surface. Same approach as
BetterDisplay's rotated Sidecar.
Reuses code deleted in Phase 21
ScreenCaptureService/StreamViewModel/StreamWindoware restored from0e5fb05.Not restored:
StreamControlView(the streaming/PiP UI) andRotationService(uses thedead
IOFBTransformpath). Only the parts this feature actually needs — the features Phase21 cut stay cut.
Traps hit along the way (all commented in the code)
Sidecar display, every newly created virtual display gets folded into that remembered
mirror set automatically. A mirrored secondary is online but not active, never appears
in
SCShareableContent, and so can't be captured — surfacing as a baffling "target displaynot found". The service now breaks the mirroring explicitly at startup. Diagnosis tip:
compare
CGGetOnlineDisplayListagainstCGGetActiveDisplayList.displayIDchanges on every reconnect (I watched it go7→8→9→12→19→24→30→32 in one debugging session). Locking the iPad's orientation or toggling
mirroring both count as reconnects, so the ID can't be cached — it observes
didChangeScreenParametersNotificationinstead.window, so it looks like the pointer vanished. macOS only lets the cursor cross between
displays that share an edge, so the Sidecar display is parked where it touches the
layout at a single corner: the arrangement stays contiguous (macOS rejects disconnected
layouts) but there's no edge to cross.
arrangeExternalAboveBuiltin()undoes that parking. It runs 500 ms after any displayreconfiguration — and parking the display is itself a reconfiguration, as is the user
dragging a display by hand. Symptom: the display "jumps back" a few seconds later. Displays
managed by this feature are now excluded while it's running.
SCShareableContentimmediately(the window server refreshes asynchronously) → poll rather than look up once.
bar draws over the rotated content and ends up along a vertical edge once the iPad is turned.
Other notes
feature restores itself after a relaunch and Stop puts the display back where the user had
it. Auto-restore is one-shot: on failure it stops and reports rather than retrying on every
display change.
rather than letting it fail mysteriously.
VirtualDisplayServicenow uses each config's own name fordescriptor.nameinstead of thehardcoded "FreeDisplay Virtual", so user-created virtual displays show their real name too.
passive output surface parked in a corner, and dragging it would break the corner-only
contact described above.
Testing
Tested on real hardware: M4 Pro / macOS 26.4.1 with an iPad over Sidecar. Verified toggling
on and off, switching orientation, restoring after a relaunch, and Sidecar disconnecting and
reconnecting.
Not covered: I have one Mac and one iPad. No Intel machine, no other iPad models.
Note the project needs Xcode 26 to build — on Xcode 16.4 the existing
BrightnessKeyService.swiftfails with "main actor-isolated property can not be mutated froma Sendable closure". Unrelated to this PR, but it caught me out when setting up CI.
One note on scope
This PR contains only the two things above. I also did an English localization (String
Catalog plus a language switcher), but it looked like someone else was already working on
localization, so it is not part of this PR — it lives only in my fork.
No rush on reviewing. Anyone who wants a prebuilt version meanwhile can grab a DMG from
https://github.com/kimberlake/FreeDisplay