mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so platform-specific code is never touched), with test targets indexed and the share extension built. 277 dead declarations removed or demoted: dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed- feature remnants (autocomplete command suggestions, back-swipe tuning, MediaSendError, GeohashParticipantTracker), unused Tor dormancy bindings, assign-only properties, unused parameters (renamed to _), and redundant public accessibility. 13 orphaned localization keys deleted across all 29 locales (old pre-#1392 location-notes UI, app_info warnings). Two real tests were flagged as unused because they never ran: Swift Testing methods missing @Test (NostrProtocolTests. testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests. testAssemblesCompressedLargeFrame). Re-armed both; they pass. Deliberately kept, now recorded in .periphery.baseline.json: iOS-only code invisible to the CI macOS scan, C FFI signatures, keep-alive NWPathMonitor reference, InboundEventKey.eventID (dedup semantics), wifiBulk capability bit (reserved for Wi-Fi bulk work, used by BitFoundation package tests), and the String secureClear cluster (exercised by package tests). New: .periphery.yml config and an advisory Dead Code CI job (mirrors the SwiftLint precedent from #1361) that fails on findings not in the committed baseline. Verified: full macOS app suite, BitFoundation (119) and BitLogger (13) package tests green; periphery scan --strict exits clean. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
import SwiftUI
|
|
import CoreImage
|
|
import CoreImage.CIFilterBuiltins
|
|
#if os(iOS)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Placeholder view to display the user's verification QR payload as text.
|
|
struct MyQRView: View {
|
|
let qrString: String
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@ThemedPalette private var palette
|
|
// Palette-tinted so the box follows the theme (green under matrix)
|
|
// instead of a fixed gray band over the glass gradient.
|
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
|
|
|
private enum Strings {
|
|
static let title: LocalizedStringKey = "verification.my_qr.title"
|
|
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
Text(Strings.title)
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
|
|
VStack(spacing: 10) {
|
|
QRCodeImage(data: qrString, size: 240)
|
|
.accessibilityLabel(Strings.accessibilityLabel)
|
|
|
|
// Non-scrolling, fully visible URL (wraps across lines)
|
|
Text(qrString)
|
|
.bitchatFont(size: 11)
|
|
.textSelection(.enabled)
|
|
.multilineTextAlignment(.leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(8)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// Render a QR code image for a given string using CoreImage
|
|
struct QRCodeImage: View {
|
|
let data: String
|
|
let size: CGFloat
|
|
@ThemedPalette private var palette
|
|
|
|
private let context = CIContext()
|
|
private let filter = CIFilter.qrCodeGenerator()
|
|
|
|
private enum Strings {
|
|
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let image = generateImage() {
|
|
ImageWrapper(image: image)
|
|
.frame(width: size, height: size)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
|
|
.frame(width: size, height: size)
|
|
.overlay(
|
|
Text(Strings.unavailable)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func generateImage() -> CGImage? {
|
|
let inputData = Data(data.utf8)
|
|
filter.message = inputData
|
|
filter.correctionLevel = "M"
|
|
guard let outputImage = filter.outputImage else { return nil }
|
|
let scale = max(1, Int(size / 32))
|
|
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
|
return context.createCGImage(transformed, from: transformed.extent)
|
|
}
|
|
}
|
|
|
|
// Platform-specific wrapper to display CGImage in SwiftUI
|
|
struct ImageWrapper: View {
|
|
let image: CGImage
|
|
var body: some View {
|
|
#if os(iOS)
|
|
let ui = UIImage(cgImage: image)
|
|
return Image(uiImage: ui)
|
|
.interpolation(.none)
|
|
.resizable()
|
|
#else
|
|
let ns = NSImage(cgImage: image, size: .zero)
|
|
return Image(nsImage: ns)
|
|
.interpolation(.none)
|
|
.resizable()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
|
struct QRScanView: View {
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@ThemedPalette private var palette
|
|
var isActive: Bool = true
|
|
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
|
@State private var input = ""
|
|
@State private var result: String = "" // not shown for iOS scanner
|
|
@State private var lastValid: String = ""
|
|
|
|
private enum Strings {
|
|
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
|
static let validate: LocalizedStringKey = "verification.scan.validate"
|
|
static func requested(_ nickname: String) -> String {
|
|
String(
|
|
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
|
locale: .current,
|
|
nickname
|
|
)
|
|
}
|
|
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
|
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
#if os(iOS)
|
|
CameraScannerView(isActive: isActive) { code in
|
|
// Deduplicate: ignore if we just processed this exact QR code
|
|
guard code != lastValid else { return }
|
|
|
|
switch verificationModel.verifyScannedPayload(code) {
|
|
case .requested:
|
|
// Successfully initiated verification; remember this QR to prevent re-scanning
|
|
lastValid = code
|
|
// Close scanner and return to "My QR" view
|
|
onSuccess?()
|
|
case .notFound, .invalid:
|
|
// Ignore invalid/no-match reads and keep scanning
|
|
break
|
|
}
|
|
}
|
|
.frame(height: 260)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
#else
|
|
Text(Strings.pastePrompt)
|
|
.bitchatFont(size: 14, weight: .medium)
|
|
TextEditor(text: $input)
|
|
.frame(height: 100)
|
|
.border(palette.secondary.opacity(0.4))
|
|
Button(Strings.validate) {
|
|
// Deduplicate: ignore if we just processed this exact QR
|
|
guard input != lastValid else {
|
|
result = Strings.requested("") // Already processed
|
|
return
|
|
}
|
|
|
|
switch verificationModel.verifyScannedPayload(input) {
|
|
case .requested(let nickname):
|
|
result = Strings.requested(nickname)
|
|
lastValid = input
|
|
// Close scanner and return to "My QR" view
|
|
onSuccess?()
|
|
case .notFound:
|
|
result = Strings.notFound
|
|
case .invalid:
|
|
result = Strings.invalid
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
#endif
|
|
// No status text under camera per design
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
import AVFoundation
|
|
|
|
struct CameraScannerView: UIViewRepresentable {
|
|
typealias UIViewType = PreviewView
|
|
var isActive: Bool
|
|
var onCode: (String) -> Void
|
|
|
|
func makeUIView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
|
context.coordinator.setActive(isActive)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
|
context.coordinator.setActive(isActive)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
|
|
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
|
private var onCode: ((String) -> Void)?
|
|
private weak var owner: PreviewView?
|
|
private let session = AVCaptureSession()
|
|
private var isRunning = false
|
|
private var permissionGranted = false
|
|
private var desiredActive = false
|
|
|
|
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
|
self.owner = sessionOwner
|
|
self.onCode = onCode
|
|
session.beginConfiguration()
|
|
session.sessionPreset = .high
|
|
guard let device = AVCaptureDevice.default(for: .video),
|
|
let input = try? AVCaptureDeviceInput(device: device),
|
|
session.canAddInput(input) else { return }
|
|
session.addInput(input)
|
|
let output = AVCaptureMetadataOutput()
|
|
guard session.canAddOutput(output) else { return }
|
|
session.addOutput(output)
|
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
|
if output.availableMetadataObjectTypes.contains(.qr) {
|
|
output.metadataObjectTypes = [.qr]
|
|
}
|
|
session.commitConfiguration()
|
|
sessionOwner.videoPreviewLayer.session = session
|
|
// Request permission and start
|
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
self.permissionGranted = granted
|
|
if granted && self.desiredActive && !self.isRunning {
|
|
self.setActive(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setActive(_ active: Bool) {
|
|
desiredActive = active
|
|
guard permissionGranted else { return }
|
|
if active && !isRunning {
|
|
isRunning = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
if !self.session.isRunning { self.session.startRunning() }
|
|
}
|
|
} else if !active && isRunning {
|
|
isRunning = false
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
if self.session.isRunning { self.session.stopRunning() }
|
|
}
|
|
}
|
|
}
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
|
for obj in metadataObjects {
|
|
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
|
m.type == .qr,
|
|
let str = m.stringValue else { continue }
|
|
onCode?(str)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class PreviewView: UIView {
|
|
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
|
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
videoPreviewLayer.videoGravity = .resizeAspectFill
|
|
}
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// Combined sheet: shows my QR by default with a button to scan instead
|
|
struct VerificationSheetView: View {
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@Binding var isPresented: Bool
|
|
@State private var showingScanner = false
|
|
@ThemedPalette private var palette
|
|
|
|
private var accentColor: Color { palette.accent }
|
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Top header (always at top)
|
|
HStack {
|
|
Text("verification.sheet.title")
|
|
.bitchatFont(size: 14, weight: .bold)
|
|
.foregroundColor(accentColor)
|
|
Spacer()
|
|
SheetCloseButton {
|
|
showingScanner = false
|
|
isPresented = false
|
|
}
|
|
.foregroundColor(accentColor)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
|
|
// Content area
|
|
Group {
|
|
if showingScanner {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("verification.scan.prompt_friend")
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(accentColor)
|
|
#if os(iOS)
|
|
QRScanView(isActive: showingScanner, onSuccess: {
|
|
showingScanner = false
|
|
})
|
|
.environmentObject(verificationModel)
|
|
.frame(height: 280)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
#else
|
|
QRScanView(onSuccess: {
|
|
showingScanner = false
|
|
})
|
|
.environmentObject(verificationModel)
|
|
#endif
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
} else {
|
|
MyQRView(qrString: verificationModel.myQRString())
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
|
|
// Centered controls moved up
|
|
VStack(spacing: 10) {
|
|
if showingScanner {
|
|
Button(action: { showingScanner = false }) {
|
|
Label("show my qr", systemImage: "qrcode")
|
|
.bitchatFont(size: 13)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
} else {
|
|
Button(action: { showingScanner = true }) {
|
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
|
.bitchatFont(size: 13, weight: .medium)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.gray)
|
|
}
|
|
|
|
// Optional: Remove verification for selected peer (if verified)
|
|
if let peerID = verificationModel.selectedPeerID,
|
|
verificationModel.isVerified(peerID: peerID) {
|
|
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
|
|
Label("remove verification", systemImage: "minus.circle")
|
|
.bitchatFont(size: 12)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.gray)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.themedSheetBackground()
|
|
.onDisappear { showingScanner = false }
|
|
}
|
|
}
|