diff --git a/.gitignore b/.gitignore index 9067afe..8c564fa 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ TESTFLIGHT_NOTES.md # Local build-claim signing key (never commit) .build-claim/ +.env.mainnet diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist index d7dcf51..9fd566f 100644 --- a/App/Resources/Info.plist +++ b/App/Resources/Info.plist @@ -33,6 +33,10 @@ #endif CosignRelayURL $(COSIGN_RELAY_URL) + CosignDevnetRelayURL + $(COSIGN_DEVNET_RELAY_URL) + CosignMainnetRelayURL + $(COSIGN_MAINNET_RELAY_URL) CosignEnvironment $(COSIGN_ENV) CFBundleShortVersionString diff --git a/App/Sources/CosignApp.swift b/App/Sources/CosignApp.swift index fc5ba95..2e31cb2 100644 --- a/App/Sources/CosignApp.swift +++ b/App/Sources/CosignApp.swift @@ -11,15 +11,16 @@ struct CosignApp: App { let demoMode: CosignDemoMode? let demoFixture: CosignDemoFixture? - @State private var networkSettings = NetworkSettingsStore() + @State private var networkSettings: NetworkSettingsStore + @State private var squadsService: SquadsService? init() { let demoMode = CosignDemoMode.launchConfiguration() self.demoMode = demoMode - demoFixture = demoMode.map { demoMode in + demoFixture = demoMode.map { mode in CosignDemoFixture.profile( - demoMode.profile, - memberAddresses: CosignDemoSigners.memberAddresses(for: demoMode.profile) + mode.profile, + memberAddresses: CosignDemoSigners.memberAddresses(for: mode.profile) ) } @@ -28,28 +29,44 @@ struct CosignApp: App { } catch { fatalError("Failed to initialize SwiftData container: \(error)") } + + let store = NetworkSettingsStore() + if demoMode == nil { + let signerCount = (try? container.mainContext.fetchCount(FetchDescriptor())) ?? 0 + store.resolveInitialNetwork(hasExistingSigners: signerCount > 0) + } + _networkSettings = State(initialValue: store) } var body: some Scene { WindowGroup { let environment = demoFixture.map(demoEnvironment) ?? networkSettings.environment - + let service = squadsService ?? makeService(environment) ContentView() .preferredColorScheme(.dark) .environment(networkSettings) .environment(networkSettings.networkHealth) .environment(\.indexerEnvironment, environment) - .environment(\.squadsService, SquadsService( - environment: environment, - demoFixture: demoFixture, - demoBroadcastMode: demoBroadcastMode(for: demoMode), - healthReporter: networkSettings.networkHealth.reporter() - )) + .environment(\.squadsService, service) .environment(\.cosignDemoMode, demoMode) + .onAppear { if squadsService == nil { squadsService = service } } + .onChange(of: networkSettings.selectedNetwork) { _, _ in + guard demoFixture == nil else { return } + squadsService = makeService(networkSettings.environment) + } } .modelContainer(container) } + private func makeService(_ environment: IndexerEnvironment) -> SquadsService { + SquadsService( + environment: environment, + demoFixture: demoFixture, + demoBroadcastMode: demoBroadcastMode(for: demoMode), + healthReporter: networkSettings.networkHealth.reporter() + ) + } + private func demoEnvironment(fixture: CosignDemoFixture) -> IndexerEnvironment { IndexerEnvironment( rpcURL: URL(string: "https://demo.cosign.local")!, diff --git a/Modules/Core/Sources/Network.swift b/Modules/Core/Sources/Network.swift new file mode 100644 index 0000000..b6f8379 --- /dev/null +++ b/Modules/Core/Sources/Network.swift @@ -0,0 +1,15 @@ +public enum Network: String, Codable, Sendable, CaseIterable { + case mainnet + case devnet + + public var displayName: String { + switch self { + case .mainnet: "Mainnet" + case .devnet: "Devnet" + } + } + + public var isMainnet: Bool { + self == .mainnet + } +} diff --git a/Modules/Core/Tests/NetworkTests.swift b/Modules/Core/Tests/NetworkTests.swift new file mode 100644 index 0000000..5cef901 --- /dev/null +++ b/Modules/Core/Tests/NetworkTests.swift @@ -0,0 +1,16 @@ +import Testing +@testable import Core + +struct NetworkTests { + @Test func rawValuesAreStable() { + #expect(Network.mainnet.rawValue == "mainnet") + #expect(Network.devnet.rawValue == "devnet") + } + + @Test func displayNamesAndFlag() { + #expect(Network.mainnet.displayName == "Mainnet") + #expect(Network.devnet.displayName == "Devnet") + #expect(Network.mainnet.isMainnet == true) + #expect(Network.devnet.isMainnet == false) + } +} diff --git a/Modules/Indexer/Sources/CosignBuildEnvironment.swift b/Modules/Indexer/Sources/CosignBuildEnvironment.swift index c2e5049..50387f2 100644 --- a/Modules/Indexer/Sources/CosignBuildEnvironment.swift +++ b/Modules/Indexer/Sources/CosignBuildEnvironment.swift @@ -8,17 +8,24 @@ import Foundation public struct CosignBuildEnvironment: Sendable { public let relayURL: URL? public let environmentName: String + public let devnetRelayURL: URL? + public let mainnetRelayURL: URL? public static func current( infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:] ) -> CosignBuildEnvironment { - let rawURL = (infoDictionary["CosignRelayURL"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + func url(_ key: String) -> URL? { + let raw = (infoDictionary[key] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? nil : URL(string: raw) + } let environment = (infoDictionary["CosignEnvironment"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return CosignBuildEnvironment( - relayURL: rawURL.isEmpty ? nil : URL(string: rawURL), - environmentName: environment + relayURL: url("CosignRelayURL"), + environmentName: environment, + devnetRelayURL: url("CosignDevnetRelayURL"), + mainnetRelayURL: url("CosignMainnetRelayURL") ) } } diff --git a/Modules/Indexer/Sources/NetworkConfig.swift b/Modules/Indexer/Sources/NetworkConfig.swift new file mode 100644 index 0000000..645ae8e --- /dev/null +++ b/Modules/Indexer/Sources/NetworkConfig.swift @@ -0,0 +1,46 @@ +import Core +import Foundation + +public struct NetworkConfig: Sendable { + public let relayURL: URL + public let explorerRPCURL: URL + public let supportsAirdrop: Bool + + public static func config(for network: Network, build: CosignBuildEnvironment) -> NetworkConfig { + switch network { + case .devnet: + NetworkConfig( + relayURL: build.devnetRelayURL ?? IndexerEnvironment.devnetRPCURL, + explorerRPCURL: IndexerEnvironment.devnetRPCURL, + supportsAirdrop: true + ) + case .mainnet: + NetworkConfig( + relayURL: build.mainnetRelayURL ?? IndexerEnvironment.mainnetRPCURL, + explorerRPCURL: IndexerEnvironment.mainnetRPCURL, + supportsAirdrop: false + ) + } + } +} + +public extension IndexerEnvironment { + static func forNetwork( + _ network: Network, + build: CosignBuildEnvironment = .current(), + healthReporter: NetworkHealthReporter? + ) -> IndexerEnvironment { + let cfg = NetworkConfig.config(for: network, build: build) + return IndexerEnvironment( + rpcURL: cfg.relayURL, + relay: HTTPRelayClient( + baseURL: cfg.relayURL, + capabilities: RelayCapability.enhancedFeatures, + healthReporter: healthReporter + ), + webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: cfg.relayURL), + explorerRPCURL: cfg.explorerRPCURL, + supportsAirdrop: cfg.supportsAirdrop + ) + } +} diff --git a/Modules/Indexer/Sources/NetworkSettingsStore.swift b/Modules/Indexer/Sources/NetworkSettingsStore.swift index 697a436..5a3afa1 100644 --- a/Modules/Indexer/Sources/NetworkSettingsStore.swift +++ b/Modules/Indexer/Sources/NetworkSettingsStore.swift @@ -1,3 +1,4 @@ +import Core import Foundation import Observation @@ -15,8 +16,17 @@ public final class NetworkSettingsStore { public let networkHealth = NetworkHealth() + /// The active Solana network. Switch with `switch(to:)`. + public private(set) var selectedNetwork: Network + + /// True when a custom relay URL is saved to the Keychain, causing + /// `environment` to route through that URL rather than the network default. + public private(set) var hasCustomRelayOverride: Bool + private let rpcURLKeychain: SecureNetworkURLKeychain private let defaultRPCURL: URL + private let networkDefaults: UserDefaults + private static let selectedNetworkKey = "cosign.selectedNetwork" public static var defaultRPCURL: URL { CosignBuildEnvironment.current().relayURL ?? IndexerEnvironment.devnetRPCURL @@ -26,14 +36,17 @@ public final class NetworkSettingsStore { self.init(rpcURLKeychain: .rpcURL) } - init(rpcURLKeychain: SecureNetworkURLKeychain) { + init(rpcURLKeychain: SecureNetworkURLKeychain, networkDefaults: UserDefaults = .standard) { let fallback = NetworkSettingsStore.defaultRPCURL defaultRPCURL = fallback self.rpcURLKeychain = rpcURLKeychain + self.networkDefaults = networkDefaults + var hadKeychainURL = false do { if let storedRPCURL = try rpcURLKeychain.load() { rpcURL = try NetworkSettingsStore.validatedRPCURL(from: storedRPCURL) + hadKeychainURL = true } else { rpcURL = fallback } @@ -41,29 +54,69 @@ public final class NetworkSettingsStore { rpcURL = fallback loadErrorMessage = "Failed to load the saved relay endpoint from the Keychain." } + hasCustomRelayOverride = hadKeychainURL + + // Use the persisted network if one exists; otherwise default to .mainnet + // until resolveInitialNetwork(_:) is called with signer context. + let stored = networkDefaults.string(forKey: NetworkSettingsStore.selectedNetworkKey) + .flatMap(Network.init(rawValue:)) + selectedNetwork = stored ?? .mainnet + if !hadKeychainURL { + rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL + } } - /// Always a relay client. RPC flows through the relay's Solana passthrough, - /// live updates through its `/ws` proxy, pricing/decoding through cosign/v1. + /// Call once after the SwiftData container is available. If no network + /// preference has been persisted yet, computes the migration default + /// (mainnet for fresh installs, devnet for installs with existing signers) + /// and persists it. + public func resolveInitialNetwork(hasExistingSigners: Bool) { + guard networkDefaults.string(forKey: Self.selectedNetworkKey) == nil else { return } + let resolved = Self.defaultNetwork(hasExistingSigners: hasExistingSigners, stored: nil) + selectedNetwork = resolved + networkDefaults.set(resolved.rawValue, forKey: Self.selectedNetworkKey) + if !hasCustomRelayOverride { + rpcURL = NetworkConfig.config(for: resolved, build: .current()).relayURL + } + } + + /// Migration default: stored preference wins; otherwise mainnet for fresh + /// installs and devnet for installs that already have signers. + public static func defaultNetwork(hasExistingSigners: Bool, stored: Network?) -> Network { + if let stored { return stored } + return hasExistingSigners ? .devnet : .mainnet + } + + /// Always a relay client. When a custom relay URL is active, routes through + /// that URL; otherwise derives everything from `selectedNetwork`. + /// In both branches, `explorerRPCURL` and airdrop follow `selectedNetwork`. public var environment: IndexerEnvironment { - IndexerEnvironment( - rpcURL: rpcURL, - relay: HTTPRelayClient( - baseURL: rpcURL, - capabilities: RelayCapability.enhancedFeatures, - healthReporter: networkHealth.reporter() - ), - webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL), - explorerRPCURL: explorerRPCURL - ) + let cfg = NetworkConfig.config(for: selectedNetwork, build: .current()) + if hasCustomRelayOverride { + return IndexerEnvironment( + rpcURL: rpcURL, + relay: HTTPRelayClient( + baseURL: rpcURL, + capabilities: RelayCapability.enhancedFeatures, + healthReporter: networkHealth.reporter() + ), + webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL), + explorerRPCURL: cfg.explorerRPCURL, + supportsAirdrop: cfg.supportsAirdrop + ) + } + return IndexerEnvironment.forNetwork(selectedNetwork, healthReporter: networkHealth.reporter()) } - /// Cluster hint for Explorer links — derived from the build env, since the - /// relay host itself doesn't reveal the cluster. - private var explorerRPCURL: URL { - CosignBuildEnvironment.current().environmentName.lowercased() == "devnet" - ? IndexerEnvironment.devnetRPCURL - : IndexerEnvironment.mainnetRPCURL + /// Switches the active network, drops any custom relay override, and + /// persists the new preference. No-op if already on the requested network. + public func `switch`(to network: Network) { + guard network != selectedNetwork else { return } + selectedNetwork = network + networkDefaults.set(network.rawValue, forKey: Self.selectedNetworkKey) + try? rpcURLKeychain.delete() + hasCustomRelayOverride = false + rpcURL = NetworkConfig.config(for: network, build: .current()).relayURL } public var redactedRPCURLString: String { @@ -78,13 +131,15 @@ public final class NetworkSettingsStore { let url = try NetworkSettingsStore.validatedRPCURL(from: value) try rpcURLKeychain.save(url.absoluteString) rpcURL = url + hasCustomRelayOverride = true pendingRPCURLDraft = nil loadErrorMessage = nil } public func resetRPCURL() throws { try rpcURLKeychain.delete() - rpcURL = defaultRPCURL + rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL + hasCustomRelayOverride = false pendingRPCURLDraft = nil loadErrorMessage = nil } diff --git a/Modules/Indexer/Tests/CosignBuildEnvironmentTests.swift b/Modules/Indexer/Tests/CosignBuildEnvironmentTests.swift new file mode 100644 index 0000000..5af30d3 --- /dev/null +++ b/Modules/Indexer/Tests/CosignBuildEnvironmentTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import Indexer + +final class CosignBuildEnvironmentTests: XCTestCase { + func testReadsBothRelayURLs() { + let env = CosignBuildEnvironment.current(infoDictionary: [ + "CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev", + "CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev" + ]) + XCTAssertEqual(env.devnetRelayURL?.absoluteString, "https://cosign-relay-devnet.fly.dev") + XCTAssertEqual(env.mainnetRelayURL?.absoluteString, "https://cosign-relay-mainnet.fly.dev") + } + + func testMissingKeysAreNil() { + let env = CosignBuildEnvironment.current(infoDictionary: [:]) + XCTAssertNil(env.devnetRelayURL) + XCTAssertNil(env.mainnetRelayURL) + } +} diff --git a/Modules/Indexer/Tests/NetworkConfigTests.swift b/Modules/Indexer/Tests/NetworkConfigTests.swift new file mode 100644 index 0000000..373aee2 --- /dev/null +++ b/Modules/Indexer/Tests/NetworkConfigTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Core +@testable import Indexer + +final class NetworkConfigTests: XCTestCase { + private let build = CosignBuildEnvironment.current(infoDictionary: [ + "CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev", + "CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev" + ]) + + func testDevnet() { + let cfg = NetworkConfig.config(for: .devnet, build: build) + XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-devnet.fly.dev") + XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.devnetRPCURL) + XCTAssertTrue(cfg.supportsAirdrop) + } + + func testMainnet() { + let cfg = NetworkConfig.config(for: .mainnet, build: build) + XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-mainnet.fly.dev") + XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.mainnetRPCURL) + XCTAssertFalse(cfg.supportsAirdrop) + } + + func testEnvironmentFactoryWiresRelayAndExplorer() { + let env = IndexerEnvironment.forNetwork(.mainnet, build: build, healthReporter: nil) + XCTAssertEqual(env.rpcURL.absoluteString, "https://cosign-relay-mainnet.fly.dev") + XCTAssertEqual(env.explorerRPCURL, IndexerEnvironment.mainnetRPCURL) + XCTAssertFalse(env.supportsAirdrop) + } +} diff --git a/Modules/Indexer/Tests/NetworkSettingsStoreTests.swift b/Modules/Indexer/Tests/NetworkSettingsStoreTests.swift index 0565b21..4dc044b 100644 --- a/Modules/Indexer/Tests/NetworkSettingsStoreTests.swift +++ b/Modules/Indexer/Tests/NetworkSettingsStoreTests.swift @@ -86,10 +86,11 @@ struct NetworkSettingsStoreTests { ) ) let deepLink = try makeNetworkSettingsLink(path: "/rpc", url: "https://devnet.helius-rpc.com/?api-key=secret") + let rpcURLBeforeDraft = store.rpcURL try store.prepareRPCURLUpdate(from: deepLink) - #expect(store.rpcURL == NetworkSettingsStore.defaultRPCURL) + #expect(store.rpcURL == rpcURLBeforeDraft) #expect(store.pendingRPCURLDraft?.rpcURL.absoluteString == "https://devnet.helius-rpc.com/?api-key=secret") #expect(savedValue == nil) } diff --git a/Modules/Indexer/Tests/NetworkStoreTests.swift b/Modules/Indexer/Tests/NetworkStoreTests.swift new file mode 100644 index 0000000..b46701f --- /dev/null +++ b/Modules/Indexer/Tests/NetworkStoreTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Core +@testable import Indexer + +final class NetworkStoreTests: XCTestCase { + func testDefaultNetworkFreshInstallIsMainnet() { + XCTAssertEqual( + NetworkSettingsStore.defaultNetwork(hasExistingSigners: false, stored: nil), + .mainnet + ) + } + + func testDefaultNetworkExistingInstallKeepsDevnet() { + XCTAssertEqual( + NetworkSettingsStore.defaultNetwork(hasExistingSigners: true, stored: nil), + .devnet + ) + } + + func testStoredPreferenceWins() { + XCTAssertEqual( + NetworkSettingsStore.defaultNetwork(hasExistingSigners: true, stored: .mainnet), + .mainnet + ) + XCTAssertEqual( + NetworkSettingsStore.defaultNetwork(hasExistingSigners: false, stored: .devnet), + .devnet + ) + } + + func testRpcURLFollowsSelectedNetworkWhenNoOverride() throws { + try? SecureNetworkURLKeychain.rpcURL.delete() // ensure no custom override + let suite = "test.combo.rpcurl.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let store = NetworkSettingsStore(rpcURLKeychain: .rpcURL, networkDefaults: defaults) + // Fresh install (empty defaults) => mainnet, and rpcURL must be the mainnet relay. + XCTAssertEqual(store.selectedNetwork, .mainnet) + XCTAssertEqual(store.rpcURL, IndexerEnvironment.mainnetRPCURL) + + // Existing install (has signers) => devnet, and rpcURL must realign to devnet. + store.resolveInitialNetwork(hasExistingSigners: true) + XCTAssertEqual(store.selectedNetwork, .devnet) + XCTAssertEqual(store.rpcURL, IndexerEnvironment.devnetRPCURL) + } +} diff --git a/Modules/UI/Sources/CosignConfirmationSheet.swift b/Modules/UI/Sources/CosignConfirmationSheet.swift index d4b7844..40fc9da 100644 --- a/Modules/UI/Sources/CosignConfirmationSheet.swift +++ b/Modules/UI/Sources/CosignConfirmationSheet.swift @@ -1,11 +1,108 @@ import SwiftUI +struct CosignCautionConfirmationSheet: View { + let title: String + let message: String + let confirmTitle: String + let cancelTitle: String + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + CosignScreen { + header + + CosignCard(radius: CosignTheme.Radius.hero, padding: 18) { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(CosignTheme.accent.opacity(0.13)) + Circle() + .stroke(CosignTheme.accent.opacity(0.26), lineWidth: 1) + Image(systemName: "arrow.right") + .font(.system(size: 26)) + .foregroundStyle(CosignTheme.accent) + } + .frame(width: 56, height: 56) + + Text(title) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(CosignTheme.ink) + .multilineTextAlignment(.center) + + Text(message) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(CosignTheme.inkDim) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + } + + VStack(spacing: 10) { + Button { + onConfirm() + } label: { + HStack { + Image(systemName: "arrow.right") + .foregroundStyle(CosignTheme.accentInk) + Text(confirmTitle) + Spacer() + } + } + .buttonStyle(CosignButtonStyle(kind: .accent)) + + Button(cancelTitle) { + onCancel() + } + .buttonStyle(CosignButtonStyle(kind: .secondary)) + } + } + .presentationDetents([.height(360), .medium]) + .presentationDragIndicator(.hidden) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 16) { + Capsule() + .fill(CosignTheme.inkGhost) + .frame(width: 42, height: 4) + .frame(maxWidth: .infinity) + + HStack { + Spacer() + CosignGlyphButton(glyph: .xmark, accessibilityLabel: CosignCopy.Common.cancel) { + onCancel() + } + } + } + } +} + struct CosignDestructiveConfirmationSheet: View { let title: String let message: String let confirmTitle: String let onCancel: () -> Void let onConfirm: () -> Void + let cancelTitle: String + + init( + title: String, + message: String, + confirmTitle: String, + onCancel: @escaping () -> Void, + onConfirm: @escaping () -> Void, + cancelTitle: String? = nil + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.onCancel = onCancel + self.onConfirm = onConfirm + self.cancelTitle = cancelTitle ?? CosignCopy.Signers.keepSignerTitle + } var body: some View { CosignScreen { @@ -40,7 +137,7 @@ struct CosignDestructiveConfirmationSheet: View { } .buttonStyle(CosignButtonStyle(kind: .destructive)) - Button(CosignCopy.Signers.keepSignerTitle) { + Button(cancelTitle) { onCancel() } .buttonStyle(CosignButtonStyle(kind: .secondary)) diff --git a/Modules/UI/Sources/CosignCopy.swift b/Modules/UI/Sources/CosignCopy.swift index fd66071..abf218b 100644 --- a/Modules/UI/Sources/CosignCopy.swift +++ b/Modules/UI/Sources/CosignCopy.swift @@ -13,6 +13,12 @@ extension CosignCopy { String(localized: "\(environment) build", bundle: .module) } + static func networkIndicatorAccessibility(_ networkName: String) -> String { + String(localized: "\(networkName) network", bundle: .module) + } + + static let demoBadgeLabel = String(localized: "DEMO", bundle: .module) + static let back = String(localized: "Back", bundle: .module) static let cancel = String(localized: "Cancel", bundle: .module) static let copy = String(localized: "Copy", bundle: .module) diff --git a/Modules/UI/Sources/CosignNetworkCopy.swift b/Modules/UI/Sources/CosignNetworkCopy.swift index 3984df5..a3f991e 100644 --- a/Modules/UI/Sources/CosignNetworkCopy.swift +++ b/Modules/UI/Sources/CosignNetworkCopy.swift @@ -20,6 +20,21 @@ public extension CosignCopy.Network { static let offlineStateName = String(localized: "Offline", bundle: .module) static let offlineStateDetail = String(localized: "Can't reach the network. Showing saved data.", bundle: .module) + static let networkSelectionSection = String(localized: "Active network", bundle: .module) + static let mainnetRowSubtitle = String(localized: "Real funds", bundle: .module) + static let devnetRowSubtitle = String(localized: "Test network", bundle: .module) + static let switchToMainnetTitle = String(localized: "Switch to mainnet?", bundle: .module) + static let switchToMainnetMessage = String( + localized: "Transactions on mainnet move real funds. Decode every action before you sign.", + bundle: .module + ) + static let switchToMainnetConfirm = String(localized: "Switch to mainnet", bundle: .module) + static let switchToMainnetCancel = String(localized: "Stay on devnet", bundle: .module) + static let switchHelper = String( + localized: "Switching to mainnet asks you to confirm. Devnet switches immediately.", + bundle: .module + ) + static let selfHostedRowTitle = String(localized: "Use a self-hosted relay", bundle: .module) static let selfHostedRowSubtitle = String( localized: "Advanced · point Cosign at your own relay build", diff --git a/Modules/UI/Sources/CosignProposalCopy.swift b/Modules/UI/Sources/CosignProposalCopy.swift index f7db869..0b1790d 100644 --- a/Modules/UI/Sources/CosignProposalCopy.swift +++ b/Modules/UI/Sources/CosignProposalCopy.swift @@ -18,6 +18,9 @@ extension CosignCopy { static let approvalActionDetail = String(localized: "Counts as 1 approval", bundle: .module) static let executeAfterApprovalDetail = String(localized: "Auto-broadcasts on success", bundle: .module) static let networkFeeEstimateDetail = String(localized: "Estimate · paid by the signer", bundle: .module) + static let networkLabel = String(localized: "Network", bundle: .module) + static let mainnetSigningDetail = String(localized: "Real funds", bundle: .module) + static let devnetSigningDetail = String(localized: "Test network", bundle: .module) static func priceAsOf(_ date: Date) -> String { String(localized: "price as of \(date.formatted(.dateTime.hour().minute()))", bundle: .module) diff --git a/Modules/UI/Sources/EnvBadge.swift b/Modules/UI/Sources/EnvBadge.swift index be2ef49..934cb42 100644 --- a/Modules/UI/Sources/EnvBadge.swift +++ b/Modules/UI/Sources/EnvBadge.swift @@ -1,12 +1,11 @@ import SwiftUI enum EnvBadgeTone { - case neutral case demo } -/// A build-environment pill shown beside the wordmark. Never rendered on -/// mainnet — see `SignersListView.envBadge` for the resolution. +/// A build-environment pill shown beside the wordmark. Only rendered in demo builds; +/// real-network builds use `NetworkIndicator` instead. struct EnvBadge: View { let label: String let tone: EnvBadgeTone @@ -27,8 +26,6 @@ struct EnvBadge: View { private var foreground: Color { switch tone { - case .neutral: - CosignTheme.inkDim case .demo: CosignTheme.riskAmber } @@ -36,8 +33,6 @@ struct EnvBadge: View { private var background: Color { switch tone { - case .neutral: - CosignTheme.surface2 case .demo: CosignTheme.accentWash } diff --git a/Modules/UI/Sources/NetworkIndicator.swift b/Modules/UI/Sources/NetworkIndicator.swift new file mode 100644 index 0000000..3c340c4 --- /dev/null +++ b/Modules/UI/Sources/NetworkIndicator.swift @@ -0,0 +1,51 @@ +import Core +import SwiftUI + +/// Persistent network identity chip shown beside the wordmark on the Signers home header. +/// Mainnet renders a calm identity chip; devnet renders an amber mono test badge. +struct NetworkIndicator: View { + let network: Network + + var body: some View { + switch network { + case .mainnet: + mainnetChip + case .devnet: + devnetBadge + } + } + + private var mainnetChip: some View { + HStack(spacing: 6) { + Circle() + .fill(CosignTheme.accent) + .frame(width: 7, height: 7) + Text(network.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(CosignTheme.ink.opacity(0.82)) + } + .padding(.leading, 9) + .padding(.trailing, 11) + .padding(.vertical, 5) + .background(CosignTheme.surface2, in: .capsule) + .overlay { + Capsule().stroke(CosignTheme.ink.opacity(0.08), lineWidth: 1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(CosignCopy.Common.networkIndicatorAccessibility(network.displayName)) + } + + private var devnetBadge: some View { + Text(network.displayName.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .tracking(0.8) + .foregroundStyle(CosignTheme.accent) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(CosignTheme.accent.opacity(0.13), in: RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7).stroke(CosignTheme.accent.opacity(0.22), lineWidth: 1) + } + .accessibilityLabel(CosignCopy.Common.networkIndicatorAccessibility(network.displayName)) + } +} diff --git a/Modules/UI/Sources/NetworkSettingsView.swift b/Modules/UI/Sources/NetworkSettingsView.swift index ec7cdd1..aa7caa6 100644 --- a/Modules/UI/Sources/NetworkSettingsView.swift +++ b/Modules/UI/Sources/NetworkSettingsView.swift @@ -18,6 +18,8 @@ public struct NetworkSettingsView: View { .foregroundStyle(CosignTheme.ink) } + NetworkSwitcherSection() + RelayConnectionStatusBlock(status: status) { networkSettings.networkHealth.retry() } @@ -50,7 +52,7 @@ public struct NetworkSettingsView: View { VStack(spacing: 0) { CosignKeyValueRow( label: CosignCopy.Network.clusterLabel, - value: CosignCopy.Network.relayClusterName(environmentName) + value: CosignCopy.Network.relayClusterName(networkSettings.selectedNetwork.rawValue) ) CosignKeyValueRow( label: CosignCopy.Network.hostLabel, @@ -99,10 +101,6 @@ public struct NetworkSettingsView: View { .accessibilityIdentifier("network-self-hosted-row") } } - - private var environmentName: String { - CosignBuildEnvironment.current().environmentName - } } struct RelayConnectionStatusBlock: View { diff --git a/Modules/UI/Sources/NetworkSwitcherSection.swift b/Modules/UI/Sources/NetworkSwitcherSection.swift new file mode 100644 index 0000000..56e2b24 --- /dev/null +++ b/Modules/UI/Sources/NetworkSwitcherSection.swift @@ -0,0 +1,146 @@ +import Core +import Indexer +import SwiftUI + +struct NetworkSwitcherSection: View { + @Environment(NetworkSettingsStore.self) private var networkSettings + @State private var showMainnetConfirm = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CosignSectionTitle(title: CosignCopy.Network.networkSelectionSection) + CosignCard(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(Network.allCases.enumerated()), id: \.element) { index, network in + if index > 0 { + Divider() + .padding(.horizontal, 16) + } + NetworkSwitcherRow( + network: network, + isSelected: networkSettings.selectedNetwork == network + ) + .contentShape(Rectangle()) + .onTapGesture { + guard networkSettings.selectedNetwork != network else { return } + if network == .mainnet { + showMainnetConfirm = true + } else { + networkSettings.switch(to: network) + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("network-row-\(network.rawValue)") + } + } + } + Text(CosignCopy.Network.switchHelper) + .font(CosignTheme.FontStyle.caption) + .foregroundStyle(CosignTheme.inkDim) + } + .sheet(isPresented: $showMainnetConfirm) { + CosignCautionConfirmationSheet( + title: CosignCopy.Network.switchToMainnetTitle, + message: CosignCopy.Network.switchToMainnetMessage, + confirmTitle: CosignCopy.Network.switchToMainnetConfirm, + cancelTitle: CosignCopy.Network.switchToMainnetCancel, + onCancel: { showMainnetConfirm = false }, + onConfirm: { + showMainnetConfirm = false + networkSettings.switch(to: .mainnet) + } + ) + } + } +} + +private struct NetworkSwitcherRow: View { + let network: Network + let isSelected: Bool + + var body: some View { + HStack(spacing: 13) { + networkIcon + VStack(alignment: .leading, spacing: 2) { + Text(network.displayName) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(titleColor) + Text(subtitle) + .font(.system(size: 12.5, weight: .medium, design: .rounded)) + .foregroundStyle(subtitleColor) + } + Spacer(minLength: 8) + selectionIndicator + } + .padding(.horizontal, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, minHeight: 66, alignment: .leading) + .background(rowBackground) + } + + private var networkIcon: some View { + ZStack { + RoundedRectangle(cornerRadius: 9) + .fill(iconBackground) + .overlay { + if network == .devnet { + RoundedRectangle(cornerRadius: 9) + .stroke(CosignTheme.ink.opacity(0.06), lineWidth: 1) + } + } + Image(systemName: iconName) + .font(.system(size: iconSize)) + .foregroundStyle(iconColor) + } + .frame(width: 34, height: 34) + } + + @ViewBuilder + private var selectionIndicator: some View { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(CosignTheme.accent) + } else { + Circle() + .stroke(CosignTheme.ink.opacity(0.14), lineWidth: 1.6) + .frame(width: 22, height: 22) + } + } + + private var rowBackground: Color { + network == .mainnet && isSelected ? CosignTheme.accent.opacity(0.055) : .clear + } + + private var titleColor: Color { + network == .mainnet ? CosignTheme.ink : CosignTheme.ink.opacity(0.82) + } + + private var subtitleColor: Color { + network == .mainnet ? CosignTheme.accent : CosignTheme.ink.opacity(0.5) + } + + private var iconBackground: Color { + network == .mainnet ? CosignTheme.accent.opacity(0.13) : CosignTheme.surface2 + } + + private var iconColor: Color { + network == .mainnet ? CosignTheme.accent : CosignTheme.inkDim + } + + private var iconName: String { + network == .mainnet ? "globe" : "testtube.2" + } + + private var iconSize: CGFloat { + network == .mainnet ? 18 : 17 + } + + private var subtitle: String { + switch network { + case .mainnet: CosignCopy.Network.mainnetRowSubtitle + case .devnet: CosignCopy.Network.devnetRowSubtitle + } + } +} diff --git a/Modules/UI/Sources/ProposalActionSheets.swift b/Modules/UI/Sources/ProposalActionSheets.swift index 4961a4f..4571bd7 100644 --- a/Modules/UI/Sources/ProposalActionSheets.swift +++ b/Modules/UI/Sources/ProposalActionSheets.swift @@ -11,6 +11,7 @@ struct ProposalSigningSheet: View { let onCancel: () -> Void let onConfirm: () -> Void @Environment(\.squadsService) private var squadsService + @Environment(NetworkSettingsStore.self) private var networkSettings: NetworkSettingsStore? @State private var confirmationText = "" @State private var footerHeight = CosignLayout.estimatedSheetStickyFooterHeight @State private var frozenSolPrice: Double? @@ -28,7 +29,7 @@ struct ProposalSigningSheet: View { approvalWouldReachThreshold: approvalWouldReachThreshold ) - ProposalSigningContextCard(items: signingContextItems) + ProposalSigningContextCard(network: networkSettings?.selectedNetwork, items: signingContextItems) hardwareContext @@ -175,63 +176,48 @@ struct ProposalSigningSheet: View { detail: feeDetail ) + var items: [ProposalSigningContextItem] = [signerItem] + switch request.action { case .approveAndExecute: - return [ - signerItem, - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.actionOneLabel, - value: CosignCopy.ProposalSigning.approveProposalTitle( - proposalIndex: proposal?.transactionIndex - ), - detail: CosignCopy.ProposalSigning.approvalActionDetail - ), - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.actionTwoLabel, - value: CosignCopy.ProposalSigning.executeActionTitle(actionTitle: reviewAction.title), - detail: CosignCopy.ProposalSigning.executeAfterApprovalDetail + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.actionOneLabel, + value: CosignCopy.ProposalSigning.approveProposalTitle( + proposalIndex: proposal?.transactionIndex ), - feeItem - ] + detail: CosignCopy.ProposalSigning.approvalActionDetail + )) + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.actionTwoLabel, + value: CosignCopy.ProposalSigning.executeActionTitle(actionTitle: reviewAction.title), + detail: CosignCopy.ProposalSigning.executeAfterApprovalDetail + )) case .execute: - return [ - signerItem, - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.approvedByLabel, - value: approvalSummary - ), - feeItem - ] + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.approvedByLabel, + value: approvalSummary + )) case .approve: - return [ - signerItem, - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.afterSigningLabel, - value: afterSigningText, - detail: CosignCopy.ProposalSigning.approvalActionDetail - ), - feeItem - ] + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.afterSigningLabel, + value: afterSigningText, + detail: CosignCopy.ProposalSigning.approvalActionDetail + )) case .reject: - return [ - signerItem, - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.effectLabel, - value: CosignCopy.ProposalSigning.rejectEffectValue - ), - feeItem - ] + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.effectLabel, + value: CosignCopy.ProposalSigning.rejectEffectValue + )) case .cancel: - return [ - signerItem, - ProposalSigningContextItem( - label: CosignCopy.ProposalSigning.effectLabel, - value: CosignCopy.ProposalSigning.cancelEffectValue, - detail: CosignCopy.ProposalSigning.cancelEffectDetail - ), - feeItem - ] + items.append(ProposalSigningContextItem( + label: CosignCopy.ProposalSigning.effectLabel, + value: CosignCopy.ProposalSigning.cancelEffectValue, + detail: CosignCopy.ProposalSigning.cancelEffectDetail + )) } + + items.append(feeItem) + return items } private var approvalSummary: String { diff --git a/Modules/UI/Sources/ProposalCreationSheets.swift b/Modules/UI/Sources/ProposalCreationSheets.swift index c91b281..da88f2f 100644 --- a/Modules/UI/Sources/ProposalCreationSheets.swift +++ b/Modules/UI/Sources/ProposalCreationSheets.swift @@ -44,6 +44,7 @@ struct ProposalCreationReviewSheet: View { let deviceStatusMessage: String? let onCancel: () -> Void let onConfirm: () -> Void + @Environment(NetworkSettingsStore.self) private var networkSettings: NetworkSettingsStore? @State private var footerHeight = CosignLayout.estimatedSheetStickyFooterHeight var body: some View { @@ -72,6 +73,9 @@ struct ProposalCreationReviewSheet: View { CosignSectionTitle(title: CosignCopy.ProposalCreation.signerSectionTitle) CosignCard { VStack(spacing: 0) { + if let net = networkSettings?.selectedNetwork { + ProposalNetworkContextRow(network: net) + } CosignKeyValueRow( label: CosignCopy.ProposalCreation.signerLabel, value: request.signer.label diff --git a/Modules/UI/Sources/ProposalSigningContextCard.swift b/Modules/UI/Sources/ProposalSigningContextCard.swift index 31402a6..ba2b3c9 100644 --- a/Modules/UI/Sources/ProposalSigningContextCard.swift +++ b/Modules/UI/Sources/ProposalSigningContextCard.swift @@ -1,3 +1,4 @@ +import Core import SwiftUI struct ProposalSigningContextItem: Identifiable { @@ -7,12 +8,81 @@ struct ProposalSigningContextItem: Identifiable { var detail: String? } +struct ProposalNetworkContextRow: View { + let network: Network + var isLast = false + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: CosignFactLayout.columnSpacing) { + CosignFactLabel(CosignCopy.ProposalSigning.networkLabel) + .frame(width: CosignFactLayout.labelWidth, alignment: .leading) + .padding(.top, 2) + .layoutPriority(3) + valueArea + .layoutPriority(2) + } + .padding(.vertical, CosignFactLayout.verticalPadding) + .padding(.horizontal, CosignFactLayout.horizontalPadding) + + if !isLast { + Divider() + .overlay(CosignTheme.line) + .padding( + .leading, + CosignFactLayout.horizontalPadding + + CosignFactLayout.labelWidth + + CosignFactLayout.columnSpacing + ) + } + } + } + + private var valueArea: some View { + HStack(spacing: 8) { + Circle() + .fill(network == .mainnet ? CosignTheme.accent : CosignTheme.ink.opacity(0.3)) + .frame(width: 7, height: 7) + Text(network.displayName) + .font( + network == .mainnet + ? .system(size: 13, weight: .semibold) + : .system(size: 13, weight: .medium) + ) + .foregroundStyle( + network == .mainnet + ? CosignTheme.ink + : CosignTheme.ink.opacity(0.82) + ) + if network == .mainnet { + Text(CosignCopy.ProposalSigning.mainnetSigningDetail) + .textCase(.uppercase) + .font(.system(size: 10.5, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(CosignTheme.accent) + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background(CosignTheme.accent.opacity(0.13), in: Capsule()) + } else { + Text(CosignCopy.ProposalSigning.devnetSigningDetail) + .font(.system(size: 12)) + .foregroundStyle(CosignTheme.ink.opacity(0.4)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + struct ProposalSigningContextCard: View { + var network: Network? let items: [ProposalSigningContextItem] var body: some View { CosignCard(padding: 0) { VStack(spacing: 0) { + if let network { + ProposalNetworkContextRow(network: network, isLast: items.isEmpty) + } ForEach(Array(items.enumerated()), id: \.element.id) { index, item in ProposalSigningContextRow( label: item.label, diff --git a/Modules/UI/Sources/SelfHostedRelayView.swift b/Modules/UI/Sources/SelfHostedRelayView.swift index c63c5c9..9f3aefc 100644 --- a/Modules/UI/Sources/SelfHostedRelayView.swift +++ b/Modules/UI/Sources/SelfHostedRelayView.swift @@ -46,7 +46,7 @@ public struct SelfHostedRelayView: View { resetEndpointURL() } .buttonStyle(CosignButtonStyle(kind: .destructive)) - .disabled(networkSettings.rpcURL == NetworkSettingsStore.defaultRPCURL) + .disabled(!networkSettings.hasCustomRelayOverride) } if let loadErrorMessage = networkSettings.loadErrorMessage { diff --git a/Modules/UI/Sources/SignerHomeView.swift b/Modules/UI/Sources/SignerHomeView.swift index 1e19757..3996561 100644 --- a/Modules/UI/Sources/SignerHomeView.swift +++ b/Modules/UI/Sources/SignerHomeView.swift @@ -12,6 +12,7 @@ public struct SignerHomeView: View { @Environment(\.indexerEnvironment) var indexerEnvironment @Environment(\.squadsService) var squadsService @Environment(NetworkHealth.self) var networkHealth: NetworkHealth? + @Environment(NetworkSettingsStore.self) private var networkSettings: NetworkSettingsStore? @Query(sort: \RegisteredSigner.createdAt, order: .forward) private var signers: [RegisteredSigner] @@ -270,8 +271,7 @@ public struct SignerHomeView: View { if demoMode?.usesMarketingNetworkFooter == true { return "mainnet" } - let buildEnvironment = CosignBuildEnvironment.current().environmentName - return buildEnvironment.isEmpty ? "network" : buildEnvironment + return networkSettings?.selectedNetwork.rawValue ?? "network" } private var footerStatus: NetworkHealthStatus { diff --git a/Modules/UI/Sources/SignersListView.swift b/Modules/UI/Sources/SignersListView.swift index 0c23d76..cb4cf24 100644 --- a/Modules/UI/Sources/SignersListView.swift +++ b/Modules/UI/Sources/SignersListView.swift @@ -13,6 +13,7 @@ public struct SignersListView: View { @Environment(\.cosignDemoMode) private var demoMode @Environment(\.squadsService) private var squadsService @Environment(NetworkHealth.self) private var networkHealth: NetworkHealth? + @Environment(NetworkSettingsStore.self) private var networkSettings: NetworkSettingsStore? @Query(sort: \RegisteredSigner.createdAt, order: .forward) private var signers: [RegisteredSigner] @@ -30,9 +31,7 @@ public struct SignersListView: View { CosignAnchoredFooterScreen { HStack { CosignWordmark() - if let env = envBadge { - EnvBadge(label: env.label, tone: env.tone) - } + headerIndicator Spacer() HStack(spacing: 2) { CosignIconButton(glyph: .search) { @@ -298,8 +297,7 @@ private extension SignersListView { if demoMode?.usesMarketingNetworkFooter == true { return "mainnet" } - let buildEnvironment = CosignBuildEnvironment.current().environmentName - return buildEnvironment.isEmpty ? "network" : buildEnvironment + return networkSettings?.selectedNetwork.rawValue ?? "network" } var footerStatus: NetworkHealthStatus { @@ -309,16 +307,13 @@ private extension SignersListView { return networkHealth?.status ?? .healthy } - var envBadge: (label: String, tone: EnvBadgeTone)? { + @ViewBuilder var headerIndicator: some View { if let demoMode { - return demoMode.usesMarketingNetworkFooter ? nil : ("DEMO", .demo) - } - let environment = CosignBuildEnvironment.current().environmentName.lowercased() - switch environment { - case "", "mainnet", "mainnet-beta": - return nil - default: - return (environment.uppercased(), .neutral) + if !demoMode.usesMarketingNetworkFooter { + EnvBadge(label: CosignCopy.Common.demoBadgeLabel, tone: .demo) + } + } else if let net = networkSettings?.selectedNetwork { + NetworkIndicator(network: net) } } } diff --git a/Project.swift b/Project.swift index ecce093..985af2b 100644 --- a/Project.swift +++ b/Project.swift @@ -40,6 +40,8 @@ func appBuildSettings( appIconName: String, relayURL: String = "", environmentName: String = "", + devnetRelayURL: String = "", + mainnetRelayURL: String = "", demoModeProfile: String? = nil ) -> Settings { let baseOverrides: SettingsDictionary = [ @@ -49,6 +51,8 @@ func appBuildSettings( "COSIGN_DEMO_MODE_PROFILE": .string(demoModeProfile ?? ""), "COSIGN_RELAY_URL": .string(relayURL), "COSIGN_ENV": .string(environmentName), + "COSIGN_DEVNET_RELAY_URL": .string(devnetRelayURL), + "COSIGN_MAINNET_RELAY_URL": .string(mainnetRelayURL), "EMBED_BUILD_CLAIM": "NO" ] let base = TargetFactory.appBuildSettings.merging(baseOverrides) @@ -85,6 +89,8 @@ func appTarget( appIconName: String, relayURL: String = "", environmentName: String = "", + devnetRelayURL: String = "", + mainnetRelayURL: String = "", demoModeProfile: String? = nil ) -> Target { .target( @@ -105,6 +111,8 @@ func appTarget( appIconName: appIconName, relayURL: relayURL, environmentName: environmentName, + devnetRelayURL: devnetRelayURL, + mainnetRelayURL: mainnetRelayURL, demoModeProfile: demoModeProfile ) ) @@ -244,32 +252,19 @@ let project = Project( // because the CosignCore Swift module re-exports types from the // cosign_coreFFI Clang module, and Swift requires that consumer targets // can resolve all transitively-referenced modules at compile time. - // Each non-demo build is pinned to one environment's relay — the app is a - // thin, verifiable client of that single endpoint. Replace the relayURL - // values below with the deployed relay hosts before shipping. - // TEMPORARY: the mainnet-bundle-id `Cosign` target ships devnet content for - // the first TestFlight build (to claim com.hackshare.cosign now). It uses - // the ribboned devnet icon so testers can tell. Switch relayURL / - // environmentName / appIconName back to mainnet (+ "AppIcon") when shipping - // mainnet; `CosignDevnet` (com.hackshare.cosign.devnet) is the separate - // devnet release. + // Cosign (com.hackshare.cosign): the combo app. Carries both relay URLs; the + // user switches devnet/mainnet at runtime (default per install migration). The + // in-app network indicator shows which network is active, so the icon is neutral. appTarget( name: "Cosign", bundleId: TargetFactory.bundleIdPrefix, displayName: "Cosign", developmentURLScheme: "cosign-dev", - appIconName: "AppIconDevnet", - relayURL: "https://cosign-relay-devnet.fly.dev", - environmentName: "devnet" - ), - appTarget( - name: "CosignDevnet", - bundleId: "\(TargetFactory.bundleIdPrefix).devnet", - displayName: "Cosign Devnet", - developmentURLScheme: "cosign-devnet-dev", - appIconName: "AppIconDevnet", - relayURL: "https://cosign-relay-devnet.fly.dev", - environmentName: "devnet" + appIconName: "AppIcon", + relayURL: "https://devnet-cosign.hackshare.com", + environmentName: "devnet", + devnetRelayURL: "https://devnet-cosign.hackshare.com", + mainnetRelayURL: "https://cosign.hackshare.com" ), appTarget( name: "CosignDemo", diff --git a/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift b/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift index 6d66122..604f9ae 100644 --- a/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift +++ b/UITests/Sources/CosignDemoDesignWalkthroughUITests.swift @@ -441,6 +441,12 @@ final class SignerFlowUITests: DemoWalkthroughUITestCase { tapButton("settings-network-row") waitForScreen("screen.network-settings") capture("36-network-settings") + tapButton("network-row-devnet") + capture("90-network-devnet-selected") + tapButton("network-row-mainnet") + waitForButton("Switch to mainnet") + capture("91-caution-switch-mainnet") + tapButton("Stay on devnet") launchDemo(profile: "appstore") waitForScreen("screen.signers") @@ -621,15 +627,16 @@ final class SigningTallyUITests: DemoWalkthroughUITestCase { // MARK: - Devnet live-relay walkthrough final class DevnetWalkthroughUITests: DemoWalkthroughUITestCase { - /// Drives the real CosignDevnet build against the deployed relay + a live - /// devnet fixture. Seeded by the fixture member's keypair, passed in via + /// Drives the combo Cosign build against the deployed relay + a live devnet + /// fixture. Seeded by the fixture member's keypair, passed in via /// `TEST_RUNNER_COSIGN_DEVNET_SEED` (hex of a 64-byte keypair) so no secret - /// is committed. The on-chain co-sign at the end is best-effort. + /// is committed. The app's migration heuristic resolves to devnet when + /// signers exist. The on-chain co-sign at the end is best-effort. func testDevnetWalkthroughAgainstLiveRelay() throws { let seed = ProcessInfo.processInfo.environment["COSIGN_DEVNET_SEED"] ?? "" try XCTSkipIf(seed.isEmpty, "set TEST_RUNNER_COSIGN_DEVNET_SEED to a 64-byte keypair hex") - app = XCUIApplication(bundleIdentifier: "com.hackshare.cosign.devnet") + app = XCUIApplication(bundleIdentifier: "com.hackshare.cosign") app.launchArguments = ["--cosign-seed-signer=\(seed)", "--ui-testing"] app.launch() diff --git a/core/fly.mainnet.toml b/core/fly.mainnet.toml new file mode 100644 index 0000000..5ed114f --- /dev/null +++ b/core/fly.mainnet.toml @@ -0,0 +1,33 @@ +# Cosign mainnet relay. Deploy: `cd core && fly deploy -c fly.mainnet.toml`. +# First-time setup: +# fly apps create cosign-relay-mainnet +# fly secrets set COSIGN_RELAY_RPC_URL="https://mainnet.helius-rpc.com/?api-key=..." -a cosign-relay-mainnet +# (The relay binary is unchanged from the devnet build; only the app name and the +# upstream RPC secret differ. It proxies + filters RPC and serves the cosign/v1 +# endpoints + Jupiter prices. The Helius mainnet key lives ONLY in the Fly secret, +# never in the repo.) +# +# Topology: two machines spread across sjc + iad, one always warm. +# fly scale count 2 --region sjc,iad -a cosign-relay-mainnet +app = "cosign-relay-mainnet" +primary_region = "sjc" + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 1 + + [[http_service.checks]] + interval = "15s" + timeout = "2s" + grace_period = "10s" + method = "GET" + path = "/_health" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/core/fly.toml b/core/fly.toml index 8d185fb..eab985e 100644 --- a/core/fly.toml +++ b/core/fly.toml @@ -9,6 +9,12 @@ primary_region = "sjc" [build] +# The devnet relay is not the canonical marketing host. Redirect its landing +# paths (/, /privacy, /favicon.svg, /assets/*) to the single canonical site; +# its RPC/API role for devnet Solana is unaffected. +[env] + COSIGN_LANDING_REDIRECT = "https://cosign.hackshare.com" + [http_service] internal_port = 8080 force_https = true diff --git a/core/src/bin/relay-server.rs b/core/src/bin/relay-server.rs index 7169d19..3ce9a5b 100644 --- a/core/src/bin/relay-server.rs +++ b/core/src/bin/relay-server.rs @@ -173,6 +173,11 @@ struct RelayConfig { explorer_rpc_url: Option, rpc_allowed_methods: BTreeSet, rate_limits: RelayRateLimits, + // When set, GET requests for the marketing paths return a 301 to this base + // instead of serving the landing page, so a non-canonical host (the devnet + // relay) points visitors at the single canonical site without affecting its + // RPC/API role. + landing_redirect: Option, } impl RelayConfig { @@ -199,6 +204,10 @@ impl RelayConfig { .filter(|value| !value.trim().is_empty()), rpc_allowed_methods: rpc_allowed_methods_from_env(), rate_limits: RelayRateLimits::from_env()?, + landing_redirect: env::var("COSIGN_LANDING_REDIRECT") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| value.trim_end_matches('/').to_string()), }) } @@ -698,6 +707,13 @@ fn handle_request_with_client( return health_response(); } + if request.method == "GET" + && let Some(base) = config.landing_redirect.as_deref() + && is_landing_path(&request.path) + { + return redirect_response(format!("{base}{}", request.path)); + } + #[cfg(feature = "landing")] if request.method == "GET" && request.path == "/" { return html_response(200, LANDING_HTML.to_string()); @@ -5445,7 +5461,32 @@ fn json_response(status: u16, body: Value) -> HttpResponse { } } +fn redirect_response(location: String) -> HttpResponse { + HttpResponse { + status: 301, + reason: reason_phrase(301), + content_type: "text/plain; charset=utf-8", + body: location.into_bytes(), + } +} + +fn is_landing_path(path: &str) -> bool { + matches!(path, "/" | "/privacy" | "/favicon.svg") || path.starts_with("/assets/") +} + fn write_response(stream: &mut impl Write, response: HttpResponse) -> Result<(), RelayError> { + // A 3xx response carries its Location target in `body` (see redirect_response). + // No non-redirect response uses a 3xx status, so this branch is unambiguous. + if matches!(response.status, 301 | 302 | 307 | 308) { + let location = String::from_utf8_lossy(&response.body); + write!( + stream, + "HTTP/1.1 {} {}\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + response.status, response.reason, location + )?; + stream.flush()?; + return Ok(()); + } write!( stream, "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -5462,6 +5503,7 @@ fn write_response(stream: &mut impl Write, response: HttpResponse) -> Result<(), fn reason_phrase(status: u16) -> &'static str { match status { 200 => "OK", + 301 => "Moved Permanently", 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", @@ -5527,6 +5569,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: methods, rate_limits: RelayRateLimits::default(), + landing_redirect: None, } } @@ -5930,6 +5973,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; assert!(matches!(config.rpc_url(), Err(RelayError::BadRequest(_)))); @@ -5945,6 +5989,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; assert_eq!(config.rpc_url().expect("rpc url"), "http://127.0.0.1:8899"); @@ -5973,6 +6018,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let request = HttpRequest { method: "GET".into(), @@ -5997,6 +6043,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let request = HttpRequest { method: "GET".into(), @@ -6016,6 +6063,41 @@ mod tests { ); } + #[test] + fn landing_redirect_moves_marketing_paths_only() { + let mut config = rpc_test_config(default_rpc_allowed_methods()); + config.landing_redirect = Some("https://cosign.hackshare.com".into()); + + let get = |path: &str| HttpRequest { + method: "GET".into(), + path: path.into(), + query: None, + body: Vec::new(), + host: None, + websocket_key: None, + }; + + let root = handle_request_with_client(&get("/"), &config, None); + assert_eq!(root.status, 301); + assert_eq!( + std::str::from_utf8(&root.body).expect("utf8"), + "https://cosign.hackshare.com/" + ); + + let privacy = handle_request_with_client(&get("/privacy"), &config, None); + assert_eq!(privacy.status, 301); + assert_eq!( + std::str::from_utf8(&privacy.body).expect("utf8"), + "https://cosign.hackshare.com/privacy" + ); + + // The relay's API role is untouched by the redirect. + assert_eq!( + handle_request_with_client(&get("/_health"), &config, None).status, + 200 + ); + } + #[cfg(feature = "landing")] #[test] fn get_privacy_serves_page_when_enabled() { @@ -6026,6 +6108,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let request = HttpRequest { method: "GET".into(), @@ -6054,6 +6137,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let request = HttpRequest { method: "GET".into(), @@ -6078,6 +6162,7 @@ mod tests { explorer_rpc_url: None, rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let request = HttpRequest { method: "GET".into(), @@ -6153,6 +6238,7 @@ mod tests { explorer_rpc_url: Some("https://relay.cosign.example".into()), rpc_allowed_methods: default_rpc_allowed_methods(), rate_limits: RelayRateLimits::default(), + landing_redirect: None, }; let response = capabilities_response(&config, Some("relay.cosign.example")); let body: Value = serde_json::from_slice(&response.body).expect("json"); diff --git a/core/static/index.html b/core/static/index.html index b53ab1f..72b467d 100644 --- a/core/static/index.html +++ b/core/static/index.html @@ -97,14 +97,13 @@
-
Public TestFlight beta · Solana devnet
-

