From ac45ec469f12aafee8c5f0cc7767da7d95dc3262 Mon Sep 17 00:00:00 2001 From: Alexander Goodkind Date: Wed, 22 Jul 2026 16:41:07 -0700 Subject: [PATCH] Compile Catalyst setup screen and relay model only on Mac Co-authored-by: Codex --- .../RelayControlBackend+ConfigDefaults.swift | 46 --- Apps/iOS/Services/RelayControlBackend.swift | 28 ++ Apps/iOS/Views/RelayScreenModel.swift | 62 ++-- Apps/iOS/Views/SetupScreen.swift | 291 +++++++++--------- 4 files changed, 213 insertions(+), 214 deletions(-) delete mode 100644 Apps/iOS/Services/RelayControlBackend+ConfigDefaults.swift diff --git a/Apps/iOS/Services/RelayControlBackend+ConfigDefaults.swift b/Apps/iOS/Services/RelayControlBackend+ConfigDefaults.swift deleted file mode 100644 index cc09fd2..0000000 --- a/Apps/iOS/Services/RelayControlBackend+ConfigDefaults.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// RelayControlBackend+ConfigDefaults.swift -// CellTunnelPhone -// -// Created by Alexander Goodkind on 2026-06-30. -// Copyright © 2026, all rights reserved. -// - -import Foundation - -// MARK: - RelayControlBackend config-op defaults - -/// No-op config-library defaults for the backends with no library: the iPhone, the -/// simulator, and the previews. `Task.yield()` keeps each a real suspension point for the -/// async contract. `AgentRelayBackend` declares concrete members for every one of these -/// protocol requirements, so its real implementations win over these defaults. -extension RelayControlBackend { - func loadConfigText(id _: UUID) async -> String? { - await Task.yield() - return nil - } - - func importConfig(url _: URL, name _: String) async { - await Task.yield() - } - - func activateConfig(id _: UUID) async { - await Task.yield() - } - - func saveConfigEdit(id _: UUID, text _: String) async { - await Task.yield() - } - - func deleteConfig(id _: UUID) async { - await Task.yield() - } - - func renameConfig(id _: UUID, name _: String) async { - await Task.yield() - } - - func importConfig(name _: String, text _: String) async { - await Task.yield() - } -} diff --git a/Apps/iOS/Services/RelayControlBackend.swift b/Apps/iOS/Services/RelayControlBackend.swift index d901f85..8f7693a 100644 --- a/Apps/iOS/Services/RelayControlBackend.swift +++ b/Apps/iOS/Services/RelayControlBackend.swift @@ -76,4 +76,32 @@ extension RelayControlBackend { false } + func loadConfigText(id _: UUID) async -> String? { + await Task.yield() + return nil + } + + func importConfig(url _: URL, name _: String) async { + await Task.yield() + } + + func activateConfig(id _: UUID) async { + await Task.yield() + } + + func saveConfigEdit(id _: UUID, text _: String) async { + await Task.yield() + } + + func deleteConfig(id _: UUID) async { + await Task.yield() + } + + func renameConfig(id _: UUID, name _: String) async { + await Task.yield() + } + + func importConfig(name _: String, text _: String) async { + await Task.yield() + } } diff --git a/Apps/iOS/Views/RelayScreenModel.swift b/Apps/iOS/Views/RelayScreenModel.swift index 217d53d..db2bb63 100644 --- a/Apps/iOS/Views/RelayScreenModel.swift +++ b/Apps/iOS/Views/RelayScreenModel.swift @@ -15,7 +15,11 @@ private let logger = CellTunnelLog.logger(category: .app) // MARK: - Constants private let emptyValuePlaceholder = "(none)" -private let openLoginItemsTitle = "Open Login Items" + +#if targetEnvironment(macCatalyst) + private let openLoginItemsTitle = "Open Login Items" +#endif + private let dataSectionTitle = "Data" private let currentSpeedSectionTitle = "Current Speed" private let connectingStatusLabel = "Connecting" @@ -248,7 +252,7 @@ struct RelayScreenModel { var status: RelayStatus { RelayStatus( errorMessage: controller.lastError, - isAgentInstalled: controller.isAgentInstalled, + isAgentInstalled: isAgentInstalled, isTunnelInstalled: controller.isTunnelInstalled, isActiveConfigPresent: controller.hasActiveConfig, peersFound: peersAvailable, @@ -257,6 +261,14 @@ struct RelayScreenModel { ) } + private var isAgentInstalled: Bool { + #if targetEnvironment(macCatalyst) + return controller.isAgentInstalled + #else + return true + #endif + } + /// The status word the screens show, the state's label except while a turn-on /// request is in flight, when it reads `Connecting` so the switch and the word /// agree during the relay connect. @@ -328,32 +340,34 @@ struct RelayScreenModel { status.action } - /// Installs the background agent, or opens Login Items when the agent is registered - /// but awaiting approval. The install-agent setup action. - func installAgent() { - logger.notice("relay screen install agent requested") - if controller.isAgentApprovalPending { - controller.openLoginItems() - } else { - controller.installAgent() + #if targetEnvironment(macCatalyst) + /// Installs the background agent, or opens Login Items when the agent is registered + /// but awaiting approval. The install-agent setup action. + func installAgent() { + logger.notice("relay screen install agent requested") + if controller.isAgentApprovalPending { + controller.openLoginItems() + } else { + controller.installAgent() + } } - } - /// Installs the tunnel profile from the imported configuration. The install-tunnel - /// setup action. - func installTunnel(configURL: URL) { - logger.notice("relay screen install tunnel requested") - Task { await controller.installTunnel(configURL: configURL) } - } + /// Installs the tunnel profile from the imported configuration. The install-tunnel + /// setup action. + func installTunnel(configURL: URL) { + logger.notice("relay screen install tunnel requested") + Task { await controller.installTunnel(configURL: configURL) } + } - /// The title for the setup screen's primary action, deferring to the agent - /// approval state when the agent is registered but pending. - var setupActionTitle: String { - if status.action == .installAgent, controller.isAgentApprovalPending { - return openLoginItemsTitle + /// The title for the setup screen's primary action, deferring to the agent + /// approval state when the agent is registered but pending. + var setupActionTitle: String { + if status.action == .installAgent, controller.isAgentApprovalPending { + return openLoginItemsTitle + } + return status.action?.title ?? "" } - return status.action?.title ?? "" - } + #endif /// The runtime error message when the status is an error, shown as a row. var errorMessage: String? { diff --git a/Apps/iOS/Views/SetupScreen.swift b/Apps/iOS/Views/SetupScreen.swift index 4dd3e43..e5887f0 100644 --- a/Apps/iOS/Views/SetupScreen.swift +++ b/Apps/iOS/Views/SetupScreen.swift @@ -6,174 +6,177 @@ // Copyright © 2026, all rights reserved. // -import CellTunnelCore -import CellTunnelLog -import SwiftUI -import UniformTypeIdentifiers - -// MARK: - Constants - -private let setupLogger = CellTunnelLog.logger(category: .app) -private let contentStackSpacing: CGFloat = 24 -private let contentMaxWidth: CGFloat = 480 -private let contentPadding: CGFloat = 32 -private let iconPointSize: CGFloat = 48 -private let buttonMinWidth: CGFloat = 200 -private let configContentTypes: [UTType] = [ - UTType(filenameExtension: "conf") ?? .plainText, - .plainText, - .data, -] - -// MARK: - SetupScreen - -/// The full-tier setup screen, shared by the iPhone and the Mac. It shows a single SF -/// Symbol, a title, a one-sentence subtitle, and one primary button driven by the -/// status's offered action: install the agent or import and install the tunnel. The -/// reduced dashboards render every other state, so this view only handles the two -/// install states. -struct SetupScreen: View { - @Environment(RelayController.self) private var controller - @State private var isImportingConfig = false - - private var model: RelayScreenModel { - RelayScreenModel(controller: controller) - } +#if targetEnvironment(macCatalyst) + import CellTunnelCore + import CellTunnelLog + import SwiftUI + import UniformTypeIdentifiers + + // MARK: - Constants + + private let setupLogger = CellTunnelLog.logger(category: .app) + private let contentStackSpacing: CGFloat = 24 + private let contentMaxWidth: CGFloat = 480 + private let contentPadding: CGFloat = 32 + private let iconPointSize: CGFloat = 48 + private let buttonMinWidth: CGFloat = 200 + private let configContentTypes: [UTType] = [ + UTType(filenameExtension: "conf") ?? .plainText, + .plainText, + .data, + ] + + // MARK: - SetupScreen + + /// The full-tier setup screen, shared by the iPhone and the Mac. It shows a single SF + /// Symbol, a title, a one-sentence subtitle, and one primary button driven by the + /// status's offered action: install the agent or import and install the tunnel. The + /// reduced dashboards render every other state, so this view only handles the two + /// install states. + struct SetupScreen: View { + @Environment(RelayController.self) private var controller + @State private var isImportingConfig = false + + private var model: RelayScreenModel { + RelayScreenModel(controller: controller) + } - // MARK: - Body + // MARK: - Body - var body: some View { - VStack(spacing: contentStackSpacing) { - icon - title - subtitle - actionButton - errorMessage - } - .frame(maxWidth: contentMaxWidth) - .padding(contentPadding) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .fileImporter( - isPresented: $isImportingConfig, - allowedContentTypes: configContentTypes, - allowsMultipleSelection: false - ) { result in - handleImport(result) + var body: some View { + VStack(spacing: contentStackSpacing) { + icon + title + subtitle + actionButton + errorMessage + } + .frame(maxWidth: contentMaxWidth) + .padding(contentPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .fileImporter( + isPresented: $isImportingConfig, + allowedContentTypes: configContentTypes, + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + .animation(.default, value: model.status) } - .animation(.default, value: model.status) - } - // MARK: - Content + // MARK: - Content - @ViewBuilder private var icon: some View { - if let action = model.heroAction { - Image(systemName: action.systemImage) - .font(.system(size: iconPointSize, weight: .light)) - .foregroundStyle(.tint) + @ViewBuilder private var icon: some View { + if let action = model.heroAction { + Image(systemName: action.systemImage) + .font(.system(size: iconPointSize, weight: .light)) + .foregroundStyle(.tint) + } } - } - private var title: some View { - Text(titleText) - .font(.title.weight(.semibold)) - .multilineTextAlignment(.center) - } + private var title: some View { + Text(titleText) + .font(.title.weight(.semibold)) + .multilineTextAlignment(.center) + } - private var subtitle: some View { - Text(subtitleText) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } + private var subtitle: some View { + Text(subtitleText) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } - @ViewBuilder private var actionButton: some View { - if model.heroAction != nil { - Button(model.setupActionTitle) { - performAction() + @ViewBuilder private var actionButton: some View { + if model.heroAction != nil { + Button(model.setupActionTitle) { + performAction() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(minWidth: buttonMinWidth) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .frame(minWidth: buttonMinWidth) } - } - @ViewBuilder private var errorMessage: some View { - if let message = model.errorMessage { - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + @ViewBuilder private var errorMessage: some View { + if let message = model.errorMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } } - } - // MARK: - Actions + // MARK: - Actions - private func performAction() { - switch model.heroAction { - case .installAgent: - model.installAgent() - case .installTunnel: - isImportingConfig = true - case .retry, .selectPeer, .none: - break + private func performAction() { + switch model.heroAction { + case .installAgent: + model.installAgent() + case .installTunnel: + isImportingConfig = true + case .retry, .selectPeer, .none: + break + } } - } - private func handleImport(_ result: Result<[URL], Error>) { - switch result { - case .success(let urls): - guard let url = urls.first else { - return + private func handleImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { + return + } + model.installTunnel(configURL: url) + case .failure(let error): + setupLogger.error( + """ + setup config import failed \ + details=\(String(describing: error), privacy: .public) recovery=keep-setup-screen + """ + ) } - model.installTunnel(configURL: url) - case .failure(let error): - setupLogger.error( - """ - setup config import failed \ - details=\(String(describing: error), privacy: .public) recovery=keep-setup-screen - """ - ) } - } - // MARK: - Copy - - private var titleText: String { - switch model.status { - case .noAgent: - return "Install the background agent" - case .noTunnelInstalled: - return "Install the tunnel" - case .error, .noActiveConfig, .noPeerSelected, .noPeersFound, .readyToRoute, - .routing: - return model.status.label + // MARK: - Copy + + private var titleText: String { + switch model.status { + case .noAgent: + return "Install the background agent" + case .noTunnelInstalled: + return "Install the tunnel" + case .error, .noActiveConfig, .noPeerSelected, .noPeersFound, .readyToRoute, + .routing: + return model.status.label + } } - } - private var subtitleText: String { - switch model.status { - case .noAgent: - return "The agent runs in the background so it keeps working " - + "after the app closes." - case .noTunnelInstalled: - return "Import a tunnel configuration to get started." - case .error, .noActiveConfig, .noPeerSelected, .noPeersFound, .readyToRoute, - .routing: - return "" + private var subtitleText: String { + switch model.status { + case .noAgent: + return "The agent runs in the background so it keeps working " + + "after the app closes." + case .noTunnelInstalled: + return "Import a tunnel configuration to get started." + case .error, .noActiveConfig, .noPeerSelected, .noPeersFound, .readyToRoute, + .routing: + return "" + } } } -} -// MARK: - Preview + // MARK: - Preview -#Preview { - SetupScreen() - .environment( - RelayController( - backend: PreviewRelayBackend(), - throughput: ThroughputCalculator(), - lifetimeStore: LifetimeDataStore() + #Preview { + SetupScreen() + .environment( + RelayController( + backend: PreviewRelayBackend(), + throughput: ThroughputCalculator(), + lifetimeStore: LifetimeDataStore() + ) ) - ) -} + } + +#endif