diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 08fdfccaf40c..c2a9fef88d8a 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -12,6 +12,19 @@ on: description: "UI test class or class/method, for example cmuxUITests/testSignInPairingAndWorkspaceShell" required: false default: "" + swift_package: + description: "Optional SwiftPM package to test instead of the simulator; test_filter selects its suite" + required: false + default: "" + type: choice + options: + - "" + - CMUXMobileCore + - CmuxSyncStore + - CmuxMobilePairedMac + - CmuxMobileChanges + - CmuxMobileShell + - CmuxMobileShellModel ios_version: description: "Optional simulator OS version, for example 17.5, 18.5, or 26.3; empty keeps the default" required: false @@ -69,7 +82,7 @@ jobs: fi BASE_MERGE_BASE="$(git merge-base "$BASE_SHA" "$HEAD_SHA")" git diff --name-only "$BASE_MERGE_BASE" "$HEAD_SHA" > /tmp/changed-files.txt - if grep -Eq '^(ios/|Packages/Shared/|Packages/iOS/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$)' /tmp/changed-files.txt; then + if grep -Eq '^(ios/|Packages/Shared/|Packages/iOS/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint_swift_namespaces\.py$|scripts/swift_source_mask\.py$|scripts/tests/test_ios_package_conventions\.py$)' /tmp/changed-files.txt; then echo "should_run=true" >> "$GITHUB_OUTPUT" else echo "No iOS-owned files changed; skipping iOS tests." @@ -77,7 +90,7 @@ jobs: fi # The conventions lint (free-function ban, namespace-type rule, ...) # covers every package, so it runs for any Packages/ change too. - if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then + if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint_swift_namespaces\.py$|scripts/swift_source_mask\.py$|scripts/tests/test_ios_package_conventions\.py$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then echo "should_lint=true" >> "$GITHUB_OUTPUT" else echo "No package-owned files changed; skipping conventions lint." @@ -99,6 +112,7 @@ jobs: - name: Lint iOS package conventions run: | + python3 scripts/tests/test_ios_package_conventions.py # Mechanical enforcement of the modular-refactor conventions over the # iOS line (no singletons / Combine / locks / timer hacks / KVO / # namespace-enums) plus the repo-wide namespace-type rule (no @@ -112,10 +126,23 @@ jobs: needs: detect-ios-changes if: >- ${{ needs.detect-ios-changes.outputs.should_run == 'true' - && !(github.event_name == 'workflow_dispatch' && inputs.test_filter != '') }} + && (!(github.event_name == 'workflow_dispatch' && inputs.test_filter != '') || inputs.swift_package != '') }} runs-on: ${{ (!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26') || inputs.runner }} - timeout-minutes: 10 + # Focused runs build the shell and its macros from a cold SwiftPM cache. + timeout-minutes: ${{ inputs.swift_package != '' && 25 || 10 }} + env: + SWIFT_TEST_FILTER: ${{ inputs.test_filter }} + FOCUSED_SWIFT_PACKAGE: ${{ inputs.swift_package }} steps: + - name: Validate package selection + env: + SELECTED_PACKAGE: ${{ inputs.swift_package }} + run: | + case "$SELECTED_PACKAGE" in + ""|CMUXMobileCore|CmuxSyncStore|CmuxMobilePairedMac|CmuxMobileChanges|CmuxMobileShell|CmuxMobileShellModel) ;; + *) echo "::error::Unsupported Swift package: $SELECTED_PACKAGE"; exit 1 ;; + esac + - name: Validate Tart runner identity if: ${{ startsWith((!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26') || inputs.runner, 'tart-') }} env: @@ -156,39 +183,105 @@ jobs: xcodebuild -version - name: Run CMUXMobileCore package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CMUXMobileCore' }} run: | - swift test --package-path Packages/Shared/CMUXMobileCore + set -euo pipefail + args=(--package-path Packages/Shared/CMUXMobileCore) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CMUXMobileCore-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CMUXMobileCore}" - name: Run CmuxSyncStore package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CmuxSyncStore' }} run: | + set -euo pipefail # Local-first sync store (raw SQLite3). Headless SwiftPM, no GhosttyKit # or app-target dependency, so it runs as a real CI gate here. - swift test --package-path Packages/Shared/CmuxSyncStore + args=(--package-path Packages/Shared/CmuxSyncStore) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CmuxSyncStore-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CmuxSyncStore}" - name: Run CmuxMobilePairedMac package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CmuxMobilePairedMac' }} run: | - swift test --package-path Packages/iOS/CmuxMobilePairedMac + set -euo pipefail + args=(--package-path Packages/iOS/CmuxMobilePairedMac) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CmuxMobilePairedMac-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CmuxMobilePairedMac}" - name: Run CmuxMobileChanges package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CmuxMobileChanges' }} run: | - swift test --package-path Packages/iOS/CmuxMobileChanges + set -euo pipefail + args=(--package-path Packages/iOS/CmuxMobileChanges) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CmuxMobileChanges-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CmuxMobileChanges}" - name: Run CmuxMobileShell package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CmuxMobileShell' }} run: | + set -euo pipefail # iOS shell replay/liveness regressions live in this package target. # Keep them under the iOS package gate so PR CI enforces the mobile # terminal mirror behavior without relying on local-only SwiftPM runs. - swift test --package-path Packages/iOS/CmuxMobileShell + args=(--package-path Packages/iOS/CmuxMobileShell) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CmuxMobileShell-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CmuxMobileShell}" - name: Run CmuxMobileShellModel package tests + if: ${{ inputs.swift_package == '' || inputs.swift_package == 'CmuxMobileShellModel' }} run: | + set -euo pipefail # Pure shell-model queue and ordering regressions live in this package. # Keep them beside the shell package gate so input framing stays covered. - swift test --package-path Packages/iOS/CmuxMobileShellModel + args=(--package-path Packages/iOS/CmuxMobileShellModel) + if [ -n "${FOCUSED_SWIFT_PACKAGE:-}" ]; then + args+=(--no-parallel) + fi + if [ -n "${SWIFT_TEST_FILTER:-}" ]; then + args+=(--filter "$SWIFT_TEST_FILTER") + fi + log_path="$RUNNER_TEMP/CmuxMobileShellModel-tests.log" + swift test "${args[@]}" 2>&1 | tee "$log_path" + ./scripts/ci/require_selected_test_execution.sh "$log_path" "${SWIFT_TEST_FILTER:-CmuxMobileShellModel}" ios-simulator: needs: detect-ios-changes - if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }} + if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' && inputs.swift_package == '' }} # Blacksmith, not the bare `macos-26` label (which our self-hosted fleet # carries). iOS simulator XCTest runs inside the Simulator, not as a # foregrounded Mac app, so the Blacksmith macOS foreground limitation that @@ -421,6 +514,15 @@ jobs: if [ -n "${TEST_FILTER:-}" ]; then if [[ "$TEST_FILTER" == cmuxUITests/* ]]; then XCODEBUILD_ARGS+=(-testPlan cmux-ui) + else + # Keep focused package assertions bounded on virtualized iOS + # runners; failed sysdiagnose collection can otherwise take 10m. + XCODEBUILD_ARGS+=( + -collect-test-diagnostics never + -test-timeouts-enabled YES + -default-test-execution-time-allowance 60 + -maximum-test-execution-time-allowance 120 + ) fi XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER") else diff --git a/CLI/CMUXCLI+RestoreAdmission.swift b/CLI/CMUXCLI+RestoreAdmission.swift index 04f100b84115..5451c9309c26 100644 --- a/CLI/CMUXCLI+RestoreAdmission.swift +++ b/CLI/CMUXCLI+RestoreAdmission.swift @@ -138,12 +138,12 @@ extension CMUXCLI { && error.v2Retryable } - /// `AgentRestoreAdmissionRetry.response` with the CLI's error classifier. + /// `AgentRestoreAdmissionRetry().response` with the CLI's error classifier. static func response( onRetry: (Int) -> Void = { _ in }, sending send: () throws -> [String: Any] ) throws -> [String: Any] { - try AgentRestoreAdmissionRetry.response( + try AgentRestoreAdmissionRetry().response( onRetry: onRetry, isRetryable: isRetryable, sending: send diff --git a/CLI/CMUXCLI+TmuxCompatSupport.swift b/CLI/CMUXCLI+TmuxCompatSupport.swift index 323e94d084b4..600535d968a0 100644 --- a/CLI/CMUXCLI+TmuxCompatSupport.swift +++ b/CLI/CMUXCLI+TmuxCompatSupport.swift @@ -422,7 +422,7 @@ extension CMUXCLI { } func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String { - CmuxPathEnvironment.prependingUniqueEntries(newEntries, to: currentPath) + CmuxPathEnvironment().prependingUniqueEntries(newEntries, to: currentPath) } } diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterPolicy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterPolicy.swift index c3e121831873..e6f75fa3b54b 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterPolicy.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterPolicy.swift @@ -3,12 +3,14 @@ public import Foundation /// Shared interpretation of HTTP `Retry-After` for every automatic client /// retry owner. A server directive is a minimum wait, never a replacement for /// a longer local backoff. -public enum CmxRetryAfterPolicy { - public static let defaultRateLimitSeconds = 60 +public struct CmxRetryAfterPolicy: Sendable { + public init() {} + + public let defaultRateLimitSeconds = 60 /// Parses either delta-seconds or an HTTP-date. Invalid, zero, and expired /// directives return nil so callers can apply their documented fallback. - public static func seconds( + public func seconds( from value: String?, now: Date = Date() ) -> Int? { @@ -34,7 +36,7 @@ public enum CmxRetryAfterPolicy { return nil } - public static func seconds( + public func seconds( from response: HTTPURLResponse, now: Date = Date(), defaultSeconds: Int? = nil @@ -45,7 +47,7 @@ public enum CmxRetryAfterPolicy { ) ?? defaultSeconds.flatMap { $0 > 0 ? $0 : nil } } - public static func delay( + public func delay( localSeconds: TimeInterval, retryAfterSeconds: Int? ) -> TimeInterval { @@ -54,7 +56,7 @@ public enum CmxRetryAfterPolicy { /// Floating point rounds Int.max up to 2^63. Saturate that conversion /// without shortening ordinary server directives or trapping on restore. - public static func roundedUpSeconds(_ seconds: TimeInterval) -> Int { + public func roundedUpSeconds(_ seconds: TimeInterval) -> Int { guard seconds > 0 else { return 0 } let rounded = seconds.rounded(.up) return rounded >= Double(Int.max) ? Int.max : Int(rounded) @@ -62,7 +64,7 @@ public enum CmxRetryAfterPolicy { /// Sleep in representable chunks while preserving the entire requested /// wait. Converting an arbitrary server delay to UInt64 nanoseconds traps. - public static func sleep( + public func sleep( seconds: TimeInterval, using sleeper: @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) @@ -121,7 +123,7 @@ public actor CmxRetryAfterGate { public func remainingSeconds() -> Int? { guard let deadline else { return nil } - let remaining = CmxRetryAfterPolicy.roundedUpSeconds(deadline - now()) + let remaining = CmxRetryAfterPolicy().roundedUpSeconds(deadline - now()) if remaining <= 0 { self.deadline = nil return nil diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift index f1ab5965d93b..ce6d64655c39 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift @@ -5,18 +5,18 @@ import Foundation /// The git SHA and dev tag are signed bundle metadata, not runtime input. The /// helper keeps iOS and macOS reports comparable and applies the same bounded /// sanitization at their shared boundary. -public enum DiagnosticBuildStamp { +extension DiagnosticReport { /// Returns a bounded `name version (build) tag sha` stamp from bundle data. - public static func make( + public static func buildStamp( infoDictionary: [String: Any]?, fallbackName: String = "cmux" ) -> String { let info = infoDictionary ?? [:] - let name = nonEmptyString(info["CFBundleName"]) ?? fallbackName - let version = nonEmptyString(info["CFBundleShortVersionString"]) ?? "?" - let build = nonEmptyString(info["CFBundleVersion"]) ?? "?" - let tag = nonEmptyString(info["CMUXDevTag"]) - let sha = nonEmptyString(info["CMUXGitSHA"]).map { + let name = buildStampNonEmptyString(info["CFBundleName"]) ?? fallbackName + let version = buildStampNonEmptyString(info["CFBundleShortVersionString"]) ?? "?" + let build = buildStampNonEmptyString(info["CFBundleVersion"]) ?? "?" + let tag = buildStampNonEmptyString(info["CMUXDevTag"]) + let sha = buildStampNonEmptyString(info["CMUXGitSHA"]).map { String($0.prefix(12)) } @@ -30,7 +30,7 @@ public enum DiagnosticBuildStamp { return DiagnosticReport.sanitizeBuildStamp(result) } - private static func nonEmptyString(_ value: Any?) -> String? { + private static func buildStampNonEmptyString(_ value: Any?) -> String? { guard let value = value as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift index 106c1e700eb1..ca94f09f3eed 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -534,6 +534,12 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "network", value: reachabilityName(raw)) case .transportCloseAttribution: return Field(key: "initiator", value: closeInitiatorName(raw)) + case .transportDialSessionLinked: + return Field(key: "attempt", value: String(raw)) + case .transportDialCancelled: + return Field(key: "cancellation", value: cancellationName(raw)) + case .transportCloseReason: + return Field(key: "reason", value: remoteCloseReasonName(raw)) case .transportPathEvent: return Field(key: "operation", value: pathEventName(raw)) case .inputSeqBehind: @@ -663,11 +669,15 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "public_relay_urls", value: String(raw)) case .discoverySucceeded: return Field(key: "relay_fleet", value: String(raw)) - case .transportDialStarted, .transportDialConnected, .transportDialFailed: + case .transportDialStarted, .transportDialConnected, .transportDialFailed, + .transportDialCancelled: return Field(key: "attempt", value: String(raw)) case .sessionClosed, .transportSessionLifecycle, - .transportCloseAttribution, .transportPathEvent: + .transportCloseAttribution, .transportPathEvent, + .transportDialSessionLinked, .transportCloseReason: return Field(key: "session", value: String(raw)) + case .recoveryStarted, .recoverySucceeded, .recoveryFailed: + return Field(key: "peer", value: String(raw)) case .composerActiveTransition: return Field(key: "terminal_input_focused", value: booleanName(raw)) case .browserStreamLifecycle, .browserInputReplayed, @@ -1386,9 +1396,42 @@ public struct DiagnosticEventPresentation: Sendable { return String(value) } + private func cancellationName(_ raw: Int) -> String { + guard let reason = DiagnosticCancellationReason(rawValue: raw) else { + return localized("diagnostics.unknown.cancellation", defaultValue: "Unknown cancellation (\(raw))") + } + switch reason { + case .unknown: return localized("diagnostics.cancellation.unknown", defaultValue: "Unknown cancellation") + case .requestCancelled: return localized("diagnostics.cancellation.requestCancelled", defaultValue: "Request cancelled") + case .requestTimedOut: return localized("diagnostics.cancellation.requestTimedOut", defaultValue: "Request timed out") + case .sessionTeardown: return localized("diagnostics.cancellation.sessionTeardown", defaultValue: "Session torn down") + case .sessionDeinitialized: return localized("diagnostics.cancellation.sessionDeinitialized", defaultValue: "Session deinitialized") + } + } + + private func remoteCloseReasonName(_ raw: Int) -> String { + guard let reason = DiagnosticRemoteCloseReason(rawValue: raw) else { + return localized("diagnostics.unknown.closeReason", defaultValue: "Unknown remote reason (\(raw))") + } + switch reason { + case .unknown: return localized("diagnostics.closeReason.unknown", defaultValue: "Unknown remote reason") + case .clientClosed: return localized("diagnostics.closeReason.clientClosed", defaultValue: "Client closed") + case .serverClosed: return localized("diagnostics.closeReason.serverClosed", defaultValue: "Server closed") + case .superseded: return localized("diagnostics.closeReason.superseded", defaultValue: "Superseded session") + case .admissionLeaseExpired: return localized("diagnostics.closeReason.admissionLeaseExpired", defaultValue: "Admission lease expired") + case .admissionRevalidationFailed: return localized("diagnostics.closeReason.admissionRevalidationFailed", defaultValue: "Admission revalidation failed") + case .sendQueueOverflow: return localized("diagnostics.closeReason.sendQueueOverflow", defaultValue: "Send queue overflow") + case .serverFailure: return localized("diagnostics.closeReason.serverFailure", defaultValue: "Server failure") + case .serverCancelled: return localized("diagnostics.closeReason.serverCancelled", defaultValue: "Server cancelled") + } + } + private func label(for key: String) -> String { switch key { case "surface": localized("diagnostics.field.surface", defaultValue: "Surface") + case "peer": localized("diagnostics.field.peer", defaultValue: "Peer") + case "recovery": localized("diagnostics.field.recovery", defaultValue: "Recovery") + case "cancellation": localized("diagnostics.field.cancellation", defaultValue: "Cancellation") case "transport": localized("diagnostics.field.transport", defaultValue: "Transport") case "failure": localized("diagnostics.field.failure", defaultValue: "Failure") case "attempt": localized("diagnostics.field.attempt", defaultValue: "Attempt") diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift index 69e25f3976c7..d9fc8045c83c 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift @@ -628,6 +628,7 @@ public final class DiagnosticLog: Sendable { } private final class TerminalTraceRateLimiter: @unchecked Sendable { + // Carve-out: the synchronous terminal event tap must admit/drop before queuing diagnostic work. private let lock = NSLock() private var windowStart: UInt64 = 0 private var admitted = 0 diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift index 7b883e0d72af..fc6fba35de9b 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift @@ -5,9 +5,9 @@ import Testing @Suite struct CmxRetryAfterPolicyTests { @Test func parsesDeltaSecondsAndHTTPDate() throws { - #expect(CmxRetryAfterPolicy.seconds(from: "45") == 45) - #expect(CmxRetryAfterPolicy.seconds(from: "0") == nil) - #expect(CmxRetryAfterPolicy.seconds(from: "invalid") == nil) + #expect(CmxRetryAfterPolicy().seconds(from: "45") == 45) + #expect(CmxRetryAfterPolicy().seconds(from: "0") == nil) + #expect(CmxRetryAfterPolicy().seconds(from: "invalid") == nil) let now = Date(timeIntervalSince1970: 1_800_000_000) let formatter = DateFormatter() @@ -15,13 +15,13 @@ import Testing formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'" let header = formatter.string(from: now.addingTimeInterval(45)) - #expect(CmxRetryAfterPolicy.seconds(from: header, now: now) == 45) + #expect(CmxRetryAfterPolicy().seconds(from: header, now: now) == 45) } @Test func serverDirectiveFloorsLocalBackoff() { - #expect(CmxRetryAfterPolicy.delay(localSeconds: 2, retryAfterSeconds: 45) == 45) - #expect(CmxRetryAfterPolicy.delay(localSeconds: 60, retryAfterSeconds: 45) == 60) - #expect(CmxRetryAfterPolicy.delay(localSeconds: 2, retryAfterSeconds: nil) == 2) + #expect(CmxRetryAfterPolicy().delay(localSeconds: 2, retryAfterSeconds: 45) == 45) + #expect(CmxRetryAfterPolicy().delay(localSeconds: 60, retryAfterSeconds: 45) == 60) + #expect(CmxRetryAfterPolicy().delay(localSeconds: 2, retryAfterSeconds: nil) == 2) } @Test func cooldownExtendsAndNeverShortens() async { @@ -68,7 +68,7 @@ import Testing @Test func oversizedSleepUsesSafeChunksWithoutShorteningTheWait() async throws { let firstChunks = AsyncStream.makeStream() do { - try await CmxRetryAfterPolicy.sleep(seconds: 18_446_744_074) { chunk in + try await CmxRetryAfterPolicy().sleep(seconds: 18_446_744_074) { chunk in firstChunks.continuation.yield(chunk) throw CancellationError() } @@ -80,7 +80,7 @@ import Testing let secondChunks = AsyncStream.makeStream() let time = RetryAfterTestTime() - try await CmxRetryAfterPolicy.sleep(seconds: 172_801) { chunk in + try await CmxRetryAfterPolicy().sleep(seconds: 172_801) { chunk in secondChunks.continuation.yield(chunk) time.advance(by: chunk) } diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift index 3d281bae56aa..5fc948ffc32b 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift @@ -3,7 +3,7 @@ import Testing @Suite struct DiagnosticBuildStampTests { @Test func includesSignedTagAndShaWithoutRuntimeInput() { - let stamp = DiagnosticBuildStamp.make(infoDictionary: [ + let stamp = DiagnosticReport.buildStamp(infoDictionary: [ "CFBundleName": "cmux", "CFBundleShortVersionString": "1.2.3", "CFBundleVersion": "42", @@ -15,7 +15,7 @@ import Testing } @Test func removesUnsafeBundleMetadata() { - let stamp = DiagnosticBuildStamp.make(infoDictionary: [ + let stamp = DiagnosticReport.buildStamp(infoDictionary: [ "CFBundleName": "cmux/unsafe", "CFBundleShortVersionString": "1\n2", "CFBundleVersion": "7", diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift index 30112728e75b..ffe2a98edeb8 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift @@ -46,7 +46,7 @@ import Testing c: 1 ) ) - #expect(plan.name == "Transport dial plan built") + #expect(plan.name == "Direct dial plan assembled") #expect(plan.fields == [ .init(key: "public_paths", value: "2"), .init(key: "private_fallback_paths", value: "0"), @@ -75,7 +75,7 @@ import Testing b: 2, c: 0 )) - #expect(join.name == "Private address candidate joined") + #expect(join.name == "Private addresses joined broker port") #expect(join.fields.contains( .init(key: "join", value: "Broker ports missing or stale") )) @@ -89,7 +89,7 @@ import Testing a: DiagnosticLANDiscoveryOutcome.policyDenied.rawValue, b: 0 )) - #expect(lan.name == "LAN discovery completed") + #expect(lan.name == "LAN discovery resolved") #expect(lan.fields.contains( .init(key: "outcome", value: "Local Network permission denied") )) @@ -111,7 +111,7 @@ import Testing a: DiagnosticLANPublicationState.policyDenied.rawValue, b: 0 )) - #expect(publication.name == "LAN publication state changed") + #expect(publication.name == "LAN advertisement state changed") #expect(publication.fields.contains( .init(key: "state", value: "Local Network permission denied") )) @@ -172,8 +172,8 @@ import Testing .init(key: "session", value: "9"), ]) let closeSummary = englishPresentation.summary(close) - #expect(closeSummary.contains("Session")) - #expect(closeSummary.contains("9")) + #expect(!closeSummary.contains("Session")) + #expect(!closeSummary.contains("9")) } @Test func describesLifecycleAndReachability() { @@ -260,12 +260,12 @@ import Testing .browserInputReplayed: "Browser input replayed", .browserEditableFocus: "Browser editable focus", .browserPanelCreateResolved: "Browser panel create resolved", - .transportDialPlanBuilt: "Transport dial plan built", - .transportPrivateAddressJoin: "Private address candidate joined", - .transportLANDiscovery: "LAN discovery completed", - .transportDialLegSucceeded: "Direct dial leg succeeded", + .transportDialPlanBuilt: "Direct dial plan assembled", + .transportPrivateAddressJoin: "Private addresses joined broker port", + .transportLANDiscovery: "LAN discovery resolved", + .transportDialLegSucceeded: "Direct dial leg connected", .transportDialLegFailed: "Direct dial leg failed", - .lanPublicationState: "LAN publication state changed", + .lanPublicationState: "LAN advertisement state changed", .transportDialSessionLinked: "Transport dial linked to session", .transportDialCancelled: "Transport dial cancelled", .transportCloseReason: "Remote close reason", diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownImageLoadDeadline.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownImageLoadDeadline.swift new file mode 100644 index 000000000000..4846a687964b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownImageLoadDeadline.swift @@ -0,0 +1,20 @@ +/// Owns the bounded failure deadline for one remote-image request. +/// Successful completion cancels the returned task; network callbacks own +/// completion and the clock is consulted only to expire an unfinished request. +struct MarkdownImageLoadDeadline: Sendable where C.Duration == Duration { + let clock: C + let timeout: Duration + + func schedule(_ expire: @escaping @Sendable () -> Void) -> Task { + let deadline = clock.now.advanced(by: timeout) + return Task { + do { + try await clock.sleep(until: deadline, tolerance: nil) + try Task.checkCancellation() + } catch { + return + } + expire() + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownRemoteImageLoader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownRemoteImageLoader.swift index 560a420e7163..d2860a546650 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownRemoteImageLoader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownRemoteImageLoader.swift @@ -13,11 +13,13 @@ public struct MarkdownRemoteImageFetchResult: Sendable { } } -public enum MarkdownRemoteImageSecurity { - public static let maximumRemoteImageBytes = 8 * 1024 * 1024 +public struct MarkdownRemoteImageSecurity: Sendable { + public init() {} - public static func remoteImageURL(from requestURL: URL) -> URL? { - guard requestURL.scheme?.lowercased() == MarkdownWebViewerScheme.remoteImage, + public let maximumRemoteImageBytes = 8 * 1024 * 1024 + + public func remoteImageURL(from requestURL: URL) -> URL? { + guard requestURL.scheme?.lowercased() == MarkdownWebViewerScheme.remoteImage.rawValue, let components = URLComponents(url: requestURL, resolvingAgainstBaseURL: false), let rawRemoteURL = components.queryItems?.first(where: { $0.name == "url" })?.value, let remoteURL = URL(string: rawRemoteURL), @@ -27,11 +29,11 @@ public enum MarkdownRemoteImageSecurity { return remoteURL } - public static func isPotentiallySafeRemoteImageURL(_ url: URL) -> Bool { + public func isPotentiallySafeRemoteImageURL(_ url: URL) -> Bool { isSafeRemoteImageURL(url, resolveHost: false) } - public static func isSafeRemoteImageURL(_ url: URL, resolveHost: Bool = true) -> Bool { + public func isSafeRemoteImageURL(_ url: URL, resolveHost: Bool = true) -> Bool { guard url.scheme?.lowercased() == "https", url.user == nil, url.password == nil, @@ -43,7 +45,7 @@ public enum MarkdownRemoteImageSecurity { return !resolveHost || hostResolvesOnlyToAllowedAddresses(host) } - public static func pinnedFetchTargets(for url: URL) -> [MarkdownRemoteImageFetchTarget] { + public func pinnedFetchTargets(for url: URL) -> [MarkdownRemoteImageFetchTarget] { guard isPotentiallySafeRemoteImageURL(url), let host = url.host(percentEncoded: false), let endpoints = resolvedAllowedEndpoints(for: host), @@ -55,7 +57,7 @@ public enum MarkdownRemoteImageSecurity { } } - public static func pathAndQuery(for url: URL) -> String { + public func pathAndQuery(for url: URL) -> String { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) var value = components?.percentEncodedPath.isEmpty == false ? components?.percentEncodedPath ?? "/" : "/" if let query = components?.percentEncodedQuery, !query.isEmpty { @@ -64,7 +66,7 @@ public enum MarkdownRemoteImageSecurity { return value } - public static func requestBytes(for url: URL, host: String) -> Data? { + public func requestBytes(for url: URL, host: String) -> Data? { guard let hostHeader = httpHostHeaderValue(for: host) else { return nil } let request = [ "GET \(pathAndQuery(for: url)) HTTP/1.1", @@ -78,7 +80,7 @@ public enum MarkdownRemoteImageSecurity { return request.data(using: .utf8) } - public static func remoteImageConsentHost(for url: URL) -> String? { + public func remoteImageConsentHost(for url: URL) -> String? { guard isPotentiallySafeRemoteImageURL(url), let host = url.host(percentEncoded: false) else { return nil @@ -87,7 +89,7 @@ public enum MarkdownRemoteImageSecurity { return normalized.isEmpty ? nil : normalized } - public static func canonicalImageMIMEType(_ raw: String?) -> String? { + public func canonicalImageMIMEType(_ raw: String?) -> String? { let mimeType = String(raw ?? "") .split(separator: ";", maxSplits: 1, omittingEmptySubsequences: true) .first? @@ -111,14 +113,14 @@ public enum MarkdownRemoteImageSecurity { } } - private static func normalizedRemoteImageHost(_ rawHost: String) -> String { + private func normalizedRemoteImageHost(_ rawHost: String) -> String { rawHost .trimmingCharacters(in: CharacterSet(charactersIn: "[]").union(.whitespacesAndNewlines)) .trimmingCharacters(in: CharacterSet(charactersIn: ".")) .lowercased() } - private static func isAllowedHostNameOrLiteral(_ rawHost: String) -> Bool { + private func isAllowedHostNameOrLiteral(_ rawHost: String) -> Bool { let host = normalizedRemoteImageHost(rawHost) guard !host.isEmpty else { return false } if host == "localhost" || host.hasSuffix(".localhost") { return false } @@ -132,12 +134,12 @@ public enum MarkdownRemoteImageSecurity { return true } - private static func hostResolvesOnlyToAllowedAddresses(_ rawHost: String) -> Bool { + private func hostResolvesOnlyToAllowedAddresses(_ rawHost: String) -> Bool { guard let endpoints = resolvedAllowedEndpoints(for: rawHost) else { return false } return !endpoints.isEmpty } - private static func resolvedAllowedEndpoints(for rawHost: String) -> [NWEndpoint.Host]? { + private func resolvedAllowedEndpoints(for rawHost: String) -> [NWEndpoint.Host]? { let host = rawHost.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) if let bytes = ipv4Bytes(host) { guard isAllowedIPv4Address(bytes), @@ -186,21 +188,21 @@ public enum MarkdownRemoteImageSecurity { return endpoints.filter { seen.insert(String(describing: $0)).inserted } } - private static func ipv4Bytes(_ host: String) -> [UInt8]? { + private func ipv4Bytes(_ host: String) -> [UInt8]? { var address = in_addr() let result = host.withCString { inet_pton(AF_INET, $0, &address) } guard result == 1 else { return nil } return Array(withUnsafeBytes(of: address.s_addr) { $0 }) } - private static func ipv6Bytes(_ host: String) -> [UInt8]? { + private func ipv6Bytes(_ host: String) -> [UInt8]? { var address = in6_addr() let result = host.withCString { inet_pton(AF_INET6, $0, &address) } guard result == 1 else { return nil } return Array(withUnsafeBytes(of: address) { $0 }) } - private static func isAllowedIPv4Address(_ bytes: [UInt8]) -> Bool { + private func isAllowedIPv4Address(_ bytes: [UInt8]) -> Bool { guard bytes.count == 4 else { return false } let first = bytes[0] let second = bytes[1] @@ -217,7 +219,7 @@ public enum MarkdownRemoteImageSecurity { return true } - private static func isAllowedIPv6Address(_ bytes: [UInt8]) -> Bool { + private func isAllowedIPv6Address(_ bytes: [UInt8]) -> Bool { guard bytes.count == 16 else { return false } if bytes.allSatisfy({ $0 == 0 }) { return false } if bytes.prefix(15).allSatisfy({ $0 == 0 }) && bytes[15] == 1 { return false } @@ -232,14 +234,14 @@ public enum MarkdownRemoteImageSecurity { return true } - private static func ipv4Endpoint(_ bytes: [UInt8]) -> NWEndpoint.Host? { + private func ipv4Endpoint(_ bytes: [UInt8]) -> NWEndpoint.Host? { guard bytes.count == 4 else { return nil } let value = bytes.map(String.init).joined(separator: ".") guard let address = IPv4Address(value) else { return nil } return .ipv4(address) } - private static func ipv6Endpoint(_ bytes: [UInt8]) -> NWEndpoint.Host? { + private func ipv6Endpoint(_ bytes: [UInt8]) -> NWEndpoint.Host? { guard bytes.count == 16 else { return nil } var address = in6_addr() withUnsafeMutableBytes(of: &address) { buffer in @@ -258,13 +260,13 @@ public enum MarkdownRemoteImageSecurity { } } - private static func isSafeHTTPHeaderValue(_ value: String) -> Bool { + private func isSafeHTTPHeaderValue(_ value: String) -> Bool { value.utf8.allSatisfy { byte in byte >= 0x21 && byte != 0x7f } } - private static func httpHostHeaderValue(for rawHost: String) -> String? { + private func httpHostHeaderValue(for rawHost: String) -> String? { let host = normalizedRemoteImageHost(rawHost) guard isSafeHTTPHeaderValue(host) else { return nil } if ipv6Bytes(host) != nil { @@ -288,29 +290,31 @@ public struct MarkdownRemoteImageFetchTarget: Sendable { } } -public enum MarkdownRemoteImageFetcher { - public static func fetch(_ url: URL) async -> MarkdownRemoteImageFetchResult? { +public struct MarkdownRemoteImageFetcher: Sendable { + public init() {} + + public func fetch(_ url: URL) async -> MarkdownRemoteImageFetchResult? { guard !Task.isCancelled, - let approvedHost = MarkdownRemoteImageSecurity.remoteImageConsentHost(for: url) else { + let approvedHost = MarkdownRemoteImageSecurity().remoteImageConsentHost(for: url) else { return nil } return await fetch(url, approvedHost: approvedHost, redirectDepth: 0) } - private static func fetch( + private func fetch( _ url: URL, approvedHost: String, redirectDepth: Int ) async -> MarkdownRemoteImageFetchResult? { guard !Task.isCancelled, redirectDepth <= 3 else { return nil } - let targets = MarkdownRemoteImageSecurity.pinnedFetchTargets(for: url) + let targets = MarkdownRemoteImageSecurity().pinnedFetchTargets(for: url) guard !Task.isCancelled else { return nil } for target in targets { guard !Task.isCancelled else { return nil } let loader = MarkdownPinnedRemoteImageLoader( target: target, - maximumBytes: MarkdownRemoteImageSecurity.maximumRemoteImageBytes + maximumBytes: MarkdownRemoteImageSecurity().maximumRemoteImageBytes ) switch await loader.fetch() { case .image(let result): @@ -319,7 +323,7 @@ public enum MarkdownRemoteImageFetcher { case .redirect(let redirectURL): guard !Task.isCancelled, let resolvedRedirect = URL(string: redirectURL.absoluteString, relativeTo: url)?.absoluteURL, - MarkdownRemoteImageSecurity.remoteImageConsentHost(for: resolvedRedirect) == approvedHost else { + MarkdownRemoteImageSecurity().remoteImageConsentHost(for: resolvedRedirect) == approvedHost else { return nil } return await fetch( @@ -345,7 +349,9 @@ private enum MarkdownRemoteImageLoadOutcome { private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { private let maximumBytes: Int private let target: MarkdownRemoteImageFetchTarget + // Carve-out: NWConnection callbacks and synchronous task cancellation arbitrate one completion. private let lock = NSLock() + // Carve-out: Network.framework and TLS verification require a callback queue. private let queue = DispatchQueue(label: "dev.cmux.markdown-remote-image", qos: .userInitiated) private var rawBody = Data() private var mimeType = "image/png" @@ -354,7 +360,7 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { private var headerParsed = false private var usesChunkedTransfer = false private var expectedBodyBytes: Int? - private var timeoutWorkItem: DispatchWorkItem? + private var timeoutTask: Task? private var completed = false init(target: MarkdownRemoteImageFetchTarget, maximumBytes: Int) { @@ -379,7 +385,7 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { } private func start(completion: @escaping (MarkdownRemoteImageLoadOutcome?) -> Void) { - guard let requestData = MarkdownRemoteImageSecurity.requestBytes( + guard let requestData = MarkdownRemoteImageSecurity().requestBytes( for: target.url, host: target.serverName ) else { @@ -408,9 +414,6 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { return } let connection = NWConnection(to: .hostPort(host: target.endpointHost, port: endpointPort), using: parameters) - let timeout = DispatchWorkItem { [weak self] in - self?.finish(nil) - } lock.lock() guard !completed else { lock.unlock() @@ -419,10 +422,14 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { } self.connection = connection self.completion = completion - timeoutWorkItem = timeout + timeoutTask = MarkdownImageLoadDeadline( + clock: ContinuousClock(), + timeout: .seconds(15) + ).schedule { [weak self] in + self?.finish(nil) + } lock.unlock() - queue.asyncAfter(deadline: .now() + 15, execute: timeout) connection.stateUpdateHandler = { [weak self] (state: NWConnection.State) in switch state { case .ready: @@ -537,12 +544,12 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { if (300..<400).contains(statusCode), let location = headers["location"], let redirectURL = URL(string: location, relativeTo: target.url)?.absoluteURL, - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL(redirectURL) { + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL(redirectURL) { return .finish(.redirect(redirectURL)) } guard (200..<300).contains(statusCode), - let responseMIMEType = MarkdownRemoteImageSecurity.canonicalImageMIMEType(headers["content-type"]) else { + let responseMIMEType = MarkdownRemoteImageSecurity().canonicalImageMIMEType(headers["content-type"]) else { return .fail } @@ -564,7 +571,7 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { guard headerParsed else { return nil } let body: Data if usesChunkedTransfer { - guard let decoded = MarkdownHTTPChunkedBodyDecoder.decode( + guard let decoded = MarkdownHTTPChunkedBodyDecoder().decode( rawBody, maximumBytes: maximumBytes ) else { @@ -591,7 +598,7 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { private func finish(_ outcome: MarkdownRemoteImageLoadOutcome?) { let callback: ((MarkdownRemoteImageLoadOutcome?) -> Void)? let connectionToCancel: NWConnection? - let timeoutToCancel: DispatchWorkItem? + let timeoutToCancel: Task? lock.lock() guard !completed else { lock.unlock() @@ -602,8 +609,8 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { completion = nil connectionToCancel = connection connection = nil - timeoutToCancel = timeoutWorkItem - timeoutWorkItem = nil + timeoutToCancel = timeoutTask + timeoutTask = nil lock.unlock() timeoutToCancel?.cancel() @@ -612,8 +619,10 @@ private final class MarkdownPinnedRemoteImageLoader: @unchecked Sendable { } } -public enum MarkdownHTTPChunkedBodyDecoder { - public static func decode(_ data: Data, maximumBytes: Int) -> Data? { +public struct MarkdownHTTPChunkedBodyDecoder: Sendable { + public init() {} + + public func decode(_ data: Data, maximumBytes: Int) -> Data? { let bytes = Array(data) var offset = 0 var decoded = Data() @@ -650,7 +659,7 @@ public enum MarkdownHTTPChunkedBodyDecoder { return nil } - private static func crlfIndex(in bytes: [UInt8], from offset: Int) -> Int? { + private func crlfIndex(in bytes: [UInt8], from offset: Int) -> Int? { guard offset < bytes.count else { return nil } var index = offset while index + 1 < bytes.count { diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownViewerAssetCompression.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownViewerAssetCompression.swift index 6da73c8c069e..d63b875b4e95 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownViewerAssetCompression.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownViewerAssetCompression.swift @@ -4,8 +4,8 @@ import zlib /// Inflates the zlib-deflated markdown viewer JS assets produced by /// `scripts/compress-markdown-viewer-assets.sh` at build time. Shared by the /// macOS and iOS asset loaders so both bundles use the identical format. -public enum MarkdownViewerAssetCompression { - public static func inflate(_ data: Data) -> Data? { +extension Data { + public static func inflateMarkdownViewerAsset(_ data: Data) -> Data? { guard !data.isEmpty else { return Data() } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownWebViewerScheme.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownWebViewerScheme.swift index 6f7255d57856..449fd5f5937e 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownWebViewerScheme.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownWebViewerScheme.swift @@ -7,7 +7,7 @@ import Foundation /// stay confined to the markdown file's directory and remote images go /// through the consent-gated HTTPS-only loader. Both the macOS panel and the /// iOS renderer register handlers for the same scheme strings. -public enum MarkdownWebViewerScheme { - public static let localImage = "cmux-local-image" - public static let remoteImage = "cmux-remote-image" +public enum MarkdownWebViewerScheme: String, Sendable { + case localImage = "cmux-local-image" + case remoteImage = "cmux-remote-image" } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownImageLoadDeadlineTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownImageLoadDeadlineTests.swift new file mode 100644 index 000000000000..c9f2d93d2756 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownImageLoadDeadlineTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CmuxAgentChat + +@Suite struct MarkdownImageLoadDeadlineTests { + @Test func expiresOnlyAfterTheClockCompletesItsDeadline() async throws { + let entered = AsyncStream.makeStream() + let advance = AsyncStream.makeStream() + let clock = ControlledDeadlineClock( + now: ContinuousClock().now, + entered: entered.continuation, + advance: advance.stream + ) + let expired = DeadlineExpirationCounter() + let task = MarkdownImageLoadDeadline(clock: clock, timeout: .seconds(15)) + .schedule { expired.increment() } + var entries = entered.stream.makeAsyncIterator() + let deadline = try #require(await entries.next()) + #expect(deadline == clock.now.advanced(by: .seconds(15))) + #expect(expired.value == 0) + advance.continuation.yield(()) + await task.value + #expect(expired.value == 1) + } + + @Test func completionCancelsThePendingExpiration() async throws { + let entered = AsyncStream.makeStream() + let advance = AsyncStream.makeStream() + let clock = ControlledDeadlineClock( + now: ContinuousClock().now, + entered: entered.continuation, + advance: advance.stream + ) + let expired = DeadlineExpirationCounter() + let task = MarkdownImageLoadDeadline(clock: clock, timeout: .seconds(15)) + .schedule { expired.increment() } + var entries = entered.stream.makeAsyncIterator() + _ = try #require(await entries.next()) + task.cancel() + advance.continuation.yield(()) + await task.value + #expect(expired.value == 0) + } +} + +/// A one-shot clock whose sleep is completed by a test signal, with no wall wait. +private struct ControlledDeadlineClock: Clock { + typealias Instant = ContinuousClock.Instant + let now: Instant + let entered: AsyncStream.Continuation + let advance: AsyncStream + var minimumResolution: Duration { .nanoseconds(1) } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + entered.yield(deadline) + for await _ in advance { return } + } +} + +private final class DeadlineExpirationCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { lock.withLock { count } } + func increment() { lock.withLock { count += 1 } } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownViewerAssetCompressionTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownViewerAssetCompressionTests.swift index 7cf3960bda11..da21d4f84ea0 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownViewerAssetCompressionTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/MarkdownViewerAssetCompressionTests.swift @@ -12,18 +12,18 @@ struct MarkdownViewerAssetCompressionTests { let deflated = try deflate(original) #expect(deflated.count < original.count) - let inflated = MarkdownViewerAssetCompression.inflate(deflated) + let inflated = Data.inflateMarkdownViewerAsset(deflated) #expect(inflated == original) } @Test("empty input inflates to empty output") func emptyInput() { - #expect(MarkdownViewerAssetCompression.inflate(Data()) == Data()) + #expect(Data.inflateMarkdownViewerAsset(Data()) == Data()) } @Test("garbage input fails instead of returning partial bytes") func garbageInput() { - #expect(MarkdownViewerAssetCompression.inflate(Data([0xDE, 0xAD, 0xBE, 0xEF])) == nil) + #expect(Data.inflateMarkdownViewerAsset(Data([0xDE, 0xAD, 0xBE, 0xEF])) == nil) } private func deflate(_ data: Data) throws -> Data { diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 6516fa3cddb1..02db1a34e604 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -1429,7 +1429,7 @@ public actor PushRegistrationService: PushRegistering { let seconds = retryAfterSeconds( response: response, body: data - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds return .failure( .rateLimited(retryAfterSeconds: seconds), retryAfter: .seconds(seconds) @@ -1445,7 +1445,7 @@ public actor PushRegistrationService: PushRegistering { response: HTTPURLResponse, body: Data ) -> Int? { - let headerDelay = CmxRetryAfterPolicy.seconds( + let headerDelay = CmxRetryAfterPolicy().seconds( from: response.value(forHTTPHeaderField: "Retry-After") ) let bodyDelay = try? JSONDecoder().decode( diff --git a/Packages/Shared/CmuxClientConfig/Sources/CmuxClientConfig/HTTPClientConfigLoader.swift b/Packages/Shared/CmuxClientConfig/Sources/CmuxClientConfig/HTTPClientConfigLoader.swift index eb17a50c40a9..bca6eac74063 100644 --- a/Packages/Shared/CmuxClientConfig/Sources/CmuxClientConfig/HTTPClientConfigLoader.swift +++ b/Packages/Shared/CmuxClientConfig/Sources/CmuxClientConfig/HTTPClientConfigLoader.swift @@ -46,10 +46,10 @@ public struct HTTPClientConfigLoader: ClientConfigLoading { throw ClientConfigError.invalidResponse } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) throw ClientConfigError.rateLimited(retryAfterSeconds: seconds) } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityInvalidationSubscriber.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityInvalidationSubscriber.swift index a722325e66f1..d26c6ef4406b 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityInvalidationSubscriber.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityInvalidationSubscriber.swift @@ -258,9 +258,9 @@ public actor CmxConnectivityInvalidationSubscriber { ) -> Int? { guard let response = task.response as? HTTPURLResponse, response.statusCode == 429 else { return nil } - return CmxRetryAfterPolicy.seconds( + return CmxRetryAfterPolicy().seconds( from: response, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds ) } } diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift index a45835a248f2..bbb0416ac943 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift @@ -531,7 +531,7 @@ public actor CmxIrohBrokerBackpressureGate { private static func remainingSeconds(floor: Floor, now: Date) -> Int { let remaining = floor.retryAt.timeIntervalSince(now) let original = floor.retryAt.timeIntervalSince(floor.recordedAt) - return max(1, CmxRetryAfterPolicy.roundedUpSeconds(min(remaining, original))) + return max(1, CmxRetryAfterPolicy().roundedUpSeconds(min(remaining, original))) } private static func normalizedRetryAfter(_ seconds: Int) -> Int { diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift index ef9e52f85998..0e337ea009a8 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift @@ -946,7 +946,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { if http.statusCode == 429 { let retryAfterSeconds = Self.retryAfterSeconds( http.value(forHTTPHeaderField: "Retry-After") - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds throw CmxIrohTrustBrokerClientError.rateLimited( code: code, retryAfterSeconds: retryAfterSeconds @@ -991,7 +991,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { } private static func retryAfterSeconds(_ value: String?) -> Int? { - CmxRetryAfterPolicy.seconds(from: value) + CmxRetryAfterPolicy().seconds(from: value) } private static func relayTokenResponse( diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift index e1a3146f36a5..9a869e5f9c8a 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/IrxControlPlaneClient.swift @@ -391,9 +391,9 @@ public actor IrxControlPlaneClient { ) -> CmxRateLimitedError? { guard let response = task.response as? HTTPURLResponse, response.statusCode == 429 else { return nil } - return CmxRateLimitedError(retryAfterSeconds: CmxRetryAfterPolicy.seconds( + return CmxRateLimitedError(retryAfterSeconds: CmxRetryAfterPolicy().seconds( from: response, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds )) } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift index da389aa0a6f1..9abec67e3616 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxAdmission.swift @@ -42,16 +42,18 @@ public struct IrxAdmissionDenied: Error, Equatable, Sendable { public typealias IrxGrantJudgment = @Sendable (_ grantJWS: String?, _ remoteEndpointIDHex: String) throws -> IrxAdmittedPeerInfo -public enum IrxAdmission { +public struct IrxAdmission: Sendable { + public init() {} + /// Admission must resolve fast or fail loud; nothing here touches the /// network beyond the connection itself. - public static let deadline: Duration = .seconds(5) + public let deadline: Duration = .seconds(5) /// Client half: open the control lane, send the hello (grantless in /// list-auth mode; the optional grant exists only for legacy dialects), /// await the admit. A denial arrives as the connection's own termination /// and is rethrown with its parsed code. - public static func performClient( + public func performClient( connection: IrxConnection, grantJWS: String? = nil, journal: IrxJournal @@ -75,7 +77,7 @@ public enum IrxAdmission { } } - private static func clientExchange( + private func clientExchange( connection: IrxConnection, grantJWS: String?, journal: IrxJournal @@ -149,7 +151,7 @@ public enum IrxAdmission { /// judge the grant against the TLS key, admit or terminate with the /// denial code. On success the remote's lane credit is raised and the /// admit frame commits the session. - public static func performServer( + public func performServer( connection: IrxConnection, judgment: IrxGrantJudgment, journal: IrxJournal @@ -182,7 +184,7 @@ public enum IrxAdmission { await connection.close(code: .admissionTimeout, origin: .local) return nil } - guard let hello, hello.proto == IrxProtocol.alpn else { + guard let hello, hello.proto == IrxProtocol().alpn else { journal.record("admission", "rejected", ["code": IrxCloseCode.protocolMismatch.rawValue]) await connection.close(code: .protocolMismatch, origin: .local) return nil diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift index 953ad1e6ff97..81f9013320df 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxConnection.swift @@ -17,7 +17,7 @@ public actor IrxStreamWriter { } public func writeControlFrame(_ value: some Encodable) async throws { - try await write(IrxFrameCodec.encode(value)) + try await write(IrxFrameCodec().encode(value)) } public func setPriority(_ priority: Int32) async throws { @@ -53,7 +53,7 @@ public actor IrxStreamReader { while true { if buffer.count >= 4 { let length = buffer.prefix(4).reduce(0) { ($0 << 8) | Int($1) } - guard length <= IrxProtocol.maximumControlFrameByteCount else { + guard length <= IrxProtocol().maximumControlFrameByteCount else { throw IrxFrameCodecError.frameTooLarge(length) } if buffer.count >= 4 + length { @@ -74,7 +74,7 @@ public actor IrxStreamReader { public func readControlFrame(_ type: T.Type) async throws -> T? { guard let body = try await readControlFrameBody() else { return nil } - return try IrxFrameCodec.decode(type, from: body) + return try IrxFrameCodec().decode(type, from: body) } /// Raw passthrough: whatever bytes are available (buffered first), or nil @@ -320,8 +320,8 @@ public actor IrxConnection { /// A failed probe retires its stream; another attempt uses a fresh stream /// on the same QUIC connection. Native closure owns dead-peer detection. public func startClientKeepalive( - interval: Duration = IrxProtocol.keepaliveInterval, - deadline: Duration = IrxProtocol.keepaliveDeadline, + interval: Duration = IrxProtocol().keepaliveInterval, + deadline: Duration = IrxProtocol().keepaliveDeadline, onDeath: @escaping @Sendable () async -> Void ) async throws { guard keepaliveSettings == nil, !isClosed, !Task.isCancelled else { return } @@ -346,7 +346,7 @@ public actor IrxConnection { /// callers share the current probe; a shorter caller deadline also retires /// that probe, so no caller retries a stopped receive stream. /// A false result is inconclusive and never authorizes connection teardown. - public func probeLiveness(deadline: Duration = IrxProtocol.keepaliveDeadline) async -> Bool { + public func probeLiveness(deadline: Duration = IrxProtocol().keepaliveDeadline) async -> Bool { guard applicationActive, !isClosed, !Task.isCancelled else { return false } if let task = probeTask, let id = probeID { let result = try? await withIrxDeadlineResult(deadline) { await task.value } diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift index ca23f4b7846c..0e9230cbaefd 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxDeviceList.swift @@ -113,7 +113,7 @@ public struct IrxDeviceListSnapshot: Equatable, Sendable { /// The synchronously readable CURRENT snapshot the accept path judges against. /// Admission must be O(1) with no actor hop (the judge closure runs inside -/// `IrxAdmission.performServer`), so this is a lock-guarded box the runtime +/// `IrxAdmission().performServer`), so this is a lock-guarded box the runtime /// swaps atomically on every directory apply and clears on sign-out. public final class IrxDeviceListCurrent: @unchecked Sendable { private let lock = NSLock() diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift index 58289bdf19e1..6012d8ad0c67 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxEndpoint.swift @@ -155,7 +155,7 @@ public actor IrxEndpointSupervisor { let accepting = try await incoming.accept() let alpn = try await accepting.alpn() let connection = try await accepting.connect() - if alpn == IrxProtocol.alpnData { + if alpn == IrxProtocol().alpnData { return .irx( IrxConnection(connection: connection, role: .acceptor, journal: journal)) } @@ -294,7 +294,7 @@ public actor IrxEndpointSupervisor { } var options = EndpointOptions(preset: presetMinimal()) options.secretKey = configuration.identity.privateKeyData - options.alpns = [IrxProtocol.alpnData] + configuration.additionalALPNs + options.alpns = [IrxProtocol().alpnData] + configuration.additionalALPNs options.relayMode = directOnly ? RelayMode.disabled() : RelayMode.custom(map: relayMap) options.portMappingEnabled = false // NAT traversal stays unauthorized until admission (automatic mode) or @@ -459,7 +459,7 @@ extension IrxEndpointSupervisor { let endpoint = try await readyEndpoint(credentials: credentials) let startedAt = DispatchTime.now() let connection = try await endpoint.connect( - addr: target, alpn: IrxProtocol.alpnData) + addr: target, alpn: IrxProtocol().alpnData) let elapsedMs = (DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxIdentity.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxIdentity.swift index 1d4977958290..7e1598a9299e 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxIdentity.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxIdentity.swift @@ -85,7 +85,7 @@ public struct IrxFileIdentityStore: IrxIdentityStoring { } } -public enum IrxIdentityProvisioner { +extension IrxIdentity { /// Loads the persisted identity or mints one. The device ID is pinned by /// the caller: if a stored identity carries a different device ID (e.g. /// the app's durable ID was reset), the identity is regenerated so the diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift index c296bc1b2477..af03ed709e5c 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxPeerEngine.swift @@ -53,8 +53,8 @@ public actor IrxPeerEngine { public init( initialBackoff: Duration = .milliseconds(400), maxBackoff: Duration = .seconds(5), - keepaliveInterval: Duration = IrxProtocol.keepaliveInterval, - keepaliveDeadline: Duration = IrxProtocol.keepaliveDeadline, + keepaliveInterval: Duration = IrxProtocol().keepaliveInterval, + keepaliveDeadline: Duration = IrxProtocol().keepaliveDeadline, foregroundProbeDeadline: Duration = .milliseconds(400) ) { self.initialBackoff = initialBackoff diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift index 7a5c47df1ba5..38a01c211a8c 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxProtocol.swift @@ -3,19 +3,21 @@ public import Foundation /// Wire identity for the irx transport. Distinct from the legacy /// `cmux/mobile/1` ALPN so an old peer can never half-connect to an irx /// endpoint: version mismatch fails at the TLS handshake, not mid-protocol. -public enum IrxProtocol { - public static let alpn = "cmux/irx/1" - public static var alpnData: Data { Data(alpn.utf8) } +public struct IrxProtocol: Sendable { + public init() {} + + public let alpn = "cmux/irx/1" + public var alpnData: Data { Data(alpn.utf8) } /// Envelope version carried on every control frame. - public static let version = 1 + public let version = 1 /// Control frames are small (hello/admit/keepalive/descriptors); anything /// larger is a protocol error, never buffered. - public static let maximumControlFrameByteCount = 256 * 1024 + public let maximumControlFrameByteCount = 256 * 1024 /// Application latency sampling cadence. Connection lifetime is owned by /// Iroh's native keepalives and negotiated connection idle timeout. - public static let keepaliveInterval: Duration = .seconds(5) + public let keepaliveInterval: Duration = .seconds(5) /// A missed application pong retires only the diagnostic stream. - public static let keepaliveDeadline: Duration = .seconds(2) + public let keepaliveDeadline: Duration = .seconds(2) } @@ -112,7 +114,7 @@ public struct IrxLaneDescriptor: Codable, Equatable, Sendable { cursor: UInt64? = nil, offset: UInt64? = nil ) { - v = IrxProtocol.version + v = IrxProtocol().version self.lane = lane self.resource = resource self.cursor = cursor @@ -131,8 +133,8 @@ public struct IrxHello: Codable, Equatable, Sendable { public var grant: String? public init(grant: String? = nil) { - v = IrxProtocol.version - proto = IrxProtocol.alpn + v = IrxProtocol().version + proto = IrxProtocol().alpn self.grant = grant } } @@ -148,10 +150,10 @@ public struct IrxAdmit: Codable, Equatable, Sendable { public var keepaliveDeadlineMs: Int public init(session: String) { - v = IrxProtocol.version + v = IrxProtocol().version self.session = session - keepaliveIntervalMs = Int(IrxProtocol.keepaliveInterval.components.seconds) * 1000 - keepaliveDeadlineMs = Int(IrxProtocol.keepaliveDeadline.components.seconds) * 1000 + keepaliveIntervalMs = Int(IrxProtocol().keepaliveInterval.components.seconds) * 1000 + keepaliveDeadlineMs = Int(IrxProtocol().keepaliveDeadline.components.seconds) * 1000 } } @@ -163,7 +165,7 @@ public struct IrxPing: Codable, Equatable, Sendable { public var pong: Bool public init(seq: UInt64, pong: Bool) { - v = IrxProtocol.version + v = IrxProtocol().version self.seq = seq self.pong = pong } @@ -185,7 +187,7 @@ public struct IrxLaneError: Codable, Equatable, Sendable { public var message: String public init(code: Code, message: String) { - v = IrxProtocol.version + v = IrxProtocol().version self.code = code self.message = message } @@ -201,10 +203,12 @@ public enum IrxFrameCodecError: Error, Equatable, Sendable { /// Length-prefixed JSON control frames: 4-byte big-endian length + body. /// Used only for the tiny control vocabulary above; application lanes carry /// raw bytes after their descriptor. -public enum IrxFrameCodec { - public static func encode(_ value: some Encodable) throws -> Data { +public struct IrxFrameCodec: Sendable { + public init() {} + + public func encode(_ value: some Encodable) throws -> Data { let body = try JSONEncoder().encode(value) - guard body.count <= IrxProtocol.maximumControlFrameByteCount else { + guard body.count <= IrxProtocol().maximumControlFrameByteCount else { throw IrxFrameCodecError.frameTooLarge(body.count) } var data = Data(capacity: 4 + body.count) @@ -214,7 +218,7 @@ public enum IrxFrameCodec { return data } - public static func decode(_ type: T.Type, from body: Data) throws -> T { + public func decode(_ type: T.Type, from body: Data) throws -> T { do { return try JSONDecoder().decode(type, from: body) } catch { diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentialAutopilot.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentialAutopilot.swift index be47a79260b9..b78580924ffd 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentialAutopilot.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentialAutopilot.swift @@ -96,7 +96,7 @@ public actor IrxRelayCredentialAutopilot { let credentials = await broker.cachedRelayCredentials() guard generation == loopGeneration, !Task.isCancelled else { return } if let soonest = credentials.map({ - IrxRelayCredentialPolicy.refreshDate( + IrxRelayCredentialPolicy().refreshDate( for: $0, jitter: Double.random(in: 0...10)) }).min(), soonest > now { let wait = soonest.timeIntervalSince(now) @@ -125,7 +125,7 @@ public actor IrxRelayCredentialAutopilot { } catch { if Task.isCancelled || generation != loopGeneration { return } let expiry = credentials.map(\.expiresAt).max() ?? Date() - let delay = IrxRelayCredentialPolicy.retryDelay( + let delay = IrxRelayCredentialPolicy().retryDelay( expiresAt: expiry, now: Date(), retryAfterSeconds: (error as? any CmxRetryAfterProviding)? diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentials.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentials.swift index 1ba93fe5d89d..938543448f9e 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentials.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxRelayCredentials.swift @@ -24,12 +24,14 @@ public struct IrxRelayCredential: Codable, Equatable, Sendable { } /// Pure refresh-policy decisions, unit-testable without clocks or network. -public enum IrxRelayCredentialPolicy { +public struct IrxRelayCredentialPolicy: Sendable { + public init() {} + /// Refresh at min(server refreshAfter, expiry - 120s): earlier than the /// legacy stack's expiry-60s so one slow broker call or a short suspension /// never eats the entire margin. Jitter (0..10s, caller-supplied) prevents /// synchronized fleets. - public static func refreshDate( + public func refreshDate( for credential: IrxRelayCredential, jitter: TimeInterval ) -> Date { @@ -41,7 +43,7 @@ public enum IrxRelayCredentialPolicy { /// On mint failure, use bounded exponential backoff independent of token /// expiry. A validated server Retry-After value remains an authoritative /// floor. - public static func retryDelay( + public func retryDelay( expiresAt: Date, now: Date, retryAfterSeconds: Int? = nil, diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxStateLocation.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxStateLocation.swift index a246a9ddfaf0..7cee046773e1 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxStateLocation.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxStateLocation.swift @@ -9,12 +9,14 @@ public import Foundation /// build's cached state to every other (08-27 invisible-Mac incident). /// Namespacing by bundle identifier AND broker host makes cross-build and /// cross-environment reuse structurally impossible. -public enum IrxStateLocation { +public struct IrxStateLocation: Sendable { + public init() {} + /// The pre-namespacing shared folder name; removed on sight because it /// held a plaintext identity key readable by every build. - static let legacySharedFolderName = "cmux-irx" + let legacySharedFolderName = "cmux-irx" - public static func directory( + public func directory( base: URL, bundleIdentifier: String?, brokerHost: String? @@ -35,14 +37,14 @@ public enum IrxStateLocation { /// that still use it re-register on their next launch (cheap, and the /// namespaced path takes over), while the plaintext identity file stops /// existing immediately. - public static func removeLegacySharedDirectory(base: URL) { + public func removeLegacySharedDirectory(base: URL) { try? FileManager.default.removeItem( at: base.appendingPathComponent( legacySharedFolderName, isDirectory: true) ) } - static func sanitized(_ raw: String?, fallback: String) -> String { + func sanitized(_ raw: String?, fallback: String) -> String { guard let raw, !raw.isEmpty else { return fallback } let cleaned = raw.lowercased().map { character in (character.isASCII && (character.isLetter || character.isNumber)) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift index 74b0d8fbb2f5..e270e9218209 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxDeviceListTests.swift @@ -339,7 +339,7 @@ struct IrxHelloGrantlessTests { #expect(!text.contains("grant")) let hello = try JSONDecoder().decode(IrxHello.self, from: encoded) #expect(hello.grant == nil) - #expect(hello.proto == IrxProtocol.alpn) + #expect(hello.proto == IrxProtocol().alpn) } @Test func legacyGrantJudgeDeniesGrantlessHello() { diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift index a2485e7dc352..cc993cbc73ea 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift @@ -22,7 +22,7 @@ enum IrxLiveTestSupport { preset: presetMinimal(), bindAddr: "127.0.0.1:0", secretKey: seed, - alpns: [IrxProtocol.alpnData], + alpns: [IrxProtocol().alpnData], relayMode: RelayMode.disabled(), portMappingEnabled: false, deferNatTraversalUntilAuthorized: false, @@ -79,7 +79,7 @@ struct IrxLiveQUICTests { let connection = try await accepting.connect() let irx = IrxConnection( connection: connection, role: .acceptor, journal: journal) - guard await IrxAdmission.performServer( + guard await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -90,9 +90,9 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (_, control) = try await IrxAdmission.performClient( + let (_, control) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) let releaseProbe = IrxControlReleaseProbe() let transport = IrxControlByteTransport( @@ -139,7 +139,7 @@ struct IrxLiveQUICTests { let connection = try await accepting.connect() let irx = IrxConnection( connection: connection, role: .acceptor, journal: journal) - guard let result = await IrxAdmission.performServer( + guard let result = await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -150,9 +150,9 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (_, control) = try await IrxAdmission.performClient( + let (_, control) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) let releaseProbe = IrxControlReleaseProbe() let transport = IrxControlByteTransport( @@ -225,7 +225,7 @@ struct IrxLiveQUICTests { let native = try await incoming.accept().connect() let connection = IrxConnection( connection: native, role: .acceptor, journal: journal) - guard let (_, control, _) = await IrxAdmission.performServer( + guard let (_, control, _) = await IrxAdmission().performServer( connection: connection, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -237,9 +237,9 @@ struct IrxLiveQUICTests { var clientPairs: [(IrxConnection, IrxLaneStream)] = [] for _ in 0..<2 { let native = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let connection = IrxConnection(connection: native, role: .dialer, journal: journal) - let (_, control) = try await IrxAdmission.performClient( + let (_, control) = try await IrxAdmission().performClient( connection: connection, grantJWS: "good-grant", journal: journal) clientPairs.append((connection, control)) } @@ -322,7 +322,7 @@ struct IrxLiveQUICTests { let connection = try await accepting.connect() let irx = IrxConnection( connection: connection, role: .acceptor, journal: journal) - guard await IrxAdmission.performServer( + guard await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -333,9 +333,9 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (_, control) = try await IrxAdmission.performClient( + let (_, control) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) let establishmentStarted = IrxAsyncLatch() let releaseEstablishment = IrxAsyncLatch() @@ -395,7 +395,7 @@ struct IrxLiveQUICTests { let connection = try await accepting.connect() let irx = IrxConnection( connection: connection, role: .acceptor, journal: journal) - guard await IrxAdmission.performServer( + guard await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -406,9 +406,9 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (_, control) = try await IrxAdmission.performClient( + let (_, control) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) let establishmentStarted = IrxAsyncLatch() let releaseEstablishment = IrxAsyncLatch() @@ -470,7 +470,7 @@ struct IrxLiveQUICTests { let connection = try await accepting.connect() let irx = IrxConnection(connection: connection, role: .acceptor, journal: journal) guard - let admitted = await IrxAdmission.performServer( + let admitted = await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -488,9 +488,9 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (admit, _) = try await IrxAdmission.performClient( + let (admit, _) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) #expect(!admit.session.isEmpty) @@ -524,7 +524,7 @@ struct IrxLiveQUICTests { let accepting = try await incoming.accept() let connection = try await accepting.connect() let irx = IrxConnection(connection: connection, role: .acceptor, journal: journal) - _ = await IrxAdmission.performServer( + _ = await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -532,10 +532,10 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) do { - _ = try await IrxAdmission.performClient( + _ = try await IrxAdmission().performClient( connection: irx, grantJWS: "stolen-grant", journal: journal) Issue.record("admission unexpectedly succeeded") } catch let denial as IrxAdmissionDenied { @@ -566,10 +566,10 @@ struct IrxLiveQUICTests { } let connection = try await client.connect( - addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) do { - _ = try await IrxAdmission.performClient( + _ = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) Issue.record("admission unexpectedly succeeded") } catch let denial as IrxAdmissionDenied { @@ -608,7 +608,7 @@ struct IrxLiveQUICTests { let irx = IrxConnection( connection: connection, role: .acceptor, journal: journal) guard - let (peer, _, sessionID) = await IrxAdmission.performServer( + let (peer, _, sessionID) = await IrxAdmission().performServer( connection: irx, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal @@ -632,9 +632,9 @@ struct IrxLiveQUICTests { journal: journal ) { let connection = try await client.connect( - addr: dialAddr, alpn: IrxProtocol.alpnData) + addr: dialAddr, alpn: IrxProtocol().alpnData) let irx = IrxConnection(connection: connection, role: .dialer, journal: journal) - let (admit, control) = try await IrxAdmission.performClient( + let (admit, control) = try await IrxAdmission().performClient( connection: irx, grantJWS: "good-grant", journal: journal) return IrxClientSession( connection: irx, admit: admit, control: control, establishedAt: Date()) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift index d27d3e3262f5..89e88310786f 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift @@ -216,7 +216,7 @@ private actor IrxLivenessTestHost { do { let native = try await incoming.accept().connect() let connection = IrxConnection(connection: native, role: .acceptor, journal: journal) - guard let (_, control, _) = await IrxAdmission.performServer(connection: connection, + guard let (_, control, _) = await IrxAdmission().performServer(connection: connection, judgment: IrxLiveTestSupport.fixedJudgment(accepting: "good-grant"), journal: journal) else { continue } connections.append(connection) let index = connections.count @@ -258,9 +258,9 @@ private actor IrxLivenessTestHost { } func dial(age: Duration = .zero) async throws -> IrxClientSession { - let native = try await client.connect(addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol.alpnData) + let native = try await client.connect(addr: IrxLiveTestSupport.loopbackAddr(of: server), alpn: IrxProtocol().alpnData) let connection = IrxConnection(connection: native, role: .dialer, journal: journal) - let (admit, control) = try await IrxAdmission.performClient(connection: connection, grantJWS: "good-grant", journal: journal) + let (admit, control) = try await IrxAdmission().performClient(connection: connection, grantJWS: "good-grant", journal: journal) return IrxClientSession(connection: connection, admit: admit, control: control, establishedAt: Date(), establishedAtMonotonic: .now - age) } diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift index cacc903374fd..abad82edc0be 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxProtocolTests.swift @@ -27,20 +27,20 @@ struct IrxProtocolTests { @Test("control frames round-trip through the codec") func controlFrameRoundTrip() throws { let hello = IrxHello(grant: "grant.jws.value") - let encoded = try IrxFrameCodec.encode(hello) + let encoded = try IrxFrameCodec().encode(hello) // 4-byte big-endian length prefix. let length = encoded.prefix(4).reduce(0) { ($0 << 8) | Int($1) } #expect(length == encoded.count - 4) - let decoded = try IrxFrameCodec.decode(IrxHello.self, from: encoded.dropFirst(4)) + let decoded = try IrxFrameCodec().decode(IrxHello.self, from: encoded.dropFirst(4)) #expect(decoded == hello) #expect(decoded.proto == "cmux/irx/1") } @Test("oversized frames are refused at encode time") func oversizedFrameRefused() { - let huge = IrxHello(grant: String(repeating: "x", count: IrxProtocol.maximumControlFrameByteCount + 1)) + let huge = IrxHello(grant: String(repeating: "x", count: IrxProtocol().maximumControlFrameByteCount + 1)) #expect(throws: IrxFrameCodecError.self) { - _ = try IrxFrameCodec.encode(huge) + _ = try IrxFrameCodec().encode(huge) } } @@ -71,8 +71,8 @@ struct IrxProtocolTests { resource: "terminal:0a1b", cursor: 42 ) - let encoded = try IrxFrameCodec.encode(descriptor) - let decoded = try IrxFrameCodec.decode( + let encoded = try IrxFrameCodec().encode(descriptor) + let decoded = try IrxFrameCodec().decode( IrxLaneDescriptor.self, from: encoded.dropFirst(4)) #expect(decoded == descriptor) #expect(decoded.cursor == 42) @@ -96,31 +96,31 @@ struct IrxRelayCredentialPolicyTests { // 300s credential, server suggests refresh at expiry-60s. Ours wins // at expiry-120s (60s earlier than the legacy stack). let credential = credential(expiresIn: 300, refreshLead: 60) - let refresh = IrxRelayCredentialPolicy.refreshDate(for: credential, jitter: 0) + let refresh = IrxRelayCredentialPolicy().refreshDate(for: credential, jitter: 0) #expect(refresh == credential.expiresAt.addingTimeInterval(-120)) // Server-suggested earlier refresh is respected. let eager = self.credential(expiresIn: 300, refreshLead: 200) - let eagerRefresh = IrxRelayCredentialPolicy.refreshDate(for: eager, jitter: 0) + let eagerRefresh = IrxRelayCredentialPolicy().refreshDate(for: eager, jitter: 0) #expect(eagerRefresh == eager.refreshAfter) } @Test("mint-failure retry backs off independently of expiry") func retryDelay() { let now = Date(timeIntervalSince1970: 2_000_000) - let far = IrxRelayCredentialPolicy.retryDelay( + let far = IrxRelayCredentialPolicy().retryDelay( expiresAt: now.addingTimeInterval(200), now: now) #expect(far == .seconds(5)) - let near = IrxRelayCredentialPolicy.retryDelay( + let near = IrxRelayCredentialPolicy().retryDelay( expiresAt: now.addingTimeInterval(1), now: now) #expect(near == .seconds(5)) - let past = IrxRelayCredentialPolicy.retryDelay( + let past = IrxRelayCredentialPolicy().retryDelay( expiresAt: now.addingTimeInterval(-5), now: now) #expect(past == .seconds(5)) - let later = IrxRelayCredentialPolicy.retryDelay( + let later = IrxRelayCredentialPolicy().retryDelay( expiresAt: now.addingTimeInterval(-5), now: now, failureCount: 4) #expect(later == .seconds(80)) - let rateLimited = IrxRelayCredentialPolicy.retryDelay( + let rateLimited = IrxRelayCredentialPolicy().retryDelay( expiresAt: now.addingTimeInterval(1), now: now, retryAfterSeconds: 45 @@ -132,10 +132,10 @@ struct IrxRelayCredentialPolicyTests { func retryJitterAndLargeFloors() { let now = Date(timeIntervalSince1970: 2_000_000) for serverFloor in [0, 600, Int.max] { - let base = IrxRelayCredentialPolicy.retryDelay( + let base = IrxRelayCredentialPolicy().retryDelay( expiresAt: now, now: now, retryAfterSeconds: serverFloor, failureCount: 20, jitterUnitInterval: 0) - let jittered = IrxRelayCredentialPolicy.retryDelay( + let jittered = IrxRelayCredentialPolicy().retryDelay( expiresAt: now, now: now, retryAfterSeconds: serverFloor, failureCount: 20, jitterUnitInterval: 1) #expect(base >= .seconds(serverFloor)) @@ -172,13 +172,13 @@ struct IrxIdentityTests { let store = IrxFileIdentityStore( fileURL: dir.appendingPathComponent("identity.json")) let deviceID = UUID().uuidString.lowercased() - let first = try IrxIdentityProvisioner.loadOrCreate(store: store, deviceID: deviceID) - let second = try IrxIdentityProvisioner.loadOrCreate(store: store, deviceID: deviceID) + let first = try IrxIdentity.loadOrCreate(store: store, deviceID: deviceID) + let second = try IrxIdentity.loadOrCreate(store: store, deviceID: deviceID) #expect(first == second) #expect(first.endpointIDHex.count == 64) // A device-ID change regenerates the identity so grant tuples and the // broker binding can never disagree. - let other = try IrxIdentityProvisioner.loadOrCreate( + let other = try IrxIdentity.loadOrCreate( store: store, deviceID: UUID().uuidString.lowercased()) #expect(other != first) try? FileManager.default.removeItem(at: dir) @@ -190,7 +190,7 @@ struct IrxIdentityTests { .appendingPathComponent("irx-tests-\(UUID().uuidString)") let store = IrxFileIdentityStore( fileURL: dir.appendingPathComponent("identity.json")) - let identity = try IrxIdentityProvisioner.loadOrCreate( + let identity = try IrxIdentity.loadOrCreate( store: store, deviceID: UUID().uuidString.lowercased()) let message = Data("attributed close reasons or bust".utf8) let signature = try identity.sign(message) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxStateLocationTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxStateLocationTests.swift index 96f74c85cd5b..85e4cdb41966 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxStateLocationTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxStateLocationTests.swift @@ -12,12 +12,12 @@ struct IrxStateLocationTests { @Test("different bundles and different brokers never share a directory") func isolation() { - let nightlyProd = IrxStateLocation.directory( + let nightlyProd = IrxStateLocation().directory( base: base, bundleIdentifier: "dev.cmux.app.nightly", brokerHost: "cmux.com") - let devStaging = IrxStateLocation.directory( + let devStaging = IrxStateLocation().directory( base: base, bundleIdentifier: "dev.cmux.app.debug.irx", brokerHost: "cmux-iroh-v2-staging.debussy.workers.dev") - let nightlyStaging = IrxStateLocation.directory( + let nightlyStaging = IrxStateLocation().directory( base: base, bundleIdentifier: "dev.cmux.app.nightly", brokerHost: "cmux-iroh-v2-staging.debussy.workers.dev") #expect(nightlyProd != devStaging) @@ -27,12 +27,12 @@ struct IrxStateLocationTests { @Test("hostile identifiers sanitize without collapsing to a shared path") func sanitization() { - #expect(IrxStateLocation.sanitized("../../etc", fallback: "x") == "..-..-etc") - #expect(IrxStateLocation.sanitized("..", fallback: "x") == "x") - #expect(IrxStateLocation.sanitized(".", fallback: "x") == "x") - #expect(IrxStateLocation.sanitized(nil, fallback: "unknown-bundle") == "unknown-bundle") - #expect(IrxStateLocation.sanitized("", fallback: "f") == "f") - #expect(IrxStateLocation.sanitized("Dev.Cmux.App", fallback: "f") == "dev.cmux.app") + #expect(IrxStateLocation().sanitized("../../etc", fallback: "x") == "..-..-etc") + #expect(IrxStateLocation().sanitized("..", fallback: "x") == "x") + #expect(IrxStateLocation().sanitized(".", fallback: "x") == "x") + #expect(IrxStateLocation().sanitized(nil, fallback: "unknown-bundle") == "unknown-bundle") + #expect(IrxStateLocation().sanitized("", fallback: "f") == "f") + #expect(IrxStateLocation().sanitized("Dev.Cmux.App", fallback: "f") == "dev.cmux.app") } @Test("cache writes are owner-only for files and directories") @@ -59,7 +59,7 @@ struct IrxStateLocationTests { try FileManager.default.createDirectory( at: legacy, withIntermediateDirectories: true) try Data("secret".utf8).write(to: legacy.appendingPathComponent("identity.json")) - IrxStateLocation.removeLegacySharedDirectory(base: base) + IrxStateLocation().removeLegacySharedDirectory(base: base) #expect(!FileManager.default.fileExists(atPath: legacy.path)) } } diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamHostPump.swift b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamHostPump.swift index 32329253c892..6c7f09979478 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamHostPump.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamHostPump.swift @@ -162,7 +162,7 @@ public actor SimStreamHostPump { public func sendState(_ update: SimStreamStateUpdate) async { guard !shutDown else { return } - try? await sink.send(SimStreamWireCodec.encodeFramed(.state(update))) + try? await sink.send(SimStreamWireCodec().encodeFramed(.state(update))) } /// True when the stream should be making progress but has not sent a @@ -235,7 +235,7 @@ public actor SimStreamHostPump { parameterSets: encoded.parameterSets, nalUnitHeaderLength: encoded.nalUnitHeaderLength ) - try await sink.send(SimStreamWireCodec.encodeFramed(.config(config))) + try await sink.send(SimStreamWireCodec().encodeFramed(.config(config))) needsConfig = false } let wireSequence = gate.consumeCredit() @@ -247,7 +247,7 @@ public actor SimStreamHostPump { payload: encoded.data ) do { - try await sink.send(SimStreamWireCodec.encodeFramed(.frame(message))) + try await sink.send(SimStreamWireCodec().encodeFramed(.frame(message))) } catch { // The frame never reached the viewer, so its credit must not // stay consumed (the viewer can never ack it). Self-settling diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamProtocol.swift b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamProtocol.swift index 3f01ae42ad75..3aefa8e52cd6 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamProtocol.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamProtocol.swift @@ -13,12 +13,14 @@ import Foundation /// - A stream is (re)established by a single `start` message. Every `start` /// is answered with `config` followed by a keyframe. There is no other /// handshake and no state survives a transport drop. -public enum SimStreamProtocol { - public static let version: UInt8 = 1 +public struct SimStreamProtocol: Sendable { + public init() {} + + public let version: UInt8 = 1 /// Capability token the Mac host advertises when it can serve this lane. - public static let capability = "simulator.stream.v2" + public let capability = "simulator.stream.v2" /// Upper bound for any single wire message (keyframes included). - public static let maximumMessageByteCount = 8 * 1024 * 1024 + public let maximumMessageByteCount = 8 * 1024 * 1024 } public enum SimStreamVideoCodec: UInt8, Sendable, Equatable, CaseIterable { @@ -55,7 +57,7 @@ public struct SimStreamStartRequest: Sendable, Equatable { public var codecPreferences: [SimStreamVideoCodec] public init( - version: UInt8 = SimStreamProtocol.version, + version: UInt8 = SimStreamProtocol().version, epoch: UInt64, maximumLongSidePixels: UInt16, codecPreferences: [SimStreamVideoCodec] diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamWireCodec.swift b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamWireCodec.swift index 45c74d4810a8..3200502d124e 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamWireCodec.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Sources/CmuxSimulatorStreamKit/SimStreamWireCodec.swift @@ -15,7 +15,9 @@ public enum SimStreamWireError: Error, Equatable { /// Integers are big-endian. Variable-length data uses a `u32` byte-count /// prefix. On the wire each body is additionally framed by a `u32` length /// prefix (see `encodeFramed` / `SimStreamFrameAccumulator`). -public enum SimStreamWireCodec { +public struct SimStreamWireCodec: Sendable { + public init() {} + private enum MessageType: UInt8 { case start = 0x01 case config = 0x02 @@ -36,7 +38,7 @@ public enum SimStreamWireCodec { // MARK: - Encode - public static func encode(_ message: SimStreamMessage) -> Data { + public func encode(_ message: SimStreamMessage) -> Data { var writer = SimStreamByteWriter() switch message { case .start(let start): @@ -89,7 +91,7 @@ public enum SimStreamWireCodec { return writer.data } - private static func encode( + private func encode( _ event: SimStreamInputEvent, into writer: inout SimStreamByteWriter ) { switch event { @@ -114,7 +116,7 @@ public enum SimStreamWireCodec { } /// Encodes a message with the `u32` wire length prefix. - public static func encodeFramed(_ message: SimStreamMessage) -> Data { + public func encodeFramed(_ message: SimStreamMessage) -> Data { let body = encode(message) var writer = SimStreamByteWriter() writer.writeUInt32(UInt32(body.count)) @@ -124,7 +126,7 @@ public enum SimStreamWireCodec { // MARK: - Decode - public static func decode(_ data: Data) throws -> SimStreamMessage { + public func decode(_ data: Data) throws -> SimStreamMessage { var reader = SimStreamByteReader(data: data) let message = try decodeBody(&reader) if reader.remainingByteCount > 0 { @@ -133,7 +135,7 @@ public enum SimStreamWireCodec { return message } - private static func decodeBody( + private func decodeBody( _ reader: inout SimStreamByteReader ) throws -> SimStreamMessage { let rawType = try reader.readUInt8() @@ -239,7 +241,7 @@ public enum SimStreamWireCodec { } } - private static func decodeInputEvent( + private func decodeInputEvent( _ reader: inout SimStreamByteReader ) throws -> SimStreamInputEvent { let rawKind = try reader.readUInt8() @@ -347,7 +349,7 @@ public struct SimStreamByteReader: Sendable { public mutating func readLengthPrefixedData() throws -> Data { let count = Int(try readUInt32()) - guard count <= SimStreamProtocol.maximumMessageByteCount else { + guard count <= SimStreamProtocol().maximumMessageByteCount else { throw SimStreamWireError.messageTooLarge(byteCount: count) } guard remainingByteCount >= count else { throw SimStreamWireError.truncated } @@ -388,7 +390,7 @@ public struct SimStreamFrameAccumulator: Sendable { let length = buffer.prefix(headerSize).reduce(into: UInt32(0)) { partial, byte in partial = partial << 8 | UInt32(byte) } - guard length <= UInt32(SimStreamProtocol.maximumMessageByteCount) else { + guard length <= UInt32(SimStreamProtocol().maximumMessageByteCount) else { throw SimStreamWireError.messageTooLarge(byteCount: Int(length)) } let total = headerSize + Int(length) diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamHostPumpTests.swift b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamHostPumpTests.swift index 4589088972f9..17b3bfbaa6e3 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamHostPumpTests.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamHostPumpTests.swift @@ -24,7 +24,7 @@ private actor CollectingSink: SimStreamMessageSending { var accumulator = SimStreamFrameAccumulator() accumulator.append(data) while let body = try accumulator.nextMessageBody() { - messages.append(try SimStreamWireCodec.decode(body)) + messages.append(try SimStreamWireCodec().decode(body)) } } diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamVideoRoundTripTests.swift b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamVideoRoundTripTests.swift index ad9a0b184d72..9ed880d42e9a 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamVideoRoundTripTests.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamVideoRoundTripTests.swift @@ -84,8 +84,8 @@ struct SimStreamVideoRoundTripTests { // Round-trip config and frames through the wire codec, as the network // would. - let decodedConfigMessage = try SimStreamWireCodec.decode( - SimStreamWireCodec.encode(.config(config!))) + let decodedConfigMessage = try SimStreamWireCodec().decode( + SimStreamWireCodec().encode(.config(config!))) guard case .config(let wireConfig) = decodedConfigMessage else { Issue.record("expected config") return @@ -97,8 +97,8 @@ struct SimStreamVideoRoundTripTests { var decodedCount = 0 for wireFrame in wireFrames { - let decodedMessage = try SimStreamWireCodec.decode( - SimStreamWireCodec.encode(.frame(wireFrame))) + let decodedMessage = try SimStreamWireCodec().decode( + SimStreamWireCodec().encode(.frame(wireFrame))) guard case .frame(let received) = decodedMessage else { Issue.record("expected frame") return diff --git a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamWireCodecTests.swift b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamWireCodecTests.swift index 717102b18dc7..0ac442799843 100644 --- a/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamWireCodecTests.swift +++ b/Packages/Shared/CmuxSimulatorStreamKit/Tests/CmuxSimulatorStreamKitTests/SimStreamWireCodecTests.swift @@ -56,8 +56,8 @@ struct SimStreamWireCodecTests { .state(SimStreamStateUpdate(status: .deviceUnavailable, detail: "sim shut down")), ] for message in messages { - let encoded = SimStreamWireCodec.encode(message) - let decoded = try SimStreamWireCodec.decode(encoded) + let encoded = SimStreamWireCodec().encode(message) + let decoded = try SimStreamWireCodec().decode(encoded) #expect(decoded == message) } } @@ -74,7 +74,7 @@ struct SimStreamWireCodecTests { ] var wire = Data() for message in messages { - wire.append(SimStreamWireCodec.encodeFramed(message)) + wire.append(SimStreamWireCodec().encodeFramed(message)) } // Feed the concatenated bytes in pathological chunk sizes. @@ -87,7 +87,7 @@ struct SimStreamWireCodecTests { accumulator.append(wire.subdata(in: offset.. Coordinator { - Coordinator() + Coordinator(assets: assets) } func makeUIView(context: Context) -> WKWebView { @@ -31,8 +32,8 @@ struct MarkdownWebContentView: UIViewRepresentable { MarkdownWebWeakScriptMessageHandler(context.coordinator), name: "cmuxLib" ) - config.setURLSchemeHandler(context.coordinator, forURLScheme: MarkdownWebViewerScheme.localImage) - config.setURLSchemeHandler(context.coordinator, forURLScheme: MarkdownWebViewerScheme.remoteImage) + config.setURLSchemeHandler(context.coordinator, forURLScheme: MarkdownWebViewerScheme.localImage.rawValue) + config.setURLSchemeHandler(context.coordinator, forURLScheme: MarkdownWebViewerScheme.remoteImage.rawValue) let webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false @@ -68,6 +69,13 @@ struct MarkdownWebContentView: UIViewRepresentable { @MainActor final class Coordinator: NSObject, @preconcurrency WKNavigationDelegate, @preconcurrency WKUIDelegate, @preconcurrency WKScriptMessageHandler, @preconcurrency WKURLSchemeHandler { + private let assets: MarkdownWebViewerAssets + + init(assets: MarkdownWebViewerAssets) { + self.assets = assets + super.init() + } + weak var webView: WKWebView? var openURL: OpenURLAction? @@ -114,7 +122,7 @@ struct MarkdownWebContentView: UIViewRepresentable { lastTheme = theme requestedLibs.removeAll() isLoaded = false - guard let html = MarkdownWebViewerAssets.shared.shellHTML() else { + guard let html = assets.shellHTML() else { isShellLoading = false return } @@ -279,8 +287,8 @@ struct MarkdownWebContentView: UIViewRepresentable { // The diagram bundles are megabytes; read and inflate them off the // main actor, then inject on main. - Task { [weak self] in - guard let sources = await MarkdownWebViewerAssets.shared.assets(specs), + Task { [weak self, assets] in + guard let sources = await assets.assets(specs), sources.contains(where: { !$0.isEmpty }) else { self?.requestedLibs.remove(lib) return @@ -318,10 +326,10 @@ struct MarkdownWebContentView: UIViewRepresentable { imageLoads[taskId] = load let reader: Task - if requestURL.scheme?.lowercased() == MarkdownWebViewerScheme.remoteImage, - let remoteURL = MarkdownRemoteImageSecurity.remoteImageURL(from: requestURL) { + if requestURL.scheme?.lowercased() == MarkdownWebViewerScheme.remoteImage.rawValue, + let remoteURL = MarkdownRemoteImageSecurity().remoteImageURL(from: requestURL) { reader = Task.detached(priority: .userInitiated) { - await MarkdownRemoteImageFetcher.fetch(remoteURL) + await MarkdownRemoteImageFetcher().fetch(remoteURL) } } else { // Local images cannot resolve on the phone — the document's @@ -421,11 +429,11 @@ struct MarkdownWebContentView: UIViewRepresentable { ) { if navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url { - if MarkdownWebLinkPolicy.isInPageFragment(url) { + if MarkdownWebLinkPolicy().isInPageFragment(url) { decisionHandler(.allow) return } - if let external = MarkdownWebLinkPolicy.externalURL(for: url) { + if let external = MarkdownWebLinkPolicy().externalURL(for: url) { openURL?(external) } decisionHandler(.cancel) @@ -441,7 +449,7 @@ struct MarkdownWebContentView: UIViewRepresentable { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url, - let external = MarkdownWebLinkPolicy.externalURL(for: url) { + let external = MarkdownWebLinkPolicy().externalURL(for: url) { openURL?(external) } return nil diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebLinkPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebLinkPolicy.swift index 9e903ff8c050..284af987e437 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebLinkPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebLinkPolicy.swift @@ -6,10 +6,12 @@ import Foundation /// anchors resolve as `about:blank#fragment`; those must stay inside the /// WebView while every other activated link leaves the app through the /// system opener. Pure so it can be unit-tested without WebKit. -enum MarkdownWebLinkPolicy { +struct MarkdownWebLinkPolicy: Sendable { + init() {} + /// Same-document fragment navigation (heading anchors) that the WebView /// should perform natively. - static func isInPageFragment(_ url: URL) -> Bool { + func isInPageFragment(_ url: URL) -> Bool { guard url.fragment != nil else { return false } if url.scheme == nil || url.scheme == "about" { return (url.host ?? "").isEmpty @@ -19,7 +21,7 @@ enum MarkdownWebLinkPolicy { /// Whether the system opener may handle an activated link. Anything the /// phone can't represent (relative paths against about:blank) is dropped. - static func externalURL(for url: URL) -> URL? { + func externalURL(for url: URL) -> URL? { if isInPageFragment(url) { return nil } guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty, scheme != "about" else { return nil diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebViewerAssets.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebViewerAssets.swift index 7ea5be329280..73e8980fda29 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebViewerAssets.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Markdown/MarkdownWebViewerAssets.swift @@ -11,8 +11,6 @@ import Foundation /// callers fall back to the native block renderer. @MainActor final class MarkdownWebViewerAssets { - static let shared = MarkdownWebViewerAssets(bundle: .main) - private let bundle: Bundle private var cache: [String: String] = [:] private var cachedShellHTML: String?? @@ -123,7 +121,7 @@ final class MarkdownWebViewerAssets { private nonisolated static func loadAsset(url: URL) -> String? { guard let data = try? Data(contentsOf: url) else { return nil } if url.lastPathComponent.hasSuffix(".deflate") { - guard let inflated = MarkdownViewerAssetCompression.inflate(data) else { return nil } + guard let inflated = Data.inflateMarkdownViewerAsset(data) else { return nil } return String(data: inflated, encoding: .utf8) } return String(data: data, encoding: .utf8) diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/MarkdownWebLinkPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/MarkdownWebLinkPolicyTests.swift index faf840b26748..fc6c3e005b34 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/MarkdownWebLinkPolicyTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/MarkdownWebLinkPolicyTests.swift @@ -7,27 +7,27 @@ import Testing struct MarkdownWebLinkPolicyTests { @Test("heading anchors resolved against about:blank stay in the page") func inPageFragments() throws { - #expect(MarkdownWebLinkPolicy.isInPageFragment(try #require(URL(string: "about:blank#usage")))) - #expect(MarkdownWebLinkPolicy.isInPageFragment(try #require(URL(string: "#top")))) + #expect(MarkdownWebLinkPolicy().isInPageFragment(try #require(URL(string: "about:blank#usage")))) + #expect(MarkdownWebLinkPolicy().isInPageFragment(try #require(URL(string: "#top")))) } @Test("links with hosts or real schemes are not in-page fragments") func externalFragments() throws { - #expect(!MarkdownWebLinkPolicy.isInPageFragment(try #require(URL(string: "https://example.com/#section")))) - #expect(!MarkdownWebLinkPolicy.isInPageFragment(try #require(URL(string: "https://example.com/docs")))) - #expect(!MarkdownWebLinkPolicy.isInPageFragment(try #require(URL(string: "file:///tmp/other.md#part")))) + #expect(!MarkdownWebLinkPolicy().isInPageFragment(try #require(URL(string: "https://example.com/#section")))) + #expect(!MarkdownWebLinkPolicy().isInPageFragment(try #require(URL(string: "https://example.com/docs")))) + #expect(!MarkdownWebLinkPolicy().isInPageFragment(try #require(URL(string: "file:///tmp/other.md#part")))) } @Test("web and app-scheme links route to the system opener") func externalRouting() throws { - #expect(MarkdownWebLinkPolicy.externalURL(for: try #require(URL(string: "https://example.com"))) != nil) - #expect(MarkdownWebLinkPolicy.externalURL(for: try #require(URL(string: "mailto:a@b.c"))) != nil) + #expect(MarkdownWebLinkPolicy().externalURL(for: try #require(URL(string: "https://example.com"))) != nil) + #expect(MarkdownWebLinkPolicy().externalURL(for: try #require(URL(string: "mailto:a@b.c"))) != nil) } @Test("in-page anchors and unresolvable relative paths never leave the app") func inertTargets() throws { - #expect(MarkdownWebLinkPolicy.externalURL(for: try #require(URL(string: "about:blank#usage"))) == nil) - #expect(MarkdownWebLinkPolicy.externalURL(for: try #require(URL(string: "about:blank"))) == nil) - #expect(MarkdownWebLinkPolicy.externalURL(for: try #require(URL(string: "docs/other.md"))) == nil) + #expect(MarkdownWebLinkPolicy().externalURL(for: try #require(URL(string: "about:blank#usage"))) == nil) + #expect(MarkdownWebLinkPolicy().externalURL(for: try #require(URL(string: "about:blank"))) == nil) + #expect(MarkdownWebLinkPolicy().externalURL(for: try #require(URL(string: "docs/other.md"))) == nil) } } diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPAnalyticsUploader.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPAnalyticsUploader.swift index b89b8e58fc83..2cbfaca46cc8 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPAnalyticsUploader.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPAnalyticsUploader.swift @@ -128,9 +128,9 @@ public struct HTTPAnalyticsUploader: AnalyticsUploading { let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return .retry } if http.statusCode == 429, - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds ) { await retryAfterGate.extend(by: seconds) } diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPMobileNetworkOutcomeUploader.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPMobileNetworkOutcomeUploader.swift index b67bbc9d88e5..517bf35a6595 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPMobileNetworkOutcomeUploader.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/HTTPMobileNetworkOutcomeUploader.swift @@ -124,9 +124,9 @@ public struct HTTPMobileNetworkOutcomeUploader: AnalyticsUploading { let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return .retry } if http.statusCode == 429, - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds ) { await retryAfterGate.extend(by: seconds) } diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift index 6f99fa3d7dec..b389e6768224 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift @@ -39,7 +39,9 @@ public final class MobileNetworkOutcomeReporter: Sendable { } private final class StateStore: @unchecked Sendable { + // Carve-out: ordered diagnostic callback delivery; the producer cannot suspend. private let queue = DispatchQueue(label: "com.cmux.mobile-network-outcomes") + // Carve-out: nonblocking admission bounds synchronous event-tap work before it is queued. private let permits = DispatchSemaphore(value: 128) private var state = State() diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileTerminalTraceReporter.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileTerminalTraceReporter.swift index 2922aad48e8c..c57e7e65625b 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileTerminalTraceReporter.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileTerminalTraceReporter.swift @@ -26,7 +26,9 @@ public final class MobileTerminalTraceReporter: Sendable { } private final class StateStore: @unchecked Sendable { + // Carve-out: ordered diagnostic callback delivery; the producer cannot suspend. private let queue = DispatchQueue(label: "com.cmux.mobile-terminal-traces") + // Carve-out: nonblocking admission bounds synchronous event-tap work before it is queued. private let permits = DispatchSemaphore(value: 128) private var state = State() diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift index 6d094826f356..58e76de67d7c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift @@ -1,23 +1,6 @@ import CMUXMobileCore import CmuxMobileShellModel -func createdTerminalDeviceIDsMatch(_ lhs: String?, _ rhs: String?) -> Bool { - switch (normalizedCreatedTerminalIdentity(lhs), normalizedCreatedTerminalIdentity(rhs)) { - case let (lhs?, rhs?): - return cmxCanonicalDeviceID(lhs) == cmxCanonicalDeviceID(rhs) - default: - return false - } -} - -func normalizedCreatedTerminalIdentity(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty else { - return nil - } - return value -} - /// Keeps a terminal created by the user selected while workspace snapshots /// catch up with the Mac. The UI row id is not stable across multi-Mac /// aggregation, so the pin is owned by the remote workspace and Mac identity. @@ -34,16 +17,16 @@ struct CreatedTerminalSelection: Equatable { terminalID: MobileTerminalPreview.ID ) { remoteWorkspaceID = workspace.rpcWorkspaceID - let workspaceMacDeviceID = normalizedCreatedTerminalIdentity(workspace.macDeviceID) + let workspaceMacDeviceID = CreatedTerminalSelection.normalizedIdentity(workspace.macDeviceID) macDeviceID = workspaceMacDeviceID - ?? normalizedCreatedTerminalIdentity(fallbackMacDeviceID) + ?? CreatedTerminalSelection.normalizedIdentity(fallbackMacDeviceID) if workspaceMacDeviceID == nil { - macInstanceTag = normalizedCreatedTerminalIdentity(workspace.macInstanceTag) - ?? normalizedCreatedTerminalIdentity(fallbackInstanceTag) + macInstanceTag = CreatedTerminalSelection.normalizedIdentity(workspace.macInstanceTag) + ?? CreatedTerminalSelection.normalizedIdentity(fallbackInstanceTag) } else { // A known workspace owner may omit its legacy instance tag. Do not // borrow the global foreground tag from a different sibling. - macInstanceTag = normalizedCreatedTerminalIdentity(workspace.macInstanceTag) + macInstanceTag = CreatedTerminalSelection.normalizedIdentity(workspace.macInstanceTag) } self.terminalID = terminalID } @@ -55,11 +38,11 @@ struct CreatedTerminalSelection: Equatable { guard workspace.rpcWorkspaceID == remoteWorkspaceID else { return false } - switch (normalizedCreatedTerminalIdentity(macDeviceID), normalizedCreatedTerminalIdentity(workspace.macDeviceID)) { + switch (CreatedTerminalSelection.normalizedIdentity(macDeviceID), CreatedTerminalSelection.normalizedIdentity(workspace.macDeviceID)) { case (nil, nil): guard allowsAnonymousForeground else { return false } case let (expected?, actual?): - guard createdTerminalDeviceIDsMatch(expected, actual) else { + guard CreatedTerminalSelection.deviceIDsMatch(expected, actual) else { return false } default: @@ -89,11 +72,11 @@ struct CreatedTerminalSelection: Equatable { allowsAnonymousForeground: Bool ) -> Bool { guard workspace.rpcWorkspaceID == remoteWorkspaceID else { return false } - let expectedDevice = normalizedCreatedTerminalIdentity(macDeviceID) - let actualDevice = normalizedCreatedTerminalIdentity(workspace.macDeviceID) + let expectedDevice = CreatedTerminalSelection.normalizedIdentity(macDeviceID) + let actualDevice = CreatedTerminalSelection.normalizedIdentity(workspace.macDeviceID) switch (expectedDevice, actualDevice) { case let (expected?, actual?): - guard createdTerminalDeviceIDsMatch(expected, actual) else { return false } + guard CreatedTerminalSelection.deviceIDsMatch(expected, actual) else { return false } default: break } @@ -117,17 +100,37 @@ struct CreatedTerminalSelection: Equatable { /// after the create response. Adopt it without retargeting a pin that /// already has an owner. mutating func adoptMacDeviceIDIfMissing(_ macDeviceID: String, instanceTag: String? = nil) { - guard normalizedCreatedTerminalIdentity(self.macDeviceID) == nil else { return } - self.macDeviceID = normalizedCreatedTerminalIdentity(macDeviceID) - if normalizedCreatedTerminalIdentity(macInstanceTag) == nil { - self.macInstanceTag = normalizedCreatedTerminalIdentity(instanceTag) + guard CreatedTerminalSelection.normalizedIdentity(self.macDeviceID) == nil else { return } + self.macDeviceID = CreatedTerminalSelection.normalizedIdentity(macDeviceID) + if CreatedTerminalSelection.normalizedIdentity(macInstanceTag) == nil { + self.macInstanceTag = CreatedTerminalSelection.normalizedIdentity(instanceTag) } } /// Learn a tag that was resolved after creation without replacing an /// already-owned sibling identity. mutating func adoptMacInstanceTagIfMissing(_ instanceTag: String) { - guard normalizedCreatedTerminalIdentity(macInstanceTag) == nil else { return } - macInstanceTag = normalizedCreatedTerminalIdentity(instanceTag) + guard CreatedTerminalSelection.normalizedIdentity(macInstanceTag) == nil else { return } + macInstanceTag = CreatedTerminalSelection.normalizedIdentity(instanceTag) + } +} + +extension CreatedTerminalSelection { + static func deviceIDsMatch(_ lhs: String?, _ rhs: String?) -> Bool { + switch (CreatedTerminalSelection.normalizedIdentity(lhs), CreatedTerminalSelection.normalizedIdentity(rhs)) { + case let (lhs?, rhs?): + return cmxCanonicalDeviceID(lhs) == cmxCanonicalDeviceID(rhs) + default: + return false + } } + + static func normalizedIdentity(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + return value + } + } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index d54a8823eca8..9a4c485a9ca2 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -609,10 +609,10 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return nil } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) } return RegistryResponse(data: data, statusCode: http.statusCode) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift index 9906d4696e9d..d33df137b193 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift @@ -10,11 +10,13 @@ internal import Foundation /// with `CMUX_DEBUG_FORCE_MAC_COMPAT=1` (or the launch argument /// `-CMUXDebugForceMacCompat YES`); the gate then evaluates the dev Mac with /// its channel derived from the reported version grammar. -func mobileMacCompatDebugOverrideForcesEvaluation( - environment: [String: String] = ProcessInfo.processInfo.environment, - defaults: UserDefaults = .standard -) -> Bool { - environment["CMUX_DEBUG_FORCE_MAC_COMPAT"] == "1" - || defaults.bool(forKey: "CMUXDebugForceMacCompat") +extension MobileMacBuildCompatibilityPolicy { + static func forcesDebugEvaluation( + environment: [String: String] = ProcessInfo.processInfo.environment, + defaults: UserDefaults = .standard + ) -> Bool { + environment["CMUX_DEBUG_FORCE_MAC_COMPAT"] == "1" + || defaults.bool(forKey: "CMUXDebugForceMacCompat") + } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift index 48eadc32f9df..243b5f4ef220 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift @@ -21,6 +21,7 @@ public final class MobileMacTagAllowlist: @unchecked Sendable { /// scope, so one fixed key is per-tag by construction. public static let defaultsKey = "CMUXCompatibleMacTags" + // Carve-out: synchronous route-admission predicates read this snapshot across executors. private let lock = NSLock() private var storage: Set private let defaults: UserDefaults? diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift index 0252188a9db3..45e591f95ee6 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift @@ -92,7 +92,7 @@ extension MobileShellComposite { return MobileMacCompatPolicy.Channel(instanceTag: instanceTag) case .development?: #if DEBUG - guard mobileMacCompatDebugOverrideForcesEvaluation() else { return nil } + guard MobileMacBuildCompatibilityPolicy.forcesDebugEvaluation() else { return nil } return macAppVersion?.contains("-nightly.") == true ? .nightly : .stable #else return nil diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift index f9f0902a30db..d372663ce217 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift @@ -336,10 +336,10 @@ extension MobileShellComposite { } for row in rows where row.stackUserID == pinnedAccountID - && macInstanceTagAuthority.sameStoredAuthority( + && (primary.instanceTag == nil || macInstanceTagAuthority.sameStoredAuthority( row.instanceTag, - computer.instanceTag - ) + primary.instanceTag + )) && !(macInstanceTagAuthority.sameStoredAuthority( row.instanceTag, primary.instanceTag diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift index 4e25ea0e91c0..f32403f06d15 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift @@ -298,16 +298,19 @@ private extension MobilePairedMac { !displayName.isEmpty else { return nil } - let reconnectRoutes = MobileShellComposite.storedReconnectRoutes( - routes, - supportedKinds: supportedKinds, - preferNonLoopback: preferNonLoopback - ) - if case let .peer(identity, _)? = reconnectRoutes.first?.endpoint { + // Presentation identity comes from stored endpoints, not the selected + // connection method. Automatic dial admission excludes raw Tailscale + // routes, but changing that method must not split a computer's aliases. + let supported = Set(supportedKinds) + let identityRoutes = routes.filter { + (supported.isEmpty || supported.contains($0.kind)) + && (!preferNonLoopback || $0.kind != .debugLoopback) + }.sorted(by: MobileShellComposite.routeSortsBefore) + if case let .peer(identity, _)? = identityRoutes.first?.endpoint { return "iroh:\(identity.endpointID):name:\(displayName.lowercased())" } guard let (host, port) = MobileShellComposite.firstReconnectHostPortRoute( - reconnectRoutes, + identityRoutes, supportedKinds: supportedKinds, preferNonLoopback: preferNonLoopback ), let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 84a4ae01a0c2..3f190e91d202 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -35,6 +35,8 @@ public typealias CMUXMobileShellStore = MobileShellComposite @MainActor @Observable public final class MobileShellComposite: MobileTerminalOutputSinking { + public let macListAuthState: MobileMacListAuthState + /// Bound the peer fleet to five live sessions: one initial focus plus four /// warm peers. After the first focus handoff, the focused peer may also keep /// its control capability without consuming another transport session. @@ -1745,7 +1747,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { MacPairingKey(macDeviceID: macID, instanceTag: row.macInstanceTag) ]?.status } - return WorkspaceAbsenceAuthority.absenceIsAuthoritative( + return WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: row != nil, rowIsForegroundServed: row.map(workspaceRowIsForegroundServed) ?? false, foregroundIsHealthy: foregroundIsHealthy, @@ -1819,6 +1821,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// - Parameter browserStreamEvents: App-lifetime browser stream state kept outside workspace previews. public init( runtime: (any MobileSyncRuntime)? = nil, + macListAuthState: MobileMacListAuthState? = nil, isSignedIn: Bool = false, connectionState: MobileConnectionState = .disconnected, connectedHostName: String = "", @@ -1869,6 +1872,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { storedMacReconnectRestoringDeadlineSeconds: Double = 15 ) { self.runtime = runtime + self.macListAuthState = macListAuthState ?? MobileMacListAuthState() self.draftStore = draftStore self.groupCollapseStore = groupCollapseStore self.lastTabStore = lastTabStore @@ -2091,6 +2095,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public static func preview( runtime: (any MobileSyncRuntime)? = nil, + macListAuthState: MobileMacListAuthState? = nil, // In-memory so previews and package tests never share persisted // last-tab state through `.standard` (the app injects a persistent // store through the composite initializer instead). @@ -11798,7 +11803,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let requiredNightlyMacVersion = requirement?.nightly.map { "\($0.minBaseVersion)-nightly.\($0.minBuild)" } - MobileMacListAuthState.shared.applyPolicyMinimumSupportedMacVersions( + macListAuthState.applyPolicyMinimumSupportedMacVersions( stable: requiredStableMacVersion, nightly: requiredNightlyMacVersion ) @@ -12649,18 +12654,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) let requestedRow = workspaces.first { $0.id == rowWorkspaceID } let requestedWorkspaceID = remoteWorkspaceID(for: rowWorkspaceID) - let requestedMacDeviceID = normalizedCreatedTerminalIdentity(requestedRow?.macDeviceID) - ?? normalizedCreatedTerminalIdentity(foregroundMacDeviceID) + let requestedMacDeviceID = CreatedTerminalSelection.normalizedIdentity(requestedRow?.macDeviceID) + ?? CreatedTerminalSelection.normalizedIdentity(foregroundMacDeviceID) // A known workspace owner may legitimately have no instance tag in a // legacy snapshot. Keep that absence instead of borrowing the global // foreground tag, which may belong to a different Mac instance. let requestedInstanceTag: String? = { - guard let requestedRow else { return normalizedCreatedTerminalIdentity(activeMacInstanceTag) } - if normalizedCreatedTerminalIdentity(requestedRow.macDeviceID) != nil { - return normalizedCreatedTerminalIdentity(requestedRow.macInstanceTag) + guard let requestedRow else { return CreatedTerminalSelection.normalizedIdentity(activeMacInstanceTag) } + if CreatedTerminalSelection.normalizedIdentity(requestedRow.macDeviceID) != nil { + return CreatedTerminalSelection.normalizedIdentity(requestedRow.macInstanceTag) } - return normalizedCreatedTerminalIdentity(requestedRow.macInstanceTag) - ?? normalizedCreatedTerminalIdentity(activeMacInstanceTag) + return CreatedTerminalSelection.normalizedIdentity(requestedRow.macInstanceTag) + ?? CreatedTerminalSelection.normalizedIdentity(activeMacInstanceTag) }() let generation = connectionGeneration do { @@ -12684,11 +12689,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applyRemoteWorkspaceList(response, mergeExistingWorkspaces: true) let selectedRow = explicitlySelectedWorkspace let selectedRowMatchesAnonymousRequest: Bool - if normalizedCreatedTerminalIdentity(requestedRow?.macDeviceID) == nil, + if CreatedTerminalSelection.normalizedIdentity(requestedRow?.macDeviceID) == nil, let foregroundMacDeviceID, let selectedRow, selectedRow.rpcWorkspaceID == requestedWorkspaceID, - createdTerminalDeviceIDsMatch( + CreatedTerminalSelection.deviceIDsMatch( selectedRow.macDeviceID, foregroundMacDeviceID ), @@ -12701,19 +12706,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { selectedRowMatchesAnonymousRequest = false } let selectedRowMatchesUnidentifiedRequest: Bool = { - guard normalizedCreatedTerminalIdentity(requestedRow?.macDeviceID) == nil, - normalizedCreatedTerminalIdentity(foregroundMacDeviceID) == nil, - normalizedCreatedTerminalIdentity(requestedInstanceTag) == nil, + guard CreatedTerminalSelection.normalizedIdentity(requestedRow?.macDeviceID) == nil, + CreatedTerminalSelection.normalizedIdentity(foregroundMacDeviceID) == nil, + CreatedTerminalSelection.normalizedIdentity(requestedInstanceTag) == nil, let selectedRow, selectedRow.rpcWorkspaceID == requestedWorkspaceID, - normalizedCreatedTerminalIdentity(selectedRow.macDeviceID) == nil, - normalizedCreatedTerminalIdentity(selectedRow.macInstanceTag) == nil else { + CreatedTerminalSelection.normalizedIdentity(selectedRow.macDeviceID) == nil, + CreatedTerminalSelection.normalizedIdentity(selectedRow.macInstanceTag) == nil else { return false } return workspaces.filter { $0.rpcWorkspaceID == requestedWorkspaceID - && normalizedCreatedTerminalIdentity($0.macDeviceID) == nil - && normalizedCreatedTerminalIdentity($0.macInstanceTag) == nil + && CreatedTerminalSelection.normalizedIdentity($0.macDeviceID) == nil + && CreatedTerminalSelection.normalizedIdentity($0.macInstanceTag) == nil }.count == 1 }() let selectedRowMatchesKnownOwnerRequest: Bool = { @@ -12721,7 +12726,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { selectedRow.rpcWorkspaceID == requestedWorkspaceID, let selectedMacDeviceID = selectedRow.macDeviceID, let requestedMacDeviceID, - createdTerminalDeviceIDsMatch(selectedMacDeviceID, requestedMacDeviceID) else { + CreatedTerminalSelection.deviceIDsMatch(selectedMacDeviceID, requestedMacDeviceID) else { return false } let selectedTag = macInstanceTagAuthority.normalize(selectedRow.macInstanceTag) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift index 3a93ad8af25b..3a338b0edc3b 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift @@ -359,10 +359,10 @@ public actor PairedMacBackupClient: PairedMacBackingUp { private func recordRetryAfterIfNeeded(_ response: HTTPURLResponse) async { guard response.statusCode == 429 else { return } - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: response, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift index 31fb5748eecc..2cc108cdbca8 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift @@ -120,10 +120,10 @@ public actor PresenceClient { } catch { if let response = task.response as? HTTPURLResponse, response.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: response, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds continuation.finish( throwing: PresenceClientError.rateLimited( retryAfterSeconds: seconds diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift index e800cb543463..6ed467bb8987 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift @@ -10,7 +10,9 @@ import CmuxMobileShellModel /// detail's last-known snapshot stays mounted until the connection resolves; /// the next healthy list then either restores the row or confirms the /// deletion, which retargets normally. -enum WorkspaceAbsenceAuthority { +struct WorkspaceAbsenceAuthority: Sendable { + init() {} + /// - Parameters: /// - hasLastKnownRow: Whether the caller still holds the vanished /// workspace's last-known row. Without one the owner cannot be @@ -24,7 +26,7 @@ enum WorkspaceAbsenceAuthority { /// entry is gone entirely. A missing entry is an authoritative /// removal (unpair/hide) when the foreground is healthy, and the /// foreground-death purge of secondary snapshots otherwise. - static func absenceIsAuthoritative( + func absenceIsAuthoritative( hasLastKnownRow: Bool, rowIsForegroundServed: Bool, foregroundIsHealthy: Bool, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalSession.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalSession.swift index 65ffd5dbe7ed..c924ac9faa57 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalSession.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalSession.swift @@ -59,7 +59,7 @@ public final class MobileIrohReleaseGateTerminalSession { do { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { @MainActor in - await client.submitTerminalRawInput(probe.command, surfaceID: surfaceID) + await self.client.submitTerminalRawInput(probe.command, surfaceID: surfaceID) try Task.checkCancellation() for try await _ in proof { try Task.checkCancellation() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift index 86ca89946487..2c612d1e516e 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift @@ -5755,7 +5755,7 @@ import Testing routeKind: .debugLoopback ), ]) - #expect(shell.connections["mac-late"]?.client === client) + #expect(shell.connections[MacPairingKey(macDeviceID: "mac-late", instanceTag: "mmpool")]?.client === client) } @Test func officialBuildAdoptsUntagged06417OnlyFromAuthorizedTailscale() async throws { @@ -5891,7 +5891,7 @@ import Testing #expect(shell.remoteClient != nil) #expect(shell.remoteClient !== originalClient) #expect(shell.foregroundMacDeviceIDForTesting() == "test-mac") - #expect(shell.connections["test-mac"]?.client === shell.remoteClient) + #expect(shell.connections.onDevice("test-mac")?.client === shell.remoteClient) } @Test func anonymousTargetRetiresWarmControlOnSamePhysicalRoute() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift index ddb543bb5f2f..007ea3877633 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift @@ -332,7 +332,7 @@ import Testing // wrong "code" was entered. let message = MobilePairingFailureCategory.invalidCode.message #expect(!message.lowercased().contains("pairing code")) - #expect(message.localizedCaseInsensitiveContains("Tailscale")) + #expect(message.localizedCaseInsensitiveContains("pairing QR")) #expect(!message.isEmpty) } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift index 554156c23128..3e887a02aa59 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxMobilePairedMac import CmuxMobileRPC import CmuxMobileShellModel import Foundation @@ -92,7 +93,11 @@ import UserNotifications @Test func dismissRoutesToOwningSecondaryMac() async throws { let foregroundRouter = RoutingHostRouter() let secondaryRouter = RoutingHostRouter() - let store = try await makeRoutingConnectedStore(router: foregroundRouter) + let store = try await makeRoutingConnectedStore( + router: foregroundRouter, + pairedMacStore: legacySecondaryPairingStore() + ) + await store.loadPairedMacs() try installSecondaryClient(on: store, macDeviceID: "mac-secondary", router: secondaryRouter) await store.dismissNotification(ids: [" n-secondary "], macDeviceID: "mac-secondary") @@ -142,8 +147,10 @@ import UserNotifications ) let store = try await makeRoutingConnectedStore( router: foregroundRouter, - pendingDismissQueue: queue + pendingDismissQueue: queue, + pairedMacStore: legacySecondaryPairingStore() ) + await store.loadPairedMacs() queue.enqueue([ PendingNotificationDismiss( id: "n-secondary", @@ -319,4 +326,20 @@ import UserNotifications #expect(event?.unreadCount == 12) } + // Device-only dismissals require exactly one remembered, untagged owner. + private func legacySecondaryPairingStore() -> DelayedTeamPairedMacStore { + DelayedTeamPairedMacStore( + recordsByTeam: ["": [MobilePairedMac( + macDeviceID: "mac-secondary", + displayName: "Secondary Mac", + routes: [], + createdAt: Date(), + lastSeenAt: Date(), + isActive: false, + stackUserID: "routing-user" + )]], + blockedTeams: [] + ) + } + } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift index 0ff698c120e3..d1b0dd3fa238 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift @@ -5,14 +5,14 @@ import Testing @Suite struct WorkspaceAbsenceAuthorityTests { @Test func foregroundRowAbsenceIsAuthoritativeOnlyWhileHealthy() { // Deleted while the foreground connection is live: retarget/pop. - #expect(WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: true, foregroundIsHealthy: true, ownerStatus: .connected )) // Vanished mid-recovery: a transient hole, hold the selection. - #expect(!WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(!WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: true, foregroundIsHealthy: false, @@ -22,20 +22,20 @@ import Testing @Test func secondaryRowAbsenceFollowsItsOwnEntryStatus() { // A secondary Mac's live list omitted the workspace: deletion. - #expect(WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: false, foregroundIsHealthy: false, ownerStatus: .connected )) // The secondary went offline or is redialing: hold the selection. - #expect(!WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(!WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: false, foregroundIsHealthy: true, ownerStatus: .unavailable )) - #expect(!WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(!WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: false, foregroundIsHealthy: true, @@ -45,14 +45,14 @@ import Testing @Test func missingOwnerEntryIsAuthoritativeOnlyWhileForegroundIsHealthy() { // Unpair/hide removes the entry while the shell is healthy: pop. - #expect(WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: false, foregroundIsHealthy: true, ownerStatus: nil )) // Foreground death purges secondary snapshots: hold the selection. - #expect(!WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(!WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: true, rowIsForegroundServed: false, foregroundIsHealthy: false, @@ -61,13 +61,13 @@ import Testing } @Test func unknownRowFallsBackToForegroundHealth() { - #expect(WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: false, rowIsForegroundServed: false, foregroundIsHealthy: true, ownerStatus: nil )) - #expect(!WorkspaceAbsenceAuthority.absenceIsAuthoritative( + #expect(!WorkspaceAbsenceAuthority().absenceIsAuthoritative( hasLastKnownRow: false, rowIsForegroundServed: false, foregroundIsHealthy: false, diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift index 48e6085867f9..2b72800913d1 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift @@ -5,7 +5,9 @@ public import Foundation /// Mirrors the Mac markdown panel's decode order: strict UTF-8 first, then an /// ISO-Latin-1 reinterpretation so legacy-encoded files still render as text /// instead of failing into an unreadable state. -public enum MacSurfaceTextDecoder { +public struct MacSurfaceTextDecoder: Sendable { + public init() {} + /// The encoding a decode resolved to. public enum Encoding: Equatable, Sendable { case utf8 @@ -27,7 +29,7 @@ public enum MacSurfaceTextDecoder { /// /// ISO-Latin-1 maps every byte to a character, so the fallback always /// succeeds and any byte payload decodes to text. - public static func decode(_ data: Data) -> DecodedText { + public func decode(_ data: Data) -> DecodedText { if let utf8 = String(data: data, encoding: .utf8) { return DecodedText(text: utf8, encoding: .utf8) } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift index 3d4d2657b9ca..9cddb89d693a 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift @@ -9,7 +9,9 @@ public import Foundation /// ``responders`` — no engine or per-terminal changes needed. Keep outputs /// realistic English developer content with no internal build-lane /// vocabulary, and end every line with `\r\n`. -public enum MobileDemoCommandCatalog { +public struct MobileDemoCommandCatalog: Sendable { + public init() {} + /// What a responder knows about the invocation. public struct Context: Sendable { /// Everything after the command name, whitespace-trimmed ("" if none). @@ -31,7 +33,7 @@ public enum MobileDemoCommandCatalog { public typealias Responder = @Sendable (Context) -> String /// The showcase command table. Add reviewer-facing commands here. - public static let responders: [String: Responder] = [ + public let responders: [String: Responder] = [ "echo": { context in context.arguments + "\r\n" }, @@ -51,7 +53,7 @@ public enum MobileDemoCommandCatalog { return formatter.string(from: context.now) + "\r\n" }, "git": { context in - gitResponse(argument: context.arguments) + MobileDemoCommandCatalog.gitResponse(argument: context.arguments) }, "help": { _ in "Available commands: ls, cd, pwd, cat, echo, git, date, whoami, clear\r\n" @@ -59,7 +61,7 @@ public enum MobileDemoCommandCatalog { ] /// Resolves one showcase command, or `nil` for command-not-found. - static func response(command: String, context: Context) -> String? { + func response(command: String, context: Context) -> String? { responders[command]?(context) } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift index 6dc06dfc06f2..f18511429d1e 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift @@ -242,7 +242,7 @@ public final class MobileDemoTerminalEngine { session.transcript = "" return "\u{1B}[2J\u{1B}[H" + session.script.prompt(atPath: session.path) default: - body = MobileDemoCommandCatalog.response( + body = MobileDemoCommandCatalog().response( command: command, context: MobileDemoCommandCatalog.Context( arguments: argument, diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift index b1443c8d4a10..b2ec773fea24 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift @@ -11,9 +11,8 @@ public import Observation /// that Mac yet. A missing build is treated as possibly too old until the Mac /// advertises its version. /// -/// A process-wide shared instance is the seam here because the writer lives -/// in `cmuxFeature` (the transport composition) and the readers live in -/// `CmuxMobileShellUI`, packages with no injection path between them today. +/// The app composition owns one instance and injects it into the transport, +/// shell store, and SwiftUI environment so account boundaries clear one state. @MainActor @Observable public final class MobileMacListAuthState { @@ -100,8 +99,6 @@ public final class MobileMacListAuthState { } } - public static let shared = MobileMacListAuthState() - /// Every directory entry retains its app, endpoint, binding, and generation. public private(set) var entriesByIdentity: [Identity: Entry] = [:] public private(set) var hasSnapshot = false diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacVersionCompatibility.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacVersionCompatibility.swift index 31fc089c37e4..4955ecfab85c 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacVersionCompatibility.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacVersionCompatibility.swift @@ -15,7 +15,7 @@ public struct MobileMacVersionCompatibility: Equatable, Sendable { stableMinimum: String?, nightlyMinimum: String? ) { - let result = mobileMacVersionCompatibility( + let result = Self.mobileMacVersionCompatibility( appVersion: appVersion, releaseTrack: releaseTrack, stableMinimum: stableMinimum, @@ -31,80 +31,82 @@ public struct MobileMacVersionCompatibility: Equatable, Sendable { } } -private func mobileMacVersionCompatibility( - appVersion: String?, - releaseTrack: String?, - stableMinimum: String?, - nightlyMinimum: String? -) -> MobileMacVersionCompatibility { - let normalizedAppVersion = appVersion?.trimmingCharacters(in: .whitespacesAndNewlines) - let nightly = releaseTrack?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "nightly" - || (releaseTrack == nil && normalizedAppVersion?.contains("-nightly.") == true) - if nightly { - // No applicable iOS policy is the explicit unconstrained state. A - // stable-only policy still requires a valid reported Nightly version. - guard stableMinimum != nil || nightlyMinimum != nil else { - return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) +extension MobileMacVersionCompatibility { + private static func mobileMacVersionCompatibility( + appVersion: String?, + releaseTrack: String?, + stableMinimum: String?, + nightlyMinimum: String? + ) -> MobileMacVersionCompatibility { + let normalizedAppVersion = appVersion?.trimmingCharacters(in: .whitespacesAndNewlines) + let nightly = releaseTrack?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "nightly" + || (releaseTrack == nil && normalizedAppVersion?.contains("-nightly.") == true) + if nightly { + // No applicable iOS policy is the explicit unconstrained state. A + // stable-only policy still requires a valid reported Nightly version. + guard stableMinimum != nil || nightlyMinimum != nil else { + return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) + } + guard let normalizedAppVersion, + let installed = parseMobileMacNightlyVersion(normalizedAppVersion) + else { + return MobileMacVersionCompatibility(isOutdated: true, requiredVersionDisplay: nightlyMinimum) + } + guard let nightlyMinimum, + let required = parseMobileMacNightlyVersion(nightlyMinimum) + else { return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) } + let outdated = mobileMacVersionPrecedes(installed.base, required.base) + || (installed.base == required.base && installed.build < required.build) + return MobileMacVersionCompatibility(isOutdated: outdated, requiredVersionDisplay: outdated ? nightlyMinimum : nil) } + guard let stableMinimum, + let required = parseMobileMacNumericVersion(stableMinimum) + else { return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) } guard let normalizedAppVersion, - let installed = parseMobileMacNightlyVersion(normalizedAppVersion) + let installed = parseMobileMacNumericVersion(normalizedAppVersion), + !normalizedAppVersion.contains("-nightly.") else { - return MobileMacVersionCompatibility(isOutdated: true, requiredVersionDisplay: nightlyMinimum) + return MobileMacVersionCompatibility(isOutdated: true, requiredVersionDisplay: stableMinimum) } - guard let nightlyMinimum, - let required = parseMobileMacNightlyVersion(nightlyMinimum) - else { return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) } - let outdated = mobileMacVersionPrecedes(installed.base, required.base) - || (installed.base == required.base && installed.build < required.build) - return MobileMacVersionCompatibility(isOutdated: outdated, requiredVersionDisplay: outdated ? nightlyMinimum : nil) - } - guard let stableMinimum, - let required = parseMobileMacNumericVersion(stableMinimum) - else { return MobileMacVersionCompatibility(isOutdated: false, requiredVersionDisplay: nil) } - guard let normalizedAppVersion, - let installed = parseMobileMacNumericVersion(normalizedAppVersion), - !normalizedAppVersion.contains("-nightly.") - else { - return MobileMacVersionCompatibility(isOutdated: true, requiredVersionDisplay: stableMinimum) + let outdated = mobileMacVersionPrecedes(installed, required) + return MobileMacVersionCompatibility(isOutdated: outdated, requiredVersionDisplay: outdated ? stableMinimum : nil) } - let outdated = mobileMacVersionPrecedes(installed, required) - return MobileMacVersionCompatibility(isOutdated: outdated, requiredVersionDisplay: outdated ? stableMinimum : nil) -} -private func parseMobileMacNumericVersion(_ raw: String) -> [Int]? { - let core = raw.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? raw - let parts = core.split(separator: ".", omittingEmptySubsequences: false) - guard (1 ... 3).contains(parts.count), - parts.allSatisfy({ - !$0.isEmpty - && $0.utf8.allSatisfy { (48 ... 57).contains($0) } - && Int($0) != nil - }) - else { return nil } - var values = parts.map { Int($0)! } - while values.count < 3 { values.append(0) } - return values -} + private static func parseMobileMacNumericVersion(_ raw: String) -> [Int]? { + let core = raw.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? raw + let parts = core.split(separator: ".", omittingEmptySubsequences: false) + guard (1 ... 3).contains(parts.count), + parts.allSatisfy({ + !$0.isEmpty + && $0.utf8.allSatisfy { (48 ... 57).contains($0) } + && Int($0) != nil + }) + else { return nil } + var values = parts.map { Int($0)! } + while values.count < 3 { values.append(0) } + return values + } -private func mobileMacVersionPrecedes(_ lhs: [Int], _ rhs: [Int]) -> Bool { - for index in 0 ..< max(lhs.count, rhs.count) { - let left = index < lhs.count ? lhs[index] : 0 - let right = index < rhs.count ? rhs[index] : 0 - if left != right { return left < right } + private static func mobileMacVersionPrecedes(_ lhs: [Int], _ rhs: [Int]) -> Bool { + for index in 0 ..< max(lhs.count, rhs.count) { + let left = index < lhs.count ? lhs[index] : 0 + let right = index < rhs.count ? rhs[index] : 0 + if left != right { return left < right } + } + return false } - return false -} -private func parseMobileMacNightlyVersion(_ raw: String) -> (base: [Int], build: UInt64)? { - let core = raw.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? raw - let marker = "-nightly." - guard let range = core.range(of: marker), - let base = parseMobileMacNumericVersion(String(core[.. (base: [Int], build: UInt64)? { + let core = raw.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? raw + let marker = "-nightly." + guard let range = core.range(of: marker), + let base = parseMobileMacNumericVersion(String(core[.. = [ + public let defaultChannelTokens: Set = [ MobileBuildType.dev.token, MobileBuildType.beta.token, MobileBuildType.internal.token, @@ -30,7 +32,7 @@ public enum MobileWhatsNewChannelPolicy { /// Whether a page carrying `channelTokens` (`nil` = undeclared) is shown /// on a build of `buildType`. - public static func isVisible( + public func isVisible( channelTokens: [String]?, buildType: MobileBuildType ) -> Bool { diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift index 4493e10fe97a..4524d1fcabe9 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift @@ -5,7 +5,7 @@ import Testing struct MacSurfaceTextDecoderTests { @Test func decodesUTF8Text() { let data = Data("# Hello 世界 🎉".utf8) - let decoded = MacSurfaceTextDecoder.decode(data) + let decoded = MacSurfaceTextDecoder().decode(data) #expect(decoded.encoding == .utf8) #expect(decoded.text == "# Hello 世界 🎉") } @@ -13,20 +13,20 @@ struct MacSurfaceTextDecoderTests { @Test func fallsBackToISOLatin1ForInvalidUTF8() { // 0xE9 alone is invalid UTF-8 but is "é" in ISO-Latin-1. let data = Data([0x63, 0x61, 0x66, 0xE9]) - let decoded = MacSurfaceTextDecoder.decode(data) + let decoded = MacSurfaceTextDecoder().decode(data) #expect(decoded.encoding == .isoLatin1) #expect(decoded.text == "café") } @Test func arbitraryBytesAlwaysDecode() { let data = Data([0xFF, 0xFE, 0x00, 0x80, 0x9F]) - let decoded = MacSurfaceTextDecoder.decode(data) + let decoded = MacSurfaceTextDecoder().decode(data) #expect(decoded.encoding == .isoLatin1) #expect(decoded.text.count == 5) } @Test func emptyDataDecodesToEmptyUTF8() { - let decoded = MacSurfaceTextDecoder.decode(Data()) + let decoded = MacSurfaceTextDecoder().decode(Data()) #expect(decoded.encoding == .utf8) #expect(decoded.text.isEmpty) } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift index 980d383a4f1c..e758f72f6d3b 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift @@ -184,10 +184,10 @@ import Testing // Engine-owned session commands stay out of the shared table; the // reviewer-facing showcase commands all live in it. for sessionCommand in ["cd", "ls", "pwd", "cat", "clear"] { - #expect(MobileDemoCommandCatalog.responders[sessionCommand] == nil) + #expect(MobileDemoCommandCatalog().responders[sessionCommand] == nil) } for showcaseCommand in ["echo", "git", "date", "whoami", "help"] { - #expect(MobileDemoCommandCatalog.responders[showcaseCommand] != nil) + #expect(MobileDemoCommandCatalog().responders[showcaseCommand] != nil) } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift index 5d2a990284b3..bf1744f04e4a 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift @@ -7,19 +7,19 @@ import Testing /// remote entry explicitly opts into the official channel. struct MobileWhatsNewChannelPolicyTests { @Test func undeclaredChannelsDefaultToTeamLanesOnly() { - #expect(MobileWhatsNewChannelPolicy.isVisible(channelTokens: nil, buildType: .dev)) - #expect(MobileWhatsNewChannelPolicy.isVisible(channelTokens: nil, buildType: .beta)) - #expect(MobileWhatsNewChannelPolicy.isVisible(channelTokens: nil, buildType: .internal)) - #expect(!MobileWhatsNewChannelPolicy.isVisible(channelTokens: nil, buildType: .demo)) - #expect(!MobileWhatsNewChannelPolicy.isVisible(channelTokens: nil, buildType: .prod)) + #expect(MobileWhatsNewChannelPolicy().isVisible(channelTokens: nil, buildType: .dev)) + #expect(MobileWhatsNewChannelPolicy().isVisible(channelTokens: nil, buildType: .beta)) + #expect(MobileWhatsNewChannelPolicy().isVisible(channelTokens: nil, buildType: .internal)) + #expect(!MobileWhatsNewChannelPolicy().isVisible(channelTokens: nil, buildType: .demo)) + #expect(!MobileWhatsNewChannelPolicy().isVisible(channelTokens: nil, buildType: .prod)) } @Test func explicitProdTokenOptsIntoTheOfficialApp() { - #expect(MobileWhatsNewChannelPolicy.isVisible( + #expect(MobileWhatsNewChannelPolicy().isVisible( channelTokens: ["beta", "internal", "dev", "prod"], buildType: .prod )) - #expect(MobileWhatsNewChannelPolicy.isVisible( + #expect(MobileWhatsNewChannelPolicy().isVisible( channelTokens: ["prod"], buildType: .prod )) @@ -28,11 +28,11 @@ struct MobileWhatsNewChannelPolicyTests { @Test func explicitListReplacesTheDefaultEntirely() { // Declaring channels narrows as well as widens: a prod-only entry is // hidden from team lanes. - #expect(!MobileWhatsNewChannelPolicy.isVisible( + #expect(!MobileWhatsNewChannelPolicy().isVisible( channelTokens: ["prod"], buildType: .beta )) - #expect(!MobileWhatsNewChannelPolicy.isVisible( + #expect(!MobileWhatsNewChannelPolicy().isVisible( channelTokens: ["prod"], buildType: .dev )) @@ -40,14 +40,14 @@ struct MobileWhatsNewChannelPolicyTests { @Test func emptyAndUnknownTokensFailClosed() { for buildType in [MobileBuildType.dev, .beta, .internal, .demo, .prod] { - #expect(!MobileWhatsNewChannelPolicy.isVisible( + #expect(!MobileWhatsNewChannelPolicy().isVisible( channelTokens: [], buildType: buildType )) } // A typo ("official" is not a token; the canonical token is "prod") // hides rather than shows. - #expect(!MobileWhatsNewChannelPolicy.isVisible( + #expect(!MobileWhatsNewChannelPolicy().isVisible( channelTokens: ["official"], buildType: .prod )) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 3955460df9d7..b9eeb3a8cf97 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -945,7 +945,7 @@ struct CMUXMobileRootView: View { isAuthenticated: isAuthenticated, connectionPhase: onboardingConnectionPhase, connectionMethod: connectionMethodStore?.method ?? .automatic, - keepAwakeOffer: OnboardingKeepAwakeOfferSource.offer(from: store), + keepAwakeOffer: OnboardingKeepAwakeOfferSource().offer(from: store), onSelectConnectionMethod: { connectionMethodStore?.method = $0 }, onEnablePush: { await pushCoordinator.enable(trigger: "onboarding") }, onReachedConnection: markOnboardingReadyToConnect, @@ -953,7 +953,7 @@ struct CMUXMobileRootView: View { onRetryConnection: retryAutomaticConnection, onStartTailscalePairing: showOnboardingPairingScanner, onSetKeepAwake: { [store] enabled in - await OnboardingKeepAwakeOfferSource.set(enabled, on: store) + await OnboardingKeepAwakeOfferSource().set(enabled, on: store) }, onComplete: completeOnboarding ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 033c1fba827f..9c31f7b93916 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -136,7 +136,7 @@ struct MobilePushReadinessPreviewView: View { return await mutateMac(.mode(.always)) case .openSystemSettings, .signInAgain, .finishAccountDeletion, .disablePushOnAnotherDevice, .signIntoMatchingAccount, - .rebuildMatchingApps, .waitForDeviceToken: + .rebuildMatchingApps, .waitForDeviceToken, .retrySecurePushSetup: return true } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift index 1b4b68133567..215f41890e99 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -23,7 +23,7 @@ public struct MobileWhatsNewPreviewView: View { .environment(center) .sheet(isPresented: $showsSheet) { MobileWhatsNewSheet( - pages: MobileWhatsNewCatalog.entries, + pages: MobileWhatsNewCatalog().entries, allowedWebHosts: [], dismiss: { showsSheet = false } ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift index 3da3562f86ff..e0dc276587d3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift @@ -59,6 +59,7 @@ private struct ComputerRowTransitionPhase: ViewModifier { /// Keeping one row identity and one `Toggle` instance lets SwiftUI carry the /// native switch transaction through the model update. private struct ComputerVisibilityRow: View { + @Environment(MobileMacListAuthState.self) private var listAuthState: MobileMacListAuthState? let item: ComputerVisibilityRowItem let setVisible: (Bool) -> Void let isVisibilityMutating: Bool @@ -178,8 +179,8 @@ private struct ComputerVisibilityRow: View { ComputerBuildBadge(label: buildLabel) } if gateWarningPairingIDs.contains(computer.id) - || (MobileMacListAuthState.shared.hasSnapshot - && MobileMacListAuthState.shared.compatibilityEntry(pairingID: computer.id).isOutdated) { + || ((listAuthState?.hasSnapshot == true) + && listAuthState?.compatibilityEntry(pairingID: computer.id).isOutdated == true) { Button { showingHiddenVersionGateWarning = true } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift index 946cbd533b60..c9bfb298e5a6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerDetailView.swift @@ -10,6 +10,7 @@ import SwiftUI /// Per-computer connection settings, appearance, and saved routes. /// This detail holds its store directly and addresses one Mac and build tag. struct MacComputerDetailView: View { + @Environment(MobileMacListAuthState.self) private var listAuthState: MobileMacListAuthState? @Bindable var store: CMUXMobileShellStore let macDeviceID: String let instanceTag: String? @@ -102,7 +103,7 @@ struct MacComputerDetailView: View { } var body: some View { Form { - if MobileMacListAuthState.shared.hasSnapshot, + if (listAuthState?.hasSnapshot == true), let listAuthEntry, listAuthEntry.isOutdated { MacComputerCompatibilitySection(entry: listAuthEntry) @@ -445,7 +446,7 @@ struct MacComputerDetailView: View { } private var listAuthEntry: MobileMacListAuthState.Entry? { - MobileMacListAuthState.shared.compatibilityEntry( + listAuthState?.compatibilityEntry( pairingID: MobilePairedMac.pairingID(macDeviceID: macDeviceID, instanceTag: instanceTag), routes: pairedMac?.routes ?? [] ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift index 8aedffa6da0b..3d418a751710 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacComputerRow.swift @@ -14,6 +14,7 @@ import SwiftUI /// primary line and dot switch to presence (green = the Mac is online and worth /// tapping), and the workspace count is dropped (it is stale while disconnected). struct MacComputerRow: View { + @Environment(MobileMacListAuthState.self) private var listAuthState: MobileMacListAuthState? /// How the row behaves and which status it leads with. enum Style { /// Computers screen: navigation to the detail view, phone-connection dot. @@ -169,15 +170,15 @@ struct MacComputerRow: View { /// Mac. A row with no remembered version warns until its first hello /// records the build version in the durable overlay. private var listAuthEntry: MobileMacListAuthState.Entry { - MobileMacListAuthState.shared.compatibilityEntry( + listAuthState?.compatibilityEntry( pairingID: computer.id, routes: computer.routes - ) + ) ?? .init(status: "unknown", revoked: false, isFresh: false) } private var showsListAuthWarning: Bool { hasVersionGateWarning - || (MobileMacListAuthState.shared.hasSnapshot && listAuthEntry.isOutdated) + || ((listAuthState?.hasSnapshot == true) && listAuthEntry.isOutdated) } /// Outdated rows carry a compact warning triangle beside the name; the diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift index 5514b6631a3c..9c99621a33ec 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift @@ -159,14 +159,14 @@ public struct MacSurfaceGalleryPreviewView: View { ChatArtifactStat( exists: true, isDirectory: false, - size: Int64(MacSurfaceGalleryFixtureBytes.body(for: path).count), + size: Int64(MacSurfaceGalleryFixtureBytes().body(for: path).count), modifiedAt: Date(timeIntervalSince1970: 1_753_800_000), kind: .text, mimeType: path.hasSuffix(".md") ? "text/markdown" : "text/plain" ) }, fetch: { path, progress in - let data = MacSurfaceGalleryFixtureBytes.body(for: path) + let data = MacSurfaceGalleryFixtureBytes().body(for: path) progress?(Int64(data.count), Int64(data.count)) return data } @@ -174,8 +174,10 @@ public struct MacSurfaceGalleryPreviewView: View { } /// Off-actor fixture bytes so the `@Sendable` loader closures can read them. -private enum MacSurfaceGalleryFixtureBytes { - static let textBody = Data(""" +private struct MacSurfaceGalleryFixtureBytes: Sendable { + init() {} + + let textBody = Data(""" cmux iOS all-surfaces UX round — panel file preview fixture. This body streams through the panel-scoped artifact loader and renders in @@ -186,7 +188,7 @@ private enum MacSurfaceGalleryFixtureBytes { - The header shows the surface kind badge, title, and Open on Mac. """.utf8) - static let markdownBody = Data(""" + let markdownBody = Data(""" # iosrf-demo Markdown panels now render **natively** on iOS through the shared @@ -205,7 +207,7 @@ private enum MacSurfaceGalleryFixtureBytes { ``` """.utf8) - static func body(for path: String) -> Data { + func body(for path: String) -> Data { path.hasSuffix(".md") ? markdownBody : textBody } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift index 5038a9df12b4..eadddf3dcf51 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift @@ -71,7 +71,7 @@ final class MarkdownSurfaceModel { } try Task.checkCancellation() guard generation == loadGeneration else { return } - phase = .loaded(text: MacSurfaceTextDecoder.decode(collected).text) + phase = .loaded(text: MacSurfaceTextDecoder().decode(collected).text) collected = Data() } catch is CancellationError { return diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDevicesToolbarLabel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDevicesToolbarLabel.swift index 2ea4638b326d..132f74174d4f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDevicesToolbarLabel.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDevicesToolbarLabel.swift @@ -7,6 +7,7 @@ import SwiftUI /// standard SF Symbol overlay so the action remains recognizable while making /// a blocked Mac discoverable before the user opens the Computers sheet. struct MobileDevicesToolbarLabel: View { + @Environment(MobileMacListAuthState.self) private var listAuthState: MobileMacListAuthState? /// Macs whose last authenticated attempt was rejected by the version gate. let gateWarningPairingIDs: Set /// The physical Macs represented by the Computers sheet opened by this @@ -15,9 +16,9 @@ struct MobileDevicesToolbarLabel: View { let computerPairingIDs: Set private var showsWarning: Bool { - let listAuth = MobileMacListAuthState.shared + let listAuth = listAuthState let hasOutdatedListAuth = computerPairingIDs.contains { pairingID in - listAuth.compatibilityEntry(pairingID: pairingID).isOutdated + listAuth?.compatibilityEntry(pairingID: pairingID).isOutdated == true } return Self.warningVisible( hasGateWarning: !gateWarningPairingIDs.isDisjoint(with: computerPairingIDs), diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index a5db372e5cb0..30158edec027 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -862,7 +862,8 @@ public final class MobilePushCoordinator { /// status. A missing Mac status fails closed. public func readiness( macStatus: MobileHostPhonePushStatus?, - macAccountMismatch: Bool = false + macAccountMismatch: Bool = false, + securePushSetupFailed: Bool = false ) -> MobilePushReadiness { MobilePushReadiness.resolve( authorization: authorization, @@ -870,7 +871,8 @@ public final class MobilePushCoordinator { mac: macStatus.map(MobilePushReadiness.MacStatus.init), macAccountMismatch: macAccountMismatch, systemSettings: systemSettings, - phoneAPIOrigin: phoneAPIOrigin + phoneAPIOrigin: phoneAPIOrigin, + securePushSetupFailed: securePushSetupFailed ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 5cfdb08b2017..ba8902a93aa3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -76,7 +76,7 @@ struct MobileSettingsView: View { var body: some View { @Bindable var displaySettings = displaySettings #if DEBUG - let whatsNewPages = whatsNewCenter?.archivePages ?? MobileWhatsNewCatalog.channelVisibleEntries() + let whatsNewPages = whatsNewCenter?.archivePages ?? MobileWhatsNewCatalog().channelVisibleEntries() let whatsNewHosts = whatsNewCenter?.allowedWebHosts ?? [] #endif return NavigationStack { @@ -591,7 +591,7 @@ struct MobileSettingsView: View { didFinishSearch: store?.didFinishStoredMacReconnectAttempt == true ), connectionMethod: connectionMethodStore?.method ?? .automatic, - keepAwakeOffer: OnboardingKeepAwakeOfferSource.offer(from: store), + keepAwakeOffer: OnboardingKeepAwakeOfferSource().offer(from: store), onSelectConnectionMethod: { connectionMethodStore?.method = $0 }, onEnablePush: { await pushCoordinator.enable(trigger: "onboarding_replay") @@ -604,7 +604,7 @@ struct MobileSettingsView: View { (startTailscalePairing ?? startPairingScanner)?() }, onSetKeepAwake: { [store] enabled in - await OnboardingKeepAwakeOfferSource.set(enabled, on: store) + await OnboardingKeepAwakeOfferSource().set(enabled, on: store) }, onComplete: { showingOnboarding = false } ) @@ -652,7 +652,7 @@ struct MobileSettingsView: View { if let whatsNewCenter { return !whatsNewCenter.archivePages.isEmpty } - return !MobileWhatsNewCatalog.channelVisibleEntries().isEmpty + return !MobileWhatsNewCatalog().channelVisibleEntries().isEmpty } private func recordBooleanSetting( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift index eab74d379126..7d286d9e48df 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift @@ -67,14 +67,16 @@ struct MobileWhatsNewPage: Identifiable { /// device's acknowledgement marker and the remote visibility list /// (`/api/whats-new` `visibleEntryIds`) both reference it, and the /// unseen computation orders pages by catalog index. -enum MobileWhatsNewCatalog { +struct MobileWhatsNewCatalog: Sendable { + init() {} + /// Newest first. The one-time sheet shows every visible entry newer than /// the acknowledgement marker. - static var entries: [MobileWhatsNewPage] { + var entries: [MobileWhatsNewPage] { [pairingOptInUpdate, connectionsUpdate] } - static func entry(withID id: String) -> MobileWhatsNewPage? { + func entry(withID id: String) -> MobileWhatsNewPage? { entries.first { $0.id == id } } @@ -83,11 +85,11 @@ enum MobileWhatsNewCatalog { /// baseline: never-fetched devices and centerless fallbacks (previews) /// use it, so an official App Store build renders NO What's New surface /// before its first fetch, while team builds keep the full catalog. - static func channelVisibleEntries( + func channelVisibleEntries( buildType: MobileBuildType = .current() ) -> [MobileWhatsNewPage] { entries.filter { page in - MobileWhatsNewChannelPolicy.isVisible( + MobileWhatsNewChannelPolicy().isVisible( channelTokens: page.channels, buildType: buildType ) @@ -97,7 +99,7 @@ enum MobileWhatsNewCatalog { /// Catalog position (0 = newest). The unseen computation compares /// positions in the FULL catalog so remotely hiding one entry cannot /// shift how other entries compare against the marker. - static func index(ofID id: String) -> Int? { + func index(ofID id: String) -> Int? { if let index = entries.firstIndex(where: { $0.id == id }) { return index } @@ -111,7 +113,7 @@ enum MobileWhatsNewCatalog { } } - static var pairingOptInUpdate: MobileWhatsNewPage { + var pairingOptInUpdate: MobileWhatsNewPage { MobileWhatsNewPage( id: "connections.v2", releaseLabel: L10n.string( @@ -127,7 +129,7 @@ enum MobileWhatsNewCatalog { ) } - static var connectionsUpdate: MobileWhatsNewPage { + var connectionsUpdate: MobileWhatsNewPage { MobileWhatsNewPage( id: "connections.v1", releaseLabel: L10n.string( @@ -199,7 +201,7 @@ enum MobileWhatsNewCatalog { ) } - static func macCompatibility( + func macCompatibility( policy: MobileMacCompatPolicy, iosVersion: String, buildType: MobileBuildType @@ -218,7 +220,7 @@ enum MobileWhatsNewCatalog { ) } - static func macUpdateDetail( + func macUpdateDetail( buildType: MobileBuildType, requiredVersion: String? ) -> String { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index 13309d40b7ef..47404f806199 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -124,7 +124,7 @@ public final class MobileWhatsNewCenter { /// (`MobileWebPageHosts`) with the web app session broker, so navigation /// and credential policy agree. var allowedWebHosts: Set { - var hosts = MobileWebPageHosts.cmuxOwned + var hosts = MobileWebPageHosts().cmuxOwned if let apiHost = requestURL?.host?.lowercased() { hosts.insert(apiHost) } @@ -146,8 +146,8 @@ public final class MobileWhatsNewCenter { /// current native page does not disappear from Settings until the API /// catches up. An explicit empty list remains a deliberate retraction. var visibleBinaryEntries: [MobileWhatsNewPage] { - let channelAllowed = MobileWhatsNewCatalog.entries.filter { page in - MobileWhatsNewChannelPolicy.isVisible( + let channelAllowed = MobileWhatsNewCatalog().entries.filter { page in + MobileWhatsNewChannelPolicy().isVisible( channelTokens: remoteList?.entryChannels?[page.id] ?? page.channels, buildType: buildType ) @@ -174,11 +174,11 @@ public final class MobileWhatsNewCenter { return remoteList.announcements.compactMap { announcement in // Channel gate first: an announcement with no channel list is // team-lanes only and never reaches the official App Store app. - guard MobileWhatsNewChannelPolicy.isVisible( + guard MobileWhatsNewChannelPolicy().isVisible( channelTokens: announcement.channels, buildType: buildType ) else { return nil } - guard MobileAppVersionCompare.version( + guard MobileAppVersionCompare().version( appVersion, isWithinMin: announcement.minVersion, max: announcement.maxVersion @@ -208,9 +208,9 @@ public final class MobileWhatsNewCenter { let visible = visibleBinaryEntries let unseenBinary: [MobileWhatsNewPage] if let marker = defaults.string(forKey: Self.markerKey) { - if let markerIndex = MobileWhatsNewCatalog.index(ofID: marker) { + if let markerIndex = MobileWhatsNewCatalog().index(ofID: marker) { unseenBinary = visible.filter { page in - (MobileWhatsNewCatalog.index(ofID: page.id) ?? Int.max) < markerIndex + (MobileWhatsNewCatalog().index(ofID: page.id) ?? Int.max) < markerIndex } } else { // The marker id is unknown to this binary (downgrade or a @@ -238,13 +238,13 @@ public final class MobileWhatsNewCenter { defaults.set(acknowledged.sorted(), forKey: Self.acknowledgedAnnouncementsKey) let shownIndices = pages.compactMap { page in - page.isAnnouncement ? nil : MobileWhatsNewCatalog.index(ofID: page.id) + page.isAnnouncement ? nil : MobileWhatsNewCatalog().index(ofID: page.id) } guard let newestShown = shownIndices.min() else { return } let currentIndex = defaults.string(forKey: Self.markerKey) - .flatMap(MobileWhatsNewCatalog.index(ofID:)) + .flatMap(MobileWhatsNewCatalog().index(ofID:)) if let currentIndex, currentIndex <= newestShown { return } - defaults.set(MobileWhatsNewCatalog.entries[newestShown].id, forKey: Self.markerKey) + defaults.set(MobileWhatsNewCatalog().entries[newestShown].id, forKey: Self.markerKey) } private var acknowledgedAnnouncementIDs: Set { @@ -294,7 +294,7 @@ public final class MobileWhatsNewCenter { private func allowlistedWebURL(_ string: String?) -> URL? { guard let string, let url = URL(string: string), - mobileWebPageURLAllowed(url, allowedHosts: allowedWebHosts) else { return nil } + MobileWebPageHosts().allows(url, allowedHosts: allowedWebHosts) else { return nil } return url } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift index f95258bd3c06..5c76cc609df3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift @@ -209,7 +209,7 @@ struct MobileWhatsNewPairingSetupContent: View { @Environment(\.colorScheme) private var colorScheme private var compatibility: MobileWhatsNewMacCompatibility { - MobileWhatsNewCatalog.macCompatibility( + MobileWhatsNewCatalog().macCompatibility( policy: macCompatCenter?.policy ?? .baked, iosVersion: AppVersionInfo.current().marketingVersion, buildType: MobileBuildType.current() @@ -330,7 +330,7 @@ struct MobileWhatsNewPairingSetupContent: View { } if MobileBuildType.current().usesInternalBuildVocabulary { - Text(MobileWhatsNewCatalog.macUpdateDetail( + Text(MobileWhatsNewCatalog().macUpdateDetail( buildType: MobileBuildType.current(), requiredVersion: compatibility.stableVersion )) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift index 5e353c71cccd..5eacb7394c93 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift @@ -16,7 +16,7 @@ struct MobileWhatsNewListView: View { // back to the binary catalog, matching the never-fetched policy — // still under the channel gate, so official builds never fall back // into team-lane announcements. - let pages = center?.archivePages ?? MobileWhatsNewCatalog.channelVisibleEntries() + let pages = center?.archivePages ?? MobileWhatsNewCatalog().channelVisibleEntries() let allowedHosts = center?.allowedWebHosts ?? [] List { ForEach(pages, id: \.listID) { page in diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift index f485ded1c73f..400aa5edff3f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewRemote.swift @@ -92,8 +92,10 @@ struct MobileWhatsNewRemoteAnnouncement: Codable, Identifiable { /// Dotted-numeric ("1.0.4"-style) version comparison; missing components are /// zero and non-numeric components compare as zero. -enum MobileAppVersionCompare { - static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { +struct MobileAppVersionCompare: Sendable { + init() {} + + func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { let lhsParts = components(of: lhs) let rhsParts = components(of: rhs) for index in 0.. [Int] { + private func components(of version: String) -> [Int] { version.split(separator: ".").map { Int($0.trimmingCharacters(in: .whitespaces)) ?? 0 } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewWebPageLoad.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewWebPageLoad.swift index 6a13d0afeecd..d5c530137a1c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewWebPageLoad.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewWebPageLoad.swift @@ -137,7 +137,7 @@ final class MobileWhatsNewWebPageLoad { decidePolicyFor navigationAction: WKNavigationAction ) async -> WKNavigationActionPolicy { guard let url = navigationAction.request.url, - mobileWebPageURLAllowed(url, allowedHosts: allowedHosts) else { + MobileWebPageHosts().allows(url, allowedHosts: allowedHosts) else { // WebKit-internal blank navigations carry no host; cancelling // one would fail a load that never touched the network. if navigationAction.request.url?.absoluteString == "about:blank" { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingKeepAwakeOffer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingKeepAwakeOffer.swift index a27d18a6ecea..82b2169d57d2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingKeepAwakeOffer.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingKeepAwakeOffer.swift @@ -27,8 +27,10 @@ struct OnboardingKeepAwakeOffer: Equatable { /// path the Computers rows and detail use, named by the connected Mac's /// settled identity — never an implicit "whichever Mac is active" write. @MainActor -enum OnboardingKeepAwakeOfferSource { - static func offer(from store: CMUXMobileShellStore?) -> OnboardingKeepAwakeOffer? { +struct OnboardingKeepAwakeOfferSource: Sendable { + init() {} + + func offer(from store: CMUXMobileShellStore?) -> OnboardingKeepAwakeOffer? { guard let store, store.connectionState == .connected, let macDeviceID = store.connectedMacDeviceID else { return nil } @@ -50,7 +52,7 @@ enum OnboardingKeepAwakeOfferSource { ) } - static func set(_ enabled: Bool, on store: CMUXMobileShellStore?) async -> Bool { + func set(_ enabled: Bool, on store: CMUXMobileShellStore?) async -> Bool { guard let store, let macDeviceID = store.connectedMacDeviceID else { return false } return await store.setCaffeineEnabled( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ReplyRelayClient.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ReplyRelayClient.swift index f1cfcf5f4511..447fbb278cd2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ReplyRelayClient.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ReplyRelayClient.swift @@ -128,7 +128,7 @@ public struct SystemReplyRelayClient: ReplyRelaying { diagnosticLog?.recordAppEvent(.pushReplyContextMissing, failure: .credentialUnavailable) return false } - guard let identity = try? PhonePushKeyStore.current( + guard let identity = try? PhonePushKeyMaterial.current( bundleID: Bundle.main.bundleIdentifier ?? "cmux", accessGroup: keychainAccessGroup ) else { @@ -144,7 +144,7 @@ public struct SystemReplyRelayClient: ReplyRelaying { macInstanceTag: reply.macInstanceTag, macBuildID: macBuildID ) - guard let peer = PhonePushPeerKeyStore.pinnedDescriptor(for: tuple) else { + guard let peer = PhonePushPeerKeyStore().pinnedDescriptor(for: tuple) else { diagnosticLog?.recordAppEvent(.pushReplyKeyMissing, failure: .credentialUnavailable) return false } @@ -162,7 +162,7 @@ public struct SystemReplyRelayClient: ReplyRelaying { var plaintextWithWorkspace = plaintext if let workspaceId = reply.workspaceId, !workspaceId.isEmpty { plaintextWithWorkspace["workspaceId"] = workspaceId } guard let plaintextData = try? JSONSerialization.data(withJSONObject: plaintextWithWorkspace), - let candidate = try? PhonePushCrypto.encrypt( + let candidate = try? PhonePushCrypto().encrypt( plaintext: plaintextData, tuple: tuple, recipientPublicKey: peer.publicKey, @@ -198,9 +198,9 @@ public struct SystemReplyRelayClient: ReplyRelaying { let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return false } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http.value(forHTTPHeaderField: "Retry-After") - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) } return (200...299).contains(http.statusCode) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebAppSessionBroker.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebAppSessionBroker.swift index db517a7f3fe3..c9cd58a556d1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebAppSessionBroker.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebAppSessionBroker.swift @@ -29,7 +29,7 @@ public final class MobileWebAppSessionBroker: MobileWebAppSessionProviding, Send ) { self.tokens = tokens self.projectID = projectID - allowedHosts = MobileWebPageHosts.allowedHosts(apiBaseURL: apiBaseURL) + allowedHosts = MobileWebPageHosts().allowedHosts(apiBaseURL: apiBaseURL) let configuration = URLSessionConfiguration.ephemeral configuration.httpShouldSetCookies = false configuration.httpCookieAcceptPolicy = .never @@ -67,7 +67,7 @@ public final class MobileWebAppSessionBroker: MobileWebAppSessionProviding, Send /// The exchange endpoint on the destination's own origin, or `nil` when /// the destination is off the allowlist (credentials never leave it). private func handoffRequest(for destination: URL) -> URLRequest? { - guard mobileWebPageURLAllowed(destination, allowedHosts: allowedHosts), + guard MobileWebPageHosts().allows(destination, allowedHosts: allowedHosts), destination.path != Self.handoffPath, var components = URLComponents( url: destination, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebPagePolicy.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebPagePolicy.swift index c31a03584cd8..fe25ea9b2c7b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebPagePolicy.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WebSession/MobileWebPagePolicy.swift @@ -8,29 +8,20 @@ import Foundation /// cannot downgrade to plaintext through a link or redirect. The web app /// session broker applies the same policy before any credential exchange, /// so auth material can only ever flow to a destination this would allow. -func mobileWebPageURLAllowed(_ url: URL, allowedHosts: Set) -> Bool { - guard let host = url.host?.lowercased(), allowedHosts.contains(host) else { return false } - switch url.scheme?.lowercased() { - case "https": - return true - case "http": - return host == "localhost" || host == "127.0.0.1" - default: - return false - } -} /// The hosts cmux-owned in-app webpages may load: the production web hosts /// plus the build's configured API host, so dev and staging builds render /// pages against their own web app. One derivation shared by the What's New /// allowlist and the web app session broker, so the navigation policy and /// the credential policy can never disagree about what "cmux-owned" means. -enum MobileWebPageHosts { +struct MobileWebPageHosts: Sendable { + init() {} + /// cmux-owned production web hosts. - static let cmuxOwned: Set = ["cmux.com", "www.cmux.com"] + let cmuxOwned: Set = ["cmux.com", "www.cmux.com"] /// The full allowlist for a build configured with `apiBaseURL`. - static func allowedHosts(apiBaseURL: String?) -> Set { + func allowedHosts(apiBaseURL: String?) -> Set { var hosts = cmuxOwned if let apiBaseURL, !apiBaseURL.isEmpty, @@ -40,4 +31,17 @@ enum MobileWebPageHosts { return hosts } } +extension MobileWebPageHosts { + func allows(_ url: URL, allowedHosts: Set) -> Bool { + guard let host = url.host?.lowercased(), allowedHosts.contains(host) else { return false } + switch url.scheme?.lowercased() { + case "https": + return true + case "http": + return host == "localhost" || host == "127.0.0.1" + default: + return false + } + } +} #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift index 51bcc28b88e4..e3e294e76947 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift @@ -12,7 +12,7 @@ import Testing @MainActor @Suite struct MobileOfficialChannelCopyTests { @Test func whatsNewCompatCopyIsNeutralOnOfficialBuilds() { - let official = MobileWhatsNewCatalog.macCompatibility( + let official = MobileWhatsNewCatalog().macCompatibility( policy: .baked, iosVersion: "1.0.4", buildType: .prod @@ -22,7 +22,7 @@ import Testing } @Test func whatsNewCompatCopyUsesTeamSpecificFloor() { - let team = MobileWhatsNewCatalog.macCompatibility( + let team = MobileWhatsNewCatalog().macCompatibility( policy: .baked, iosVersion: "1.0.4", buildType: .beta @@ -32,7 +32,7 @@ import Testing } @Test func whatsNewMacUpdateDetailUsesTheResolvedFloor() { - let team = MobileWhatsNewCatalog.macUpdateDetail( + let team = MobileWhatsNewCatalog().macUpdateDetail( buildType: .beta, requiredVersion: "0.64.20" ) @@ -40,7 +40,7 @@ import Testing #expect(team.contains("BETA")) #expect(!team.contains("%@")) - let official = MobileWhatsNewCatalog.macUpdateDetail( + let official = MobileWhatsNewCatalog().macUpdateDetail( buildType: .prod, requiredVersion: "0.64.25" ) @@ -50,7 +50,7 @@ import Testing } @Test func whatsNewUsesTheCustomPairingPage() throws { - let page = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v2")) + let page = try #require(MobileWhatsNewCatalog().entry(withID: "connections.v2")) #expect(page.footnote == nil) guard case .pairingSetup(let features) = page.body else { Issue.record("connections update page lost its custom body") diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 614b0ef01cb1..e2737ccc0f67 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -1,4 +1,5 @@ import CmuxAuthRuntime +import CmuxMobileRPC import Foundation import Testing import UserNotifications @@ -252,6 +253,39 @@ private final class LifecyclePushURLProtocol: URLProtocol, } @Suite struct MobilePushCoordinatorLifecycleTests { + @MainActor + @Test func readinessForwardsSecurePushSetupFailure() async { + let registration = LifecyclePushRegistration(snapshot: PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: true, + backendState: .registered + )) + let suiteName = "push-coordinator-secure-setup-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + phoneAPIOrigin: "https://cmux.com", + defaults: defaults, + authorizationStatus: { .authorized } + ) + await coordinator.refreshReadiness() + let mac = MobileHostPhonePushStatus( + forwardingEnabled: true, + mode: .always, + admission: .allowed, + queuePersistence: .healthy, + apiOrigin: "https://cmux.com", + accountScope: .verifiedSameAccount + ) + + #expect(coordinator.readiness(macStatus: mac) == .ready(mode: .always)) + let failed = coordinator.readiness(macStatus: mac, securePushSetupFailed: true) + #expect(failed == .blocked(.securePushSetupFailed)) + #expect(failed.repair == .retrySecurePushSetup) + } + @MainActor @Test func callbackFailureOffersRetryAndSuccessfulTokenRecoversReadiness() async { let registration = LifecyclePushRegistration() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 4c54fdc3f712..0921275f76f7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -69,14 +69,14 @@ import Testing } @Test func pairingPageFocusesOnPairingRequirement() throws { - let page = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v2")) + let page = try #require(MobileWhatsNewCatalog().entry(withID: "connections.v2")) guard case .pairingSetup(let features) = page.body else { Issue.record("connections.v2 should render the custom pairing page") return } #expect(features.isEmpty) #expect(page.title == "Action Required: Enable iOS pairing on your Mac") - #expect(MobileWhatsNewCatalog.entry(withID: "pairing-opt-in.v1") == nil) + #expect(MobileWhatsNewCatalog().entry(withID: "pairing-opt-in.v1") == nil) } @Test func archiveKeepsBothUpdatesAfterAcknowledgingPairing() async throws { @@ -87,7 +87,7 @@ import Testing await center.refresh() #expect(center.archivePages.map(\.id) == ["connections.v2", "connections.v1"]) #expect(center.unseenPages.map(\.id) == ["connections.v2", "connections.v1"]) - let oldPage = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v1")) + let oldPage = try #require(MobileWhatsNewCatalog().entry(withID: "connections.v1")) guard case .features(let features) = oldPage.body else { Issue.record("The earlier connection update must keep its feature rows") return @@ -101,7 +101,7 @@ import Testing } @Test func compatibilityCopyUsesTheRemotePolicyShape() { - let beta = MobileWhatsNewCatalog.macCompatibility( + let beta = MobileWhatsNewCatalog().macCompatibility( policy: .baked, iosVersion: "1.0.4", buildType: .beta @@ -109,7 +109,7 @@ import Testing #expect(beta.stableVersion == "0.64.20") #expect(beta.nightlyVersion == nil) - let official = MobileWhatsNewCatalog.macCompatibility( + let official = MobileWhatsNewCatalog().macCompatibility( policy: .baked, iosVersion: "1.0.4", buildType: .prod @@ -123,7 +123,7 @@ import Testing let center = makeCenter(buildType: buildType) #expect( center.visibleBinaryEntries.map(\.id) - == MobileWhatsNewCatalog.entries.map(\.id) + == MobileWhatsNewCatalog().entries.map(\.id) ) #expect(!center.unseenPages.isEmpty) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift index 16f95bc1b491..ca9317a5f108 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift @@ -39,10 +39,12 @@ import Testing defer { defaults.removePersistentDomain(forName: suite) } let center = MobileWhatsNewCenter(apiBaseURL: "https://cmux.test", buildType: .beta, defaults: defaults) let before = center.unseenPages.map(\.listID) + let firstID = try #require(before.first) + let lastID = try #require(before.last) let replay = try #require(MobileWhatsNewReplay( pages: center.archivePages, - firstID: try #require(before.first), - lastID: try #require(before.last) + firstID: firstID, + lastID: lastID )) #expect(!replay.pages.isEmpty) #expect(center.unseenPages.map(\.listID) == before) diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamDisplayView.swift b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamDisplayView.swift index ad7ccbc3abad..d3f0385df989 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamDisplayView.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamDisplayView.swift @@ -98,12 +98,12 @@ } private func normalizedPoint(for touch: UITouch) -> CGPoint? { - SimStreamTouchMapping.normalizedPoint( + SimStreamTouchMapping().normalizedPoint( touch.location(in: self), pixelSize: configPixelSize, in: bounds) } private func clampedNormalizedPoint(for touch: UITouch) -> CGPoint { - SimStreamTouchMapping.clampedNormalizedPoint( + SimStreamTouchMapping().clampedNormalizedPoint( touch.location(in: self), pixelSize: configPixelSize, in: bounds) ?? .zero } diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamTouchMapping.swift b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamTouchMapping.swift index 5bbd2df1d46a..75ab56c1cfd4 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamTouchMapping.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamTouchMapping.swift @@ -4,10 +4,12 @@ import Foundation /// Pure geometry for touch forwarding: where the aspect-fit video actually /// sits inside the view, and how a view point maps to the normalized [0,1] /// coordinates the simulator HID expects. -public enum SimStreamTouchMapping { +public struct SimStreamTouchMapping: Sendable { + public init() {} + /// The aspect-fit rect of a video with `pixelSize` inside `bounds` /// (mirrors AVSampleBufferDisplayLayer's `.resizeAspect`). - public static func videoRect(pixelSize: CGSize, in bounds: CGRect) -> CGRect { + public func videoRect(pixelSize: CGSize, in bounds: CGRect) -> CGRect { guard pixelSize.width > 0, pixelSize.height > 0, bounds.width > 0, bounds.height > 0 else { return .zero } @@ -25,7 +27,7 @@ public enum SimStreamTouchMapping { /// Maps a view-space point into normalized video coordinates, or nil for /// touches in the letterbox area outside the video. - public static func normalizedPoint( + public func normalizedPoint( _ point: CGPoint, pixelSize: CGSize, in bounds: CGRect ) -> CGPoint? { let rect = videoRect(pixelSize: pixelSize, in: bounds) @@ -39,7 +41,7 @@ public enum SimStreamTouchMapping { /// Like `normalizedPoint` but clamps outside points to the nearest video /// edge, so a drag that leaves the letterbox tracks the edge instead of /// freezing. Returns nil only for degenerate geometry. - public static func clampedNormalizedPoint( + public func clampedNormalizedPoint( _ point: CGPoint, pixelSize: CGSize, in bounds: CGRect ) -> CGPoint? { let rect = videoRect(pixelSize: pixelSize, in: bounds) diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamViewerEngine.swift b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamViewerEngine.swift index 3fdec52906b7..a7fbc00d3625 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamViewerEngine.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Sources/CmuxMobileSimulatorStream/SimStreamViewerEngine.swift @@ -76,7 +76,7 @@ public actor SimStreamViewerEngine { maximumLongSidePixels: maximumLongSidePixels, codecPreferences: [.hevc, .h264] ) - try await lane.send(SimStreamWireCodec.encodeFramed(.start(start))) + try await lane.send(SimStreamWireCodec().encodeFramed(.start(start))) // Input staged while the lane was still dialing flushes now. drainInputIfNeeded() var accumulator = SimStreamFrameAccumulator() @@ -84,7 +84,7 @@ public actor SimStreamViewerEngine { guard !chunk.isEmpty else { continue } accumulator.append(chunk) while let body = try accumulator.nextMessageBody() { - try await handle(SimStreamWireCodec.decode(body)) + try await handle(SimStreamWireCodec().decode(body)) } } await closeLane() @@ -110,14 +110,14 @@ public actor SimStreamViewerEngine { maximumLongSidePixels: maximumLongSidePixels, codecPreferences: [.hevc, .h264] ) - try? await lane.send(SimStreamWireCodec.encodeFramed(.start(start))) + try? await lane.send(SimStreamWireCodec().encodeFramed(.start(start))) } /// Sends `stop` and closes; used for deliberate viewer-initiated stops /// so the host tears down promptly instead of on lane error. public func stop() async { if let lane { - try? await lane.send(SimStreamWireCodec.encodeFramed(.stop)) + try? await lane.send(SimStreamWireCodec().encodeFramed(.stop)) } await closeLane() } @@ -170,7 +170,7 @@ public actor SimStreamViewerEngine { let receipt = UInt64( Double(DispatchTime.now().uptimeNanoseconds) / 1_000) try await lane.send( - SimStreamWireCodec.encodeFramed( + SimStreamWireCodec().encodeFramed( .ack( SimStreamAck( sequence: frame.sequence, @@ -189,7 +189,7 @@ public actor SimStreamViewerEngine { if presentFailureRun >= 3, !requestedRecoveryKeyframe, let lane { requestedRecoveryKeyframe = true await presenter.reset() - try await lane.send(SimStreamWireCodec.encodeFramed(.keyframeRequest)) + try await lane.send(SimStreamWireCodec().encodeFramed(.keyframeRequest)) } } @@ -212,7 +212,7 @@ public actor SimStreamViewerEngine { defer { isDrainingInput = false } while let lane, let batch = outbox.drainBatch() { do { - try await lane.send(SimStreamWireCodec.encodeFramed(.input(batch))) + try await lane.send(SimStreamWireCodec().encodeFramed(.input(batch))) } catch { // The read loop surfaces the lane failure; pending input for // a dead lane is discarded rather than replayed into a diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamTouchMappingTests.swift b/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamTouchMappingTests.swift index 382adc62f547..2d588fd1e6f3 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamTouchMappingTests.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamTouchMappingTests.swift @@ -8,7 +8,7 @@ struct SimStreamTouchMappingTests { @Test func tallVideoInWideBoundsPillarboxes() { let bounds = CGRect(x: 0, y: 0, width: 400, height: 400) - let rect = SimStreamTouchMapping.videoRect( + let rect = SimStreamTouchMapping().videoRect( pixelSize: CGSize(width: 100, height: 200), in: bounds) #expect(rect == CGRect(x: 100, y: 0, width: 200, height: 400)) } @@ -16,7 +16,7 @@ struct SimStreamTouchMappingTests { @Test func centerOfVideoMapsToCenterNormalized() { let bounds = CGRect(x: 0, y: 0, width: 400, height: 400) - let point = SimStreamTouchMapping.normalizedPoint( + let point = SimStreamTouchMapping().normalizedPoint( CGPoint(x: 200, y: 200), pixelSize: CGSize(width: 100, height: 200), in: bounds) #expect(point == CGPoint(x: 0.5, y: 0.5)) } @@ -24,7 +24,7 @@ struct SimStreamTouchMappingTests { @Test func letterboxTouchesAreNil() { let bounds = CGRect(x: 0, y: 0, width: 400, height: 400) - let point = SimStreamTouchMapping.normalizedPoint( + let point = SimStreamTouchMapping().normalizedPoint( CGPoint(x: 10, y: 200), pixelSize: CGSize(width: 100, height: 200), in: bounds) #expect(point == nil) } @@ -35,31 +35,31 @@ struct SimStreamTouchMappingTests { let pixelSize = CGSize(width: 100, height: 200) // Left of the pillarboxed video: clamps to x = 0. #expect( - SimStreamTouchMapping.clampedNormalizedPoint( + SimStreamTouchMapping().clampedNormalizedPoint( CGPoint(x: 10, y: 200), pixelSize: pixelSize, in: bounds) == CGPoint(x: 0, y: 0.5)) // Beyond the bottom-right: clamps to (1, 1). #expect( - SimStreamTouchMapping.clampedNormalizedPoint( + SimStreamTouchMapping().clampedNormalizedPoint( CGPoint(x: 990, y: 990), pixelSize: pixelSize, in: bounds) == CGPoint(x: 1, y: 1)) // Inside matches the unclamped mapping. #expect( - SimStreamTouchMapping.clampedNormalizedPoint( + SimStreamTouchMapping().clampedNormalizedPoint( CGPoint(x: 200, y: 200), pixelSize: pixelSize, in: bounds) == CGPoint(x: 0.5, y: 0.5)) #expect( - SimStreamTouchMapping.clampedNormalizedPoint( + SimStreamTouchMapping().clampedNormalizedPoint( .zero, pixelSize: .zero, in: bounds) == nil) } @Test func degenerateGeometryIsSafe() { #expect( - SimStreamTouchMapping.videoRect(pixelSize: .zero, in: CGRect(x: 0, y: 0, width: 10, height: 10)) + SimStreamTouchMapping().videoRect(pixelSize: .zero, in: CGRect(x: 0, y: 0, width: 10, height: 10)) == .zero) #expect( - SimStreamTouchMapping.normalizedPoint( + SimStreamTouchMapping().normalizedPoint( .zero, pixelSize: CGSize(width: 10, height: 10), in: .zero) == nil) } } diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamViewerEngineTests.swift b/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamViewerEngineTests.swift index 105387e688b9..b49b262f03c6 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamViewerEngineTests.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Tests/CmuxMobileSimulatorStreamTests/SimStreamViewerEngineTests.swift @@ -16,7 +16,7 @@ private actor FakeLane: MobileSimulatorStreamLaneConnection { private var sendError: Error? func hostSends(_ message: SimStreamMessage) { - push(SimStreamWireCodec.encodeFramed(message)) + push(SimStreamWireCodec().encodeFramed(message)) } func hostFinishes() { @@ -49,7 +49,7 @@ private actor FakeLane: MobileSimulatorStreamLaneConnection { var accumulator = SimStreamFrameAccumulator() accumulator.append(data) while let body = try accumulator.nextMessageBody() { - sent.append(try SimStreamWireCodec.decode(body)) + sent.append(try SimStreamWireCodec().decode(body)) } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift index 1f9760e9f0d0..3c5b91898714 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift @@ -18,9 +18,9 @@ final class AccessoryActionButton: UIButton { /// A sticky-locked modifier stays applied to every keystroke until the user /// taps it off, whereas an armed modifier is consumed by the next key. On /// iOS 26 both states share the same prominent-glass blue fill, so the lock - /// needs its own visual cue: a white capsule border drawn on the button's - /// layer, *over* the glass, mirroring the 2pt white stroke the pre-26 flat - /// style already used for the locked state. The border is drawn at the layer + /// needs its own visual cue: a contrasting capsule border drawn on the button's + /// layer, *over* the glass, mirroring the 2pt contrasting stroke the pre-26 + /// flat style already used for the locked state. The border is drawn at the layer /// level (not via `UIButton.Configuration.background.strokeColor`) so it /// composites on top of Liquid Glass regardless of how the glass material /// renders its own background, and adds zero intrinsic width so it does not @@ -32,7 +32,7 @@ final class AccessoryActionButton: UIButton { } } - /// Contrasting stroke used to distinguish the sticky modifier state. + /// Appearance-aware stroke used to distinguish the sticky modifier state. var stickyLockBorderColor: UIColor = .white { didSet { updateStickyLockBorder() } } @@ -60,7 +60,18 @@ final class AccessoryActionButton: UIButton { updateStickyLockBorder() } - /// Sync the layer-level white capsule border to ``isStickyLocked``. + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard previousTraitCollection == nil + || previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true else { + return + } + // The border is drawn by CALayer, so resolve the dynamic foreground + // against the button's new appearance when Light/Dark Mode changes. + updateStickyLockBorder() + } + + /// Sync the layer-level capsule border to ``isStickyLocked``. /// /// Always clears the border when not locked, so a button that transitions /// locked → armed → resting never keeps a stale border. @@ -68,7 +79,7 @@ final class AccessoryActionButton: UIButton { if isStickyLocked { layer.cornerRadius = bounds.height / 2 layer.cornerCurve = .continuous - layer.borderColor = stickyLockBorderColor.cgColor + layer.borderColor = stickyLockBorderColor.resolvedColor(with: traitCollection).cgColor layer.borderWidth = Self.stickyLockBorderWidth } else { layer.borderWidth = 0 diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalPixelScroll.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalPixelScroll.swift index bc8677c7a0d4..29d8eeaefcff 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalPixelScroll.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalPixelScroll.swift @@ -169,6 +169,7 @@ extension GhosttySurfaceView { operation: LocalPixelScrollSurfaceOperation, deltaPixels: Double, rebaseFromHeldPosition: Bool, + // Carve-out: the gesture snapshot must be read atomically inside this synchronous libghostty batch. pixelState: OSAllocatedUnfairLock, // lint:allow lock - the view's cumulative push counter threaded to the // serial batch; same discipline as pixelState above. diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 9f64e75bd68c..0af0a76e766f 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -359,6 +359,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { var lastPerfLogTime: CFTimeInterval = 0 #endif } + // Carve-out: main-actor gestures and synchronous libghostty callbacks share one pixel-scroll snapshot. nonisolated let localPixelScrollState = OSAllocatedUnfairLock(initialState: .init()) /// Cumulative rows this view has pushed into its local mirror's scrollback diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkQueue.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkQueue.swift index 6875620d0827..4aa5a416e7f0 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkQueue.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkQueue.swift @@ -4,6 +4,7 @@ import Foundation /// All mutable state is accessed only from `queue`; main-actor code replaces whole instances on recovery. final class GhosttySurfaceWorkQueue: @unchecked Sendable { let queue: DispatchQueue + // Carve-out: synchronous C-event admission must reject a full queue before allocating more work. private let pendingLock = NSLock() private var pendingPriority: [@Sendable () -> Void] = [] private var pendingNormal: [@Sendable () -> Void] = [] diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index a1e718d1430d..e47cdd2a45d6 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -81,7 +81,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// ``TerminalInputModifierState`` reducer. This view is now a dumb /// first-responder that forwards taps into the reducer and reads its state /// back for byte encoding and button styling. - private var modifierState = TerminalInputModifierState() + var modifierState = TerminalInputModifierState() private var controlAccessoryArmed: Bool { modifierState.isArmed(.control) } private var alternateAccessoryArmed: Bool { modifierState.isArmed(.alternate) } private var commandAccessoryArmed: Bool { modifierState.isArmed(.command) } @@ -1117,7 +1117,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // and background styling so resting custom icons stay white and // armed built-ins keep their blue active state. let restingForeground = themeChromeColor - let activeForeground = UIColor.systemBlue.terminalReadableForeground + let activeForeground = UIColor.terminalAccessoryActiveForeground config.imageColorTransformer = UIConfigurationColorTransformer { _ in armed || sticky ? activeForeground : restingForeground } @@ -1131,11 +1131,11 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { config.contentInsets = Self.accessoryButtonContentInsets button.configuration = config if let actionButton = button as? AccessoryActionButton { - actionButton.stickyLockBorderColor = UIColor.systemBlue.terminalReadableForeground + actionButton.stickyLockBorderColor = UIColor.terminalAccessoryActiveForeground // On iOS 26 the armed and sticky states share the same prominent-glass blue fill, so the double-tap *lock* is - // distinguished by a white capsule border drawn over the glass (see + // distinguished by an appearance-aware capsule border drawn over the glass (see // ``AccessoryActionButton/isStickyLocked``). On earlier OSes the - // flat style already renders the locked white stroke through the + // flat style already renders the locked contrasting stroke through the // background configuration, so the layer border stays off to avoid // a doubled stroke. if #available(iOS 26.0, *) { @@ -1148,7 +1148,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { private func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { let activeBackground = UIColor.systemBlue - let activeForeground = activeBackground.terminalReadableForeground + let activeForeground = UIColor.terminalAccessoryActiveForeground if #available(iOS 26.0, *) { var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() config.baseForegroundColor = armed || sticky ? activeForeground : themeChromeColor diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/UIColor+TerminalContrast.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/UIColor+TerminalContrast.swift index e15c5c8b36ea..da7ff695098f 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/UIColor+TerminalContrast.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/UIColor+TerminalContrast.swift @@ -2,6 +2,13 @@ import UIKit extension UIColor { + /// Foreground for the blue active state in the terminal accessory bar. + /// + /// `label` is a dynamic system color: it resolves to white in Dark Mode + /// and black in Light Mode, keeping the active glyph and sticky-lock + /// border aligned with the surrounding appearance. + static var terminalAccessoryActiveForeground: UIColor { .label } + var terminalReadableForeground: UIColor { terminalPrefersDarkForeground ? .black : .white } diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceThemeTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceThemeTests.swift index 2b8f5f5bf489..9bfc4ce492ba 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceThemeTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceThemeTests.swift @@ -63,6 +63,79 @@ import UIKit } } +@MainActor +@Test func activeAccessoryButtonRecolorsAndClearsStickyBorder() throws { + func expectMonochrome(_ color: UIColor, white: CGFloat) { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + #expect(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)) + #expect(abs(red - white) < 0.001) + #expect(abs(green - white) < 0.001) + #expect(abs(blue - white) < 0.001) + #expect(alpha == 1) + } + + let input = TerminalInputTextView() + let controller = UIViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 100)) + let previousKeyWindow = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.windows.first(where: \.isKeyWindow) } + .first + controller.view.addSubview(input.toolbarView) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + previousKeyWindow?.makeKey() + window.isHidden = true + } + let button = try #require(input.toolbarView.descendant( + withAccessibilityIdentifier: "terminal.inputAccessory.control" + ) as? AccessoryActionButton) + button.frame = CGRect(x: 0, y: 0, width: 32, height: 28) + let restingForeground = button.configuration?.baseForegroundColor + + // Drive the modifier's clock explicitly, avoiding a wall-clock double tap. + input.modifierState.tap(.control, now: 0) + input.refreshThemeColors() + let armedForeground = try #require(button.configuration?.baseForegroundColor) + expectMonochrome(armedForeground.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)), white: 1) + expectMonochrome(armedForeground.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)), white: 0) + #expect(button.layer.borderWidth == 0) + + input.modifierState.tap(.control, now: 0.1) + input.refreshThemeColors() + for appearance in [UIUserInterfaceStyle.dark, .light, .dark] { + window.overrideUserInterfaceStyle = appearance + window.layoutIfNeeded() + button.setNeedsLayout() + button.layoutIfNeeded() + try #require(button.traitCollection.userInterfaceStyle == appearance) + let expected: CGFloat = appearance == .dark ? 1 : 0 + let foreground = try #require(button.configuration?.baseForegroundColor) + expectMonochrome(foreground.resolvedColor(with: button.traitCollection), white: expected) + if #available(iOS 26.0, *) { + #expect(button.isStickyLocked) + #expect(button.layer.borderWidth == 2) + let border = try #require(button.layer.borderColor) + expectMonochrome(UIColor(cgColor: border), white: expected) + } else { + let background = try #require(button.configuration?.background) + #expect(background.strokeWidth == 2) + let border = try #require(background.strokeColor) + expectMonochrome(border.resolvedColor(with: button.traitCollection), white: expected) + } + } + + input.modifierState.tap(.control, now: 1) + input.refreshThemeColors() + #expect(!button.isStickyLocked) + #expect(button.layer.borderWidth == 0) + #expect(button.layer.borderColor == nil) + #expect(button.configuration?.baseForegroundColor == restingForeground) +} + @MainActor @Test func reverseModeOSCResetsUseRawConfigDefaults() async throws { let runtime = try GhosttyRuntime.shared() diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceWorkQueueTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceWorkQueueTests.swift index ad5b55809db2..bf5bbab4ac8d 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceWorkQueueTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceWorkQueueTests.swift @@ -1,5 +1,8 @@ import Foundation import Testing +import os + +@testable import CmuxMobileTerminal @Test("scroll priority runs ahead of queued repaint work") func scrollPriorityRunsAheadOfQueuedRepaintWork() { @@ -7,29 +10,22 @@ func scrollPriorityRunsAheadOfQueuedRepaintWork() { let firstStarted = DispatchSemaphore(value: 0) let releaseFirst = DispatchSemaphore(value: 0) let completed = DispatchSemaphore(value: 0) - let lock = NSLock() - var order: [String] = [] + let order = OSAllocatedUnfairLock(initialState: [String]()) workQueue.async { firstStarted.signal() releaseFirst.wait() - lock.lock() - order.append("first") - lock.unlock() + order.withLock { $0.append("first") } completed.signal() } #expect(firstStarted.wait(timeout: .now() + 1) == .success) workQueue.async { - lock.lock() - order.append("repaint") - lock.unlock() + order.withLock { $0.append("repaint") } completed.signal() } workQueue.asyncPriority { - lock.lock() - order.append("scroll") - lock.unlock() + order.withLock { $0.append("scroll") } completed.signal() } releaseFirst.signal() @@ -37,9 +33,7 @@ func scrollPriorityRunsAheadOfQueuedRepaintWork() { #expect(completed.wait(timeout: .now() + 1) == .success) #expect(completed.wait(timeout: .now() + 1) == .success) #expect(completed.wait(timeout: .now() + 1) == .success) - lock.lock() - let observedOrder = order - lock.unlock() + let observedOrder = order.withLock { $0 } #expect(observedOrder == ["first", "scroll", "repaint"]) } @@ -47,28 +41,24 @@ func scrollPriorityRunsAheadOfQueuedRepaintWork() { func normalWorkIsServicedDuringSustainedScrollPriority() { let workQueue = GhosttySurfaceWorkQueue(generation: 2) let completed = DispatchSemaphore(value: 0) - let lock = NSLock() - var order: [String] = [] + let order = OSAllocatedUnfairLock(initialState: [String]()) + // Enqueue the whole competing batch before the worker can select a job. + workQueue.queue.suspend() for index in 0..<5 { workQueue.asyncPriority { - lock.lock() - order.append("scroll-\(index)") - lock.unlock() + order.withLock { $0.append("scroll-\(index)") } completed.signal() } } workQueue.async { - lock.lock() - order.append("repaint") - lock.unlock() + order.withLock { $0.append("repaint") } completed.signal() } + workQueue.queue.resume() for _ in 0..<6 { #expect(completed.wait(timeout: .now() + 1) == .success) } - lock.lock() - let observedOrder = order - lock.unlock() + let observedOrder = order.withLock { $0 } #expect(observedOrder[4] == "repaint") } @@ -76,22 +66,32 @@ func normalWorkIsServicedDuringSustainedScrollPriority() { func newInteractionStartsWithScrollPriorityAfterIdle() { let workQueue = GhosttySurfaceWorkQueue(generation: 3) let completed = DispatchSemaphore(value: 0) - let lock = NSLock() - var order: [String] = [] + let order = OSAllocatedUnfairLock(initialState: [String]()) + workQueue.queue.suspend() for _ in 0..<4 { workQueue.asyncPriority { - lock.lock(); order.append("scroll"); lock.unlock(); completed.signal() + order.withLock { $0.append("scroll") } + completed.signal() } } + workQueue.queue.resume() for _ in 0..<4 { #expect(completed.wait(timeout: .now() + 1) == .success) } + // The last callback signals before scheduleNext enqueues its idle check. + // Two FIFO fences wait for both the callback and that idle check. + workQueue.queue.sync {} + workQueue.queue.sync {} + workQueue.queue.suspend() workQueue.async { - lock.lock(); order.append("repaint"); lock.unlock(); completed.signal() + order.withLock { $0.append("repaint") } + completed.signal() } workQueue.asyncPriority { - lock.lock(); order.append("new-scroll"); lock.unlock(); completed.signal() + order.withLock { $0.append("new-scroll") } + completed.signal() } + workQueue.queue.resume() #expect(completed.wait(timeout: .now() + 1) == .success) #expect(completed.wait(timeout: .now() + 1) == .success) - lock.lock(); let observedOrder = order; lock.unlock() + let observedOrder = order.withLock { $0 } #expect(observedOrder.suffix(2).first == "new-scroll") } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index 454d0290f8e3..2c8351fefc08 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -34,6 +34,7 @@ final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Send private let monitor: NWPathMonitor private let readiness: CmxTailscaleRouteReadiness + // Carve-out: stamp NWPath callbacks synchronously before actor hops can reorder delivery. private let observationSequence: OSAllocatedUnfairLock init( @@ -44,6 +45,7 @@ final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Send clock: clock, readinessDeadline: readinessDeadline ) + // Carve-out: this counter orders monitor callbacks and synchronous current-path captures. let sequence = OSAllocatedUnfairLock(initialState: 0) let monitor = NWPathMonitor() self.readiness = readiness @@ -105,6 +107,7 @@ final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Send } private static func nextSequence( + // Carve-out: stamp the synchronous Network.framework observation before scheduling ingestion. _ lock: OSAllocatedUnfairLock ) -> UInt64 { lock.withLock { sequence in diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreAdmissionRetry.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreAdmissionRetry.swift index 3d93452b7b24..60fb26da6b52 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreAdmissionRetry.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreAdmissionRetry.swift @@ -7,10 +7,12 @@ import Foundation /// session-start hooks at once, so that churn is routine for a few seconds. /// Giving up on the first answer leaves a bare shell whose binding then /// retires, and the next relaunch has nothing to resume. -public enum AgentRestoreAdmissionRetry { +public struct AgentRestoreAdmissionRetry: Sendable { + public init() {} + /// Roughly 45 seconds of waiting, front-loaded so a brief storm costs /// little and a longer one still resolves before the user notices. - public static let delaysSeconds: [TimeInterval] = [0.5, 1, 2, 3, 5, 5, 5, 5, 5, 5, 5] + public let delaysSeconds: [TimeInterval] = [0.5, 1, 2, 3, 5, 5, 5, 5, 5, 5, 5] /// Runs `send` until it succeeds, fails with an error `isRetryable` rejects, /// or the delay budget is spent; the last retryable error then propagates. @@ -21,13 +23,14 @@ public enum AgentRestoreAdmissionRetry { /// - onRetry: called with the zero-based retry index before each pause. /// - isRetryable: whether a thrown error should be retried. /// - send: the admission request. - public static func response( - delays: [TimeInterval] = delaysSeconds, + public func response( + delays: [TimeInterval]? = nil, sleep: (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) }, onRetry: (Int) -> Void = { _ in }, isRetryable: (Error) -> Bool, sending send: () throws -> Response ) throws -> Response { + let delays = delays ?? delaysSeconds var attempt = 0 while true { do { diff --git a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/AgentCursorPointerView.swift b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/AgentCursorPointerView.swift index 05690bff6575..c8a0e47b20af 100644 --- a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/AgentCursorPointerView.swift +++ b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/AgentCursorPointerView.swift @@ -80,7 +80,9 @@ private enum ComputerUseCursorArtwork { /// records translation 257.8472/257.8472, scale 45.7900, roundness 16.5, and a /// 59% gradient midpoint. @MainActor -public enum ComputerUseHelperIconRenderer { +// lint:allow namespace-type, stateless renderer preserves the existing type-level API +public struct ComputerUseHelperIconRenderer { + private init() {} private static var cachedImage: NSImage? /// The image exposed to the host application. diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CmuxInternalHostnames.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CmuxInternalHostnames.swift index c5fcef551380..38350b97e854 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CmuxInternalHostnames.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/CmuxInternalHostnames.swift @@ -14,30 +14,32 @@ import Foundation /// Shared between the app (which builds the link a person clicks) and the CLI /// (`cmux vpn hosts`, which owns writing `/etc/hosts`) so the slug algorithm /// producing the name can never drift between the two. -public enum CmuxInternalHostnames { +public struct CmuxInternalHostnames: Sendable { + public init() {} + /// Markers bounding cmux's block in `/etc/hosts`. Anything outside them /// (the user's own entries, other tools') is preserved byte-for-byte. - public static let blockBeginMarker = "# BEGIN cmux managed hosts (cmux vpn hosts)" - public static let blockEndMarker = "# END cmux managed hosts" + public let blockBeginMarker = "# BEGIN cmux managed hosts (cmux vpn hosts)" + public let blockEndMarker = "# END cmux managed hosts" /// The default tunnel scope (production): its block keeps the historical /// unscoped markers. Any other scope — a dev build on the same Mac, whose /// machines live on another private network behind its own tunnel — gets /// markers carrying the scope name, so each build owns exactly one block /// and never rewrites or clears the other's. - public static let defaultScope = "cmux" + public let defaultScope = "cmux" - public static func blockBeginMarker(scope: String?) -> String { + public func blockBeginMarker(scope: String?) -> String { guard let scope = scopedName(scope) else { return blockBeginMarker } return "# BEGIN cmux managed hosts [\(scope)] (cmux vpn hosts)" } - public static func blockEndMarker(scope: String?) -> String { + public func blockEndMarker(scope: String?) -> String { guard let scope = scopedName(scope) else { return blockEndMarker } return "# END cmux managed hosts [\(scope)]" } - private static func scopedName(_ scope: String?) -> String? { + private func scopedName(_ scope: String?) -> String? { guard let scope = scope?.trimmingCharacters(in: .whitespacesAndNewlines), !scope.isEmpty, scope != defaultScope else { return nil } @@ -60,7 +62,7 @@ public enum CmuxInternalHostnames { /// The hostname this app would offer for a machine, given its stable id /// and (optional) display label — the label when it slugs to something /// non-empty, else the id. Both end in `.internal`. - public static func hostname(id: String, label: String?) -> String { + public func hostname(id: String, label: String?) -> String { if let label, let slug = slug(label), !slug.isEmpty { return "\(slug).internal" } @@ -70,7 +72,7 @@ public enum CmuxInternalHostnames { /// Lowercase alphanumeric-and-hyphen, collapsing runs and trimming edges — /// a valid DNS label, or nil when nothing usable survives (emoji-only /// labels, etc.), so the caller falls back to the id. - public static func slug(_ raw: String) -> String? { + public func slug(_ raw: String) -> String? { let lowered = raw.lowercased() var out = "" var lastWasHyphen = false @@ -87,7 +89,7 @@ public enum CmuxInternalHostnames { return out.isEmpty ? nil : out } - private static func slugOrRaw(_ raw: String) -> String { + private func slugOrRaw(_ raw: String) -> String { slug(raw) ?? raw } @@ -99,14 +101,14 @@ public enum CmuxInternalHostnames { /// only resolves once `/etc/hosts` has been synced, and a link that only /// sometimes works is worse than one that always does. An IPv6 literal is /// bracketed the way every URL scheme requires. - public static func directPortURL(privateAddress: String, port: Int) -> String { + public func directPortURL(privateAddress: String, port: Int) -> String { let bracketed = privateAddress.contains(":") ? "[\(privateAddress)]" : privateAddress return "http://\(bracketed):\(port)" } /// Render entries as the managed block's body (no markers): one `ip host` /// line per hostname, sorted for a stable, diffable file. - public static func renderBlockBody(_ entries: [Entry]) -> String { + public func renderBlockBody(_ entries: [Entry]) -> String { var lines: [String] = [] for entry in entries { for host in entry.hostnames { @@ -122,7 +124,7 @@ public enum CmuxInternalHostnames { /// end in one). `body` may be empty, which removes the block entirely /// (leaving one trailing newline) — used when this Mac has no machines /// left to publish. - public static func mergedHostsFile(current: String, body: String, scope: String? = nil) -> String { + public func mergedHostsFile(current: String, body: String, scope: String? = nil) -> String { let beginMarker = blockBeginMarker(scope: scope) let endMarker = blockEndMarker(scope: scope) let block = body.isEmpty ? "" : "\(beginMarker)\n\(body)\n\(endMarker)" diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CmuxPathEnvironment.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CmuxPathEnvironment.swift index 7eeeb4a04841..0f2032323cbb 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CmuxPathEnvironment.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/CmuxPathEnvironment.swift @@ -1,7 +1,9 @@ public import Foundation /// Composes PATH values that cmux exports across process boundaries. -public enum CmuxPathEnvironment { +public struct CmuxPathEnvironment: Sendable { + public init() {} + /// Returns PATH components that can be safely represented as UTF-8. /// /// Foundation decodes malformed inherited environment bytes as the @@ -9,7 +11,7 @@ public enum CmuxPathEnvironment { /// components, prevents cmux from re-exporting a value that strict tools /// reject while preserving empty components and their current-directory /// semantics. - public static func components(from path: String?) -> [String] { + public func components(from path: String?) -> [String] { guard let path else { return [] } return path .split(separator: ":", omittingEmptySubsequences: false) @@ -20,7 +22,7 @@ public enum CmuxPathEnvironment { } /// Prepends entries once while discarding malformed inherited components. - public static func prependingUniqueEntries( + public func prependingUniqueEntries( _ newEntries: [String], to currentPath: String? ) -> String { @@ -36,7 +38,7 @@ public enum CmuxPathEnvironment { } /// Prepends a standardized directory once while discarding malformed PATH components. - public static func prependingUniqueDirectory(_ directory: String, to path: String) -> String { + public func prependingUniqueDirectory(_ directory: String, to path: String) -> String { let trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDirectory.isEmpty else { return path } let standardizedDirectory = URL(fileURLWithPath: trimmedDirectory, isDirectory: true) @@ -57,7 +59,7 @@ public enum CmuxPathEnvironment { return entries.joined(separator: ":") } - private static func isMalformedScalar(_ scalar: Unicode.Scalar) -> Bool { + private func isMalformedScalar(_ scalar: Unicode.Scalar) -> Bool { CharacterSet.controlCharacters.contains(scalar) || scalar == "\u{FFFD}" } } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxInternalHostnamesTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxInternalHostnamesTests.swift index b99f11900464..6aedf3959020 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxInternalHostnamesTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxInternalHostnamesTests.swift @@ -9,17 +9,17 @@ import Testing struct CmuxInternalHostnamesTests { @Test("Prefers a slugged label over the id; falls back when the label has nothing usable") func hostnamePrefersLabel() { - #expect(CmuxInternalHostnames.hostname(id: "vm-abc123", label: "My Server!") == "my-server.internal") - #expect(CmuxInternalHostnames.hostname(id: "vm-abc123", label: nil) == "vm-abc123.internal") - #expect(CmuxInternalHostnames.hostname(id: "vm-abc123", label: "🎉🎉🎉") == "vm-abc123.internal") + #expect(CmuxInternalHostnames().hostname(id: "vm-abc123", label: "My Server!") == "my-server.internal") + #expect(CmuxInternalHostnames().hostname(id: "vm-abc123", label: nil) == "vm-abc123.internal") + #expect(CmuxInternalHostnames().hostname(id: "vm-abc123", label: "🎉🎉🎉") == "vm-abc123.internal") } @Test("Slugging lowercases, collapses runs of separators, and trims edges") func slugNormalizes() { - #expect(CmuxInternalHostnames.slug("My Cool_Server!!") == "my-cool-server") - #expect(CmuxInternalHostnames.slug(" --leading and trailing-- ") == "leading-and-trailing") - #expect(CmuxInternalHostnames.slug("") == nil) - #expect(CmuxInternalHostnames.slug("!!!") == nil) + #expect(CmuxInternalHostnames().slug("My Cool_Server!!") == "my-cool-server") + #expect(CmuxInternalHostnames().slug(" --leading and trailing-- ") == "leading-and-trailing") + #expect(CmuxInternalHostnames().slug("") == nil) + #expect(CmuxInternalHostnames().slug("!!!") == nil) } @Test("Renders one sorted line per hostname, across machines") @@ -28,7 +28,7 @@ struct CmuxInternalHostnamesTests { CmuxInternalHostnames.Entry(address: "10.0.0.2", hostnames: ["vm-b.internal", "staging.internal"]), CmuxInternalHostnames.Entry(address: "10.0.0.1", hostnames: ["vm-a.internal"]), ] - #expect(CmuxInternalHostnames.renderBlockBody(entries) == [ + #expect(CmuxInternalHostnames().renderBlockBody(entries) == [ "10.0.0.1 vm-a.internal", "10.0.0.2 staging.internal", "10.0.0.2 vm-b.internal", @@ -38,23 +38,23 @@ struct CmuxInternalHostnamesTests { @Test("Appends the managed block to a file with no prior block") func appendsWhenAbsent() { let current = "127.0.0.1 localhost\n" - let merged = CmuxInternalHostnames.mergedHostsFile(current: current, body: "10.0.0.1 vm-a.internal") + let merged = CmuxInternalHostnames().mergedHostsFile(current: current, body: "10.0.0.1 vm-a.internal") #expect(merged.contains("127.0.0.1 localhost")) - #expect(merged.contains(CmuxInternalHostnames.blockBeginMarker)) + #expect(merged.contains(CmuxInternalHostnames().blockBeginMarker)) #expect(merged.contains("10.0.0.1 vm-a.internal")) - #expect(merged.contains(CmuxInternalHostnames.blockEndMarker)) + #expect(merged.contains(CmuxInternalHostnames().blockEndMarker)) } @Test("Replaces a prior block in place, leaving surrounding lines untouched") func replacesExistingBlock() { let current = """ 127.0.0.1 localhost - \(CmuxInternalHostnames.blockBeginMarker) + \(CmuxInternalHostnames().blockBeginMarker) 10.0.0.1 vm-old.internal - \(CmuxInternalHostnames.blockEndMarker) + \(CmuxInternalHostnames().blockEndMarker) 192.168.1.1 nas.local """ - let merged = CmuxInternalHostnames.mergedHostsFile(current: current, body: "10.0.0.2 vm-new.internal") + let merged = CmuxInternalHostnames().mergedHostsFile(current: current, body: "10.0.0.2 vm-new.internal") #expect(!merged.contains("vm-old.internal")) #expect(merged.contains("vm-new.internal")) #expect(merged.contains("192.168.1.1 nas.local")) @@ -66,11 +66,11 @@ struct CmuxInternalHostnamesTests { let current = """ 127.0.0.1 localhost - \(CmuxInternalHostnames.blockBeginMarker) + \(CmuxInternalHostnames().blockBeginMarker) 10.0.0.1 vm-old.internal - \(CmuxInternalHostnames.blockEndMarker) + \(CmuxInternalHostnames().blockEndMarker) """ - let merged = CmuxInternalHostnames.mergedHostsFile(current: current, body: "") + let merged = CmuxInternalHostnames().mergedHostsFile(current: current, body: "") #expect(!merged.contains("cmux managed")) #expect(!merged.contains("vm-old.internal")) #expect(merged == "127.0.0.1 localhost") @@ -79,7 +79,7 @@ struct CmuxInternalHostnamesTests { @Test("An already-empty file with an empty body changes nothing") func emptyBodyOnFileWithNoBlockIsANoOp() { let current = "127.0.0.1 localhost\n" - #expect(CmuxInternalHostnames.mergedHostsFile(current: current, body: "") == current) + #expect(CmuxInternalHostnames().mergedHostsFile(current: current, body: "") == current) } @Test("A port link is always the raw private address, never the .internal name") @@ -87,31 +87,31 @@ struct CmuxInternalHostnamesTests { // Deliberately not the `.internal` name: it only resolves once // `cmux vpn hosts` has synced `/etc/hosts`, and a link that only // sometimes works is worse than one that always does. - #expect(CmuxInternalHostnames.directPortURL(privateAddress: "10.0.0.2", port: 8000) == "http://10.0.0.2:8000") + #expect(CmuxInternalHostnames().directPortURL(privateAddress: "10.0.0.2", port: 8000) == "http://10.0.0.2:8000") } @Test("Brackets an IPv6 address the way every URL scheme requires") func directPortURLBracketsIPv6() { #expect( - CmuxInternalHostnames.directPortURL(privateAddress: "fd60:1e5e:6720::3", port: 22) + CmuxInternalHostnames().directPortURL(privateAddress: "fd60:1e5e:6720::3", port: 22) == "http://[fd60:1e5e:6720::3]:22" ) } @Test("A dev build's scoped block coexists with the production block; each clears only its own") func scopedBlocksCoexist() { - let prod = CmuxInternalHostnames.mergedHostsFile(current: "127.0.0.1 localhost\n", body: "10.16.179.2 prod.internal") - let both = CmuxInternalHostnames.mergedHostsFile(current: prod, body: "10.16.170.2 dev.internal", scope: "cmux-staging") + let prod = CmuxInternalHostnames().mergedHostsFile(current: "127.0.0.1 localhost\n", body: "10.16.179.2 prod.internal") + let both = CmuxInternalHostnames().mergedHostsFile(current: prod, body: "10.16.170.2 dev.internal", scope: "cmux-staging") #expect(both.contains("10.16.179.2 prod.internal")) #expect(both.contains("10.16.170.2 dev.internal")) #expect(both.contains("# BEGIN cmux managed hosts [cmux-staging] (cmux vpn hosts)")) - #expect(both.contains(CmuxInternalHostnames.blockBeginMarker), "the production block keeps its historical markers") + #expect(both.contains(CmuxInternalHostnames().blockBeginMarker), "the production block keeps its historical markers") // The default scope name is the unscoped block. - #expect(CmuxInternalHostnames.blockBeginMarker(scope: "cmux") == CmuxInternalHostnames.blockBeginMarker) - #expect(CmuxInternalHostnames.blockBeginMarker(scope: nil) == CmuxInternalHostnames.blockBeginMarker) - let devCleared = CmuxInternalHostnames.mergedHostsFile(current: both, body: "", scope: "cmux-staging") + #expect(CmuxInternalHostnames().blockBeginMarker(scope: "cmux") == CmuxInternalHostnames().blockBeginMarker) + #expect(CmuxInternalHostnames().blockBeginMarker(scope: nil) == CmuxInternalHostnames().blockBeginMarker) + let devCleared = CmuxInternalHostnames().mergedHostsFile(current: both, body: "", scope: "cmux-staging") #expect(devCleared.contains("prod.internal") && !devCleared.contains("dev.internal")) - let prodCleared = CmuxInternalHostnames.mergedHostsFile(current: both, body: "", scope: "cmux") + let prodCleared = CmuxInternalHostnames().mergedHostsFile(current: both, body: "", scope: "cmux") #expect(!prodCleared.contains("prod.internal") && prodCleared.contains("dev.internal")) } } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift index 5f1a4d1cec92..c8763709f5b0 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift @@ -309,11 +309,11 @@ public actor GitHubPullRequestRequestCoordinator { } if response.statusCode == 403 || response.statusCode == 429, - let retryAfterSeconds = CmxRetryAfterPolicy.seconds( + let retryAfterSeconds = CmxRetryAfterPolicy().seconds( from: response, now: now(), defaultSeconds: response.statusCode == 429 - ? CmxRetryAfterPolicy.defaultRateLimitSeconds + ? CmxRetryAfterPolicy().defaultRateLimitSeconds : nil ) { extendRateLimitRetryDate( diff --git a/Packages/macOS/CmuxPhonePush/Sources/CmuxPhonePush/PhonePushCrypto.swift b/Packages/macOS/CmuxPhonePush/Sources/CmuxPhonePush/PhonePushCrypto.swift index f962e2c95893..b0038380974f 100644 --- a/Packages/macOS/CmuxPhonePush/Sources/CmuxPhonePush/PhonePushCrypto.swift +++ b/Packages/macOS/CmuxPhonePush/Sources/CmuxPhonePush/PhonePushCrypto.swift @@ -92,11 +92,13 @@ public enum PhonePushCryptoError: Error, Sendable { case keychain(OSStatus) } -public enum PhonePushReplyFreshness { - public static let clockSkew: TimeInterval = 30 - public static let maximumLifetime: TimeInterval = 15 * 60 +public struct PhonePushReplyFreshness: Sendable { + public init() {} - public static func accepts( + public let clockSkew: TimeInterval = 30 + public let maximumLifetime: TimeInterval = 15 * 60 + + public func accepts( issuedAt: TimeInterval, expiresAt: TimeInterval, now: TimeInterval @@ -108,10 +110,12 @@ public enum PhonePushReplyFreshness { } } -public enum PhonePushCrypto { - public static let algorithm = "x25519-hpke-sha256-chacha20poly1305-v2" +public struct PhonePushCrypto: Sendable { + public init() {} + + public let algorithm = "x25519-hpke-sha256-chacha20poly1305-v2" - public static func encrypt( + public func encrypt( plaintext: Data, tuple: PhonePushDeviceTuple, recipientPublicKey: Data, @@ -139,7 +143,7 @@ public enum PhonePushCrypto { ) } - public static func decrypt( + public func decrypt( envelope: PhonePushEncryptedPayload, tuple: PhonePushDeviceTuple, recipientInstallationID: String, @@ -182,7 +186,7 @@ public enum PhonePushCrypto { } } - private static func info( + private func info( tuple: PhonePushDeviceTuple, keyID: String, senderKeyID: String @@ -191,7 +195,7 @@ public enum PhonePushCrypto { + canonicalTupleData(tuple) } - private static func aad( + private func aad( tuple: PhonePushDeviceTuple, keyID: String, senderKeyID: String @@ -200,7 +204,7 @@ public enum PhonePushCrypto { + canonicalTupleData(tuple) } - private static func canonicalTupleData(_ tuple: PhonePushDeviceTuple) -> Data { + private func canonicalTupleData(_ tuple: PhonePushDeviceTuple) -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return (try? encoder.encode(tuple)) ?? Data() @@ -227,7 +231,7 @@ public struct PhonePushKeyMaterial: Sendable { } } -public enum PhonePushKeyStore { +extension PhonePushKeyMaterial { public static func current(bundleID: String, accessGroup: String? = nil) throws -> PhonePushKeyMaterial { let service = "ai.manaflow.cmux.phone-push.\(bundleID)" var query: [String: Any] = [ @@ -305,57 +309,59 @@ public enum PhonePushKeyStore { } } -public enum PhonePushPeerKeyStore { +public struct PhonePushPeerKeyStore { private static let prefix = "cmux.phone-push.peer.v2." private static let registryKey = prefix + "registry" private static let maximumEntries = 128 private static let lock = NSLock() - private nonisolated(unsafe) static var defaults: UserDefaults { + private let defaults: UserDefaults + + public init(defaults: UserDefaults? = nil) { #if os(iOS) - return UserDefaults(suiteName: PhonePushActiveAccountStore.appGroupIdentifier) ?? .standard + self.defaults = defaults ?? UserDefaults(suiteName: PhonePushActiveAccountStore.appGroupIdentifier) ?? .standard #else - return .standard + self.defaults = defaults ?? .standard #endif } - public static func pin(_ descriptor: PhonePushPeerDescriptor, for tuple: PhonePushDeviceTuple) { + public func pin(_ descriptor: PhonePushPeerDescriptor, for tuple: PhonePushDeviceTuple) { guard !descriptor.keyID.isEmpty else { return } - lock.withLock { + Self.lock.withLock { let storageKey = key(for: tuple) defaults.set(try? JSONEncoder().encode(descriptor), forKey: storageKey) - var orderedKeys = defaults.stringArray(forKey: registryKey) ?? [] + var orderedKeys = defaults.stringArray(forKey: Self.registryKey) ?? [] let discoveredKeys = defaults.dictionaryRepresentation().keys.filter { - $0.hasPrefix(prefix) && $0 != registryKey + $0.hasPrefix(Self.prefix) && $0 != Self.registryKey } for discoveredKey in discoveredKeys where !orderedKeys.contains(discoveredKey) { orderedKeys.append(discoveredKey) } orderedKeys.removeAll { $0 == storageKey } orderedKeys.append(storageKey) - while orderedKeys.count > maximumEntries { + while orderedKeys.count > Self.maximumEntries { let staleKey = orderedKeys.removeFirst() defaults.removeObject(forKey: staleKey) } - defaults.set(orderedKeys, forKey: registryKey) + defaults.set(orderedKeys, forKey: Self.registryKey) } } - public static func pin(_ publicKey: Data, keyID: String, for tuple: PhonePushDeviceTuple) { + public func pin(_ publicKey: Data, keyID: String, for tuple: PhonePushDeviceTuple) { pin(PhonePushPeerDescriptor(keyID: keyID, publicKey: publicKey), for: tuple) } - public static func pinnedDescriptor(for tuple: PhonePushDeviceTuple) -> PhonePushPeerDescriptor? { - lock.withLock { + public func pinnedDescriptor(for tuple: PhonePushDeviceTuple) -> PhonePushPeerDescriptor? { + Self.lock.withLock { guard let data = defaults.data(forKey: key(for: tuple)) else { return nil } return try? JSONDecoder().decode(PhonePushPeerDescriptor.self, from: data) } } - public static func pinnedKey(for tuple: PhonePushDeviceTuple) -> Data? { + public func pinnedKey(for tuple: PhonePushDeviceTuple) -> Data? { pinnedDescriptor(for: tuple)?.publicKey } - public static func save(_ publicKey: Data, macDeviceID: String, instanceTag: String?) { + public func save(_ publicKey: Data, macDeviceID: String, instanceTag: String?) { let tuple = PhonePushDeviceTuple( accountID: nil, teamID: nil, @@ -368,7 +374,7 @@ public enum PhonePushPeerKeyStore { pin(publicKey, keyID: "legacy", for: tuple) } - public static func load(macDeviceID: String, instanceTag: String?) -> Data? { + public func load(macDeviceID: String, instanceTag: String?) -> Data? { let tuple = PhonePushDeviceTuple( accountID: nil, teamID: nil, @@ -381,50 +387,60 @@ public enum PhonePushPeerKeyStore { return pinnedKey(for: tuple) } - private static func key(for tuple: PhonePushDeviceTuple) -> String { + private func key(for tuple: PhonePushDeviceTuple) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let data = (try? encoder.encode(tuple)) ?? Data() - return prefix + data.base64EncodedString() + return Self.prefix + data.base64EncodedString() } } -public enum PhonePushActiveAccountStore { +public struct PhonePushActiveAccountStore { public static let appGroupIdentifier = "group.dev.cmux.ios" private static let accountKeyPrefix = "cmux.activeAccountID." private static let lock = NSLock() - private static var hostBundleIdentifier: String? { - let bundle = Bundle.main + private let bundle: Bundle + private let defaults: UserDefaults? + + public init( + bundle: Bundle = .main, + defaults: UserDefaults? = UserDefaults(suiteName: PhonePushActiveAccountStore.appGroupIdentifier) + ) { + self.bundle = bundle + self.defaults = defaults + } + + private var hostBundleIdentifier: String? { let hostID = bundle.object(forInfoDictionaryKey: "CMUXHostBundleIdentifier") as? String let value = hostID ?? bundle.bundleIdentifier guard let value, !value.isEmpty, !value.contains("$(") else { return nil } return value } - private static func accountKey(bundleID: String?) -> String? { + private func accountKey(bundleID: String?) -> String? { guard let bundleID, !bundleID.isEmpty else { return nil } - return accountKeyPrefix + bundleID + return Self.accountKeyPrefix + bundleID } - public static func current() -> String? { - lock.withLock { + public func current() -> String? { + Self.lock.withLock { guard let key = accountKey(bundleID: hostBundleIdentifier) else { return nil } - return UserDefaults(suiteName: appGroupIdentifier)?.string(forKey: key) + return defaults?.string(forKey: key) } } - public static func set(_ accountID: String) { - lock.withLock { + public func set(_ accountID: String) { + Self.lock.withLock { guard let key = accountKey(bundleID: hostBundleIdentifier) else { return } - UserDefaults(suiteName: appGroupIdentifier)?.set(accountID, forKey: key) + defaults?.set(accountID, forKey: key) } } - public static func clear() { - lock.withLock { + public func clear() { + Self.lock.withLock { guard let key = accountKey(bundleID: hostBundleIdentifier) else { return } - UserDefaults(suiteName: appGroupIdentifier)?.removeObject(forKey: key) + defaults?.removeObject(forKey: key) } } } diff --git a/Packages/macOS/CmuxPhonePush/Tests/CmuxPhonePushTests/PhonePushCryptoTests.swift b/Packages/macOS/CmuxPhonePush/Tests/CmuxPhonePushTests/PhonePushCryptoTests.swift index 9a512fee5b7f..5a7068734ba4 100644 --- a/Packages/macOS/CmuxPhonePush/Tests/CmuxPhonePushTests/PhonePushCryptoTests.swift +++ b/Packages/macOS/CmuxPhonePush/Tests/CmuxPhonePushTests/PhonePushCryptoTests.swift @@ -19,7 +19,7 @@ struct PhonePushCryptoTests { func roundTrip() throws { let sender = Curve25519.KeyAgreement.PrivateKey() let recipient = Curve25519.KeyAgreement.PrivateKey() - let envelope = try PhonePushCrypto.encrypt( + let envelope = try PhonePushCrypto().encrypt( plaintext: Data(#"{"replyId":"reply-1","text":"hello"}"#.utf8), tuple: tuple, recipientPublicKey: recipient.publicKey.rawRepresentation, @@ -30,7 +30,7 @@ struct PhonePushCryptoTests { ) let wire = try JSONEncoder().encode(envelope) let decoded = try JSONDecoder().decode(PhonePushEncryptedPayload.self, from: wire) - let plaintext = try PhonePushCrypto.decrypt( + let plaintext = try PhonePushCrypto().decrypt( envelope: decoded, tuple: tuple, recipientInstallationID: tuple.iosInstallationID, @@ -46,7 +46,7 @@ struct PhonePushCryptoTests { func rejectsBindingMismatches() throws { let sender = Curve25519.KeyAgreement.PrivateKey() let recipient = Curve25519.KeyAgreement.PrivateKey() - let envelope = try PhonePushCrypto.encrypt( + let envelope = try PhonePushCrypto().encrypt( plaintext: Data("secret".utf8), tuple: tuple, recipientPublicKey: recipient.publicKey.rawRepresentation, @@ -56,7 +56,7 @@ struct PhonePushCryptoTests { installationID: tuple.iosInstallationID ) #expect(throws: PhonePushCryptoError.self) { - try PhonePushCrypto.decrypt( + try PhonePushCrypto().decrypt( envelope: envelope, tuple: PhonePushDeviceTuple( accountID: "other-account", @@ -75,7 +75,7 @@ struct PhonePushCryptoTests { ) } #expect(throws: PhonePushCryptoError.self) { - try PhonePushCrypto.decrypt( + try PhonePushCrypto().decrypt( envelope: envelope, tuple: tuple, recipientInstallationID: tuple.iosInstallationID, @@ -86,7 +86,7 @@ struct PhonePushCryptoTests { ) } #expect(throws: PhonePushCryptoError.self) { - try PhonePushCrypto.decrypt( + try PhonePushCrypto().decrypt( envelope: envelope, tuple: tuple, recipientInstallationID: tuple.iosInstallationID, @@ -100,9 +100,9 @@ struct PhonePushCryptoTests { @Test("rejects replayed, future, and overlong authenticated timestamps") func replyFreshness() { - #expect(PhonePushReplyFreshness.accepts(issuedAt: 1_000, expiresAt: 1_900, now: 1_100)) - #expect(!PhonePushReplyFreshness.accepts(issuedAt: 1_000, expiresAt: 1_900, now: 2_000)) - #expect(!PhonePushReplyFreshness.accepts(issuedAt: 2_000, expiresAt: 2_900, now: 1_000)) - #expect(!PhonePushReplyFreshness.accepts(issuedAt: 1_000, expiresAt: 16_001, now: 1_100)) + #expect(PhonePushReplyFreshness().accepts(issuedAt: 1_000, expiresAt: 1_900, now: 1_100)) + #expect(!PhonePushReplyFreshness().accepts(issuedAt: 1_000, expiresAt: 1_900, now: 2_000)) + #expect(!PhonePushReplyFreshness().accepts(issuedAt: 2_000, expiresAt: 2_900, now: 1_000)) + #expect(!PhonePushReplyFreshness().accepts(issuedAt: 1_000, expiresAt: 16_001, now: 1_100)) } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift index aa6e23f351e9..da81288f9d21 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift @@ -91,7 +91,7 @@ extension TerminalSurface { /// Prepends `directory` to a `PATH`-style string exactly once. public static func pathByPrependingUniqueDirectory(_ directory: String, to path: String) -> String { - CmuxPathEnvironment.prependingUniqueDirectory(directory, to: path) + CmuxPathEnvironment().prependingUniqueDirectory(directory, to: path) } /// Merges base, additional, and override environments with key diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererWindowVisibility.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererWindowVisibility.swift index 48714074dd44..f5f8400bb604 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererWindowVisibility.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererWindowVisibility.swift @@ -10,15 +10,31 @@ import Foundation /// so gating presentation on that bit alone leaves the terminal at zero /// presents forever. Until a window has reported `.visible` at least once, its /// ordinary on-screen state is trusted instead; a key window is always visible. -public enum TerminalRendererWindowVisibility { - public static func isVisible( +public struct TerminalRendererWindowVisibility: Sendable { + public let occlusionVisible: Bool + public let windowHasReportedVisible: Bool + public let isWindowVisible: Bool + public let isMiniaturized: Bool + public let isOnActiveSpace: Bool + public let isKeyWindow: Bool + + public init( occlusionVisible: Bool, windowHasReportedVisible: Bool, isWindowVisible: Bool, isMiniaturized: Bool, isOnActiveSpace: Bool, isKeyWindow: Bool - ) -> Bool { + ) { + self.occlusionVisible = occlusionVisible + self.windowHasReportedVisible = windowHasReportedVisible + self.isWindowVisible = isWindowVisible + self.isMiniaturized = isMiniaturized + self.isOnActiveSpace = isOnActiveSpace + self.isKeyWindow = isKeyWindow + } + + public var isVisible: Bool { if occlusionVisible || isKeyWindow { return true } // Occlusion has been trustworthy for this window: honor its verdict. if windowHasReportedVisible { return false } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index a2a47f81f0c3..5105fc61710b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -107,7 +107,7 @@ extension TerminalSurface { let inheritedPath = env["PATH"] ?? ProcessInfo.processInfo.environment["PATH"] ?? "" - return CmuxPathEnvironment.components(from: inheritedPath).joined(separator: ":") + return CmuxPathEnvironment().components(from: inheritedPath).joined(separator: ":") } let sanitizedPath = currentManagedPath() diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererWindowVisibilityTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererWindowVisibilityTests.swift index bbdd489db489..afbd8238b90a 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererWindowVisibilityTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererWindowVisibilityTests.swift @@ -14,14 +14,14 @@ struct TerminalRendererWindowVisibilityTests { activeSpace: Bool = true, key: Bool = false ) -> Bool { - TerminalRendererWindowVisibility.isVisible( + TerminalRendererWindowVisibility( occlusionVisible: occlusion, windowHasReportedVisible: reported, isWindowVisible: onScreen, isMiniaturized: miniaturized, isOnActiveSpace: activeSpace, isKeyWindow: key - ) + ).isVisible } @Test func onScreenWindowThatNeverReportedVisiblePresents() { diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift index 324183e83008..2d2a01629ece 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift @@ -12,8 +12,12 @@ /// /// An overlay scroller reserves nothing, so it stays hidden while nothing can /// scroll and never sits on top of the rightmost column of a full-screen app. -public enum TerminalScrollBarPresencePolicy { - /// Returns whether the scroller is present. +public struct TerminalScrollBarPresencePolicy: Sendable { + private let allowedBySettings: Bool + private let scrollerStyle: TerminalScrollerStyle + private let hasScrollback: Bool? + + /// Creates a snapshot of the scrollbar layout inputs. /// /// - Parameters: /// - allowedBySettings: Whether the Ghostty `scrollbar` config and the @@ -21,11 +25,18 @@ public enum TerminalScrollBarPresencePolicy { /// - scrollerStyle: How the host's scroller participates in layout. /// - hasScrollback: Whether the surface has rows above its viewport, or /// nil while the runtime has not published its first scrollbar state. - public static func isPresent( + public init( allowedBySettings: Bool, scrollerStyle: TerminalScrollerStyle, hasScrollback: Bool? - ) -> Bool { + ) { + self.allowedBySettings = allowedBySettings + self.scrollerStyle = scrollerStyle + self.hasScrollback = hasScrollback + } + + /// Whether the snapshot requires a scroller. + public var isPresent: Bool { guard allowedBySettings else { return false } // A legacy scroller is part of the layout; keep it so the grid width // is the same with and without history. diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift index 14140156e8be..9a898b975e8f 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift @@ -9,21 +9,21 @@ import CmuxTerminalCore @Test("A legacy scroller is present regardless of scrollback") func legacyReservesTheGutter() { - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: false)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: nil)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: true)) + #expect(Policy(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: false).isPresent) + #expect(Policy(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: nil).isPresent) + #expect(Policy(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: true).isPresent) } @Test("An overlay scroller follows scrollback and assumes history until told otherwise") func overlayFollowsScrollback() { - #expect(!Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: false)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: true)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: nil)) + #expect(!Policy(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: false).isPresent) + #expect(Policy(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: true).isPresent) + #expect(Policy(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: nil).isPresent) } @Test("Settings that disallow the scroller win over every style") func settingsWin() { - #expect(!Policy.isPresent(allowedBySettings: false, scrollerStyle: .legacy, hasScrollback: true)) - #expect(!Policy.isPresent(allowedBySettings: false, scrollerStyle: .overlay, hasScrollback: true)) + #expect(!Policy(allowedBySettings: false, scrollerStyle: .legacy, hasScrollback: true).isPresent) + #expect(!Policy(allowedBySettings: false, scrollerStyle: .overlay, hasScrollback: true).isPresent) } } diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 37d9d24f3aa8..56aecdf90488 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -784,7 +784,7 @@ enum CloudTreeNodeBuilder { // Cloud machines only: the local Mac has no private-network address to // begin with, so it never reaches here with one. guard machine.isLocal == false else { return nil } - return CmuxInternalHostnames.directPortURL(privateAddress: address, port: port) + return CmuxInternalHostnames().directPortURL(privateAddress: address, port: port) } private static func cloudChildren( machine: SurfaceMachineID, diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 1c63920a9530..c6fe55d8be9b 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -197,10 +197,10 @@ final class DeviceRegistryClient { lastRegistration = registration } else { if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) } NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode) diff --git a/Sources/Cloud/MacPairedMacBackupPublisher.swift b/Sources/Cloud/MacPairedMacBackupPublisher.swift index af8083d4982c..c75d6c01ad57 100644 --- a/Sources/Cloud/MacPairedMacBackupPublisher.swift +++ b/Sources/Cloud/MacPairedMacBackupPublisher.swift @@ -190,10 +190,10 @@ final class MacPairedMacBackupPublisher { let (_, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { return } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) return } diff --git a/Sources/Cloud/PhonePushClient.swift b/Sources/Cloud/PhonePushClient.swift index 32d3e0a68867..79cd3ef64de8 100644 --- a/Sources/Cloud/PhonePushClient.swift +++ b/Sources/Cloud/PhonePushClient.swift @@ -616,7 +616,7 @@ final class PhonePushClient { guard !identity.accountID.isEmpty, let macDeviceID = payload.macDeviceId, let macBuildID = Bundle.main.bundleIdentifier, - let macKey = try? PhonePushKeyStore.current( + let macKey = try? PhonePushKeyMaterial.current( bundleID: Bundle.main.bundleIdentifier ?? "cmux" ) else { return nil } let recipients = trustedRecipients( @@ -642,7 +642,7 @@ final class PhonePushClient { macBuildID: macBuildID ).body let encrypted = try recipients.map { recipient in - try PhonePushCrypto.encrypt( + try PhonePushCrypto().encrypt( plaintext: plaintext, tuple: PhonePushDeviceTuple( accountID: identity.accountID, @@ -705,7 +705,7 @@ final class PhonePushClient { macInstanceTag: payload.macInstanceTag, macBuildID: macBuildID ) - guard let pinned = PhonePushPeerKeyStore.pinnedDescriptor(for: tuple) else { + guard let pinned = PhonePushPeerKeyStore().pinnedDescriptor(for: tuple) else { return false } return pinned.keyID == recipient.keyID diff --git a/Sources/Cloud/PhonePushHTTPResult.swift b/Sources/Cloud/PhonePushHTTPResult.swift index b4b191dd76d4..0ef305930827 100644 --- a/Sources/Cloud/PhonePushHTTPResult.swift +++ b/Sources/Cloud/PhonePushHTTPResult.swift @@ -86,7 +86,7 @@ enum PhonePushHTTPResult: Equatable, Sendable { response: HTTPURLResponse, data: Data ) -> Int? { - let header = CmxRetryAfterPolicy.seconds( + let header = CmxRetryAfterPolicy().seconds( from: response.value(forHTTPHeaderField: "Retry-After") ) let summary = try? JSONDecoder().decode( @@ -101,7 +101,7 @@ enum PhonePushHTTPResult: Equatable, Sendable { .compactMap { $0 } .first(where: { $0 > 0 }) guard let value = directive ?? (response.statusCode == 429 - ? CmxRetryAfterPolicy.defaultRateLimitSeconds + ? CmxRetryAfterPolicy().defaultRateLimitSeconds : nil) else { return nil } return value } diff --git a/Sources/Cloud/PhoneReplyInboxClient.swift b/Sources/Cloud/PhoneReplyInboxClient.swift index cd359ba4199c..fc89b45e893e 100644 --- a/Sources/Cloud/PhoneReplyInboxClient.swift +++ b/Sources/Cloud/PhoneReplyInboxClient.swift @@ -145,10 +145,10 @@ final class PhoneReplyInboxClient { } private func recordRetryAfter(_ response: HTTPURLResponse) async { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: response, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) } diff --git a/Sources/Cloud/PhoneReplyInboxCoordinator.swift b/Sources/Cloud/PhoneReplyInboxCoordinator.swift index 9cf232bb235e..f59a73c134d8 100644 --- a/Sources/Cloud/PhoneReplyInboxCoordinator.swift +++ b/Sources/Cloud/PhoneReplyInboxCoordinator.swift @@ -257,18 +257,18 @@ final class PhoneReplyInboxCoordinator { } let identity: PhonePushKeyMaterial do { - identity = try PhonePushKeyStore.current( + identity = try PhonePushKeyMaterial.current( bundleID: Bundle.main.bundleIdentifier ?? "cmux" ) } catch { return .retryable } - guard let sender = PhonePushPeerKeyStore.pinnedDescriptor(for: encryptedPayload.tuple) else { + guard let sender = PhonePushPeerKeyStore().pinnedDescriptor(for: encryptedPayload.tuple) else { return .retryable } let data: Data do { - data = try PhonePushCrypto.decrypt( + data = try PhonePushCrypto().decrypt( envelope: encryptedPayload, tuple: encryptedPayload.tuple, recipientInstallationID: identity.installationID, @@ -283,7 +283,7 @@ final class PhoneReplyInboxCoordinator { guard let result = try? JSONDecoder().decode(DecryptedReply.self, from: data), result.replyId == reply.replyId, result.accountID == accountID else { return .permanentFailure } - guard PhonePushReplyFreshness.accepts( + guard PhonePushReplyFreshness().accepts( issuedAt: result.issuedAtEpochSeconds, expiresAt: result.expiresAtEpochSeconds, now: now().timeIntervalSince1970 diff --git a/Sources/Cloud/PortForward/CloudPortRoutePlan.swift b/Sources/Cloud/PortForward/CloudPortRoutePlan.swift index 384efaf6709f..8da088ea0a8e 100644 --- a/Sources/Cloud/PortForward/CloudPortRoutePlan.swift +++ b/Sources/Cloud/PortForward/CloudPortRoutePlan.swift @@ -19,7 +19,7 @@ enum CloudPortRoutePlan: Equatable, Sendable { } let raw = resource.url ?? (desktop ? CmuxTuiSurfaceProvider.privateDesktopURL(privateAddress: address) - : CmuxInternalHostnames.directPortURL(privateAddress: address, port: port)) + : CmuxInternalHostnames().directPortURL(privateAddress: address, port: port)) guard let url = privateURL(raw, address: address) else { return .unsupported(String(localized: "cloud.portAccess.invalidURL", defaultValue: "This port does not have a valid HTTP or HTTPS address.")) } diff --git a/Sources/Cloud/PresenceHeartbeatClient.swift b/Sources/Cloud/PresenceHeartbeatClient.swift index 72bdd6d33d23..6c3609cd3daf 100644 --- a/Sources/Cloud/PresenceHeartbeatClient.swift +++ b/Sources/Cloud/PresenceHeartbeatClient.swift @@ -240,10 +240,10 @@ final class PresenceHeartbeatClient { let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { return } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await retryAfterGate.extend(by: seconds) return } diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 1928580b838c..4ca43d1a96b4 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -2272,7 +2272,7 @@ actor VMClient { statusCode: http.statusCode, retryAfterHeader: http.value(forHTTPHeaderField: "Retry-After") ) ?? 2 - try await CloudOperationContext.phase(.retryWait, attempt: attempt) { try await CmxRetryAfterPolicy.sleep(seconds: delaySeconds) } + try await CloudOperationContext.phase(.retryWait, attempt: attempt) { try await CmxRetryAfterPolicy().sleep(seconds: delaySeconds) } continue } if retryTransientServiceUnavailable, @@ -2280,7 +2280,7 @@ actor VMClient { let delaySeconds = Self.transientVMRetryDelay(http: http, data: data) { retriesLeft -= 1 onRetry() - try await CloudOperationContext.phase(.retryWait, attempt: attempt) { try await CmxRetryAfterPolicy.sleep(seconds: TimeInterval(delaySeconds.components.seconds)) } + try await CloudOperationContext.phase(.retryWait, attempt: attempt) { try await CmxRetryAfterPolicy().sleep(seconds: TimeInterval(delaySeconds.components.seconds)) } continue } // The private gateway has not forwarded this request yet. Every @@ -2291,7 +2291,7 @@ actor VMClient { retriesLeft -= 1 onRetry() try await CloudOperationContext.phase(.retryWait, attempt: attempt) { - try await CmxRetryAfterPolicy.sleep(seconds: 2) + try await CmxRetryAfterPolicy().sleep(seconds: 2) } continue } @@ -2359,8 +2359,8 @@ actor VMClient { ) -> TimeInterval? { guard statusCode == 429 else { return nil } return TimeInterval( - CmxRetryAfterPolicy.seconds(from: retryAfterHeader) - ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + CmxRetryAfterPolicy().seconds(from: retryAfterHeader) + ?? CmxRetryAfterPolicy().defaultRateLimitSeconds ) } diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index fc12c05fe10a..aa3d689b6a1d 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -597,10 +597,10 @@ final class CmuxFeatureFlags { guard let (bytes, response) = try? await session.bytes(for: request), let http = response as? HTTPURLResponse else { return nil } if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( + let seconds = CmxRetryAfterPolicy().seconds( from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds + defaultSeconds: CmxRetryAfterPolicy().defaultRateLimitSeconds + ) ?? CmxRetryAfterPolicy().defaultRateLimitSeconds await releaseControlRetryAfterGate.extend(by: seconds) return nil } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ae275fb86da6..9014787e6a39 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9055,14 +9055,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { Self.windowsThatReportedVisible.add(window) } terminalSurface?.setRendererWindowVisible( - TerminalRendererWindowVisibility.isVisible( + TerminalRendererWindowVisibility( occlusionVisible: occlusionVisible, windowHasReportedVisible: Self.windowsThatReportedVisible.contains(window), isWindowVisible: window.isVisible, isMiniaturized: window.isMiniaturized, isOnActiveSpace: window.isOnActiveSpace, isKeyWindow: window.isKeyWindow - ) + ).isVisible ) } @@ -13159,11 +13159,11 @@ final class GhosttySurfaceScrollView: NSView { } private func shouldShowTerminalScrollBar() -> Bool { - TerminalScrollBarPresencePolicy.isPresent( + TerminalScrollBarPresencePolicy( allowedBySettings: terminalScrollBarAllowedBySettings(), scrollerStyle: scrollView.scrollerStyle == .legacy ? .legacy : .overlay, hasScrollback: surfaceHasScrollback() - ) + ).isPresent } } diff --git a/Sources/Mobile/MobileHostDiagnostics.swift b/Sources/Mobile/MobileHostDiagnostics.swift index 440706591cea..16d18fc75e3b 100644 --- a/Sources/Mobile/MobileHostDiagnostics.swift +++ b/Sources/Mobile/MobileHostDiagnostics.swift @@ -6,7 +6,7 @@ import OSLog final class MobileHostDiagnostics { /// Remains readable off the main actor when a stalled UI needs diagnostics. nonisolated static let log = DiagnosticLog( - buildStamp: DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary), + buildStamp: DiagnosticReport.buildStamp(infoDictionary: Bundle.main.infoDictionary), role: .macHost ) diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 2b78fd4cc441..eeedba110907 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -756,7 +756,7 @@ final class MobileHostIrxRuntime: MobileHostPairingRuntime { ) async { let journal = Self.journal guard - let (peer, control, sessionID) = await IrxAdmission.performServer( + let (peer, control, sessionID) = await IrxAdmission().performServer( connection: irx, judgment: judgment, journal: journal diff --git a/Sources/Mobile/MobileHostService+PhonePushKeyExchange.swift b/Sources/Mobile/MobileHostService+PhonePushKeyExchange.swift index 3366b2c6514d..46f24ec6af3d 100644 --- a/Sources/Mobile/MobileHostService+PhonePushKeyExchange.swift +++ b/Sources/Mobile/MobileHostService+PhonePushKeyExchange.swift @@ -23,7 +23,7 @@ extension MobileHostService { let macDeviceID = MobileHostIdentity.deviceID() let macInstanceTag = MobileHostIdentity.instanceTag() let macBuildID = Bundle.main.bundleIdentifier ?? "cmux" - let macKey = try PhonePushKeyStore.current(bundleID: macBuildID) + let macKey = try PhonePushKeyMaterial.current(bundleID: macBuildID) // The account and Mac identity are all derived locally. The phone // supplies only its key descriptor, build label, and independent @@ -38,7 +38,7 @@ extension MobileHostService { macInstanceTag: macInstanceTag, macBuildID: macBuildID ) - PhonePushPeerKeyStore.pin( + PhonePushPeerKeyStore().pin( exchange.descriptor.publicKey, keyID: exchange.descriptor.keyID, for: tuple diff --git a/Sources/Mobile/MobileSimulatorStreamV2Session.swift b/Sources/Mobile/MobileSimulatorStreamV2Session.swift index c9e69a377f67..9485b7ab13b1 100644 --- a/Sources/Mobile/MobileSimulatorStreamV2Session.swift +++ b/Sources/Mobile/MobileSimulatorStreamV2Session.swift @@ -141,7 +141,7 @@ final class MobileSimulatorStreamV2Session { guard !data.isEmpty else { continue } accumulator.append(data) while let body = try accumulator.nextMessageBody() { - let message = try SimStreamWireCodec.decode(body) + let message = try SimStreamWireCodec().decode(body) guard await route(message) else { return } } } diff --git a/Sources/Panels/MarkdownViewerAssets.swift b/Sources/Panels/MarkdownViewerAssets.swift index dec2b60bcacb..5400bcf96da8 100644 --- a/Sources/Panels/MarkdownViewerAssets.swift +++ b/Sources/Panels/MarkdownViewerAssets.swift @@ -136,7 +136,7 @@ final class MarkdownViewerAssets { private static func loadDeflatedTextAsset(url: URL) -> String? { guard let compressed = try? Data(contentsOf: url), - let decompressed = MarkdownViewerAssetCompression.inflate(compressed) else { + let decompressed = Data.inflateMarkdownViewerAsset(compressed) else { return nil } return String(data: decompressed, encoding: .utf8) diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index fa59369e8676..892f20cc6154 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -4,8 +4,8 @@ import SwiftUI import WebKit struct MarkdownWebRenderer: NSViewRepresentable { - static let localImageURLScheme = MarkdownWebViewerScheme.localImage - static let remoteImageURLScheme = MarkdownWebViewerScheme.remoteImage + static let localImageURLScheme = MarkdownWebViewerScheme.localImage.rawValue + static let remoteImageURLScheme = MarkdownWebViewerScheme.remoteImage.rawValue let markdown: String let theme: MarkdownWebTheme @@ -569,10 +569,10 @@ struct MarkdownWebRenderer: NSViewRepresentable { } if scheme == MarkdownWebRenderer.remoteImageURLScheme { - let remoteURL = MarkdownRemoteImageSecurity.remoteImageURL(from: requestURL) + let remoteURL = MarkdownRemoteImageSecurity().remoteImageURL(from: requestURL) return Task.detached(priority: .userInitiated) { guard let remoteURL, - let fetched = await MarkdownRemoteImageFetcher.fetch(remoteURL) else { + let fetched = await MarkdownRemoteImageFetcher().fetch(remoteURL) else { return ImageLoadResult(data: Data(), mimeType: "image/png") } return ImageLoadResult(data: fetched.data, mimeType: fetched.mimeType) diff --git a/Sources/RemoteTmuxTransportRegistry.swift b/Sources/RemoteTmuxTransportRegistry.swift index f14f2f9acc4e..99ad315a431c 100644 --- a/Sources/RemoteTmuxTransportRegistry.swift +++ b/Sources/RemoteTmuxTransportRegistry.swift @@ -11,7 +11,7 @@ import Foundation /// controller sequences around its own `await` gaps. @MainActor final class RemoteTmuxTransportRegistry { - private var transports: [String: RemoteTmuxSSHTransport] = [:] + var transports: [String: RemoteTmuxSSHTransport] = [:] /// Returns (creating if needed) the transport for a host. func transport(for host: RemoteTmuxHost) -> RemoteTmuxSSHTransport { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 4232b4fbf0fe..1c007e3ea4c8 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -1285,7 +1285,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// The noVNC URL uses only the VM private address. The private network is /// the access check, so no public preview token or endpoint is required. nonisolated static func privateDesktopURL(privateAddress: String) -> String { - let base = CmuxInternalHostnames.directPortURL( + let base = CmuxInternalHostnames().directPortURL( privateAddress: privateAddress, port: CmuxTuiSnapshotParser.desktopPort ) @@ -1319,7 +1319,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { updated.url = privateDesktopURL(privateAddress: privateAddress) case .browser: if resource.id.key.hasPrefix("port:"), let port = resource.port { - updated.url = CmuxInternalHostnames.directPortURL( + updated.url = CmuxInternalHostnames().directPortURL( privateAddress: privateAddress, port: port ) diff --git a/Sources/Surfaces/SurfaceCatalog+CloudPorts.swift b/Sources/Surfaces/SurfaceCatalog+CloudPorts.swift index 9aeea59eafd7..3825bbccd0a8 100644 --- a/Sources/Surfaces/SurfaceCatalog+CloudPorts.swift +++ b/Sources/Surfaces/SurfaceCatalog+CloudPorts.swift @@ -150,7 +150,7 @@ extension SurfaceCatalog { } let directURL = provider.info.privateAddress.map { - CmuxInternalHostnames.directPortURL(privateAddress: $0, port: port) + CmuxInternalHostnames().directPortURL(privateAddress: $0, port: port) } if var existing = resources[id] { // A machine address can be assigned after the first catalog pass. @@ -298,7 +298,7 @@ extension CmuxTuiSurfaceProvider { if let port = resource.id.forwardedPort { refreshed.port = port refreshed.url = privateAddress.map { - CmuxInternalHostnames.directPortURL(privateAddress: $0, port: port) + CmuxInternalHostnames().directPortURL(privateAddress: $0, port: port) } } return refreshed @@ -316,12 +316,12 @@ extension CmuxTuiSurfaceProvider { if var existing = previous[id] { existing.port = port existing.url = privateAddress.map { - CmuxInternalHostnames.directPortURL(privateAddress: $0, port: port) + CmuxInternalHostnames().directPortURL(privateAddress: $0, port: port) } return existing } let directURL = privateAddress.map { - CmuxInternalHostnames.directPortURL(privateAddress: $0, port: port) + CmuxInternalHostnames().directPortURL(privateAddress: $0, port: port) } return CmuxTuiSnapshotParser.portBrowser(machine: machine, port: port, directURL: directURL) } @@ -394,7 +394,7 @@ extension CmuxTuiSurfaceProvider { // direct URL so an address withdrawal cannot leave a stale link in // the catalog; the provider endpoint cache remains independent. if let privateAddress { - canonical.url = CmuxInternalHostnames.directPortURL(privateAddress: privateAddress, port: port) + canonical.url = CmuxInternalHostnames().directPortURL(privateAddress: privateAddress, port: port) } else { canonical.url = nil } diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift index 2d9e686f8a37..31331dd3947b 100644 --- a/cmuxTests/MarkdownPanelTests.swift +++ b/cmuxTests/MarkdownPanelTests.swift @@ -1447,130 +1447,130 @@ final class MarkdownPanelTests: XCTestCase { } XCTAssertTrue( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://example.com/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("http://example.com/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://user:pass@example.com/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://example.com:8443/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://localhost/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://127.0.0.1/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://10.0.0.2/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://172.16.0.1/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://192.168.1.1/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://169.254.169.254/latest/meta-data") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://[::1]/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://[fe80::1]/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://[fec0::1]/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://[fc00::1]/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isPotentiallySafeRemoteImageURL( + MarkdownRemoteImageSecurity().isPotentiallySafeRemoteImageURL( try url("https://[::127.0.0.1]/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isSafeRemoteImageURL( + MarkdownRemoteImageSecurity().isSafeRemoteImageURL( try url("https://2130706433/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isSafeRemoteImageURL( + MarkdownRemoteImageSecurity().isSafeRemoteImageURL( try url("https://0x7f000001/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isSafeRemoteImageURL( + MarkdownRemoteImageSecurity().isSafeRemoteImageURL( try url("https://127.1/image.png") ) ) XCTAssertFalse( - MarkdownRemoteImageSecurity.isSafeRemoteImageURL( + MarkdownRemoteImageSecurity().isSafeRemoteImageURL( try url("https://10.1/image.png") ) ) - let pinnedTargets = MarkdownRemoteImageSecurity.pinnedFetchTargets( + let pinnedTargets = MarkdownRemoteImageSecurity().pinnedFetchTargets( for: try url("https://1.1.1.1/image.png") ) XCTAssertEqual(pinnedTargets.count, 1) XCTAssertEqual(pinnedTargets.first?.serverName, "1.1.1.1") let approvedHost = try XCTUnwrap( - MarkdownRemoteImageSecurity.remoteImageConsentHost( + MarkdownRemoteImageSecurity().remoteImageConsentHost( for: try url("https://images.example.com/pixel.png") ) ) XCTAssertEqual( - MarkdownRemoteImageSecurity.remoteImageConsentHost( + MarkdownRemoteImageSecurity().remoteImageConsentHost( for: try url("https://images.example.com/redirected.png") ), approvedHost ) XCTAssertNotEqual( - MarkdownRemoteImageSecurity.remoteImageConsentHost( + MarkdownRemoteImageSecurity().remoteImageConsentHost( for: try url("https://cdn.example.com/redirected.png") ), approvedHost ) - XCTAssertEqual(MarkdownRemoteImageSecurity.canonicalImageMIMEType("image/png"), "image/png") - XCTAssertEqual(MarkdownRemoteImageSecurity.canonicalImageMIMEType("image/svg+xml"), "image/svg+xml") + XCTAssertEqual(MarkdownRemoteImageSecurity().canonicalImageMIMEType("image/png"), "image/png") + XCTAssertEqual(MarkdownRemoteImageSecurity().canonicalImageMIMEType("image/svg+xml"), "image/svg+xml") XCTAssertEqual( - MarkdownRemoteImageSecurity.canonicalImageMIMEType("image/svg+xml;charset=utf-8"), + MarkdownRemoteImageSecurity().canonicalImageMIMEType("image/svg+xml;charset=utf-8"), "image/svg+xml" ) let ipv6RequestBytes = try XCTUnwrap( - MarkdownRemoteImageSecurity.requestBytes( + MarkdownRemoteImageSecurity().requestBytes( for: try url("https://[2606:4700:4700::1111]/image.png"), host: "2606:4700:4700::1111" ) @@ -1588,20 +1588,20 @@ final class MarkdownPanelTests: XCTestCase { func testMarkdownRemoteImageChunkedDecoderRejectsOversizedChunks() { XCTAssertEqual( - MarkdownHTTPChunkedBodyDecoder.decode( + MarkdownHTTPChunkedBodyDecoder().decode( Data("3\r\nabc\r\n0\r\n\r\n".utf8), maximumBytes: 8 ), Data("abc".utf8) ) XCTAssertNil( - MarkdownHTTPChunkedBodyDecoder.decode( + MarkdownHTTPChunkedBodyDecoder().decode( Data("9\r\nabcdefghi\r\n0\r\n\r\n".utf8), maximumBytes: 8 ) ) XCTAssertNil( - MarkdownHTTPChunkedBodyDecoder.decode( + MarkdownHTTPChunkedBodyDecoder().decode( Data("7fffffffffffffff\r\n".utf8), maximumBytes: 8 ) diff --git a/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift index 40539417b32c..9dba37fe3d59 100644 --- a/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift +++ b/cmuxTests/RemoteTmuxMirrorCloseDetachTests.swift @@ -377,7 +377,6 @@ import Testing /// must leave the caller's current cmux window active. @Test func dedicatedWindowSocketDefaultsToFocusNeutral() async throws { let support = RemoteTmuxMirrorCloseDetachTests() - let sshOverrideKey = support.sshOverrideKey let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("remote-tmux-focus-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -394,9 +393,6 @@ import Testing exit 0 """ ) - let previousSSH = support.environmentValue(for: sshOverrideKey) - setenv(sshOverrideKey, sshURL.path, 1) - defer { support.restoreEnvironment(sshOverrideKey, previousValue: previousSSH) } let remoteTmuxKey = SettingCatalog().betaFeatures.remoteTmux.userDefaultsKey let previousRemoteTmux = UserDefaults.standard.object(forKey: remoteTmuxKey) UserDefaults.standard.set(true, forKey: remoteTmuxKey) @@ -415,6 +411,15 @@ import Testing harness.tearDown() } let host = RemoteTmuxHost(destination: "focus-\(UUID().uuidString)@example.test") + // Ghostty retains its startup environment buffer. Calling setenv after + // initialization can invalidate that buffer before this test's new + // window creates a native terminal. Inject only this host's transport. + let transport = RemoteTmuxSSHTransport( + host: host, + sshExecutablePath: sshURL.path + ) + harness.controller.transportRegistry.transports[host.connectionHash] = transport + #expect(harness.controller.transport(for: host) === transport) defer { harness.controller.detach(host: host, sessionName: "one") } harness.cacheConnection(host: host, session: "one") #expect(harness.appDelegate.focusMainWindow(windowId: harness.windowId)) diff --git a/cmuxTests/RestoreAdmissionRetryTests.swift b/cmuxTests/RestoreAdmissionRetryTests.swift index f1726c365206..e2b154a9236d 100644 --- a/cmuxTests/RestoreAdmissionRetryTests.swift +++ b/cmuxTests/RestoreAdmissionRetryTests.swift @@ -32,7 +32,7 @@ import Testing var slept: [TimeInterval] = [] var retriesAnnounced: [Int] = [] - let response = try AgentRestoreAdmissionRetry.response( + let response = try AgentRestoreAdmissionRetry().response( delays: [0.5, 1, 2], sleep: { slept.append($0) }, onRetry: { retriesAnnounced.append($0) }, @@ -55,7 +55,7 @@ import Testing var slept: [TimeInterval] = [] #expect(throws: AdmissionFailure.self) { - try AgentRestoreAdmissionRetry.response( + try AgentRestoreAdmissionRetry().response( delays: [0.5, 1], sleep: { slept.append($0) }, isRetryable: Self.isRetryable @@ -74,7 +74,7 @@ import Testing var slept: [TimeInterval] = [] #expect(throws: AdmissionFailure.self) { - try AgentRestoreAdmissionRetry.response( + try AgentRestoreAdmissionRetry().response( delays: [0.5, 1], sleep: { slept.append($0) }, isRetryable: Self.isRetryable @@ -89,10 +89,10 @@ import Testing @Test func defaultBudgetWaitsLongEnoughForAHookStormToSubside() { - let total = AgentRestoreAdmissionRetry.delaysSeconds.reduce(0, +) + let total = AgentRestoreAdmissionRetry().delaysSeconds.reduce(0, +) #expect(total >= 30) #expect(total <= 60) - #expect(AgentRestoreAdmissionRetry.delaysSeconds.first.map { $0 <= 1 } == true) + #expect(AgentRestoreAdmissionRetry().delaysSeconds.first.map { $0 <= 1 } == true) } } diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index e4f1f0b99344..5d6fe4dd6b29 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -21,7 +21,7 @@ final class NotificationService: UNNotificationServiceExtension { return } suppressOnExpiration = true - guard let installation = try? PhonePushKeyStore.current( + guard let installation = try? PhonePushKeyMaterial.current( bundleID: Bundle.main.object(forInfoDictionaryKey: "CMUXHostBundleIdentifier") as? String ?? "dev.cmux.ios", accessGroup: Bundle.main.object(forInfoDictionaryKey: "CMUXKeychainAccessGroup") as? String ) else { @@ -38,13 +38,13 @@ final class NotificationService: UNNotificationServiceExtension { finishSuppressed(content) return } - guard PhonePushActiveAccountStore.current() == envelope.tuple.accountID, - let sender = PhonePushPeerKeyStore.pinnedDescriptor(for: envelope.tuple), + guard PhonePushActiveAccountStore().current() == envelope.tuple.accountID, + let sender = PhonePushPeerKeyStore().pinnedDescriptor(for: envelope.tuple), let senderPublicKey = Optional(sender.publicKey) else { finishSuppressed(content) return } - guard let data = try? PhonePushCrypto.decrypt( + guard let data = try? PhonePushCrypto().decrypt( envelope: envelope, tuple: envelope.tuple, recipientInstallationID: installation.installationID, diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index 41fbd8acb336..c1ce50121695 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -236,7 +236,7 @@ final class AppCompositionRoot { let signingOutAccountID = auth.coordinator.currentUser?.id let signingOutScope = auth.coordinator.authenticatedTeamScope return { accessToken, refreshToken in - PhonePushActiveAccountStore.clear() + PhonePushActiveAccountStore().clear() await withTaskGroup(of: Void.self) { group in group.addTask { await pushCoordinator.unregisterFromServer( @@ -358,7 +358,7 @@ final class AppCompositionRoot { /// Bundle-owned build identity used in explicit diagnostic exports. /// Values come only from signed app metadata, never user input. static var diagnosticBuildStamp: String { - DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary) + DiagnosticReport.buildStamp(infoDictionary: Bundle.main.infoDictionary) } private static var crashReportingEnabled: Bool { diff --git a/ios/cmux/CmuxAppDelegate.swift b/ios/cmux/CmuxAppDelegate.swift index d8909fae9fe9..4c707ae4d882 100644 --- a/ios/cmux/CmuxAppDelegate.swift +++ b/ios/cmux/CmuxAppDelegate.swift @@ -192,7 +192,7 @@ final class CmuxAppDelegate: NSObject, @preconcurrency UIApplicationDelegate, UN return userInfo["cmux"] as? [String: Any] } guard - let installation = try? PhonePushKeyStore.current( + let installation = try? PhonePushKeyMaterial.current( bundleID: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", accessGroup: configuredKeychainAccessGroup() ) else { return nil } @@ -203,9 +203,9 @@ final class CmuxAppDelegate: NSObject, @preconcurrency UIApplicationDelegate, UN && $0.tuple.iosInstallationID == installation.installationID && $0.tuple.iosBuildID == (Bundle.main.bundleIdentifier ?? "dev.cmux.ios") }), - PhonePushActiveAccountStore.current() == envelope.tuple.accountID, - let sender = PhonePushPeerKeyStore.pinnedDescriptor(for: envelope.tuple), - let data = try? PhonePushCrypto.decrypt( + PhonePushActiveAccountStore().current() == envelope.tuple.accountID, + let sender = PhonePushPeerKeyStore().pinnedDescriptor(for: envelope.tuple), + let data = try? PhonePushCrypto().decrypt( envelope: envelope, tuple: envelope.tuple, recipientInstallationID: installation.installationID, diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index 112aa4f307b6..d72e32444cf8 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -1,5 +1,6 @@ import CMUXMobileCore import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileSupport import CmuxMobileTransport import Foundation @@ -41,6 +42,7 @@ struct cmuxApp: App { ) let v2Configuration = MobileIrohV2Configuration.current(projectID: auth.config.stack.projectId) let irx = MobileIrxRuntimeComposition(configuration: v2Configuration, + macListAuthState: MobileMacListAuthState(), keychainAccessGroup: auth.keychainAccessGroup) Task { await irx.configure(auth: auth.coordinator) } @@ -173,9 +175,11 @@ struct cmuxApp: App { private var mobileRootScene: CMUXMobileRootScene { CMUXMobileRootScene( runtime: Self.root.runtime, + macListAuthState: Self.root.irx.macListAuthState, auth: Self.root.auth, reachability: Self.root.reachability, analytics: Self.root.analytics.emitter, + analyticsClientID: Self.root.analytics.anonymousID, terminalLatencyObserver: Self.root.analytics.terminalLatencyReporter, pushCoordinator: Self.root.pushCoordinator, displaySettings: Self.root.displaySettings, diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 07eb17f5db3e..c1ede88f3fd4 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -35,9 +35,11 @@ private let mobileRootSceneLog = Logger(subsystem: "dev.cmux.ios", category: "mo /// `@Environment` instead of `AuthManager.shared`. public struct CMUXMobileRootScene: View { private let runtime: CMUXMobileRuntime + private let macListAuthState: MobileMacListAuthState private let auth: MobileAuthComposition private let reachability: any ReachabilityProviding private let analytics: any AnalyticsEmitting + private let analyticsClientID: String? private let terminalLatencyObserver: any MobileTerminalLatencyObserving package let signOutHook: MobileSignOutHook private let personalIrohRouteCatalog: MobileIrohRouteCatalog? @@ -139,9 +141,11 @@ public struct CMUXMobileRootScene: View { /// Diagnostics export. public init( runtime: CMUXMobileRuntime, + macListAuthState: MobileMacListAuthState? = nil, auth: MobileAuthComposition, reachability: any ReachabilityProviding, analytics: any AnalyticsEmitting, + analyticsClientID: String? = nil, terminalLatencyObserver: any MobileTerminalLatencyObserving = NoopMobileTerminalLatencyObserver(), pushCoordinator: MobilePushCoordinator, displaySettings: MobileDisplaySettings, @@ -160,9 +164,11 @@ public struct CMUXMobileRootScene: View { v2Configuration: MobileIrohV2Configuration? = nil ) { self.runtime = runtime + self.macListAuthState = macListAuthState ?? MobileMacListAuthState() self.auth = auth self.reachability = reachability self.analytics = analytics + self.analyticsClientID = analyticsClientID self.terminalLatencyObserver = terminalLatencyObserver self.pushCoordinator = pushCoordinator self.displaySettings = displaySettings @@ -197,16 +203,20 @@ public struct CMUXMobileRootScene: View { /// Creates the root scene (non-iOS: no push). public init( runtime: CMUXMobileRuntime, + macListAuthState: MobileMacListAuthState? = nil, auth: MobileAuthComposition, reachability: any ReachabilityProviding, analytics: any AnalyticsEmitting, + analyticsClientID: String? = nil, buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy, signOutHook: MobileSignOutHook = MobileSignOutHook() ) { self.runtime = runtime + self.macListAuthState = macListAuthState ?? MobileMacListAuthState() self.auth = auth self.reachability = reachability self.analytics = analytics + self.analyticsClientID = analyticsClientID self.terminalLatencyObserver = NoopMobileTerminalLatencyObserver() self.signOutHook = signOutHook self.personalIrohRouteCatalog = nil @@ -356,8 +366,9 @@ public struct CMUXMobileRootScene: View { // window and the ToastCenter environment. .toastHost(toastCenter, haptics: displaySettings.haptics) .environment(auth.coordinator) + .environment(macListAuthState) .analytics(analytics) - .analyticsClientID(analytics.anonymousID) + .analyticsClientID(analyticsClientID) .environment(\.mobileDiagnosticLog, diagnosticLog) .environment(\.mobileAppLog, appLog) .tailscaleStatusMonitor(tailscaleStatusMonitor) @@ -439,7 +450,7 @@ public struct CMUXMobileRootScene: View { let accessGroup = auth.keychainAccessGroup return MobilePhonePushKeyExchangeHooks( makeDescriptor: { - let key = try PhonePushKeyStore.current( + let key = try PhonePushKeyMaterial.current( bundleID: bundleID, accessGroup: accessGroup ) @@ -460,7 +471,7 @@ public struct CMUXMobileRootScene: View { macInstanceTag: context.macInstanceTag, macBuildID: context.macBuildID ) - PhonePushPeerKeyStore.pin( + PhonePushPeerKeyStore().pin( descriptor.publicKey, keyID: descriptor.keyID, for: tuple @@ -514,6 +525,7 @@ public struct CMUXMobileRootScene: View { #endif let store = CMUXMobileShellStore( runtime: runtime, + macListAuthState: macListAuthState, pairedMacStore: backedUpPairedMacStore, connectionMethodStore: connectionMethodStore, buildCompatibilityPolicy: buildCompatibilityPolicy, diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift index 0471aba9a2d3..f94f4e548859 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift @@ -174,7 +174,7 @@ public struct MobileAuthComposition { isTokenStorageAvailable: { await MainActor.run { availability.isAvailable } }, onSignedIn: { await deferredSignIn.run() } ) - let pushIdentity = try? PhonePushKeyStore.current( + let pushIdentity = try? PhonePushKeyMaterial.current( bundleID: bundle.bundleIdentifier ?? "", accessGroup: keychainAccessGroup ) @@ -187,7 +187,7 @@ public struct MobileAuthComposition { pushKeyID: pushIdentity?.keyID, pushPublicKey: pushIdentity?.publicKeyData.base64EncodedString(), pushIdentityProvider: { - guard let identity = try? PhonePushKeyStore.current( + guard let identity = try? PhonePushKeyMaterial.current( bundleID: bundle.bundleIdentifier ?? "", accessGroup: keychainAccessGroup ) else { return nil } @@ -202,7 +202,7 @@ public struct MobileAuthComposition { deferredSignIn.set { let accountID = await MainActor.run { coordinator.currentUser?.id } if let accountID { - PhonePushActiveAccountStore.set(accountID) + PhonePushActiveAccountStore().set(accountID) } await push.syncTokenIfPossible() } @@ -225,9 +225,9 @@ public struct MobileAuthComposition { protectedDataAvailability.startObserving { [coordinator, taskOwner, pushRegistration] in taskOwner.revalidateSession(using: coordinator) { if let accountID = coordinator.currentUser?.id { - PhonePushActiveAccountStore.set(accountID) + PhonePushActiveAccountStore().set(accountID) } else { - PhonePushActiveAccountStore.clear() + PhonePushActiveAccountStore().clear() } Task { await pushRegistration.syncTokenIfPossible() @@ -438,7 +438,7 @@ public struct MobileAuthComposition { } private static func keychainAccessGroup(in bundle: Bundle) -> String? { - MobileKeychainAccessGroupPolicy.resolve( + String.cmuxKeychainAccessGroup(from: bundle.object(forInfoDictionaryKey: "CMUXKeychainAccessGroup") as? String ) } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Dial.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Dial.swift index a0538df89015..a8fe42d34d66 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Dial.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Dial.swift @@ -156,7 +156,7 @@ extension MobileIrxRuntimeComposition { let connection = try await supervisor.dial(address: address, credentials: credentials) do { try await assertScope(scope, epoch: currentEpoch) - let (admit, control) = try await IrxAdmission.performClient(connection: connection, journal: journal) + let (admit, control) = try await IrxAdmission().performClient(connection: connection, journal: journal) try await assertScope(scope, epoch: currentEpoch) await connection.raiseRemoteStreamCredit(bi: 0, uni: 4) if !forceRelayOnly, case .automatic = intent { await connection.authorizeDirectPaths() } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Directory.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Directory.swift index d7fcd102b17f..483f232ee1c6 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Directory.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Directory.swift @@ -39,7 +39,7 @@ extension MobileIrxRuntimeComposition { guard let auth else { return } await MainActor.run { guard auth.isAuthenticatedTeamScopeCurrent(scope) else { return } - MobileMacListAuthState.shared.replace(entriesByIdentity: entries) + self.macListAuthState.replace(entriesByIdentity: entries) } } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Lifecycle.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Lifecycle.swift index bdf2ea84184c..533294b0457f 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Lifecycle.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition+Lifecycle.swift @@ -158,7 +158,7 @@ extension MobileIrxRuntimeComposition { lastFailure = snapshot.failure.map { String(describing: $0) } publish() if snapshot.cache.authorityRevoked { - await MainActor.run { MobileMacListAuthState.shared.clear() } + await MainActor.run { self.macListAuthState.clear() } guard (try? await assertScope(scope, epoch: currentEpoch)) != nil else { return } let engines = Array(enginesByPeer.values) let supervisor = endpointSupervisor @@ -275,7 +275,7 @@ extension MobileIrxRuntimeComposition { enginesByPeer.removeAll(); dialIntentByPeer.removeAll(); activeDialIntentByPeer.removeAll() expectedDeviceIDByPeer.removeAll(); controlLaneClaims.removeAll(); claimedEventSessions.removeAll() publish() - await MainActor.run { MobileMacListAuthState.shared.clear() } + await MainActor.run { self.macListAuthState.clear() } return DetachedRuntime( control: oldControl, endpointSupervisor: oldSupervisor, diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift index 57ec3299d599..00a0cd2f3f02 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrxRuntimeComposition.swift @@ -2,6 +2,7 @@ public import CMUXMobileCore import CmuxAuthRuntime public import CmuxIrohTransport import CmuxIrxTransport +public import CmuxMobileShellModel public import CmuxMobileTransport public import Foundation @@ -15,6 +16,7 @@ public actor MobileIrxRuntimeComposition { case direct([CmxIrohDirectDialCandidate]) } + public nonisolated let macListAuthState: MobileMacListAuthState public nonisolated let configuration: MobileIrohV2Configuration public nonisolated var tag: String { configuration.buildTag } public nonisolated var forceRelayOnly: Bool { configuration.forceRelayOnly } @@ -55,8 +57,9 @@ public actor MobileIrxRuntimeComposition { var admittedSessionCount = 0 /// Dependencies are owned here; authentication is supplied later without copying its persistence. - public init(configuration: MobileIrohV2Configuration, keychainAccessGroup: String? = nil, + public init(configuration: MobileIrohV2Configuration, macListAuthState: MobileMacListAuthState, keychainAccessGroup: String? = nil, session: URLSession = .shared) { + self.macListAuthState = macListAuthState self.configuration = configuration self.urlSession = session localPaths = MobileIrohV2LocalPathStore(root: configuration.stateDirectory) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileKeychainAccessGroupPolicy.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileKeychainAccessGroupPolicy.swift index 329122c156ea..96398ae7724b 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileKeychainAccessGroupPolicy.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileKeychainAccessGroupPolicy.swift @@ -10,8 +10,8 @@ import Foundation /// single broker fetch. Resolving such a value to nil leaves /// kSecAttrAccessGroup unset, and SecItem then uses the app's default /// entitlement access group, which is the group the signature actually grants. -enum MobileKeychainAccessGroupPolicy { - static func resolve(_ raw: String?) -> String? { +extension String { + static func cmuxKeychainAccessGroup(from raw: String?) -> String? { guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrxRuntimeLifecycleTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrxRuntimeLifecycleTests.swift index 3e44725acbe4..f906901a809c 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrxRuntimeLifecycleTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileIrxRuntimeLifecycleTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import CmuxAuthRuntime +import CmuxMobileShellModel @testable import CmuxIrxTransport @testable import cmuxFeature @@ -8,7 +9,7 @@ import CmuxAuthRuntime struct MobileIrxRuntimeLifecycleTests { @Test func endpointReadyPublishesRuntimeChanges() async { - let composition = makeComposition() + let composition = await makeComposition() let updates = await composition.changes() var initial = updates.makeAsyncIterator() _ = await initial.next() @@ -32,7 +33,7 @@ struct MobileIrxRuntimeLifecycleTests { @Test(arguments: [false, true]) func nextScopeDoesNotWaitForOldSocketClose(signOutHook: Bool) async throws { - let composition = makeComposition() + let composition = await makeComposition() let previous = scope(generation: 1) let next = scope(generation: 2) let started = AsyncStream.makeStream() @@ -100,6 +101,7 @@ struct MobileIrxRuntimeLifecycleTests { teamID: "team", generation: generation) } + @MainActor private func makeComposition() -> MobileIrxRuntimeComposition { MobileIrxRuntimeComposition( configuration: MobileIrohV2Configuration( @@ -112,7 +114,8 @@ struct MobileIrxRuntimeLifecycleTests { displayName: "Test", stateDirectory: FileManager.default.temporaryDirectory .appendingPathComponent("cmux-iroh-runtime-tests-\(UUID().uuidString)") - ) + ), + macListAuthState: MobileMacListAuthState() ) } } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileKeychainAccessGroupResolutionTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileKeychainAccessGroupResolutionTests.swift index 24ce5cefadc8..945483c39534 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileKeychainAccessGroupResolutionTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/MobileKeychainAccessGroupResolutionTests.swift @@ -5,28 +5,28 @@ import Testing struct MobileKeychainAccessGroupPolicyTests { @Test func acceptsDevTagAndProductionGroups() { - #expect(MobileKeychainAccessGroupPolicy.resolve( + #expect(String.cmuxKeychainAccessGroup(from: "7WLXT3NR37.dev.cmux.ios.tflex" ) == "7WLXT3NR37.dev.cmux.ios.tflex") - #expect(MobileKeychainAccessGroupPolicy.resolve( + #expect(String.cmuxKeychainAccessGroup(from: "7WLXT3NR37.com.cmux.app" ) == "7WLXT3NR37.com.cmux.app") } @Test func trimsWhitespaceAroundAValidGroup() { - #expect(MobileKeychainAccessGroupPolicy.resolve( + #expect(String.cmuxKeychainAccessGroup(from: " 7WLXT3NR37.dev.cmux.app.beta\n" ) == "7WLXT3NR37.dev.cmux.app.beta") } @Test func rejectsPrefixLessNilAndMalformedValues() { - #expect(MobileKeychainAccessGroupPolicy.resolve("dev.cmux.app.beta") == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve(nil) == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve("") == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve(".dev.cmux.app.beta") == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve( + #expect(String.cmuxKeychainAccessGroup(from: "dev.cmux.app.beta") == nil) + #expect(String.cmuxKeychainAccessGroup(from: nil) == nil) + #expect(String.cmuxKeychainAccessGroup(from: "") == nil) + #expect(String.cmuxKeychainAccessGroup(from: ".dev.cmux.app.beta") == nil) + #expect(String.cmuxKeychainAccessGroup(from: "$(AppIdentifierPrefix)dev.cmux.app.beta" ) == nil) } @@ -35,9 +35,9 @@ struct MobileKeychainAccessGroupPolicyTests { func rejectsEmptyBundleComponentsAfterTheTeamIdentifier() { // An empty interior or trailing component is a bake gone wrong, not a // grantable group; it must fall back rather than resolve. - #expect(MobileKeychainAccessGroupPolicy.resolve("7WLXT3NR37..dev") == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve("7WLXT3NR37.dev.") == nil) - #expect(MobileKeychainAccessGroupPolicy.resolve( + #expect(String.cmuxKeychainAccessGroup(from: "7WLXT3NR37..dev") == nil) + #expect(String.cmuxKeychainAccessGroup(from: "7WLXT3NR37.dev.") == nil) + #expect(String.cmuxKeychainAccessGroup(from: "7WLXT3NR37.dev..cmux.app.beta" ) == nil) } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 8a984348c94b..615d00cae2c9 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -1104,6 +1104,7 @@ final class TerminalOutputCollector { ) let responses = ScriptedTransportResponses([ try rpcWorkspaceListFrame(workspaceID: workspaceID, title: "Scoped Workspace"), + try rpcHostStatusFrame(renderGrid: false, macDeviceID: ticket.macDeviceID), ]) let runtime = testRuntime( supportedRouteKinds: [.debugLoopback], @@ -1112,9 +1113,10 @@ final class TerminalOutputCollector { let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() - await store.connectPairingURL(try attachURL(for: ticket).absoluteString) + let connected = await store.connectPairingURL(try attachURL(for: ticket).absoluteString) let requests = try await responses.sentRequests() + try #require(connected, Comment(rawValue: "Connection failed: \(store.connectionError ?? "unknown"); methods: \(requests.compactMap(\.method))")) let workspaceList = try #require(requests.first { $0.method == "workspace.list" }) #expect(workspaceList.workspaceID == nil) #expect(workspaceList.attachToken == "ticket-secret") diff --git a/scripts/lint-ios-package-conventions.sh b/scripts/lint-ios-package-conventions.sh index e0b1c85b5ccc..60ad940af8d0 100755 --- a/scripts/lint-ios-package-conventions.sh +++ b/scripts/lint-ios-package-conventions.sh @@ -62,7 +62,7 @@ scan() { # rule severity pattern carveout(0/1) pathspec... if report "$rule" "$sev" "$f" "$n" "$(echo "$text" | sed 's/^[[:space:]]*//' | cut -c1-90)"; then [ "$sev" = ERROR ] && fail=1 fi - done < <(grep -rnE "$pat" "$@" --include='*.swift' 2>/dev/null) + done < <(grep -rnE "$pat" "$@" --include='*.swift' --exclude-dir=.build 2>/dev/null) } echo "== singletons (no shared-singleton accessors) ==" @@ -90,232 +90,16 @@ scan global WARN '\b(UserDefaults\.standard|FileManager\.default|Bundle\.main)\b echo "== free functions (scope functionality to a type) ==" scan free-function ERROR '^(@[A-Za-z()_ ]+ )?(public |internal |package |private |fileprivate )?func [a-zA-Z]' 0 "${SCOPES[@]}" -echo "== namespace-enums (caseless enum with static members) ==" -while IFS= read -r f; do - case "$f" in */Tests/*|*Tests.swift|*/.build/*) continue ;; esac - python3 - "$BASELINE_FILE" "$f" <<'PY' -import os -import re, sys -baseline_path = sys.argv[1] -path = sys.argv[2] -baseline = set() -if os.path.exists(baseline_path): - for raw in open(baseline_path, encoding="utf-8"): - entry = raw.rstrip("\n") - if entry and not entry.startswith("#"): - baseline.add(entry) -src = open(path).read() -for m in re.finditer(r'(?:public\s+|package\s+)?enum\s+(\w+)[^{]*\{', src): - name = m.group(1) - i, depth = m.end(), 1 - while i < len(src) and depth: - depth += src[i] == '{' - depth -= src[i] == '}' - i += 1 - body = src[m.end():i] - # Only DECLARATION-level cases count: strip nested {...} bodies first so - # switch-statement cases inside member funcs don't mask a caseless enum. - top, depth = [], 0 - for ch in body: - if ch == '{': - depth += 1 - elif ch == '}': - depth -= 1 - elif depth == 0: - top.append(ch) - top_level = ''.join(top) - if not re.search(r'(^|\n)\s*(indirect\s+)?case\s', top_level) and 'static' in body: - head = src[:m.start()] - ctx = head[head.rfind('\n', 0, head.rfind('\n'))+1:] - if 'lint:allow' in ctx: - continue - if f'namespace-enum\t{path}\t{name}' in baseline: - continue - line = head.count('\n') + 1 - print(f'ERROR namespace-enum {path}:{line} enum {name} (caseless, static members) -> scope onto the owning type') -PY -done < <(grep -rlE '\benum [A-Z]' "${SCOPES[@]}" --include='*.swift' 2>/dev/null) | tee /tmp/.lint-ns-enum-$$ -grep -q ERROR /tmp/.lint-ns-enum-$$ 2>/dev/null && fail=1 -rm -f /tmp/.lint-ns-enum-$$ - -echo "== namespace-types (static-only public types; instantiate or extend the receiver) ==" -# Owner rule: types must never act as namespaces. Flagged: a public -# struct/class/actor whose entire public surface is static members and which -# cannot be meaningfully instantiated (private init, or no instance surface at -# all), and a caseless public enum of statics. Receiver-natural pure -# transforms belong in an extension on the receiver type; dependency-bearing -# logic belongs on an instantiated value with the dependency injected. -# Statics that are constants/factories OF a real value type are never flagged -# because such a type has instance surface. SwiftUI static-requirement key -# conformances (PreferenceKey/EnvironmentKey/...) are exempt automatically. -# Sanctioned exceptions (C/FFI trampoline holders, GhosttyRuntimeCInterop-style -# seams) carry a one-line justification on the decl line or up to 3 lines -# above, e.g. "lint:allow namespace-type — ". Unlike the per-line rules -# above, this rule scans every package in the repo, not just the iOS line. -# Pre-existing offenders in packages owned by other refactor sessions are -# grandfathered in scripts/lint-namespace-types-baseline.txt; that list may -# only shrink. +echo "== namespace-enums and namespace-types ==" NS_TYPE_ROOTS=() for d in Packages/*/*/Sources ios/cmuxPackage/Sources ios/cmux; do [ -d "$d" ] && NS_TYPE_ROOTS+=("$d") done -if ! python3 - scripts/lint-namespace-types-baseline.txt "$BASELINE_FILE" "${NS_TYPE_ROOTS[@]}" <<'PY' -import os -import re -import sys - -baseline_path = sys.argv[1] -general_baseline_path = sys.argv[2] -roots = sys.argv[3:] - -baseline = set() -if os.path.exists(baseline_path): - for raw in open(baseline_path, encoding="utf-8"): - entry = raw.strip() - if entry and not entry.startswith("#"): - baseline.add(entry) - -general_baseline = set() -if os.path.exists(general_baseline_path): - for raw in open(general_baseline_path, encoding="utf-8"): - entry = raw.rstrip("\n") - if entry and not entry.startswith("#"): - general_baseline.add(entry) - -DECL = re.compile( - r"(?m)^(?P[ \t]*)(?P(?:@\w+(?:\([^)]*\))?[ \t]+)*" - r"(?:(?:public|package|internal|open|final|private|fileprivate)[ \t]+)*" - r"(?Pstruct|class|enum|actor)[ \t]+(?P\w+))" -) -EXT = re.compile(r"(?m)^[ \t]*(?:@\w+(?:\([^)]*\))?[ \t]+)*" - r"(?:(?:public|package|internal|private|fileprivate)[ \t]+)?" - r"extension[ \t]+(?P[\w.]+)") -MEMBER = re.compile( - r"\b(case|init|func|var|let|subscript|struct|class|enum|actor|typealias)\b" -) -MARKER = re.compile(r"lint:allow|TRANSITIONAL|carve-out|justification|sanctioned", re.I) -# Protocols whose requirements are static by design; conformers are -# intentionally never instantiated. -STATIC_KEY_PROTOCOLS = re.compile( - r"\b(PreferenceKey|EnvironmentKey|FocusedValueKey|LayoutValueKey|" - r"TransactionKey|ContainerValueKey|EntryKey)\b" -) - - -def body_and_end(src, brace): - depth, i = 1, brace + 1 - while i < len(src) and depth: - if src[i] == "{": - depth += 1 - elif src[i] == "}": - depth -= 1 - i += 1 - return src[brace + 1:i - 1] - - -def depth_prefix(s): - d, out = 0, [] - for ch in s: - out.append(d) - d += (ch == "{") - (ch == "}") - return out - - -def tally(body, counts): - dp = depth_prefix(body) - for mm in MEMBER.finditer(body): - if dp[mm.start()] != 0: - continue - kw = mm.group(0) - line_start = body.rfind("\n", 0, mm.start()) + 1 - prefix = body[line_start:mm.start()] - if "//" in prefix: - continue - if kw in ("struct", "enum", "actor", "typealias"): - continue - if kw == "class": - continue # nested class decl; `class func/var` is seen by func/var - if kw == "case": - counts["cases"] += 1 - elif kw == "init": - if re.search(r"\b(private|fileprivate)\b", prefix): - counts["private_inits"] += 1 - else: - counts["open_inits"] += 1 - elif re.search(r"\b(static|class)\b", prefix): - counts["statics"] += 1 - else: - counts["instances"] += 1 - - -fail = False -for root in roots: - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in (".build", "Tests")] - for fn in sorted(filenames): - if not fn.endswith(".swift") or fn.endswith("Tests.swift"): - continue - path = os.path.join(dirpath, fn) - src = open(path, encoding="utf-8", errors="replace").read() - lines = src.split("\n") - - ext_counts = {} - for em in EXT.finditer(src): - brace = src.find("{", em.end()) - if brace < 0: - continue - name = em.group("name").split(".")[-1] - counts = ext_counts.setdefault( - name, - {"cases": 0, "private_inits": 0, "open_inits": 0, - "statics": 0, "instances": 0}, - ) - tally(body_and_end(src, brace), counts) - - for m in DECL.finditer(src): - head = m.group("head") - if "public" not in head and "package" not in head: - continue - brace = src.find("{", m.end()) - if brace < 0: - continue - conformances = src[m.end():brace] - if STATIC_KEY_PROTOCOLS.search(conformances): - continue - counts = {"cases": 0, "private_inits": 0, "open_inits": 0, - "statics": 0, "instances": 0} - tally(body_and_end(src, brace), counts) - ext = ext_counts.get(m.group("name")) - if ext: - for key in counts: - counts[key] += ext[key] - if counts["statics"] == 0 or counts["instances"] > 0: - continue - if m.group("kind") == "enum": - if counts["cases"] > 0: - continue - else: - if counts["open_inits"] > 0: - continue - line = src.count("\n", 0, m.start()) + 1 - ctx = "\n".join(lines[max(0, line - 4):line]) - if MARKER.search(ctx): - continue - if f"{path}:{m.group('name')}" in baseline: - continue - if f"namespace-type\t{path}\t{m.group('name')}" in general_baseline: - continue - print( - f"ERROR namespace-type {path}:{line} " - f"{m.group('kind')} {m.group('name')} (all-static public " - "surface, not instantiable) -> extension on the receiver " - "type or an instantiated value with injected dependencies" - ) - fail = True - -sys.exit(1 if fail else 0) -PY -then +if ! python3 scripts/lint_swift_namespaces.py \ + --baseline scripts/lint-namespace-types-baseline.txt \ + --general-baseline "$BASELINE_FILE" \ + --enum-roots "${SCOPES[@]}" \ + --type-roots "${NS_TYPE_ROOTS[@]}"; then fail=1 fi diff --git a/scripts/lint-stored-dispatch-work-items.py b/scripts/lint-stored-dispatch-work-items.py index 4a2519312ce7..da2f5536cf17 100755 --- a/scripts/lint-stored-dispatch-work-items.py +++ b/scripts/lint-stored-dispatch-work-items.py @@ -114,14 +114,6 @@ class RequiredSourceRootMissingError(RuntimeError): 1, "function-local, single-shot UI-test deadline", ), - Allowance( - "Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/MarkdownViewer/MarkdownRemoteImageLoader.swift", - "timeoutWorkItem", - "DispatchWorkItem?", - "member:MarkdownPinnedRemoteImageLoader", - 1, - "single-shot network timeout protected by finish()", - ), Allowance( "Sources/TabManager.swift", "timeoutWork", diff --git a/scripts/lint_swift_namespaces.py b/scripts/lint_swift_namespaces.py new file mode 100644 index 000000000000..3aa054b65871 --- /dev/null +++ b/scripts/lint_swift_namespaces.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Check Swift namespace declarations without interpreting literal/comment braces.""" + +import os +import re +import sys +from swift_source_mask import mask_swift_source + +import argparse +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--baseline', required=True) +parser.add_argument('--general-baseline', required=True) +parser.add_argument('--enum-roots', nargs='+', required=True) +parser.add_argument('--type-roots', nargs='+', required=True) +args = parser.parse_args() +baseline_path = args.baseline +general_baseline_path = args.general_baseline +roots = args.type_roots +enum_roots = [Path(root) for root in args.enum_roots] + +baseline = set() +if os.path.exists(baseline_path): + for raw in open(baseline_path, encoding="utf-8"): + entry = raw.strip() + if entry and not entry.startswith("#"): + baseline.add(entry) + +general_baseline = set() +if os.path.exists(general_baseline_path): + for raw in open(general_baseline_path, encoding="utf-8"): + entry = raw.rstrip("\n") + if entry and not entry.startswith("#"): + general_baseline.add(entry) + +DECL = re.compile( + r"(?m)^(?P[ \t]*)(?P(?:@\w+(?:\([^)]*\))?[ \t]+)*" + r"(?:(?:public|package|internal|open|final|private|fileprivate)[ \t]+)*" + r"(?Pstruct|class|enum|actor)[ \t]+(?P\w+))" +) +EXT = re.compile(r"(?m)^[ \t]*(?:@\w+(?:\([^)]*\))?[ \t]+)*" + r"(?:(?:public|package|internal|private|fileprivate)[ \t]+)?" + r"extension[ \t]+(?P[\w.]+)") +MEMBER = re.compile( + r"\b(case|init|func|var|let|subscript|struct|class|enum|actor|typealias)\b" +) +MARKER = re.compile(r"lint:allow|TRANSITIONAL|carve-out|justification|sanctioned", re.I) +# Protocols whose requirements are static by design; conformers are +# intentionally never instantiated. +STATIC_KEY_PROTOCOLS = re.compile( + r"\b(PreferenceKey|EnvironmentKey|FocusedValueKey|LayoutValueKey|" + r"TransactionKey|ContainerValueKey|EntryKey)\b" +) + + +def body_and_end(src, brace): + depth, i = 1, brace + 1 + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[brace + 1:i - 1] + + +def depth_prefix(s): + d, out = 0, [] + for ch in s: + out.append(d) + d += (ch == "{") - (ch == "}") + return out + + +def tally(body, counts): + dp = depth_prefix(body) + for mm in MEMBER.finditer(body): + if dp[mm.start()] != 0: + continue + kw = mm.group(0) + line_start = body.rfind("\n", 0, mm.start()) + 1 + prefix = body[line_start:mm.start()] + if "//" in prefix: + continue + if kw in ("struct", "enum", "actor", "typealias"): + continue + if kw == "class": + continue # nested class decl; `class func/var` is seen by func/var + if kw == "case": + counts["cases"] += 1 + elif kw == "init": + if re.search(r"\b(private|fileprivate)\b", prefix): + counts["private_inits"] += 1 + else: + counts["open_inits"] += 1 + elif re.search(r"\b(static|class)\b", prefix): + counts["statics"] += 1 + else: + counts["instances"] += 1 + + +fail = False +for root in roots: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in (".build", "Tests")] + for fn in sorted(filenames): + if not fn.endswith(".swift") or fn.endswith("Tests.swift"): + continue + path = os.path.join(dirpath, fn) + original = open(path, encoding="utf-8", errors="replace").read() + src = mask_swift_source(original) + lines = original.split("\n") + + if any(Path(path).is_relative_to(root) for root in enum_roots): + for enum in re.finditer(r'\benum\s+(\w+)[^{]*\{', src): + name = enum.group(1) + body = body_and_end(src, enum.end() - 1) + depths = depth_prefix(body) + top = ''.join(ch if depths[i] == 0 else ' ' for i, ch in enumerate(body)) + if re.search(r'(^|\n)\s*(indirect\s+)?case\s', top) or 'static' not in body: + continue + line = src.count('\n', 0, enum.start()) + 1 + context = '\n'.join(lines[max(0, line - 3):line]) + if 'lint:allow' in context or f'namespace-enum\t{path}\t{name}' in general_baseline: + continue + print(f'ERROR namespace-enum {path}:{line} enum {name} (caseless, static members) -> scope onto the owning type') + fail = True + + ext_counts = {} + for em in EXT.finditer(src): + brace = src.find("{", em.end()) + if brace < 0: + continue + name = em.group("name").split(".")[-1] + counts = ext_counts.setdefault( + name, + {"cases": 0, "private_inits": 0, "open_inits": 0, + "statics": 0, "instances": 0}, + ) + tally(body_and_end(src, brace), counts) + + for m in DECL.finditer(src): + head = m.group("head") + if "public" not in head and "package" not in head: + continue + brace = src.find("{", m.end()) + if brace < 0: + continue + conformances = src[m.end():brace] + if STATIC_KEY_PROTOCOLS.search(conformances): + continue + counts = {"cases": 0, "private_inits": 0, "open_inits": 0, + "statics": 0, "instances": 0} + tally(body_and_end(src, brace), counts) + ext = ext_counts.get(m.group("name")) + if ext: + for key in counts: + counts[key] += ext[key] + if counts["statics"] == 0 or counts["instances"] > 0: + continue + if m.group("kind") == "enum": + if counts["cases"] > 0: + continue + else: + if counts["open_inits"] > 0: + continue + line = src.count("\n", 0, m.start()) + 1 + ctx = "\n".join(lines[max(0, line - 4):line]) + if MARKER.search(ctx): + continue + if f"{path}:{m.group('name')}" in baseline: + continue + if f"namespace-type\t{path}\t{m.group('name')}" in general_baseline: + continue + print( + f"ERROR namespace-type {path}:{line} " + f"{m.group('kind')} {m.group('name')} (all-static public " + "surface, not instantiable) -> extension on the receiver " + "type or an instantiated value with injected dependencies" + ) + fail = True + +sys.exit(1 if fail else 0) diff --git a/scripts/swift_source_mask.py b/scripts/swift_source_mask.py new file mode 100644 index 000000000000..955dd9feaafd --- /dev/null +++ b/scripts/swift_source_mask.py @@ -0,0 +1,78 @@ +"""Hide Swift comments and string contents while preserving source offsets.""" + +import re + +_STRING_START = re.compile(r'(\#*)("""|")') + + +def _comment_end(source, index): + if source.startswith("//", index): + end = source.find("\n", index) + return len(source) if end < 0 else end + depth = 1 + index += 2 + while index < len(source) and depth: + if source.startswith("/*", index): + depth += 1 + index += 2 + elif source.startswith("*/", index): + depth -= 1 + index += 2 + else: + index += 1 + return index + + +def _interpolation_end(source, index): + depth = 1 + while index < len(source) and depth: + if source.startswith(("//", "/*"), index): + index = _comment_end(source, index) + elif literal := _STRING_START.match(source, index): + index = _string_end(source, literal) + else: + depth += (source[index] == "(") - (source[index] == ")") + index += 1 + return index + + +def _string_end(source, literal): + hashes, quotes = literal.groups() + index = literal.end() + closing = quotes + hashes + escape = "\\" + hashes + while index < len(source): + if source.startswith(escape, index): + index += len(escape) + if source.startswith("(", index): + index = _interpolation_end(source, index + 1) + else: + index += 1 + elif source.startswith(closing, index): + return index + len(closing) + else: + index += 1 + return min(index, len(source)) + + +def mask_swift_source(source): + masked = list(source) + + def hide(start, end): + for index in range(start, end): + if masked[index] != "\n": + masked[index] = " " + + index = 0 + while index < len(source): + start = index + if source.startswith(("//", "/*"), index): + index = _comment_end(source, index) + else: + literal = _STRING_START.match(source, index) + if not literal: + index += 1 + continue + index = _string_end(source, literal) + hide(start, index) + return "".join(masked) diff --git a/scripts/tests/test_ios_package_conventions.py b/scripts/tests/test_ios_package_conventions.py new file mode 100644 index 000000000000..8c3c7254bdf4 --- /dev/null +++ b/scripts/tests/test_ios_package_conventions.py @@ -0,0 +1,74 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +class NamespaceLintTests(unittest.TestCase): + def lint(self, source): + root = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory() as temporary: + checkout = Path(temporary) + scripts = checkout / 'scripts' + scripts.mkdir() + for name in ('lint-ios-package-conventions.sh', 'swift_source_mask.py', 'lint_swift_namespaces.py'): + path = root / 'scripts' / name + if path.exists(): + shutil.copy2(path, scripts / name) + package = checkout / 'Packages/iOS/CmuxMobileFixture/Sources/Fixture' + package.mkdir(parents=True) + (package / 'Fixture.swift').write_text(source) + return subprocess.run( + ['bash', str(scripts / 'lint-ios-package-conventions.sh')], + text=True, capture_output=True, timeout=30, + ) + + def test_braces_in_strings_and_comments_do_not_hide_instance_members(self): + result = self.lint('''public struct Resolver { + public static let delimiters = ["}", "{"] + /* nested comment: /* } */ } */ + public let root: String + public init(root: String) { self.root = root } + public func resolve() -> String { root } +} +''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_cases_in_comments_and_strings_do_not_hide_namespace_enum(self): + result = self.lint('''public enum HiddenNamespace { + // case imaginary + public static let text = """ + case alsoImaginary + """ + public static func value() -> Int { 1 } +} +''') + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn('namespace-enum', result.stdout) + self.assertIn('namespace-type', result.stdout) + + def test_real_cases_and_value_factories_remain_allowed(self): + result = self.lint('''public enum Choice { + case first, second + public static func preferred() -> Self { .first } +} +public struct Value { + public let number: Int + public static func one() -> Self { Self(number: 1) } +} +''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_nested_literals_inside_interpolation_preserve_type_boundaries(self): + result = self.lint(r'''public struct Resolver { + public static let marker = "\(String(describing: "}"))" + public let root: String + public init(root: String) { self.root = root } +} +''') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main()