Know what
you sign.

-

Squads multisig proposals arrive encoded, and most signers approve what they cannot read. Cosign is a native iOS signer that decodes every instruction into plain language, so you review the real action before you add your signature.

+

Know what
you sign.

+

Squads multisig proposals arrive encoded, and most signers approve what they cannot read. Cosign is a native iOS signer that decodes every instruction into plain language, so you review the real action before you add your signature. One app, two networks: test on devnet, operate on mainnet, where you are signing real funds.

-
Native iOS · SwiftUI · Squads v4 · keys held on device
+
Public beta · Native iOS · SwiftUI · Squads v4 · keys held on device
@@ -199,6 +198,30 @@

+
+
+
One app, two networks
+

Test on devnet.
Operate on mainnet.

+

Rehearse the whole flow on devnet, create squads, build proposals, sign, with nothing real at stake. Switch to mainnet in Settings when you are ready to operate. The switch is deliberate: mainnet asks you to confirm, and every mainnet signature is marked, so you always know which network you are on.

+
+
+
+
ACTIVE NETWORK
+
+
Devnet
Test network · practice freely
+
Mainnet
Real funds
REAL FUNDS
+
+
+
Switching to mainnet asks you to confirm
+
Every mainnet signature is marked at signing
+
+
+
+
+

