Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2c43d31
Decouple iOS primary terminal scroll
lawrencecchen Jun 14, 2026
eee05fb
Configure iOS dev API base URL
lawrencecchen Jun 14, 2026
25eb92e
Fix iOS native auth LAN callback
lawrencecchen Jun 14, 2026
2438ab6
Fix native auth route tests
lawrencecchen Jun 14, 2026
171d624
Add iOS science demo bypass
lawrencecchen Jun 14, 2026
8e4636f
Improve iOS science demo scrollback
lawrencecchen Jun 14, 2026
a8b2ead
Update Ghostty pixel scroll renderer
lawrencecchen Jun 14, 2026
a1697cc
Enable desktop smooth scroll path
lawrencecchen Jun 14, 2026
c156503
Use wheel events for desktop smooth scroll
lawrencecchen Jun 14, 2026
f82bcbf
Use fractional row offsets for desktop smooth scroll
lawrencecchen Jun 14, 2026
aff8198
Add debug fractional scroll verification hook
lawrencecchen Jun 14, 2026
b6e9f97
Fix smooth scroll fractional direction
lawrencecchen Jun 14, 2026
f73e2c3
Preload Ghostty rows for smooth scroll edges
lawrencecchen Jun 15, 2026
415b28b
Add iOS instant terminal scroll mode
lawrencecchen Jun 15, 2026
c617afa
Hydrate iOS terminal replay with full scrollback
lawrencecchen Jun 15, 2026
d0797e3
Fix pixel scroll boundary clamp
lawrencecchen Jun 16, 2026
ed18dda
Retry mobile replay after workspace mapping
lawrencecchen Jun 16, 2026
6b54974
Fix mobile dogfood attach during session restore
lawrencecchen Jun 16, 2026
a7c2a20
Merge remote-tracking branch 'origin/main' into feat-ios-smooth-scrol…
lawrencecchen Jun 16, 2026
e05ca47
Fix iOS local scrollback offset control
lawrencecchen Jun 16, 2026
c3b8822
Gate iOS replay on terminal geometry
lawrencecchen Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ extension MobileShellComposite {
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if let immediate {
continuation.yield(
MobileTerminalOutputChunk(data: immediate.bytes, streamToken: streamToken)
MobileTerminalOutputChunk(
data: immediate.bytes,
streamToken: streamToken,
activeScreen: immediate.activeScreen
)
)
}
}
Expand All @@ -45,6 +49,10 @@ extension MobileShellComposite {
terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else {
return
}
continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken))
continuation.yield(MobileTerminalOutputChunk(
data: next.bytes,
streamToken: streamToken,
activeScreen: next.activeScreen
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ struct TerminalOutputDelivery: Equatable, Sendable {
frame.vtPatchBytes()
}
}

var activeScreen: MobileTerminalRenderGridFrame.Screen? {
switch payload {
case .bytes:
nil
case .renderGrid(let frame):
frame.activeScreen
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/// Backpressure queue for one mounted mobile terminal output stream.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ import Testing
#expect(!vt.contains("old"))
}

@Test func terminalOutputDeliveryCarriesRenderGridActiveScreen() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal",
stateSeq: 1,
columns: 12,
rows: 2,
rowSpans: [],
activeScreen: .alternate
)
let delivery = TerminalOutputDelivery(renderGrid: frame, replaceable: false)
let rawDelivery = TerminalOutputDelivery(bytes: Data("raw".utf8), replaceable: false)

#expect(delivery.activeScreen == .alternate)
#expect(rawDelivery.activeScreen == nil)
}

