import SwiftUI #if DEBUG import BitLogger #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif #endif struct AppInfoView: View { @Environment(\.dismiss) var dismiss @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue /// Supplies the mesh topology map data. Nil (previews, missing wiring) /// hides the topology row entirely. var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? @State private var showTopology = false #if DEBUG // Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off. @AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = "" @AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort @State private var exportedLogURL: URL? @State private var isPresentingLogShare = false #endif private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } private var backgroundColor: Color { palette.background } private var textColor: Color { palette.primary } private var secondaryTextColor: Color { palette.secondary } // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title" enum Features { static let title: LocalizedStringKey = "app_info.features.title" static let offlineComm = AppInfoFeatureInfo( icon: "wifi.slash", title: "app_info.features.offline.title", description: "app_info.features.offline.description" ) static let encryption = AppInfoFeatureInfo( icon: "lock.shield", title: "app_info.features.encryption.title", description: "app_info.features.encryption.description" ) static let extendedRange = AppInfoFeatureInfo( icon: "antenna.radiowaves.left.and.right", title: "app_info.features.extended_range.title", description: "app_info.features.extended_range.description" ) static let mentions = AppInfoFeatureInfo( icon: "at", title: "app_info.features.mentions.title", description: "app_info.features.mentions.description" ) static let favorites = AppInfoFeatureInfo( icon: "star.fill", title: "app_info.features.favorites.title", description: "app_info.features.favorites.description" ) static let geohash = AppInfoFeatureInfo( icon: "number", title: "app_info.features.geohash.title", description: "app_info.features.geohash.description" ) } enum Legend { static let title: LocalizedStringKey = "app_info.legend.title" /// Every glyph the peer lists and headers use, in one place — /// nothing else in the app defines them. static let items: [(icon: String, text: LocalizedStringKey)] = [ ("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"), ("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"), ("globe", "app_info.legend.nostr"), ("person", "app_info.legend.offline"), ("mappin.and.ellipse", "app_info.legend.location_nearby"), ("face.dashed", "app_info.legend.teleported"), ("lock.fill", "app_info.legend.encrypted"), ("lock.slash", "app_info.legend.encryption_failed"), ("checkmark.seal.fill", "app_info.legend.verified"), ("star.fill", "app_info.legend.favorite"), ("envelope.fill", "app_info.legend.unread"), ("nosign", "app_info.legend.blocked") ] } enum Network { static let title: LocalizedStringKey = "app_info.network.title" static let topology = AppInfoFeatureInfo( icon: "point.3.connected.trianglepath.dotted", title: "app_info.network.topology.title", description: "app_info.network.topology.description" ) } enum Privacy { static let title: LocalizedStringKey = "app_info.privacy.title" static let noTracking = AppInfoFeatureInfo( icon: "eye.slash", title: "app_info.privacy.no_tracking.title", description: "app_info.privacy.no_tracking.description" ) static let ephemeral = AppInfoFeatureInfo( icon: "shuffle", title: "app_info.privacy.ephemeral.title", description: "app_info.privacy.ephemeral.description" ) static let panic = AppInfoFeatureInfo( icon: "hand.raised.fill", title: "app_info.privacy.panic.title", description: "app_info.privacy.panic.description" ) } enum HowToUse { static let title: LocalizedStringKey = "app_info.how_to_use.title" static let instructions: [LocalizedStringKey] = [ "app_info.how_to_use.set_nickname", "app_info.how_to_use.change_channels", "app_info.how_to_use.open_sidebar", "app_info.how_to_use.start_dm", "app_info.how_to_use.clear_chat", "app_info.how_to_use.commands" ] } } var body: some View { #if os(macOS) VStack(spacing: 0) { // Custom header for macOS HStack { Spacer() Button("app_info.done") { dismiss() } .buttonStyle(.plain) .foregroundColor(textColor) .padding() } .themedSurface(opacity: 0.95) ScrollView { infoContent } .themedSheetBackground() } .frame(width: 600, height: 700) .sheet(isPresented: $showTopology) { if let topologyProvider { MeshTopologyView(provider: topologyProvider) } } #else NavigationView { ScrollView { infoContent } .themedSheetBackground() .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { SheetCloseButton { dismiss() } .foregroundColor(textColor) } } } .sheet(isPresented: $showTopology) { if let topologyProvider { MeshTopologyView(provider: topologyProvider) } } #endif } @ViewBuilder private var infoContent: some View { VStack(alignment: .leading, spacing: 24) { // Header VStack(alignment: .center, spacing: 8) { Text(Strings.appName) .bitchatFont(size: 32, weight: .bold) .foregroundColor(textColor) Text(Strings.tagline) .bitchatFont(size: 16) .foregroundColor(secondaryTextColor) } .frame(maxWidth: .infinity) .padding(.vertical) // Appearance — single row: label left, theme chips right HStack(spacing: 12) { SectionHeader(Strings.appearanceTitle) Spacer() ForEach(AppTheme.allCases) { theme in Button { appThemeRawValue = theme.rawValue } label: { Text(theme.displayNameKey) .bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular) .foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor) .padding(.horizontal, 10) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityAddTraits(selectedTheme == theme ? .isSelected : []) } } // How to Use VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.HowToUse.title) VStack(alignment: .leading, spacing: 8) { ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in Text(instruction) } } .bitchatFont(size: 14) .foregroundColor(textColor) } // Network diagnostics if topologyProvider != nil { VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Network.title) Button { showTopology = true } label: { HStack(spacing: 0) { FeatureRow(info: Strings.Network.topology) Image(systemName: "chevron.right") .font(.bitchatSystem(size: 12)) .foregroundColor(secondaryTextColor) } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityHint(Text("app_info.network.topology.hint")) } } // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) FeatureRow(info: Strings.Features.offlineComm) FeatureRow(info: Strings.Features.encryption) FeatureRow(info: Strings.Features.extendedRange) FeatureRow(info: Strings.Features.favorites) FeatureRow(info: Strings.Features.geohash) FeatureRow(info: Strings.Features.mentions) } // Symbols legend VStack(alignment: .leading, spacing: 10) { SectionHeader(Strings.Legend.title) ForEach(Strings.Legend.items, id: \.icon) { item in HStack(alignment: .top, spacing: 12) { Image(systemName: item.icon) .font(.bitchatSystem(size: 14)) .foregroundColor(textColor) .frame(width: 30) Text(item.text) .bitchatFont(size: 13) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) Spacer() } .accessibilityElement(children: .combine) } } // Privacy VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Privacy.title) FeatureRow(info: Strings.Privacy.noTracking) FeatureRow(info: Strings.Privacy.ephemeral) FeatureRow(info: Strings.Privacy.panic) } #if DEBUG debugSection #endif } .padding() } #if DEBUG // Tester-only observability panel: live LAN log streaming + a logs export. // Never compiled into release (privacy-first: release ships no such UI). @ViewBuilder private var debugSection: some View { VStack(alignment: .leading, spacing: 12) { SectionHeader("debug & logs") Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.") .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost) .textFieldStyle(.roundedBorder) .bitchatFont(size: 13) #if os(iOS) .keyboardType(.numbersAndPunctuation) .autocapitalization(.none) .disableAutocorrection(true) #endif .accessibilityLabel(Text("log collector host")) TextField("port", value: $logSinkPort, format: .number.grouping(.never)) .textFieldStyle(.roundedBorder) .bitchatFont(size: 13) .frame(width: 70) #if os(iOS) .keyboardType(.numberPad) #endif .accessibilityLabel(Text("log collector port")) } .onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() } .onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() } Button(action: exportLogs) { HStack(alignment: .top, spacing: 12) { Image(systemName: "square.and.arrow.up") .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) VStack(alignment: .leading, spacing: 4) { Text("Export Logs") .bitchatFont(size: 14, weight: .semibold) .foregroundColor(textColor) Text("Share the recent in-memory log buffer as a .txt file.") .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } Spacer() } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(Text("Export logs")) .accessibilityHint(Text("Shares the recent log buffer as a text file")) } #if os(iOS) .sheet(isPresented: $isPresentingLogShare) { if let exportedLogURL { LogShareSheet(activityItems: [exportedLogURL]) } } #endif } /// Write the buffered log to a temp `.txt` and present the platform share. private func exportLogs() { let text = LogExportBuffer.shared.snapshot() let stamp = ISO8601DateFormatter().string(from: Date()) .replacingOccurrences(of: ":", with: "-") let url = FileManager.default.temporaryDirectory .appendingPathComponent("bitchat-logs-\(stamp).txt") do { try text.data(using: .utf8)?.write(to: url) } catch { return } #if os(iOS) exportedLogURL = url isPresentingLogShare = true #elseif os(macOS) let panel = NSSavePanel() panel.nameFieldStringValue = url.lastPathComponent panel.allowedContentTypes = [.plainText] if panel.runModal() == .OK, let dest = panel.url { try? text.data(using: .utf8)?.write(to: dest) } #endif } #endif } #if DEBUG && os(iOS) /// Minimal UIActivityViewController bridge for the Export Logs share sheet. private struct LogShareSheet: UIViewControllerRepresentable { let activityItems: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: activityItems, applicationActivities: nil) } func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} } #endif struct AppInfoFeatureInfo { let icon: String let title: LocalizedStringKey let description: LocalizedStringKey } struct SectionHeader: View { let title: LocalizedStringKey @ThemedPalette private var palette private var textColor: Color { palette.primary } init(_ title: LocalizedStringKey) { self.title = title } var body: some View { Text(title) .bitchatFont(size: 16, weight: .bold) .foregroundColor(textColor) .padding(.top, 8) } } struct FeatureRow: View { let info: AppInfoFeatureInfo @ThemedPalette private var palette private var textColor: Color { palette.primary } private var secondaryTextColor: Color { palette.secondary } var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: info.icon) .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) VStack(alignment: .leading, spacing: 4) { Text(info.title) .bitchatFont(size: 14, weight: .semibold) .foregroundColor(textColor) Text(info.description) .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } Spacer() } } } #Preview("Default") { AppInfoView() } #Preview("Dynamic Type XXL") { AppInfoView() .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) } #Preview("Dynamic Type XS") { AppInfoView() .environment(\.sizeCategory, .extraSmall) }