diff --git a/Sources/ColumbaApp/Resources/Localizable.xcstrings b/Sources/ColumbaApp/Resources/Localizable.xcstrings index 25127c6e..217b5e7e 100644 --- a/Sources/ColumbaApp/Resources/Localizable.xcstrings +++ b/Sources/ColumbaApp/Resources/Localizable.xcstrings @@ -3054,6 +3054,196 @@ } } } + }, + "Running": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } + }, + "Starting…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting…" + } + } + } + }, + "Reconnecting…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting…" + } + } + } + }, + "Stopping…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping…" + } + } + } + }, + "Not configured": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not configured" + } + } + } + }, + "Stopped": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopped" + } + } + } + }, + "Reticulum Network": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reticulum Network" + } + } + } + }, + "Manually stop or start the background Reticulum service.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manually stop or start the background Reticulum service." + } + } + } + }, + "Start": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + } + } + }, + "Stop": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + } + } + }, + "Learn more & set up": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Learn more & set up" + } + } + } + }, + "The indicator means iOS has an active network extension. It stays visible while Reticulum is running.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The indicator means iOS has an active network extension. It stays visible while Reticulum is running." + } + } + } + }, + "Not a commercial VPN": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not a commercial VPN" + } + } + } + }, + "Your other apps and web traffic are not proxied or routed through Columba.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your other apps and web traffic are not proxied or routed through Columba." + } + } + } + }, + "The component carries Columba and Reticulum traffic, which uses the relay and interfaces you configured.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The component carries Columba and Reticulum traffic, which uses the relay and interfaces you configured." + } + } + } + }, + "The VPN indicator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The VPN indicator" + } + } + } + }, + "What it does": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What it does" + } + } + } + }, + "Receives messages, calls, and announcements even when Columba isn't open.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Receives messages, calls, and announcements even when Columba isn't open." + } + } + } + }, + "Keeps you connected to the Reticulum network in the background.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keeps you connected to the Reticulum network in the background." + } + } + } } }, "version": "1.0" diff --git a/Sources/ColumbaApp/Views/Components/BackgroundVPNExplainer.swift b/Sources/ColumbaApp/Views/Components/BackgroundVPNExplainer.swift index 53ddfb8f..190d4380 100644 --- a/Sources/ColumbaApp/Views/Components/BackgroundVPNExplainer.swift +++ b/Sources/ColumbaApp/Views/Components/BackgroundVPNExplainer.swift @@ -22,35 +22,34 @@ import SwiftUI struct VPNNotCommercialExplainer: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - VPNExplainerUI.sectionHeader(icon: "lock.shield.fill", title: "Not a commercial VPN") + VPNExplainerUI.sectionHeader(icon: "lock.shield.fill", title: String(localized: "Not a commercial VPN")) - Text("Columba uses Apple's VPN system to keep its own network component available in the background. It is not a commercial VPN service.") + Text(String(localized: "Columba uses Apple's VPN system to keep its own network component available in the background. It is not a commercial VPN service.")) .font(.caption) .foregroundStyle(Theme.textSecondary) .fixedSize(horizontal: false, vertical: true) VPNExplainerUI.explainerRow( icon: "checkmark.shield.fill", - text: "Your other apps and web traffic are not proxied or routed through Columba." + text: String(localized: "Your other apps and web traffic are not proxied or routed through Columba.") ) VPNExplainerUI.explainerRow( icon: "iphone", - text: "The component carries Columba and Reticulum traffic, which may use the relay and interfaces you configured." + text: String(localized: "The component carries Columba and Reticulum traffic, which uses the relay and interfaces you configured.") ) } } } /// "The VPN badge" explainer — pre-empts the iOS status-bar VPN badge, the thing that -/// makes the feature look like a traditional VPN. `featureName` fills the trailing -/// sentence so each surface uses its own term ("background transport" vs "delivery"). +/// makes the feature look like a traditional VPN. The indicator is tied to the in-NE +/// Reticulum runtime, so the trailing sentence reads "…while Reticulum is running". @available(iOS 17.0, macOS 14.0, *) struct VPNBadgeExplainer: View { - let featureName: String var body: some View { VStack(alignment: .leading, spacing: 10) { - VPNExplainerUI.sectionHeader(icon: "rectangle.topthird.inset.filled", title: "The VPN indicator") + VPNExplainerUI.sectionHeader(icon: "rectangle.topthird.inset.filled", title: String(localized: "The VPN indicator")) HStack(spacing: 10) { Text("VPN") @@ -61,13 +60,13 @@ struct VPNBadgeExplainer: View { .background(Theme.accentColor) .clipShape(RoundedRectangle(cornerRadius: 4)) - Text("While this is on, iOS shows a VPN indicator.") + Text(String(localized: "While this is on, iOS shows a VPN indicator.")) .font(.subheadline) .foregroundStyle(Theme.textPrimary) .fixedSize(horizontal: false, vertical: true) } - Text("The indicator means iOS has an active network extension. It stays visible while \(featureName) is enabled.") + Text(String(localized: "The indicator means iOS has an active network extension. It stays visible while Reticulum is running.")) .font(.caption) .foregroundStyle(Theme.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -81,7 +80,7 @@ struct VPNBadgeExplainer: View { @available(iOS 17.0, macOS 14.0, *) @MainActor enum VPNExplainerUI { - static func sectionHeader(icon: String, title: LocalizedStringKey) -> some View { + static func sectionHeader(icon: String, title: String) -> some View { HStack(spacing: 8) { Image(systemName: icon) .font(.system(size: 16, weight: .semibold)) @@ -93,7 +92,7 @@ enum VPNExplainerUI { } } - static func explainerRow(icon: String, text: LocalizedStringKey) -> some View { + static func explainerRow(icon: String, text: String) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: icon) .font(.system(size: 16)) diff --git a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift index e45312c8..7f633fd9 100644 --- a/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift +++ b/Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift @@ -157,11 +157,11 @@ struct BackgroundDeliveryPage: View { .explainerCardStyle() } - /// "The VPN badge" note, shared with settings via `VPNBadgeExplainer`. The feature - /// name reads "background delivery" to match this flow (settings says "background - /// transport"). + /// "The VPN badge" note, shared with settings via `VPNBadgeExplainer`. The + /// indicator text reads "…while Reticulum is running" (the badge is tied to + /// the in-NE Reticulum runtime on every surface). private var badgeCard: some View { - VPNBadgeExplainer(featureName: "background delivery") + VPNBadgeExplainer() .explainerCardStyle() } } diff --git a/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift index 1b8647da..104bf85c 100644 --- a/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift +++ b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift @@ -139,19 +139,15 @@ struct BackgroundTransportView: View { private var explainerCard: some View { VStack(alignment: .leading, spacing: 12) { - VPNExplainerUI.sectionHeader(icon: "questionmark.circle", title: "What it does") + VPNExplainerUI.sectionHeader(icon: "questionmark.circle", title: String(localized: "What it does")) VPNExplainerUI.explainerRow( icon: "tray.and.arrow.down.fill", - text: "Receives messages, calls, and announcements even when Columba isn't open." + text: String(localized: "Receives messages, calls, and announcements even when Columba isn't open.") ) VPNExplainerUI.explainerRow( icon: "point.3.connected.trianglepath.dotted", - text: "Keeps your TCP and local-network (LAN) links connected to the Reticulum mesh in the background." - ) - VPNExplainerUI.explainerRow( - icon: "bolt.fill", - text: "Uses more battery and data than running only in the foreground." + text: String(localized: "Keeps you connected to the Reticulum network in the background.") ) } .padding(16) @@ -161,7 +157,7 @@ struct BackgroundTransportView: View { // MARK: - Status-Bar Badge Card private var badgeCard: some View { - VPNBadgeExplainer(featureName: "background transport") + VPNBadgeExplainer() .padding(16) .glassCard() } diff --git a/Sources/ColumbaApp/Views/Settings/SettingsView.swift b/Sources/ColumbaApp/Views/Settings/SettingsView.swift index 124f2a18..2a241421 100644 --- a/Sources/ColumbaApp/Views/Settings/SettingsView.swift +++ b/Sources/ColumbaApp/Views/Settings/SettingsView.swift @@ -57,8 +57,12 @@ struct SettingsView: View { @AppStorage(ComposerKeyboardPreference.key) private var sendsOnReturn = ComposerKeyboardPreference.defaultValue #if COLUMBA_RUNTIME_MODEL_B - /// Presents the background-transport explainer / enable sheet. - @State private var showBackgroundTransport = false + /// Expanded state for the Reticulum Network card (Settings). + @State private var isReticulumExpanded = false + /// Transient busy state for the card's Start/Stop action. + @State private var isReticulumActionWorking = false + /// Presents the Reticulum Network explainer / first-time consent sheet. + @State private var showReticulumExplainer = false #endif @State private var interfaceRepository: InterfaceRepository? /// Persisted across body re-evaluations so showRNodeWizard=true is not lost @@ -75,12 +79,12 @@ struct SettingsView: View { // Network networkCard(vm) - // Background Transport — the Model B Network Extension VPN - // tunnel that keeps the experimental native node alive when - // the app is backgrounded. It is compiled entirely out of the - // shipping Python product. + // Reticulum Network — the Model B in-NE RNS runtime. + // Manually start/stop it; defaults to enabled on app + // launch. Compiled entirely out of the shipping Python + // product. #if COLUMBA_RUNTIME_MODEL_B - backgroundTransportCard() + reticulumNetworkCard() #endif // Delivery & Retrieval @@ -526,97 +530,111 @@ struct SettingsView: View { // card and its NetworkExtension types are absent from the shipping Python app. @ViewBuilder - private func backgroundTransportCard() -> some View { + private func reticulumNetworkCard() -> some View { if let tunnel = appServices.tunnelManager { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Theme.accentColor) - - Text("Background Transport") - .font(.headline) - .foregroundStyle(Theme.textPrimary) + ExpandableSettingsCard( + icon: "antenna.radiowaves.left.and.right.circle.fill", + title: String(localized: "Reticulum Network"), + isExpanded: $isReticulumExpanded + ) { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "Manually stop or start the background Reticulum service.")) + .font(.caption) + .foregroundStyle(Theme.textSecondary) - Spacer() + HStack(spacing: 6) { + Circle() + .fill(reticulumStatusColor(tunnel)) + .frame(width: 8, height: 8) + Text(reticulumStatusLabel(tunnel)) + .font(.caption) + .foregroundStyle(reticulumStatusColor(tunnel)) + } - // Quick toggle for users who've already set this up. - // Enabling installs the VPN profile (which also arms - // on-demand connect) before starting, matching the - // explainer screen's Enable path. The full explainer + - // first-time consent lives behind "Learn more & set up". - Toggle("", isOn: Binding( - get: { tunnel.isRunning }, - set: { newValue in - Task { - if newValue { - try? await tunnel.install() - try? await tunnel.start() + // Stop/Start the in-NE RNS runtime. Stop is non-persistent: + // disable() clears the on-demand connect rule so the tunnel + // actually stays down for this session (a bare stop() would + // let iOS auto-reconnect it shortly after, making Stop feel + // broken). It does NOT touch the backgroundDeliveryEnabled + // flag, so the default-enabled behavior is preserved — on the + // next app launch ensureBackgroundDeliveryTunnel() sees the + // flag still set, re-installs + re-arms on-demand, and brings + // the tunnel back up without prompting. Start reinstalls the + // profile (re-arms on-demand) and reconnects in this session. + Button { + guard !isReticulumActionWorking else { return } + let stopping = tunnel.isRunning + isReticulumActionWorking = true + Task { + defer { isReticulumActionWorking = false } + do { + if stopping { + try await tunnel.disable() } else { - // disable() clears on-demand so iOS won't - // auto-reconnect (a bare stop() would). - try? await tunnel.disable() + try await tunnel.install() + try await tunnel.start() } + } catch { + DiagLog.log("[RNS-CARD] start/stop failed: \(error.localizedDescription)") } } - )) - .labelsHidden() - .tint(Theme.accentColor) - } - - Text("Keep TCP and LAN connections alive when the app is backgrounded. Enables receiving messages and notifications without opening the app.") - .font(.caption) - .foregroundStyle(Theme.textSecondary) - - HStack(spacing: 6) { - Circle() - .fill(backgroundTransportStatusColor(tunnel)) - .frame(width: 8, height: 8) - - Text(backgroundTransportStatusLabel(tunnel)) - .font(.caption) - .foregroundStyle(backgroundTransportStatusColor(tunnel)) - } + } label: { + Group { + if isReticulumActionWorking { + ProgressView().tint(.white) + } else { + Text(tunnel.isRunning ? String(localized: "Stop") : String(localized: "Start")) + } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(tunnel.isRunning + ? LinearGradient(colors: [Theme.error], startPoint: .top, endPoint: .bottom) + : Theme.accentGradient) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + .disabled(isReticulumActionWorking) - Button { - showBackgroundTransport = true - } label: { - HStack(spacing: 8) { - Image(systemName: "info.circle") - .font(.system(size: 14, weight: .medium)) - Text("Learn more & set up") - .font(.system(size: 15, weight: .medium)) + Button { + showReticulumExplainer = true + } label: { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .font(.system(size: 14, weight: .medium)) + Text(String(localized: "Learn more & set up")) + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Theme.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) } - .foregroundStyle(Theme.textPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Theme.backgroundTertiary) - .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) } } - .padding(16) - .glassCard() - .sheet(isPresented: $showBackgroundTransport) { + .sheet(isPresented: $showReticulumExplainer) { BackgroundTransportView(tunnel: tunnel) { - showBackgroundTransport = false + showReticulumExplainer = false } } } } - private func backgroundTransportStatusLabel(_ tunnel: TunnelManager) -> String { + private func reticulumStatusLabel(_ tunnel: TunnelManager) -> String { switch tunnel.status { - case .connected: return "Running" - case .connecting: return "Connecting…" - case .reasserting: return "Reconnecting…" - case .disconnecting: return "Disconnecting…" - case .invalid: return "Not configured" - case .disconnected: return "Stopped" - @unknown default: return tunnel.isEnabled ? "Enabled" : "Stopped" + case .connected: return String(localized: "Running") + case .connecting: return String(localized: "Starting…") + case .reasserting: return String(localized: "Reconnecting…") + case .disconnecting: return String(localized: "Stopping…") + case .invalid: return String(localized: "Not configured") + case .disconnected: return String(localized: "Stopped") + @unknown default: return tunnel.isEnabled ? String(localized: "Enabled") : String(localized: "Stopped") } } - private func backgroundTransportStatusColor(_ tunnel: TunnelManager) -> Color { + private func reticulumStatusColor(_ tunnel: TunnelManager) -> Color { switch tunnel.status { case .connected: return Theme.success case .connecting, .reasserting, .disconnecting: return Theme.warning