@Test func terminalOutputQueuePreservesNonreplaceableBarriers() {
var queue = TerminalOutputDeliveryQueue()
let inFlight = TerminalOutputDelivery(bytes: Data("in-flight".utf8), replaceable: false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
public import CMUXMobileCore
public import Foundation

/// A seam exposing per-surface terminal output as an `AsyncStream`.
Expand All @@ -14,10 +15,18 @@ public import Foundation
/// propagation is a structured, cancellable `AsyncSequence` instead of a stored
/// callback.
public struct MobileTerminalOutputChunk: Sendable {
/// The active terminal screen captured by the render-grid frame that
/// produced ``data``. Raw byte fallback chunks carry `nil`.
public let activeScreen: MobileTerminalRenderGridFrame.Screen?
public let data: Data
public let streamToken: UUID

public init(data: Data, streamToken: UUID) {
public init(
data: Data,
streamToken: UUID,
activeScreen: MobileTerminalRenderGridFrame.Screen? = nil
) {
self.activeScreen = activeScreen
self.data = data
self.streamToken = streamToken
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
guard let surfaceView else { return }
surfaceView.applyTerminalOutputMetadata(activeScreen: chunk.activeScreen)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply screen metadata after processing output

When a render-grid chunk switches screens, this updates activeScreen before the VT patch has been applied to the local Ghostty surface. While processOutputAndWait is still queued/running, a user scroll can hit the old local surface state but the new forwarding policy; for example an exit-from-alt-screen chunk marks the view primary, the still-alt local scroll drops the wheel bytes, and the guard suppresses the host scroll, so the gesture is lost. Apply the metadata only after the chunk has been processed, or make the local scroll/forward decision use the same committed screen state.

Useful? React with 👍 / 👎.

await surfaceView.processOutputAndWait(chunk.data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Metadata update races VT bytes during processOutputAndWait suspension

applyTerminalOutputMetadata commits the new activeScreen (e.g., .primary) before the await processOutputAndWait suspension returns. Because the @MainActor is free to interleave work during that suspension, a display-link scroll flush can fire in the window where activeScreen already says .primary but local Ghostty is still rendering the alternate surface. In that state flushPendingScrollIfNeeded calls applyLocalScrollbackScroll on what is still an alternate-screen surface, while simultaneously suppressing host forwarding — the net effect is a scroll event that neither reaches the TUI nor lands usefully in the local scrollback. Swapping the call order so metadata is applied after processOutputAndWait closes this window.

store.terminalOutputDidProcess(
surfaceID: surfaceID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,6 @@ import UIKit

private let log = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.surface")

// lint:allow namespace-enum — file-local DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave.
enum TerminalInputDebugLog {
private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1"
private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input")

static func log(_ message: String) {
#if DEBUG
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
return
}
#endif
guard isEnabled else { return }
logger.debug("input: \(message, privacy: .public)")
}

static func textSummary(_ text: String) -> String {
let summary = String(reflecting: text)
guard summary.count > 96 else { return summary }
return "\(summary.prefix(96))..."
}

static func dataSummary(_ data: Data) -> String {
let prefix = data.prefix(32)
let prefixData = Data(prefix)
let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ")
let utf8 = String(data: prefixData, encoding: .utf8) ?? "<non-utf8>"
let suffix = data.count > prefix.count ? " ..." : ""
return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))"
}
}

@MainActor
public protocol GhosttySurfaceViewDelegate: AnyObject {
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didProduceInput data: Data)
Expand Down Expand Up @@ -622,6 +591,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
private var lastAppliedContentScale: CGFloat = 0
private var surfaceHasReceivedOutput: Bool = false
private var shouldScrollInitialOutputToBottom = true
private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale activeScreen survives stream tear-down

activeScreen is never reset when the output stream is detached and reattached (e.g., after a network drop or background transition). If the previous session ended while the terminal was in .alternate mode, the property stays .alternate until the first render-grid frame arrives in the new stream. During that window every primary-screen scroll gesture is forwarded to the host instead of staying local. Consider resetting activeScreen to .primary in the view's stream-teardown path, or alternatively applying the reset at the start of each new stream token before yielding the first chunk.

private let scrollForwardingPolicy = MobileTerminalScrollForwardingPolicy()
/// Serial background queue for `ghostty_surface_process_output`, which
Comment on lines +605 to 611

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle unknown screen metadata as “forward” to preserve raw-byte fallback behavior.

Line 625 initializes activeScreen to .primary, and Line 2127 drops nil metadata. For raw-byte fallback chunks (where metadata is nil), Line 1816 always treats scroll as primary and suppresses host forwarding, which breaks alternate-screen TUI wheel events on legacy-host compatibility paths.

💡 Suggested fix
-    private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary
+    private var activeScreen: MobileTerminalRenderGridFrame.Screen?
@@
-        guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {
-            return
-        }
+        if let activeScreen,
+           !scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) {
+            return
+        }
@@
     public func applyTerminalOutputMetadata(
         activeScreen: MobileTerminalRenderGridFrame.Screen?
     ) {
         guard let activeScreen else { return }
         self.activeScreen = activeScreen
     }

Also applies to: 1816-1819, 2124-2129

🤖 Prompt for 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.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`
around lines 625 - 627, The code currently defaults to treating unknown screen
metadata (when nil) as primary screen and suppressing host forwarding, breaking
alternate-screen TUI wheel events on legacy hosts. Fix this by modifying the
scroll handling logic to forward scroll events when screen metadata is unknown
instead of suppressing them. At the activeScreen initialization site (lines
625-627), ensure the default handling accommodates nil metadata. At the scroll
processing logic (lines 1816-1819), modify the condition to forward scroll
events when metadata is nil rather than assuming primary screen behavior. At the
metadata handling site (lines 2124-2129), ensure nil metadata is preserved
through the pipeline rather than dropped so downstream logic can apply
forward-fallback behavior.

/// blocks on libghostty's internal renderer/IO futex. Running it on the
/// main thread hangs the app until the scene-update watchdog kills it.
Expand Down Expand Up @@ -1811,6 +1782,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
let cell = pendingScrollCell
pendingScrollLines = 0
applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row)
guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve alternate-screen state before gating scrolls

Here activeScreen is not authoritative for all chunks yet: raw-byte fallback events carry nil so the view remains at its default .primary, and render-grid cursor-only/no-row-change deltas are built in MobileTerminalRenderObserver.emitRenderGrid without snapshot.frame.activeScreen, so they encode .primary even while a TUI is still on the alternate screen. In both cases an alternate-screen app such as vim/less/htop stops receiving wheel events after that chunk because this returns before didScrollLines; keep forwarding for unknown state and preserve the screen on every delta before using it to suppress host scrolls.

Useful? React with 👍 / 👎.

return
}
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve scrollback prefetch for primary-screen scrolls

When activeScreen is .primary, this new guard returns before the delegate call, so GhosttySurfaceRepresentable never invokes store.scrollTerminal(...). That RPC path is also the only place that primes and refreshes the larger max_scrollback_rows render-grid prefetch, so after a user scrolls beyond the cold-attach local scrollback window the phone has no way to fetch older primary-screen history and scrollback stops short even though the Mac still has it.

Useful? React with 👍 / 👎.

delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row)
}

Expand Down Expand Up @@ -2109,6 +2083,20 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
processOutput(data, completion: nil)
}

/// Applies metadata attached to the next terminal output chunk.
///
/// Render-grid output carries the authoritative active screen, which lets
/// local scrollback stay phone-local on the primary screen while alternate
/// screen TUIs still receive host mouse-wheel events.
/// - Parameter activeScreen: The active screen from the render-grid frame,
/// or `nil` for raw byte fallback chunks.
public func applyTerminalOutputMetadata(
activeScreen: MobileTerminalRenderGridFrame.Screen?
) {
guard let activeScreen else { return }
self.activeScreen = activeScreen
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

/// Process terminal output and return after the output has been applied.
///
/// The call still performs libghostty output processing on the serial
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if canImport(UIKit)
import Foundation
import OSLog

// lint:allow namespace-enum — DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave.
enum TerminalInputDebugLog {
private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1"
private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input")

static func log(_ message: String) {
#if DEBUG
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
return
}
#endif
guard isEnabled else { return }
logger.debug("input: \(message, privacy: .public)")
Comment on lines +10 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make debug logging lazy to avoid per-keystroke overhead when disabled.

On Line 10, log(_:) takes an eager String, so callers still build textSummary(...)/dataSummary(...) before guard isEnabled runs. On the input path this adds avoidable work even when debug logging is off.

Proposed fix
-    static func log(_ message: String) {
-        `#if` DEBUG
-        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
-            return
-        }
-        `#endif`
-        guard isEnabled else { return }
-        logger.debug("input: \(message, privacy: .public)")
-    }
+    static func log(_ message: `@autoclosure` () -> String) {
+        guard isEnabled else { return }
+        `#if` DEBUG
+        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
+            return
+        }
+        `#endif`
+        logger.debug("input: \(message(), privacy: .public)")
+    }

