Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,100 @@
import Foundation

/// Pure policy for the Mac-side mobile pairing host's preferred TCP port.
public struct MobileHostPortPolicy: Sendable {
/// Inclusive TCP port range accepted by the mobile-host setting.
public static let validPortRange: ClosedRange<Int> = 1...65_535

/// Dynamic/private TCP port range used for deterministic tagged DEBUG defaults.
public static let taggedDevelopmentPortRange: ClosedRange<Int> = 49_152...65_535

private let portDefaultsKey: String
private let catalogDefaultPort: Int

/// Creates a policy for the supplied settings key and catalog default.
public init(
portDefaultsKey: String = SettingCatalog().mobile.iOSPairingPort.userDefaultsKey,
catalogDefaultPort: Int = SettingCatalog().mobile.iOSPairingPort.defaultValue
) {
self.portDefaultsKey = portDefaultsKey
self.catalogDefaultPort = catalogDefaultPort
}

/// Returns the preferred port for the mobile host listener.
///
/// A valid explicit setting wins. When unset or invalid, tagged DEBUG builds
/// derive a stable port from ``SocketControlSettings/launchTag(environment:)``;
/// release and untagged builds use the catalog default.
public func configuredPort(
defaults: UserDefaults = .standard,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Int {
let fallback = defaultConfiguredPort(
environment: environment,
catalogDefaultPort: catalogDefaultPort
)
guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else {
return fallback
}
return Self.validPortRange.contains(raw) ? raw : fallback
}

/// Returns the desired listener port, or `nil` when a stored explicit value is
/// present but invalid and should not disturb a running listener.
public func resolvedDesiredPort(
defaults: UserDefaults = .standard,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Int? {
guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else {
return defaultConfiguredPort(
environment: environment,
catalogDefaultPort: catalogDefaultPort
)
}
return Self.validPortRange.contains(raw) ? raw : nil
}

/// Returns the fallback port for unset or invalid settings.
public func defaultConfiguredPort(
environment: [String: String] = ProcessInfo.processInfo.environment,
catalogDefaultPort: Int = SettingCatalog().mobile.iOSPairingPort.defaultValue
) -> Int {
#if DEBUG
if let port = taggedDevelopmentDefaultPort(
environment: environment,
catalogDefaultPort: catalogDefaultPort
) {
return port
}
#endif
return catalogDefaultPort
}

#if DEBUG
private func taggedDevelopmentDefaultPort(
environment: [String: String],
catalogDefaultPort: Int
) -> Int? {
guard let tag = SocketControlSettings.launchTag(environment: environment) else {
return nil
}
let normalizedTag = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalizedTag.isEmpty, normalizedTag != "default" else { return nil }

var hash: UInt32 = 2_166_136_261
for byte in normalizedTag.utf8 {
hash ^= UInt32(byte)
hash &*= 16_777_619
}

let lowerBound = Self.taggedDevelopmentPortRange.lowerBound
let upperBound = Self.taggedDevelopmentPortRange.upperBound
let portCount = upperBound - lowerBound + 1
var port = lowerBound + Int(hash % UInt32(portCount))
if port == catalogDefaultPort {
port = lowerBound + ((port - lowerBound + 1) % portCount)
}
return port
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import Testing

@testable import CmuxSettings

@Suite struct MobileHostPortPolicyTests {
#if DEBUG
@Test func taggedDebugDefaultUsesStablePerTagPortWhenUnset() throws {
let suiteName = "MobileHostPortPolicyTests.Tagged.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

let catalogDefault = SettingCatalog().mobile.iOSPairingPort.defaultValue
let nodivsEnvironment = [SocketControlSettings.launchTagEnvKey: "nodivs"]
let wtodoEnvironment = [SocketControlSettings.launchTagEnvKey: "wtodo"]
let policy = MobileHostPortPolicy()

let nodivs = policy.configuredPort(
defaults: defaults,
environment: nodivsEnvironment
)
let nodivsRelaunch = policy.configuredPort(
defaults: defaults,
environment: nodivsEnvironment
)
let wtodo = policy.configuredPort(
defaults: defaults,
environment: wtodoEnvironment
)

#expect(nodivs == nodivsRelaunch)
#expect(nodivs != catalogDefault)
#expect(wtodo != catalogDefault)
#expect(nodivs != wtodo)
#expect(MobileHostPortPolicy.taggedDevelopmentPortRange.contains(nodivs))
#expect(MobileHostPortPolicy.taggedDevelopmentPortRange.contains(wtodo))
#expect(policy.resolvedDesiredPort(
defaults: defaults,
environment: wtodoEnvironment
) == wtodo)
}
#endif

@Test func validOverrideWinsOverTaggedDefault() throws {
let suiteName = "MobileHostPortPolicyTests.Override.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

defaults.set(9000, forKey: SettingCatalog().mobile.iOSPairingPort.userDefaultsKey)
let environment = [SocketControlSettings.launchTagEnvKey: "nodivs"]
let policy = MobileHostPortPolicy()

#expect(policy.configuredPort(
defaults: defaults,
environment: environment
) == 9000)
#expect(policy.resolvedDesiredPort(
defaults: defaults,
environment: environment
) == 9000)
}
}
40 changes: 23 additions & 17 deletions Sources/Mobile/MobileHostService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -472,16 +472,19 @@ final class MobileHostService {
/// The preferred TCP port the listener should try to bind, read from
/// settings.
///
/// Falls back to the catalog default (which mirrors
/// `CmxMobileDefaults.defaultHostPort`) when unset or outside the valid
/// `1...65535` range. The listener still falls back to an OS-assigned
/// ephemeral port if this port is unavailable at bind time.
nonisolated static func configuredPort(defaults: UserDefaults = .standard) -> Int {
let fallback = SettingCatalog().mobile.iOSPairingPort.defaultValue
guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else {
return fallback
}
return (1...65535).contains(raw) ? raw : fallback
/// Falls back to the launch identity's default when unset or outside the
/// valid `1...65535` range. Tagged DEBUG builds get a stable per-tag port;
/// release and untagged builds keep the catalog default. The listener still
/// falls back to an OS-assigned ephemeral port if this port is unavailable
/// at bind time.
nonisolated static func configuredPort(
defaults: UserDefaults = .standard,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Int {
MobileHostPortPolicy().configuredPort(
defaults: defaults,
environment: environment
)
}

/// The port a settings change should reconcile the *running* listener to, or
Expand All @@ -490,13 +493,16 @@ final class MobileHostService {
/// Distinguished from ``configuredPort(defaults:)`` so an invalid value the
/// user is still editing (the field shows a warning) does not tear down a
/// running listener and silently rebind it to the default port. Returns the
/// catalog default when unset, the override when valid, and `nil` when the
/// stored value is out of range.
nonisolated static func resolvedDesiredPort(defaults: UserDefaults = .standard) -> Int? {
guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else {
return SettingCatalog().mobile.iOSPairingPort.defaultValue
}
return (1...65535).contains(raw) ? raw : nil
/// launch identity's default when unset, the override when valid, and `nil`
/// when the stored value is out of range.
nonisolated static func resolvedDesiredPort(
defaults: UserDefaults = .standard,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Int? {
MobileHostPortPolicy().resolvedDesiredPort(
defaults: defaults,
environment: environment
)
}

/// Pure reconciliation between the desired settings and the live listener
Expand Down
31 changes: 31 additions & 0 deletions cmuxTests/MobileHostAuthorizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,37 @@ struct MobileHostAuthorizationTests {
#expect(authorization.createdWorkspaceIDs == Set(["created-workspace"]))
#expect(authorization.createdTerminalIDs == Set(["created-terminal"]))
}
@Test func testPhysicalDeviceAttachURLUsesActualSelectedRouteEndpoint() throws {
let store = MobileAttachTicketStore()
let route = try CmxAttachRoute(
id: "tailscale",
kind: .tailscale,
endpoint: .hostPort(host: "100.64.0.7", port: 52_461),
priority: 10
)
let ticket = try store.createTicket(
workspaceID: "",
terminalID: nil,
routes: [route],
ttl: 3600,
macUserID: "mac-user",
macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion,
macAppVersion: "0.65.0",
macAppBuild: "9"
)

let payload = try store.payload(for: ticket, target: .physicalDevice)
let rawURL = try #require(payload["attach_url"] as? String)
let components = try #require(URLComponents(string: rawURL))
let decoded = try CmxPairingQRCode().decode(components)

#expect(decoded.routes == [route])
guard case let .hostPort(host, port) = decoded.routes.first?.endpoint else {
return #expect(Bool(false), "expected host/port route")
}
#expect(host == "100.64.0.7")
#expect(port == 52_461)
}
@Test func testMobileWorkspaceRPCRequiresAuthorization() async {
let request = MobileHostRPCRequest(
id: "workspace-list",
Expand Down
Loading
Loading