Skip to content

feat: Sidecar portrait mode + fix blank menu bar panel on macOS 26 - #9

Open
kimberlake wants to merge 2 commits into
huberdf:mainfrom
kimberlake:feature/sidecar-portrait-cn
Open

feat: Sidecar portrait mode + fix blank menu bar panel on macOS 26#9
kimberlake wants to merge 2 commits into
huberdf:mainfrom
kimberlake:feature/sidecar-portrait-cn

Conversation

@kimberlake

Copy link
Copy Markdown

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 26

On 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 ScrollView whose only height constraint is
.frame(maxHeight: 700) — a maximum, not a height. MenuBarExtra(.window) proposes an
indefinite size, and an unconstrained ScrollView given an indefinite proposal collapses
to zero height.

One file, MenuBarView.swift, +6 −1: give the scroll content a definite width plus
.fixedSize(vertical:), give the ScrollView a min/max height range, and change the outer
.frame(maxHeight:) to .fixedSize(vertical:). No behaviour change beyond the panel
actually rendering.

If you only want this fix, that commit stands alone.

2. feat: Sidecar portrait mode

Use an iPad as a vertical display.

A Sidecar display can't be rotated: CGDisplayRotation is read-only, System Settings shows
no Rotation control for Sidecar, and the old IOFBTransform approach is dead on Apple
Silicon (zero IODisplayConnect services 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:

  1. Create a virtual display with the dimensions swapped (landscape 1280×854 → portrait 854×1280)
  2. Capture it with ScreenCaptureKit
  3. Rotate the frames 90° with CoreImage
  4. Render into a borderless full-screen window pinned to the Sidecar display

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 / StreamWindow are restored from 0e5fb05.
Not restored: StreamControlView (the streaming/PiP UI) and RotationService (uses the
dead IOFBTransform path). Only the parts this feature actually needs — the features Phase
21 cut stay cut.

Traps hit along the way (all commented in the code)

  • macOS persists mirror arrangements. If a display has ever been mirrored onto the
    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 display
    not found". The service now breaks the mirroring explicitly at startup. Diagnosis tip:
    compare CGGetOnlineDisplayList against CGGetActiveDisplayList.
  • A Sidecar display's displayID changes on every reconnect (I watched it go
    7→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
    didChangeScreenParametersNotification instead.
  • The cursor strays onto the physical Sidecar display, which shows nothing but our output
    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 display
    reconfiguration — 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.
  • A freshly created virtual display isn't listed in SCShareableContent immediately
    (the window server refreshes asynchronously) → poll rather than look up once.
  • The output window has to sit above the menu bar layer, or the physical display's own menu
    bar draws over the rotated content and ends up along a vertical edge once the iPad is turned.

Other notes

  • Enabled state, orientation and the pre-parking display position are persisted, so the
    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.
  • Requires Screen Recording permission (ScreenCaptureKit). The UI lists the prerequisites
    rather than letting it fail mysteriously.
  • VirtualDisplayService now uses each config's own name for descriptor.name instead of the
    hardcoded "FreeDisplay Virtual", so user-created virtual displays show their real name too.
  • Arrange Displays hides the physical Sidecar display while portrait mode runs — it's a
    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.swift fails with "main actor-isolated property can not be mutated from
a 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

kimberlake and others added 2 commits August 12, 2026 17:33
现象:菜单栏图标正常显示,点开后面板是空的,什么都不渲染。

原因:面板内容是一个 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>
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