As per coding guidelines, typing-latency-sensitive paths must avoid adding unnecessary per-event work, and this helper is used on terminal input events.

🤖 Prompt for 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.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputDebugLog.swift`
around lines 10 - 17, The `log(_:)` method accepts an eager String parameter,
which forces callers to construct the message string (via `textSummary(...)` or
`dataSummary(...)`) before the function runs, adding unnecessary per-keystroke
overhead when debug logging is disabled. Change the parameter from `_ message:
String` to use a closure parameter (either `_ message: `@autoclosure` () ->
String` or `_ message: () -> String`) so the message construction is deferred
until after the `guard isEnabled else { return }` check. If using
`@autoclosure`, the callers will not need to change their call sites; if using a
regular closure, callers will need to wrap their message arguments in a closure.

Source: Coding guidelines

}

static func textSummary(_ text: String) -> String {
let summary = String(reflecting: text)
guard summary.count > 96 else { return summary }
return "\(summary.prefix(96))..."
}

static func dataSummary(_ data: Data) -> String {
let prefix = data.prefix(32)
let prefixData = Data(prefix)
let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ")
let utf8 = String(data: prefixData, encoding: .utf8) ?? "<non-utf8>"
let suffix = data.count > prefix.count ? " ..." : ""
return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))"
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
public import CMUXMobileCore

/// Decides whether a mobile terminal scroll gesture must be sent to the Mac.
public struct MobileTerminalScrollForwardingPolicy: Sendable {
/// Creates the forwarding policy.
public init() {}

/// Returns whether a scroll should be forwarded to the host surface.
///
/// Primary-screen scrollback is already mirrored into the phone's local
/// Ghostty surface, so forwarding would make scroll feel network-bound.
/// Alternate-screen scroll must still reach the host so TUIs with mouse
/// reporting receive wheel events.
/// - Parameter activeScreen: The screen currently rendered by the mobile
/// Ghostty mirror.
/// - Returns: `true` when the scroll should be sent to the Mac.
public func shouldForwardToHost(
activeScreen: MobileTerminalRenderGridFrame.Screen
) -> Bool {
activeScreen == .alternate
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import CMUXMobileCore
import CmuxMobileTerminalKit
import Testing

@Suite struct MobileTerminalScrollForwardingPolicyTests {
@Test func primaryScreenScrollStaysLocal() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .primary) == false)
}

@Test func alternateScreenScrollForwardsToHost() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .alternate))
}
}
8 changes: 6 additions & 2 deletions ios/Config/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<string>$(CMUX_DEV_TAG)</string>
<key>CMUXGitSHA</key>
<string>$(CMUX_GIT_SHA)</string>
<key>CMUXApiBaseURL</key>
<string>$(CMUX_API_BASE_URL)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
Expand Down Expand Up @@ -63,8 +65,10 @@
ONLY WKWebView web content from ATS; the app's own API/auth/pairing
traffic stays under ATS and still requires HTTPS. NSAllowsLocalNetworking
alone would not cover routable private-LAN dev servers. -->
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
Comment thread
cursor[bot] marked this conversation as resolved.
Comment on lines +76 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep arbitrary ATS loads out of release builds

This shared iOS Info.plist is used by both Debug and Release/TestFlight configs, so setting NSAllowsArbitraryLoads here disables ATS for all app networking, not just the WKWebView case described by the surrounding comment. In a release build this means auth/API/pairing URLSession calls can use plain HTTP if a URL is ever misconfigured or overridden; the dev-only API override should be scoped to Debug or to a narrow exception instead of globally weakening ATS.

Useful? React with 👍 / 👎.

<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
Comment on lines +76 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security NSAllowsArbitraryLoads globally disables ATS for every app connection

Adding NSAllowsArbitraryLoads: true removes ATS for all URLSession traffic — API, auth, and pairing — not just WKWebView. This directly contradicts the comment just above it ("the app's own API/auth/pairing traffic stays under ATS and still requires HTTPS"). The existing NSAllowsArbitraryLoadsInWebContent: true was already sufficient for WKWebView; the new key is redundant for that purpose but breaks ATS for everything else. If an HTTP CMUX_API_BASE_URL override needs to work in dev, the right fix is a domain-specific exception under NSExceptionDomains or gating this key to debug build configurations only — not shipping it unconditionally in production.

Comment on lines +76 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove global ATS bypass (NSAllowsArbitraryLoads)

Line 68 enables unrestricted HTTP for all app traffic, which defeats ATS globally and contradicts the scope documented in Lines 62-67 (“ONLY WKWebView”). Keep NSAllowsArbitraryLoadsInWebContent if needed, but remove NSAllowsArbitraryLoads (or replace with narrowly scoped exception domains).

Suggested fix
-		<key>NSAllowsArbitraryLoads</key>
-		<true/>
 		<key>NSAllowsArbitraryLoadsInWebContent</key>
 		<true/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
🤖 Prompt for 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.

In `@ios/Config/Info.plist` around lines 68 - 71, Remove the
NSAllowsArbitraryLoads key and its corresponding true value from the Info.plist
file (lines 68-69). This setting globally disables App Transport Security for
all app traffic, which contradicts the documented scope that restricts HTTP to
WKWebView only. Keep the NSAllowsArbitraryLoadsInWebContent setting if needed
for web content, but ensure global ATS bypass is not enabled.

</dict>
<key>UIApplicationSceneManifest</key>
<dict>
Expand Down
14 changes: 13 additions & 1 deletion ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ public struct MobileAuthComposition {
private static func localConfigStringOverrides(in bundle: Bundle) -> [String: String] {
guard let path = bundle.path(forResource: "LocalConfig", ofType: "plist"),
let dict = NSDictionary(contentsOfFile: path) as? [String: Any] else {
return [:]
return infoPlistStringOverrides(in: bundle)
}
var overrides: [String: String] = [:]
for (key, value) in dict {
Expand All @@ -151,6 +151,18 @@ public struct MobileAuthComposition {
}
}
}
for (key, value) in infoPlistStringOverrides(in: bundle) where overrides[key] == nil {
overrides[key] = value
}
return overrides
}

private static func infoPlistStringOverrides(in bundle: Bundle) -> [String: String] {
guard let apiBaseURL = bundle.object(forInfoDictionaryKey: "CMUXApiBaseURL") as? String else {
return [:]
}
let trimmed = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else { return [:] }
return ["ApiBaseURL": trimmed]
}
}
3 changes: 3 additions & 0 deletions ios/scripts/reload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ SIMULATOR_NAME="${IOS_SIMULATOR_NAME:-iPhone 17}"
DEVICE_ID="${IOS_DEVICE_ID:-}"
DEVICE_NAME="${IOS_DEVICE_NAME:-}"
DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-}"
CMUX_API_BASE_URL="${CMUX_API_BASE_URL:-}"
LAUNCH=1
RELOAD_SIMULATOR=1
RELOAD_DEVICE=0
Expand Down Expand Up @@ -408,6 +409,7 @@ reload_simulator() {
PRODUCT_DISPLAY_NAME="$DISPLAY_NAME" \
CMUX_GIT_SHA="$GIT_SHA" \
CMUX_DEV_TAG="$TAG" \
CMUX_API_BASE_URL="$CMUX_API_BASE_URL" \
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
SWIFT_OPTIMIZATION_LEVEL=-O \
Expand Down Expand Up @@ -509,6 +511,7 @@ reload_device() {
PRODUCT_DISPLAY_NAME="$DISPLAY_NAME"
CMUX_GIT_SHA="$GIT_SHA"
CMUX_DEV_TAG="$TAG"
CMUX_API_BASE_URL="$CMUX_API_BASE_URL"
EXCLUDED_SOURCE_FILE_NAMES=Info.plist
CODE_SIGNING_ALLOWED=YES
CODE_SIGN_STYLE=Automatic
Expand Down
38 changes: 1 addition & 37 deletions web/app/handler/after-sign-in/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { stackServerApp } from "../../lib/stack";
import { env } from "../../env";
import { isAllowedNativeReturnTo } from "../native-auth-helpers";
import type { Locale } from "../../../i18n/routing";
import { locales, routing } from "../../../i18n/routing";

export const dynamic = "force-dynamic";

const NATIVE_SCHEME = "cmux://";
const NATIVE_SCHEMES = new Set(["cmux", "cmux-nightly"]);
const NATIVE_HANDOFF_COOKIE = "cmux-native-auth-handoff";
const NATIVE_HANDOFF_PARAM = "cmux_auth_handoff";

Expand All @@ -23,42 +23,6 @@ type LocalizedAfterSignInMessages = {
messages: AfterSignInMessages;
};

function isLocalRequest(request: NextRequest): boolean {
const hostHeader = request.headers.get("host");
const host = (hostHeader?.split(":")[0] ?? request.nextUrl.hostname).toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
}

function localAllowedNativeSchemes(): Set<string> {
const values = [
process.env.CMUX_AUTH_CALLBACK_SCHEME,
process.env.CMUX_ALLOWED_NATIVE_CALLBACK_SCHEMES,
process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES,
];
const schemes = new Set<string>();
for (const value of values) {
for (const raw of value?.split(/[\s,]+/) ?? []) {
const scheme = raw.trim().replace(/:\/\/.*$/, "").replace(/:$/, "");
if (/^cmux-dev-[a-z0-9-]+$/.test(scheme)) schemes.add(scheme);
}
}
return schemes;
}

function isAllowedNativeReturnTo(href: string, request: NextRequest): boolean {
try {
const url = new URL(href);
if (url.hostname !== "auth-callback") return false;
if (url.pathname !== "" && url.pathname !== "/") return false;
const scheme = url.protocol.replace(":", "");
if (NATIVE_SCHEMES.has(scheme)) return true;
if (scheme === "cmux-dev") return isLocalRequest(request);
return isLocalRequest(request) && localAllowedNativeSchemes().has(scheme);
} catch {
return false;
}
}

function findStackCookie(
cookieStore: { getAll: () => { name: string; value: string }[] },
baseName: string
Expand Down
Loading
Loading