+
@@ -315,7 +338,7 @@

Know what you sign.

-

The beta runs on Solana devnet, so you can create squads, build proposals, and co-sign without real funds at risk.

+

The safety is the same on either network: your keys stay on your device, every action is decoded before you sign, and each build is verifiable. Practice on devnet, operate on mainnet.

Join the TestFlight beta View on GitHub @@ -326,7 +349,7 @@

cosign

-
TestFlight beta · Solana devnet
+
Public beta · devnet + mainnet

diff --git a/scripts/deploy-sim.sh b/scripts/deploy-sim.sh index 59a53cc..0969e5a 100755 --- a/scripts/deploy-sim.sh +++ b/scripts/deploy-sim.sh @@ -8,7 +8,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -SCHEMES="${SCHEMES:-CosignDevnet CosignDemo}" +SCHEMES="${SCHEMES:-Cosign CosignDemo}" UDID="$(xcrun simctl list devices booted 2>/dev/null \ | grep -oE '\([0-9A-Fa-f-]{36}\)' | tr -d '()' | head -1)" diff --git a/scripts/devnet-fixtures.sh b/scripts/devnet-fixtures.sh index d228cf8..833cf86 100755 --- a/scripts/devnet-fixtures.sh +++ b/scripts/devnet-fixtures.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Populate devnet fixtures: a real 2-of-3 Squads multisig + an active proposal, -# so the CosignDevnet build has live data. Reads/writes .env.devnet. +# so the combo Cosign build has live devnet data. Reads/writes .env.devnet. set -euo pipefail cd "$(dirname "$0")/.." exec cargo run --quiet --manifest-path core/Cargo.toml --example devnet_fixture "$@"