Nightly: build app with Xcode 26 SDK so Tahoe gets Liquid Glass - #5077
Conversation
Nightly was building the whole app with the runner's default Xcode 16.4 (macOS 15.5 SDK). On macOS 26 a binary linked against a pre-26 SDK is forced into the legacy non-Liquid-Glass appearance, so the Settings window (and everything else) rendered with old chrome. This regressed in #5022, which moved nightly off the macOS 26 runner to fix the universal Ghostty helper link. Keep the macOS 15 host (zig 0.15.2 can only cross-link the x86_64 helper slice against a pre-26 SDK) but build the Swift app with the macOS 26 Xcode that already ships on the runner image, matching the team's .xcode-version pin. The universal helper is built separately with the older Xcode and injected into the bundle before signing; the existing architecture-verification step still guards universality. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe nightly macOS workflow now implements a split Xcode toolchain strategy: selecting an SDK 26+ Xcode for the main app and a pre-26 Xcode for the CLI helper, building the universal helper separately with validation, skipping the in-Xcode helper build, and injecting the prebuilt universal binary into the final app bundle. ChangesNightly macOS Workflow Update
Possibly related PRs
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Caution Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional.
❌ Failed checks (1 error)
✅ Passed checks (17 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR fixes the regression introduced in #5022 where the nightly build switched to a macOS 15 runner and lost Liquid Glass on Tahoe because the app was linked against the macOS 15 SDK. The fix keeps the macOS 15 host but builds the Swift app with the macOS 26 Xcode (and SDK) already present on the runner, while building the Ghostty CLI helper with the pre-26 Xcode (needed because zig 0.15.2 cannot cross-link x86_64 against the macOS 26 SDK).
Confidence Score: 5/5Safe to merge — purely a CI workflow change with no production Swift or runtime code touched, all failure modes fail loudly, and injection happens before signing. The dual-Xcode discovery uses numeric version ranking (correctly handling multi-digit minor versions), env-var propagation from GITHUB_ENV is standard GitHub Actions behavior, and all three verification gates (lipo at build time, lipo at inject time, and the existing arch-verification step) remain active. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant R as macOS 15 Runner
participant X26 as Xcode 26+ (macOS 26 SDK)
participant X16 as Xcode 16.x (pre-26 SDK)
participant Zig as zig 0.15.2
participant Bundle as App Bundle
R->>R: Select Xcode (scan /Applications, numeric sdk_rank)
R-->>R: "DEVELOPER_DIR = Xcode 26+ path"
R-->>R: "HELPER_DEVELOPER_DIR = pre-26 path"
R->>X16: Build universal Ghostty CLI helper
X16->>Zig: cross-link x86_64 slice (pre-26 SDK)
Zig-->>X16: arm64 + x86_64 slices
X16-->>R: /tmp/cmux-ghostty-helper-universal
R->>R: lipo verify arm64 + x86_64
R->>X26: "Build nightly app (CMUX_SKIP_ZIG_BUILD=1)"
X26-->>Bundle: cmux.app (macOS 26 SDK linked)
R->>Bundle: Inject helper at Contents/Resources/bin/ghostty
R->>R: Theme picker regression
R->>R: Verify binary architectures (app + CLI + helper)
R->>R: Inject nightly identities and sign
R->>R: Notarize and publish DMG
Reviews (2): Last reviewed commit: "Address review: numeric SDK comparison +..." | Re-trigger Greptile |
| while IFS= read -r app; do | ||
| [ -n "$app" ] || continue | ||
| dev="$app/Contents/Developer" | ||
| [ -d "$dev" ] || continue | ||
| sdk_ver="$(DEVELOPER_DIR="$dev" xcrun --sdk macosx --show-sdk-version 2>/dev/null || true)" | ||
| [ -n "$sdk_ver" ] || continue | ||
| major="${sdk_ver%%.*}" | ||
| echo "Found $app -> macOS SDK $sdk_ver" | ||
| if [ "$major" -ge 26 ]; then | ||
| APP_DEVELOPER_DIR="$dev" | ||
| APP_SDK_MAJOR="$major" | ||
| else | ||
| echo "No Xcode.app found under /Applications" >&2 | ||
| exit 1 | ||
| HELPER_DEVELOPER_DIR="$dev" | ||
| HELPER_SDK_MAJOR="$major" | ||
| fi | ||
| done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -print 2>/dev/null | sort) |
There was a problem hiding this comment.
Lexicographic sort misorders double-digit Xcode minor versions
The sort pipe produces ASCII-lexicographic order, so Xcode_16.10.app < Xcode_16.9.app (character '1' < '9'). When multiple Xcodes exist in the same tier, the loop overwrites on each iteration and the last-sorted one wins — meaning Xcode_16.9.app would be chosen over Xcode_16.10.app even though 16.10 is newer. Apple hasn't shipped Xcode 16.10 yet, but Xcode 26 could reach that threshold quickly given its beta cadence, and the same flaw applies to the 26+ tier. A sort -V (version sort, available on macOS via GNU coreutils or via sort -t. -k1,1n -k2,2n) or an explicit xcrun --find xcodebuild pass would give a reliable selection.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/nightly.yml:
- Around line 153-183: The current selection loop relies on lexicographic sort
which can pick the wrong Xcode; change the logic in the loop that reads app
paths to track and compare SDK versions numerically instead of relying on sort
order: keep a stored best SDK string/number for the APP slot
(APP_DEVELOPER_DIR/APP_SDK_MAJOR) and one for the HELPER slot
(HELPER_DEVELOPER_DIR/HELPER_SDK_MAJOR), parse sdk_ver (and major/minor as
needed) and update the corresponding slot only when the newly found sdk_ver is
strictly greater than the stored one (for APP pick only sdk_ver >=26, for HELPER
pick only sdk_ver <26), or alternatively replace the pipeline sort with
version-aware sort (sort -V) before the loop; ensure the rest of the script
continues to export APP_DEVELOPER_DIR/HELPER_DEVELOPER_DIR and their SDK majors
as before.
- Around line 252-263: Remove the explicit pre-existence check for DEST and
instead ensure the parent directory exists before installing the helper: drop
the `[ ! -e "$DEST" ]` guard in the GitHub Actions step, call mkdir -p
"$(dirname "$DEST")" to create the parent bin directory, then run install -m 755
/tmp/cmux-ghostty-helper-universal "$DEST" and report architectures (retain the
lipo check); reference the DEST variable, the install invocation, and
scripts/build-ghostty-cli-helper.sh which already creates the stub when
CMUX_SKIP_ZIG_BUILD=1.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0f227e82-71ee-4b62-968a-75c4cf9e4838
📒 Files selected for processing (1)
.github/workflows/nightly.yml
There was a problem hiding this comment.
1 issue found across 1 file
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
- Select Xcode by numeric SDK rank instead of lexicographic sort, so 26.10 ranks above 26.3 and the newest SDK in each slot (>=26 for the app, <26 for the zig helper) is chosen deterministically. - Inject step now mkdir -p's the bin directory and installs, instead of erroring when the slot is absent; the build phase still writes the stub when CMUX_SKIP_ZIG_BUILD=1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dismissing per maintainer instruction; both comments addressed in 6ed048f.
…low-ai#5851) (#7) * ci: publish iOS TestFlight (beta) on iOS-affecting merges to main (#5453) Add a push trigger so every merge to main that changes the iOS app publishes to the beta lane (dev.cmux.app.beta) immediately, instead of waiting up to a day for the nightly. Path-filtered to inputs that actually rebuild the iOS app (ios/, the linked Swift packages, GhosttyKit + its fetch scripts, and this workflow); macOS-only Sources/ changes don't change the iOS app so they don't trigger an upload. The nightly + manual dispatch stay as-is. Push runs always build (the dedup SHA gate is schedule-only); each merge is a distinct commit, so no duplicate uploads. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Restore menu bar icon dropdown menu on click (#5451) PR #3908 changed the cmux menu bar status item so a left-click opened the 720x460 global search popover, and only a right/control-click showed the dropdown menu. This reverts the status item to its standard behavior: any click shows the dropdown menu again. Global search stays reachable via the "Search All Windows…" menu item and its keyboard shortcut, so nothing is lost. The change just restores statusItem.menu = menu and removes the custom button click routing added by #3908. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Mobile workspace list: propagate renames + match bonsplit terminal order (#5446) * test: add failing mobile workspace-list fidelity tests Adds MobileWorkspaceListFidelityTests (behavioral) covering the two bugs: a pure terminal reorder must wake the observer and change the mobile summary hash, and a terminal rename (which writes panelCustomTitles) must change the hash. These fail against current behavior: - mobileTerminalPanels orders focused-first/UUID, not spatial order - the observer never subscribes to reorder/custom-title changes and hashes the sorted panel-id set + raw panelTitles, so reorders and custom renames don't re-emit to the phone Only the structural seam the tests need to compile is added here: Workspace.orderedPanelIds (spatial order from bonsplit) and the Workspace.paneLayoutVersion counter. The behavioral wiring lands in the next commit, so CI goes red here and green there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: mobile workspace list reflects renames + spatial terminal order Serialize the phone's terminal list in the on-screen bonsplit spatial order (left-to-right, top-to-bottom) and re-emit workspace.updated on terminal renames and pure drag-reorders, fixing the two regressions the prior commit's tests exercise. - mobileTerminalPanels(in:) now routes through orderedPanels(in:), which delegates to Workspace.orderedPanelIds (bonsplit allTabIds order), instead of focused-first/UUID sort. The payload still carries is_focused. - MobileWorkspaceListObserver subscribes to $panelCustomTitles (terminal rename writes panelCustomTitles, not panelTitles) and $paneLayoutVersion (reorder wakeup), and hashes the ordered panel-id sequence + custom-aware panelTitle() instead of the sorted id set + raw panelTitles. - Workspace.didChangeGeometry bumps paneLayoutVersion only when orderedPanelIds actually changed, so divider drags and selection-only events stay quiet. Workspace rename already propagated (setCustomTitle sets title; observer watches $title; response sends workspace.title) and needs no change. Host-only serialization change; no iOS app rebuild required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: move fidelity tests to a dedicated Swift Testing file (cmux policy) cmux test-framework policy: new non-UI tests use Swift Testing, not XCTest. The fidelity tests were appended to the XCTest file WorkspaceUnitTests.swift; move them to a dedicated cmuxTests/MobileWorkspaceListFidelityTests.swift written in Swift Testing (#expect/#require/@Test/@Suite(.serialized)), wired into the cmux-unit target via project.pbxproj. WorkspaceUnitTests.swift is restored to base (its XCTest import stays only for its pre-existing suites, which we must not bulk-rewrite). Same behavioral assertions; the prior two commits keep the XCTest red/green proof in history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: keep orphan panelDirectories in mobile summary hash The prior hash change dropped the loop that hashes every panelDirectories entry (including ids not yet in `panels`), which broke the pre-existing testMobileWorkspaceListHashIncludesDisplayedDirectories (it sets a directory for an orphan UUID and expects the hash to change). Restore that loop; the bug-fix changes (ordered panel ids + custom-aware panelTitle) stay. Keeps the existing behavior where a directory update is detected before its panel registers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Add VS Code-style context keys and comparison operators to shortcut `when` clauses Generalize the keyboard-shortcut `when` engine (#5189) from four boolean focus atoms to a typed, extensible context system, closing most of the gap with VS Code's `when`-clause vocabulary while staying fully backwards compatible. - Operators: add ==, !=, =~ (regex), <, <=, >, >=, and `in [a, b]`, layered into the grammar with VS Code precedence. Existing boolean clauses parse identically. - Context keys (typed registry ShortcutContextKnownKey): commandPaletteVisible, terminalFindVisible (bool), sidebarMode (string), paneCount, workspaceCount (int), wired from synchronous window state in KeyboardShortcutContext. - Typed model: ShortcutContext / ShortcutContextValue / ShortcutContextOperand / ShortcutRegex value types; the app populates a Sendable snapshot so the package never imports app types. - canCoexist (conflict detection) generalized to a sound free-variable enumeration: byte-identical for focus-only clauses, conservative for typed comparisons. - ShortcutWhenClause stays additive (.key/.compare added; .atom and the evaluate(ShortcutFocusState) overload unchanged). One intentional change: an unknown bare key now parses to .key (always-false), matching VS Code. - Treat an empty/whitespace `shortcuts.when` clause as non-restricting so it no longer suppresses an action's menu equivalent. - Package Swift Testing suite + app integration test; docs updated in cmux.schema.json and the en/ja message catalogs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Browser omnibar: first focus-gaining click selects whole URL (Chrome parity) Match the Chrome/Safari/Arc omnibar click model exactly: 1. The first click on an unfocused omnibar showing a URL selects the entire contents, so the next keystroke replaces the URL. 2. A subsequent click while the omnibar is already first responder places the caret at the click point (preserves issue #5268). 3. A double-click selects the word under the cursor (unchanged field-editor behavior). The mouse-gesture state machine in OmnibarNativeTextField already owns the mouse selection decision, so the change stays there rather than routing a mouse click through the async, notification-based requestAddressBarFocus selection-intent path (which is for keyboard/programmatic focus like Cmd+L). MouseSelectionState now records whether the click gained focus and whether Shift was held; mouseUp consults the pure, testable decision function browserOmnibarFocusGainingClickShouldSelectAll to select all only on an undragged, unmodified focus-gaining click. Drags and Shift-clicks keep their explicit range; double-clicks never reach this path. Closes #5459 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: keep restored hidden webview attached during capture * test: cover OMO tmux respawn panes * Omnibar: count UTF-16 length without NSString bridge Address Greptile P2: use editor.string.utf16.count for the select-all range length instead of bridging to NSString just to read .length. Same value, no Obj-C bridge cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Scale browser chrome with the tab bar font size The browser omnibar text and toolbar icon buttons (back/forward/reload, lock, screenshot, cursor grab, profile, theme, dev tools) were a fixed size and ignored the user's font settings, so the top chrome looked inconsistent once the tab bar font size was changed. Derive every omnibar/toolbar size from the existing `surfaceTabBarFontSize` setting via a new pure `BrowserChromeMetrics` value type: a scale anchored to the shipped default (11pt) so the default appearance is byte-identical, clamped to a sane range so a malformed config can't blow up the toolbar. BrowserPanelView seeds the size from the cached config and refreshes it live on `.ghosttyConfigDidReload` — the same observation path the tab strip and terminal panels already use — so the chrome re-lays-out the instant the tab bar font size changes. No new setting is introduced. Closes #5463 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: respawn OMO subagent panes * fix(tests): pass fontSize to OmnibarTextFieldRepresentable test constructions The new required fontSize parameter on OmnibarTextFieldRepresentable broke two existing test-target call sites, failing the tests job to compile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: align tmux respawn pane semantics * fix: retain browser screenshot URL waiter * test: cover -infinity and clarify min-font comment (Greptile) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: expose terminal wait-after debug state * Add failing main window min-size regression test * Clamp main window AppKit fitting size to policy floor * fix: keep respawned tmux panes attached * fix: tighten tmux respawn semantics * test: isolate omo respawn assertions * fix: clear respawn warning budget * fix: preserve respawn startup environment * Fix SIGTRAP when ASWebAuthenticationSession completes off the main thread The completion closure handed to ASWebAuthenticationSession was formed inside the @MainActor factory, so under the package's Swift 6 language mode it inherited main-actor isolation and the compiler emitted a dynamic isolation assertion at the ObjC boundary. macOS 26 delivers the session's cancel-path completion on the SafariLaunchAgent XPC queue (the deleted AuthManager's Swift 5 app-target code documented this off-main behavior but emitted no check), so dismissing the sign-in popup trapped in dispatch_assert_queue. The completion is now built by a nonisolated @Sendable bridge that carries no isolation assumption and hops to the main actor itself. A true red/green regression commit is not practical here: the trapping closure was only reachable through a live ASWebAuthenticationSession callback, and the trap is a compiler-inserted assertion, not logic the old shape exposed to tests. The new tests pin the bridge's contract by delivering the completion from non-main queues. Part of https://github.com/manaflow-ai/cmux/issues/5375 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Extract SocketControlServer into CmuxControlSocket (stage 2) (#5432) * Extract SocketControlServer into CmuxControlSocket (stage 2) Move the control-socket listener out of TerminalController into the CmuxControlSocket package: startup path reservation, bind/listen lifecycle, the generation-counted accept source with failure backoff and rearm, the socket-path monitor, and synchronous state reads. The former nonisolated(unsafe) field block and NSLock collapse into one OSAllocatedUnfairLock-guarded state machine (documented carve-out: all drivers are synchronous - DispatchSource handlers, client reader threads, and app-termination teardown that must finish before exit). App-shaped concerns cross a closure seam (SocketControlServerEvents): telemetry breadcrumbs/failures with the existing capture cooldown, the .socketListenerDidStart notification + PortScanner wiring at the same point in start, accepted-client hand-off to the existing thread-per-client handler, and the path-missing/rearm restart triggers, which keep their main-thread scheduling in the app. Also moved: SocketFastPathState (DispatchQueue-as-lock -> lock-guarded package type keyed on raw state strings; dead shouldPublishDirectory dropped) and the peer PID/UID/ancestry checks (SocketTransport+Peer). TerminalController keeps a thin facade with unchanged signatures, the client read loop, auth, and command dispatch (those move in stage 3). All telemetry stage strings unchanged. -855 net lines. 20 new package tests (real-socket lifecycle, crash-reclaim stale socket replacement, reservation consumption, path-monitor delete detection, per-mode permissions, dedupe cache, peer verification); 65 total green. * review: rename print prefixes, asyncAfter justification, DocC examples - print() prefixes in SocketControlServer+Startup say SocketControlServer instead of the legacy TerminalController name (stdout prints kept for launch-time visibility parity with the legacy listener). - One-line justification comment on the accept-source resume asyncAfter (bounded backoff deadline in a non-async type; stale fires are no-ops via the generation/identity/suspended guards). - DocC usage examples + cross-references on SocketTransport peer APIs and SocketFastPathState. * Make the session completion bridge an instance method nonisolated on the instance method escapes the factory's @MainActor isolation just as well as static did, and the bridge can use the instance's own log instead of taking it as a parameter. Part of https://github.com/manaflow-ai/cmux/issues/5375 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: harden iOS TestFlight publish (main-only guard + CODEOWNERS) (#5476) Defense-in-depth for the publish pipeline: - Upload job gains `github.ref == 'refs/heads/main'`, so a publish can only run from main. push/schedule already run on main; this blocks shipping arbitrary code by dispatching the workflow against a feature branch. - Add .github/CODEOWNERS for secret-touching paths (all workflows, the upload-testflight.sh publish script, ios/Config) so changes there require an owner's review. NOTE: CODEOWNERS only enforces once branch protection on main sets require_code_owner_review=true (+ required_approving_review_count>=1). That branch-protection change is the actual gate and is an admin action; these two changes make it effective and add depth, they are not the gate themselves. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix: address omo respawn review feedback * Fold AuthErrorMapper into AuthError The mapper was a stateless value whose whole job was translating raw backend errors into the AuthError vocabulary; that conversion now lives on the type itself as AuthError(displaySafe:) (failable: nil means the original Stack error is already display-safe and the sign-in UI renders it unchanged), with the cached-session recovery decision as a property. The StackAuth-dependent conversion sits in AuthError+DisplaySafe.swift so AuthError.swift stays Foundation-only. Part of https://github.com/manaflow-ai/cmux/issues/5375 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Organize CMUXAuthCore: one type per file, DocC, no namespace-enums Every public symbol now carries DocC, matching CmuxAuthRuntime, and every type has its own file (CMUXAuthEnvironment, the key-value store protocol, and the launch-input types move out of shared files). The two namespace-enums become real shapes the conventions allow: - CMUXAuthLaunchConfig's parsers are now failable initializers on the values they construct: CMUXAuthAutoLoginCredentials(environment:...) and CMUXAuthUser(uiTestFixtureEnvironment:...). - CMUXAuthMagicLinkCode is a value (code + nonce) with a composed property instead of a caseless enum with a static compose. - CMUXAuthConfig.resolve and AuthConfig.resolve become initializers. Part of https://github.com/manaflow-ai/cmux/issues/5375 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: name the TestFlight beta "cmux BETA" on device (#5485) Release builds ship the beta lane (dev.cmux.app.beta) but displayed as plain "cmux", indistinguishable from a future App Store "cmux". Override PRODUCT_DISPLAY_NAME = "cmux BETA" in Release.xcconfig (after the Shared.xcconfig include, so it wins). Debug builds (dev.cmux.ios) stay "cmux". Verified deterministically: Release config's base is Release.xcconfig (pbxproj baseConfigurationReference), there is no direct PRODUCT_DISPLAY_NAME in the pbxproj, and INFOPLIST_KEY_CFBundleDisplayName = $(PRODUCT_DISPLAY_NAME), so the Release app's CFBundleDisplayName resolves to "cmux BETA". Matches the macOS "cmux NIGHTLY" / "cmux DEV" variant-naming convention. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix: address main window sizing review feedback * test: address main window sizing review comments * Remove mobile host legacy defaults (#5484) * Fix notification sound selection playback (#5480) * Fix notification sound selection playback * Address notification sound staging review * Fix sidebar close button hidden under wrapped workspace titles (#5488) The workspace row's close (x) button was a floating overlay(alignment: .topTrailing) that reserved no layout space, while the title used frame(maxWidth: .infinity) with no trailing inset. A title long enough to wrap (or any long single-line title) therefore filled the top-right corner, and the semi-transparent x rendered on top of the title glyphs with no background, so it read as missing. Short titles left that corner empty, which is why single-line names looked fine. Move the close button into the title HStack as a trailing sibling that always reserves its width when the workspace is closable, toggling visibility via opacity (so hover never re-lays-out the row). The title now wraps/truncates before the button's corner, leaving a clear area where the x always shows. This matches the existing group-header plus-button pattern. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(ios): archive unsigned, sign only at export (fix TestFlight cert-cap failure) (#5496) * ci(ios): archive unsigned, sign only at export (fix dev-cert churn) Automatic signing during `xcodebuild archive` (-allowProvisioningUpdates + CODE_SIGN_STYLE=Automatic) makes each ephemeral CI runner mint a new Apple Development certificate, which exhausted the account's certificate cap and broke every on-merge TestFlight publish ("maximum number of certificates" / "no profiles for dev.cmux.app.beta"). Archive without signing; the export step applies the (reused, cloud-managed) distribution cert, which does not churn. Includes a TEMP push trigger + ref-guard relaxation for this branch to validate a real upload before merge; both reverted before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: remove temp branch validation hooks (archive-no-sign verified) Validated on-branch: archive unsigned + cloud-distribution export uploaded cleanly (UPLOAD SUCCEEDED, Delivery UUID 2f8bd406..., build 202606060130), no new dev cert minted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): 14-digit build number so TestFlight offers updates (#5499) CFBundleVersion regressed from 14-digit (yyyyMMddHHmmss, e.g. 20260520031606) to 12-digit (yyyyMMddHHmm, e.g. 202606060220). Numerically the 12-digit values (~2.0e11) are LOWER than the legacy 14-digit ones (~2.0e13), so every recent build sorted BELOW the May builds and TestFlight never offered an Update (it picks the highest CFBundleVersion as "latest"). Restore seconds so build numbers exceed the legacy max and increase monotonically. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): 14-digit build number in the workflow fallback too (#5503) PR #5499 fixed the script default, but the workflow's "Resolve build number" step ALWAYS passes --build-number explicitly, and its no-input fallback was still 12-digit (date -u +%Y%m%d%H%M). So every automatic push/schedule build overrode the script's 14-digit default with a 12-digit value, leaving the CFBundleVersion below the legacy max (20260520031606) and TestFlight never offered it as an update. Match the script: yyyyMMddHHmmss. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): enforce monotonic TestFlight build numbers against App Store Connect (#5504) * ci(ios): enforce monotonic TestFlight build numbers against App Store Connect The build-number scheme regressing from 14 to 12 digits silently shipped builds with a CFBundleVersion below the existing max, so TestFlight never offered them as an update (it ranks the highest build number as "latest"). Restoring the scheme (#5499, #5503) fixed the symptom but nothing enforced the actual invariant, and a bad manual --build-number would reproduce it. Add a behavioral guard: before archiving, asc_max_build.py asks App Store Connect for the current max integer CFBundleVersion (mints an ES256 JWT, resolves the app by bundle id, pages builds and maxes as int because ASC sort=-version is a string sort). upload-testflight.sh self-heals BUILD_NUMBER up to max+1 when it would not be the highest, with a loud warning. Fail-open by design: any ASC/network/JWT error (or missing `cryptography`) logs a warning and keeps the timestamp build number, so a transient API hiccup never blocks a publish. The workflow best-effort installs `cryptography` for the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): address autoreview on build-number guard - Reused archives (--archive-path): the embedded CFBundleVersion ships, not the shell BUILD_NUMBER, so the guard now reads the archive's CFBundleVersion and fails (re-archive needed) instead of self-healing a value that won't apply. Skipped for --export-only (no upload). - Supply chain: install `cryptography` BEFORE the App Store Connect private key is written to disk, and pin to a wheel-satisfiable range, so a compromised install can't read the signing credential. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): never return a partial App Store Connect build max Page until ASC stops returning a `next` link instead of capping at 20 pages and returning whatever was seen so far. A truncated read could be below the true max, letting the caller self-heal to a number still <= the real max (the exact non-updatable build this guard prevents). MAX_PAGES is now only a runaway backstop; hitting it with more pages pending raises, so the caller fails open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): address PR-bot findings on the build-number guard - Invoke asc_max_build.py via explicit `python3` (not a bare path), so a lost exec bit can't silently make the guard always fail open. (cursor) - Reused --archive-path: require a NUMERIC embedded CFBundleVersion; if it can't be read, skip the guard with a warning instead of falsely "bumping" a value that never applies to the archive. (cursor) - asc_max_build.py: raise on a `next` URL that doesn't start with API_BASE, so a malformed pagination link can't silently truncate to a partial max. (CodeRabbit, cursor) - asc_max_build.py: emit only HTTP status + ASC error code, never the raw response body, to keep upstream payloads out of logs. (CodeRabbit) - asc_max_build.py: drop the ambiguous saw_any flag; return highest (0 = no floor). (greptile) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): report the post-guard CFBundleVersion in the workflow summary After the monotonic guard self-heals BUILD_NUMBER, the workflow summary was still printing the pre-guard value, so a release audit could show a CFBundleVersion that doesn't match the uploaded IPA. The script now writes the shipped build number to CMUX_BUILD_NUMBER_OUT_FILE (the archive's embedded version for reused archives), and the workflow reads it into a step output the summary consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): remove PyPI dep from signing job; fail closed on reused archives Addresses two autoreview findings on the build-number guard: - Supply chain: drop the `cryptography` dependency entirely. asc_max_build.py now mints the ES256 JWT via `openssl` (preinstalled) + stdlib, so the TestFlight job that holds the signing/upload credential never `pip install`s third-party code that could persist in site-packages and run once the key is on disk. The workflow's "Ensure cryptography" install step is removed. - Reused --archive-path can't be renumbered, so it must be verifiable before an upload: fail CLOSED when its embedded CFBundleVersion is unreadable or when App Store Connect can't be reached, instead of fail-open. Fresh builds keep fail-open (the timestamp scheme is already correct). --export-only never uploads, so it only warns. Verified: openssl-signed JWT authenticates to ASC (both key-path and base64-key paths); all guard branches (fresh fail-open, reused fail-closed x3, export-only skip) behave correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): close reused-archive verification hole on the Apple ID upload path The reused-archive fail-closed guarantee only held when ASC API creds gated the guard block. A reused --archive-path uploaded via the Apple ID/app-specific- password path (no ASC creds) skipped the block and shipped unverified. Track REUSED_ARCHIVE_VERIFIED (set only after a real ASC max comparison) and refuse the upload for any reused archive that reaches it unverified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): fail closed for explicit build numbers when ASC can't be verified Fail-open is only safe for the generated UTC timestamp (monotonic by construction). An explicit --build-number could be stale, so if App Store Connect can't be reached the guard now fails closed for explicit values while keeping fail-open for the generated default. To make the distinction real, the workflow no longer passes --build-number for push/schedule runs: the script generates the timestamp itself (single source of the numbering scheme, removing the workflow/script duplication that caused the 12-vs-14-digit regression). --build-number is passed only for a manual workflow_dispatch build_number input, which is exactly the explicit case that now fails closed when unverifiable. The summary reads the shipped number back from CMUX_BUILD_NUMBER_OUT_FILE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): close explicit-build-number bypass on the Apple ID upload path Symmetric to the reused-archive fix: an explicit --build-number uploaded via the Apple ID path (no ASC API creds) skipped the guard and could ship a stale build. Generalize the verification flag (REUSED_ARCHIVE_VERIFIED -> GUARD_VERIFIED, set only after a real ASC max comparison) and the final pre-upload gate now refuses BOTH an unverified reused archive AND an unverified explicit --build-number. The generated UTC timestamp stays exempt (monotonic by construction, fail-open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(ios): only mark a build number verified after a real numeric comparison GUARD_VERIFIED was set as soon as asc_max_build.py succeeded, before checking that both values are numeric. A non-numeric explicit --build-number then fell to the "keep" branch already marked verified, bypassing the fail-closed gate. Restructure: set GUARD_VERIFIED only inside the numeric branch, after a real comparison. Non-numeric or unreadable ASC max leaves it unset. Consolidate the fail-closed check into one gate run BEFORE the archive (fail fast): an unverified reused archive or explicit --build-number is refused; the generated UTC timestamp stays exempt (fail-open). Verified-but-stale explicit values now also fail with a clear message instead of being silently bumped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * iOS pairing onboarding: clearer auth error + Download via TestFlight link (#5506) * iOS pairing: honest copy for generic auth failure The Mac collapses every non-account-mismatch authorization failure (invalid token, wrong Stack project/environment, missing local user, timeout) into a single `unauthorized` code, which the phone maps to `.authorizationFailed`. Its copy asserted "Sign in on your computer with the same account…", which is a specific (often wrong) cause. The most common trigger in practice is a dev-vs-prod Stack project mismatch (same email, different per-project user ID), where the token simply can't be verified against the Mac's project. Reword to state the actual condition without blaming the Mac's account, and hint at the build/environment cause. The distinct `account_mismatch` path keeps its accurate "different cmux account" message. en + ja updated in ios Localizable.xcstrings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS pairing onboarding: "Download via TestFlight" link Add a "Download via TestFlight" link to both iOS onboarding surfaces — the "No devices" empty state (DisconnectedWorkspaceShellView) and the Add device screen (PairingView) — pointing at the Founders Edition page (https://github.com/manaflow-ai/cmux#founders-edition) since TestFlight is invite-only for now. The Founders Edition page covers both TestFlight enrollment and the Mac download. en + ja added in ios Localizable.xcstrings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add macOS iOS pairing/onboarding window (#5493) * Add macOS iOS pairing/onboarding window Adds a dedicated Mac-side window for pairing an iPhone: a scannable QR code (with a host:port fallback), step-by-step instructions, and live pairing-host state. Opening it auto-enables the iOS pairing listener, mints a short-lived attach ticket, and renders the code. Entry point is a "Pair a Device" button in Settings → Mobile. - Sources/Mobile/Pairing/: MobilePairingWindowController, MobilePairingView, MobilePairingModel, MobilePairingQRImageView. - MobileHostService.ensureListeningAndReady(): one async entry that starts the listener and resolves on readiness via a continuation drained from the existing listener-state handlers (no polling; no changes to the accept path). - SettingsHostActions.openMobilePairingWindow() seam + host implementation; MobileSection gains the Pair a Device row. - 17 strings localized in en + ja. Revoke and a connected-device list are deferred to a follow-up: there is no per-device identity on the Mac today to revoke against. Design in cmuxterm-hq plans/feat-ios-device-revoke/DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pairing window: sign-in gate + Tailscale guidance Restructure the pairing window into a requirements flow (sign in → Tailscale → QR), since both gate whether a phone can actually pair: - Sign-in gate: check AuthManager first. When signed out, show a Sign In button (beginSignInAndAwait) and don't enable the listener or show a code. When signed in, show "Signed in to cmux" with the account email, then prepare a code. Authorization is a Stack same-account check, so the Mac must be signed in for any phone to pair. - Tailscale guidance: a requirements row driven by real reachability. The route resolver only publishes Tailscale routes (plus DEBUG loopback), so a real iPhone needs Tailscale. When no Tailscale route resolves, show a warning + a "Get Tailscale" link and note both devices need it on the same account; when it resolves, show "Reachable over Tailscale" and the host:port. - 10 new strings localized in en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Adapt pairing window to AuthCoordinator; add TestFlight link Rebased onto main; #5387 replaced AuthManager with an injected @Observable AuthCoordinator + HostBrowserSignInFlow. Migrate the pairing model/view: - Read isAuthenticated / currentUser / awaitBootstrapped from AppDelegate.shared.auth.coordinator; trigger sign-in via browserSignIn.beginSignIn() (fire-and-forget). The view re-runs refresh() on the coordinator's isAuthenticated and the browser flow's isSigningIn settling (handles cancel without spinning). - Resolve rebase conflicts in MobileHostService (keep the new auth property + the readiness continuation) and SettingsHostActions (keep both openMobilePairingWindow and the new previewNotificationSound signature). Also add a "Download via TestFlight" link under the install step pointing at the Founders Edition page (TestFlight is invite-only for now). en + ja added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pairing window: handle no-Tailscale-route as guided state Autoreview caught that on a release Mac with no Tailscale address, createAttachTicket throws noRoutes (release has no debug loopback route) and the catch showed the raw enum text. Add a dedicated `needsTailscale` state: guard before minting when status.routes is empty, map noRoutes/routeUnavailable in the catch, and replace the raw String(describing:) fallback with localized copy. The state renders "no Tailscale address… install Tailscale, then refresh" with a Get Tailscale button, and the Tailscale checklist row shows the warning. en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pairing window: auto-refresh QR before ticket expiry Autoreview caught that the 600s attach ticket could expire while the window still showed the QR with a perpetual "Waiting…", so a delayed scan would fail. Schedule a bounded, cancellable re-mint ~30s before TTL elapses (cancelled on each refresh and on window close via onDisappear), keeping the displayed code always valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Settings search: index the Pair a Device row Autoreview noted the new Settings → Mobile "Pair a Device" row wasn't reachable from Settings search (search indexes only curated entries). Add a curated entry (id pairDevice) with pairing/QR/scan/iPhone/iPad/Tailscale/onboarding synonyms, matching the row's setting:mobile:pairDevice anchor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Tests: add pairDevice to settings reachability contract The new curated pairDevice search entry is an action row (no cmux.json path), so SettingsRowAnchorResolutionTests.everyCuratedSettingEntryIsReachable would flag it unreachable. Add setting:mobile:pairDevice to explicitlyAnchoredEntryIDs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pairing window: address review findings + fix warning budget - Fix tests-build-and-lag warning budget: MobileHostService.shared was read from a nonisolated default-arg context; resolve `.shared` in the @MainActor init body (host: MobileHostService? = nil). - Serialize refresh() with a generation guard so a slower in-flight run can't overwrite a newer ticket (race flagged by Cursor + CodeRabbit). - Stop rendering the raw NWListener error string; show localized listener-offline copy (CodeRabbit). - Deminiaturize a reused pairing window before bringing it front (CodeRabbit). - Accessibility label on the QR placeholder (CodeRabbit). - Drain readiness waiters if the ephemeral-fallback bind fails synchronously, so ensureListeningAndReady() doesn't wait the full deadline (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover OSC 11 socket input preservation * fix: preserve OSC terminal control sends * iOS: remove dead TerminalArrowNubView.Direction.escapeSequence (#5505) The arrow nub drives repeats through TerminalArrowRepeatService -> TerminalKeyEncoder; the hardcoded escapeSequence property duplicated those bytes and was never referenced (grep-confirmed zero call sites). Drop it so TerminalKeyEncoder stays the single source of truth for arrow encoding. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make iOS pairing port configurable with live bound-port status (#5489) * Make iOS pairing port configurable with live bound-port status Settings > Mobile gains a configurable pairing-listener port (mobile.iOSPairingHost.port, default 58465). The port is a preference: the listener still falls back to an OS-assigned ephemeral port when it's in use, and the iOS pairing payload uses the actual bound port, so pairing survives a fallback. A live bound-port indicator shows the real port and warns when it differs from the configured one, so a configured port can't silently fail. Also adds a Mac display-name override (mobile.iOSPairingHost. displayName) and read-only diagnostics (connected-device count + reachable routes) while pairing is enabled. The listener reconciles on settings change through a pure, unit-tested syncDecision (start/stop/restart only when the enabled state or port actually changes), so unrelated UserDefaults writes never drop active iOS connections. Live status reaches the Foundation-only settings package via new SettingsHostActions seams backed by a mobileHostStatusDidChange notification. Adds curated search entries + aliases for the new settings and en/ja localization for all new strings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Mobile settings: surface out-of-range port + show resolved name placeholder An out-of-range port (e.g. 99999) clamps to the default internally, so without this it would render a reassuring green "Listening on <default>" with no hint the typed value was ignored. Show an explicit orange "Port must be between 1 and 65535." instead (even while pairing is off). Use the resolved system name as the Display Name field placeholder when no override is set, so the user sees the actual default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix Swift warning budget: bridge pairing-status notification via Void signal The MainActor for-await over NotificationCenter.notifications(named:) tripped the warning budget (non-Sendable Notification crossing isolation in next()). Mirror UserDefaultsSettingsStore.values(for:): a block observer yields to a Sendable AsyncStream<Void>, and the MainActor drain task reads the snapshot, so Notification never crosses. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix stale connected-device count: notify on registry mutation, not accept beginConnection() fires at accept time, before the connection is inserted into MobileHostConnectionRegistry, but the status snapshot's activeConnectionCount reads that registry. The status stream could yield the old count and then never update after the insert. Post mobileHostStatusDidChange from the registry's insert/remove/removeAll (where the authoritative count changes) instead. Addresses autoreview finding on the live connection-count path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Don't rebind pairing listener for invalid port input Typing an out-of-range port (e.g. 70000) persists the raw value and syncToSettings mapped it to the default via configuredPort(), so a listener running on a custom valid port would restart and move to the default (dropping devices) while the UI only showed an "invalid" warning. Add resolvedDesiredPort() which returns nil for an out-of-range stored value; syncToSettings then reuses the applied port (no restart) until a valid port is entered. A fresh start still binds the default. Addresses autoreview finding on invalid-port handling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Source display-name placeholder from static system name, not live status The status stream only fires on listener/route/connection events, so the snapshot's displayName went stale when the user edited or cleared the override (the display-name write is a plain UserDefaults change that posts no status notification). Drop displayName from the snapshot and add SettingsHostActions.mobilePairingDefaultDisplayName() returning the Mac's system name (Host.current().localizedName), which the placeholder uses. That name is stable, so the placeholder never goes stale. The override itself still drives the real pairing name via MobileHostIdentity. Addresses autoreview finding on the display-name placeholder. (The curated search-entry title finding is the existing package convention — all 103 entries hard-code English titles; the row labels and search synonyms are localized, so this is left consistent with the rest of the catalog.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Doc the SettingsHostActions mobile default implementations Add Swift-DocC one-liners to the new mobilePairingStatus/ mobilePairingStatusUpdates/mobilePairingDefaultDisplayName default implementations to satisfy the package documentation policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add explicit Apply button with port availability check Editing the port no longer rebinds the listener. The field is a local draft; an Apply button (enabled when the draft is valid and differs from the port in effect) checks the port is free before doing anything: - free -> persist + rebind (devices reconnect on the new port) - in use -> leave the running listener untouched, show "Port X is in use, still listening on Y" - pairing off -> save for when pairing is enabled Availability is a synchronous one-shot bind probe (INADDR_ANY, no SO_REUSEADDR, matching NWListener's default); the live bound-port status stays authoritative so any rare dual-stack disagreement self-corrects. Decision logic is the pure, unit-tested portApplyDecision. Also fix a latent gap: a preferred port held by another process can surface as .waiting(.posix(.EADDRINUSE)) rather than .failed, where the listener would wait forever; treat address-unavailable .waiting the same as a failure so the ephemeral fallback fires. Shared via handleListenerBindFailure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix port draft sync + IPv6-accurate availability probe (autoreview) Two autoreview findings on the Apply flow: - P1: the field seeded its draft from DefaultsValueModel's initial *default* (it yields the saved value asynchronously), so a user with a saved port saw the default and could overwrite it. The field now tracks port.current via an optional editedPort (nil = follow persisted, set = user edit), so it reflects the saved port once loaded and never clobbers it. - P2: the IPv4-only bind probe missed an IPv6-only conflict, so apply could restart and drop connections despite the "untouched on conflict" contract. Probe with a throwaway NWListener using the same NWParameters as the real bind (dual-stack), one-shot continuation under the lock carve-out. applyConfiguredPort is now async and probes only when a running listener would move to a different in-range port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Force rebind when applying a freed port after ephemeral fallback After an ephemeral fallback, appliedPreferredPort holds the configured port while the listener is on an ephemeral one. Re-applying the (now-freed) configured port persisted the same value, so the settings observer's syncToSettings saw no change and the listener stayed on the ephemeral port even though apply reported success. applyConfiguredPort now restarts directly whenever the listener is not bound to the requested port, instead of relying on a persisted-value change to drive the rebind. Addresses autoreview finding on the apply state machine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Don't show stale Apply feedback after the pairing toggle changes The Apply message was cleared only on a port edit, so toggling pairing could leave a contradictory note: "Will use port X when iOS Pairing is on" after enabling, or "Still listening on Y" after disabling. Gate the saved-for-later note to pairing-off and the in-use note to pairing-on, so the live indicator takes over the moment the toggle flips (these read the @Observable toggle state, so they re-evaluate reactively). Addresses autoreview finding on stale Apply feedback. (The curated search-title localization finding is the catalog's documented English-only design — "English-only until the package ships an xcstrings catalog" — so localizing only the new entries would contradict it and split the table; the row labels and synonyms are localized.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address CodeRabbit: IPv6 endpoint brackets, accurate fallback flag, honest defaults - MobilePairingRoute.endpoint wraps IPv6 literals in brackets ([host]:port) per RFC 3986 so the port colon isn't ambiguous. - makeStatus reports usesEphemeralFallback from the stored bind outcome (listenerUsesEphemeralFallback) instead of recomputing listenerPort vs the current configured port, which could flip during an edit/restart window. - syncToSettings() / applyConfiguredPort() drop their UserDefaults parameter: they drive the live singleton listener, which always binds against UserDefaults.standard (start/restart read it too), so a caller-supplied store was never honored for the actual bind. The pure read-only statics keep their defaults parameter for unit testing. CodeRabbit's in-field port-validation nitpick is already handled: an out-of-range value disables Apply and shows the range warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix port-availability probe hang: NWListener needs a newConnectionHandler NWListener does not transition to .ready unless newConnectionHandler is set before start(), so the probe never resumed its continuation on a *free* port — hanging applyConfiguredPort (and the Apply button) on the common success path. Set a reject-everything newConnectionHandler on the probe. Caught by Greptile (P1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden port probe against hangs + localize new search titles - isPortAvailable now races the NWListener probe against a bounded 2s deadline via a task group; cancellation tears down the probe listener through a cancellation handler (.cancelled resolves as unavailable), so an unclassified/stuck listener state can never hang Apply. On timeout the port is reported unavailable (safe: leaves the running listener untouched). - Curated search-entry titles for the new mobile rows are now localized (reuse the row-label keys), so JP search results don't show English. Addresses autoreview (P2 hang, P3 localization) and Cursor/Greptile probe findings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make Apply atomic: make-before-break instead of probe-then-restart The probe-then-restart path was not atomic: the requested port could be taken (or the probe's own socket not yet released) between the availability probe and the real bind, by which point the old listener had already been stopped — dropping connections and landing on an ephemeral port despite the "in-use port leaves the running listener untouched" contract. applyConfiguredPort now binds a *candidate* listener on the requested port while the current one keeps running, and only tears down the old listener and adopts the candidate once it actually reaches .ready. If the candidate can't bind (in use), it's discarded and the live listener is untouched (portInUse). A bounded, cancellable 2s deadline guarantees Apply can't hang. The separate availability probe is removed; the candidate bind is the real bind. portApplyDecision becomes the pure pre-bind classifier portApplyPreBindOutcome (nil = a real bind is needed). Addresses autoreview P1 (non-atomic apply). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document OSC background routing * Add Mobile Connect to the command palette (#5518) * Add Mobile Connect to the command palette New "Mobile Connect" entry in the Cmd+Shift+P command palette that opens the iOS/iPadOS pairing window (same shared MobilePairingWindowController path as Settings → Mobile → Pair a Device). Searchable by ios, ipados, iphone, ipad, pair, pairing, mobile, connect, device, phone, tablet, qr. Keywords live in one place (ContentView.commandPaletteMobileConnectKeywords) so the contribution and its behavioral test can't drift. Test runs the real fuzzy search engine and asserts the command is the top result for "ios" and "ipados" (plus iphone/ipad/pair/mobile connect) against a dense decoy corpus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Rename palette command to "Connect iPhone/iPad" + localize all languages Verb-first name matching the palette's house style; the visible label is "Connect iPhone/iPad" while keywords still match mobile, phone, ios, ipados, iphone, ipad, pair, connect, device, tablet, qr. Title and subtitle are now translated for all 20 locales in Localizable.xcstrings (was en + ja). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Localize numbered shortcut digit validation * fix: measure visible sidebar rows * Add auth commands to command palette (#5529) * Bump version to 0.64.14 * Pair onboarding: drop leading row icons, keep text (#5520) * Pair onboarding: drop leading row icons, keep text The "Pair your iPhone" requirements checklist showed a leading SF Symbol per row (person/checkmark for sign-in, globe/checkmark/warning for Tailscale). Remove the glyph so each row is just title + subtitle text; the "Get Tailscale" trailing link and all state-driven subtitles stay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pair onboarding: also drop header computer/iPhone icon "Get rid of icons, just leave the text" covers the whole card. Removing the header glyph too makes the heading, sign-in row, and Tailscale row all text-only and flush-left at the same inset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix React script warning in web layout (#5525) * fix: avoid React script warning in web layout * fix: preserve theme bootstrap behavior * fix: keep initial theme color media-safe * fix: insert theme bootstrap outside hydration * fix: share theme color constants * CodeRabbit: stop blocking merges (request_changes_workflow=false) (#5538) CodeRabbit was submitting reviews in the Request Changes state, which shows as a blocker in the PR merge box. It is not a required status check, so flipping request_changes_workflow to false makes it post the same findings as plain Comment reviews that never block a merge. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Match iOS viewport border to pane divider color (#5530) * Match iOS viewport border to pane divider color When an iOS device is connected, macOS draws a border marking the area visible to the phone. It hardcoded NSColor.separatorColor at 0.95 alpha, which renders as a bright near-white line in dark mode and doesn't match any other border in the app. Stroke the resolved split-divider color instead (the single source of truth pane dividers already use via GhosttyConfig.resolvedSplitDividerColor), so the viewport border matches every other border and the color lives in one place. Repaint the overlay on background changes so it tracks theme switches while connected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Match iOS viewport border to window-chrome separator color Ground-truth pixel sampling of the rendered borders showed the viewport border (right/bottom of the visible area) did not match the pane outline, sidebar trailing edge, and tab-bar separators: those use WindowChromeSeparatorColor (~rgb(54,55,49) over the default dark bg), while the split-divider color is darker and the old separatorColor@0.95 was much brighter (~rgb(74,76,71)). Stroke WindowChromeSeparatorColor.current() so the viewport border is pixel-identical to every other chrome border, using the same single source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS: redesign the default terminal toolbar layout (#5532) Reorder the iOS terminal accessory bar so the high-traffic keys sit up front: after the modifier keys come Tab, ^C/^D, the Claude/Codex launchers, the arrow keys, then a Clear button (^L, relabeled "Clear"). The zoom controls move from the leading pinned region to a new trailing pinned region at the end of the bar. The remaining punctuation and navigation keys keep their slots, with the pipe positioned after @. The curated default arrangement lives in TerminalInputAccessoryAction.defaultConfigurableOrder, separate from the enum rawValue order, so persisted display-order identifiers are untouched. TerminalAccessoryLayoutReducer takes the default order and defensively appends any omitted configurable id, so a gap in the curated list can never drop an action from the bar. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS: rename and pin workspaces from the phone (#5512) * iOS: rename and pin workspaces from the phone Adds a per-workspace context menu (Rename, Pin/Unpin) to the iOS workspace list, sorts pinned workspaces to the top with a pin glyph, and a rename sheet. The phone drives the Mac's existing workspace-scoped `workspace.action` RPC (pin/unpin/rename). Security: `MobileHostService` only newly authorizes `workspace.action`, and only its pin/unpin/rename sub-actions. The action param is normalized exactly as the handler's `v2ActionKey` (lowercase, '-'->'_') so the gate and handler can never disagree on which action runs, and workspace scope is enforced with the same check used for terminal input: a workspace-scoped ticket may only act on its own workspace, a Mac-wide pairing on any, a terminal-scoped ticket on none. The destructive/global sub-actions (move_*, close_*, set_color, …) and the global methods (reorder_many, group.*, the dedicated workspace.rename) stay Mac-only. New XCTest cases in MobileHostAuthorizationTests lock this down. The Mac now emits `is_pinned` in the mobile workspace-list payload, and the workspace-list observer watches `$isPinned` (and hashes it) so a pure pin toggle pushes to the phone. iOS decodes it backward-compatibly (nil on older Macs), updates the list optimistically with rollback on RPC failure, and the authoritative `workspace.updated` push reconciles. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix PR2 mobile gate: route workspace.action through the real mobile dispatch The live mobile data plane authorizes identity via same-Stack-account verification and gates method exposure with an explicit allowlist in TerminalController.mobileHostHandleRPC (default -> method_not_found), not via MobileHostService.ticketAuthorizationError (which is only reached by the test hook). The earlier allowlist edit + tests targeted that test-only function, so workspace.action would have returned method_not_found at runtime and rename/pin would silently fail. Revert the ineffective MobileHostService change and its tests. Add a gated case "workspace.action" to mobileHostHandleRPC via v2MobileWorkspaceAction, which rejects every sub-action except pin/unpin/rename (normalized exactly as v2ActionKey) before calling v2WorkspaceAction, so move_*/close_*/set_color/etc. stay Mac-only. Cover the gate with a pure mobileAllowsWorkspaceAction test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2 review: roll back only the targeted workspace field, match-guarded Autoreview P1: the optimistic rename/pin restored the whole `workspaces` snapshot on RPC failure, which could clobber newer authoritative state (a reconnect or a workspace.updated refresh landing while the request was in flight). Roll back only the single workspace's name/isPinned, and only when our optimistic value is still present, so a concurrent refresh is never reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2 review: drop optimistic rename/pin, rely on authoritative push Second review iteration still found an optimistic-rollback race (overlapping pin then unpin, both failing, could leave stale local state). Stop patching the rollback and remove the optimistic mutation entirely: the Mac applies the rename/pin and its workspace-list observer pushes workspace.updated (now also on $isPinned), which refreshes the list. Fire-and-forget RPC, no local mutation, so no rollback and no overlap race. (The review's "removes terminal OSC/background handling" note is incorrect; this branch touches no terminal-rendering code.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2 review: gate rename/pin UI on a host capability Review P2: the rename/pin affordances were shown on every connected Mac, but an older Mac lacking the new mobile workspace.action handler returns method_not_found, so the actions silently no-op. Advertise a workspace.actions.v1 capability in mobile.host.status, capture it on the client when reading host status, and only pass the rename/pin closures when the connected Mac supports it (reusing the existing nil-closure hiding). Reset on disconnect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2: build the capability-gated closures as literals The previous commit's `store.supportsWorkspaceActions ? method : nil` ternary tripped a Swift type-checker bug ("failed to produce diagnostic") inside the large WorkspaceListView initializer. Build the optional rename/pin closures as explicit closure literals capturing the store instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2 review: advertise workspace.actions.v1 on the live status paths Review P1: the mobile listener intercepts mobile.host.status and returns the public-status cache / publicHostStatusResult before TerminalController runs, so adding workspace.actions.v1 only to TerminalController's status left it invisible to the iOS client. supportsWorkspaceActions stayed false and rename/pin were hidden even on a supporting Mac. Consolidate all three capability lists into one source of truth (MobileHostService.mobileHostCapabilities), advertised on every status path, so the lists cannot drift again. Add a contract test asserting the shared list advertises workspace.actions.v1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR2 review: require an explicit valid workspace_id for mobile actions Review P2: v2MobileWorkspaceAction forwarded to v2WorkspaceAction, which falls back to the Mac's selected workspace when workspace_id is missing. A malformed or omitted workspace_id from the mobile plane could therefore pin/rename the wrong workspace. Validate like the other mobile handlers and additionally require the id to be present and resolvable before dispatching this mutating action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iOS: multi-Mac host switcher (#5513) * iOS: multi-Mac host switcher Adds a Settings -> Connection -> "Switch Mac" picker that lists every Mac paired with this device, marks the active one, switches the live connection on tap, forgets on swipe, and pairs another Mac by scanning its QR without dropping the others. The on-device SQLite store already persisted N paired Macs; only the UI was missing (loadAll was test-only). MobileShellComposite gains pairedMacs, activeMacDeviceID, loadPairedMacs(), switchToMac(macDeviceID:) (setActive then reconnect via the existing launch-time reconnect path), and forgetMac(macDeviceID:). MobileHostPickerView drives them, reached from MobileSettingsView; the store is threaded as an optional through WorkspaceShellView -> WorkspaceListView -> MobileSettingsView so workspace rows stay value-only. Scope: switches among distinct Macs (each QR pairing stores a real macDeviceID). Multiple cmux instances on one Mac share a macDeviceID and need a schema change to distinguish, so that remains a deliberate follow-up. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR3 review: scope setActive's clear to the target Mac's Stack user Autoreview P1: switchToMac called the unscoped setActive, which cleared is_active across every row. On a shared device, switching hosts for one signed-in user wiped another user's active pairing, so they failed to auto-reconnect after signing back in. Scope the clear to the target Mac's own stack_user_id via a null-safe subquery (mirroring upsert's scoped clear). Add a store regression test proving a second Stack user's active pairing survives the switch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR3 review: connect before persisting active Mac; clear cache on sign-out Two review findings: - P1: switchToMac persisted the new active row before the reconnect, so switching to an offline/stale-route Mac stranded the user on an unreachable host that recovery kept retrying, with no way back to the switcher. Now it connects to the target's route first and persists setActive only on a successful connect, so a failed switch leaves the previously-working Mac active and reachable. - P2: the cached pairedMacs list could leak across signed-in users on a shared device. Clear it on sign-out and gate loadPairedMacs on isSignedIn. Also drop the unreliable activeMacDeviceID (the live attach ticket carries a transient manual id after a reconnect, not the stored Mac's real id); the switcher now marks the active row by the store's isActive flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR3 review: fix switch race, cross-user load, and active-row forget Third review pass, three findings: - P1: a switch superseded mid-connect could persist the wrong active Mac (post-hoc connectionState check wasn't tied to this connect). Persist setActive only when the live route matches this Mac's normalized host:port. - P1: loadPairedMacs passed a nil Stack user id straight to loadAll, which returns every user's pairings. Treat a missing current user as no pairings. - P2: forgetting the active row deleted by the live ticket's transient manual id, which may not be the stored row, leaving it behind. Always remove the selected real id, and tear down the live connection via the new disconnectLiveConnection helper when that row is active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR3 review: restore previous connection on failed switch; guard stale load Fourth review pass: - P1: connectManualHost is destructive (replaces the live client before the new route proves usable), so a failed switch to an offline/stale Mac dropped the working session. Capture the previously-active Mac and, when the switch does not connect, reconnect to it (it remains the store's active row since setActive only runs on success), so a failed switch self-restores instead of stranding. - P2: loadPairedMacs assigned results after an await without rechecking the user; a slow load could repopulate another user's hosts after sign-out. Re-check isSignedIn and the current Stack user after the await and discard on mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PR3: document disconnectAndForgetActiveMac (Aziz doc policy) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add React and Solid agent session panels (#4429) * Escape inlined agent session bundles * Use file URL for agent session shells * Avoid stale agent session web cache * Use persistent agent session web store * Add CLI path for agent session surfaces * Avoid focus reads during PR refresh scheduling * Load agent session shell from HTML string * Fill agent session web panels * Fix agent session web view hosting * Flush agent session page paint after load * Flush agent session page after render frames * Flush agent session paint when visible * Address agent session review findings * Polish transparent agent session UI * Make agent session panel background transparent * Speak Codex app-server JSON-RPC * Avoid blanking retained agent webviews * Auto-start themed agent sessions * Cover agent GUI auto-start po…
Problem
Nightly settings (and the whole app) render with the legacy, non-Liquid-Glass appearance on macOS 26 Tahoe. This regressed in #5022 (2026-05-30), which moved the nightly build off the macOS 26 runner onto macOS 15 to fix the universal Ghostty CLI helper link.
Root cause is the linked SDK, not any opt-out key. The macOS 15 runner's default Xcode is 16.4 (MacOSX15.5 SDK), confirmed from the latest nightly run log. A binary linked against a pre-26 SDK is automatically placed in the legacy compatibility appearance on Tahoe, the same effect as
UIDesignRequiresCompatibility=YES. Before #5022 nightly built on the Tahoe runner (#2231, 2026-03-26) and got Liquid Glass.Fix
Keep the macOS 15 host, because zig 0.15.2 can cross-link the x86_64 helper slice only against a pre-26 SDK, but build the Swift app with the macOS 26 Xcode that already ships on the runner image (matching the
.xcode-version26 pin). The universal Ghostty helper is built in a separate step with the older Xcode and injected into the bundle before signing.Select Xcodenow picks the newest macOS 26+ Xcode for the app build (DEVELOPER_DIR) and the newest pre-26 Xcode for the helper (HELPER_DEVELOPER_DIR), failing loudly if either is missing so a legacy-SDK build can't silently ship again.Build universal Ghostty CLI helperstep builds the arm64+x86_64 helper with the older SDK and verifies both slices.CMUX_SKIP_ZIG_BUILD=1(the in-Xcode helper build would fail to cross-link x86_64 against the 26 SDK), then the real helper is injected atContents/Resources/bin/ghosttybefore the theme-picker regression, arch verification, and signing.The existing
Verify nightly binary architecturesstep still gates universality of the app, CLI, and helper.Validation
Triggering a branch run of
nightly.yml(should_publish=false, so it builds + signs + notarizes a branch DMG without moving thenightlytag or publishing) to confirm the universal arch checks pass and the produced app adopts Liquid Glass on Tahoe.Note
release.ymlhas the identicalSelect Xcode(Xcode 16.4) pattern, so stable releases also ship the legacy appearance on Tahoe. The same fix should be applied there; not included here to keep this change focused.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.Summary by cubic
Build the nightly app with the macOS 26 SDK so Tahoe renders with Liquid Glass. The universal Ghostty CLI helper is built against a pre-26 SDK and injected to keep a universal binary.
DEVELOPER_DIR) and pre-26 for the helper (HELPER_DEVELOPER_DIR) using numeric SDK ranking; fail if either is missing.zig 0.15.2and verifyarm64/x86_64.CMUX_SKIP_ZIG_BUILD=1and inject the verified helper before signing; use mkdir/install to make the inject step robust.Written for commit 6ed048f. Summary will update on new commits.
Summary by CodeRabbit