@@ -5,10 +5,11 @@ import AppKit
55struct DarkwareZapretApp : App {
66 @StateObject private var zapretManager = ZapretManager ( )
77 @StateObject private var installerManager = InstallerManager ( )
8+ @StateObject private var diagnosticsManager = DiagnosticsManager ( )
89
910 var body : some Scene {
1011 MenuBarExtra {
11- ContentView ( zapretManager: zapretManager, installerManager: installerManager)
12+ ContentView ( zapretManager: zapretManager, installerManager: installerManager, diagnosticsManager : diagnosticsManager )
1213 } label: {
1314 Image ( systemName: zapretManager. isRunning ? " checkmark.shield.fill " : " xmark.shield.fill " )
1415 }
@@ -19,6 +20,7 @@ struct DarkwareZapretApp: App {
1920struct ContentView : View {
2021 @ObservedObject var zapretManager : ZapretManager
2122 @ObservedObject var installerManager : InstallerManager
23+ @ObservedObject var diagnosticsManager : DiagnosticsManager
2224
2325 var body : some View {
2426 VStack ( spacing: 0 ) {
@@ -151,6 +153,19 @@ struct ContentView: View {
151153 . font ( . subheadline)
152154 . foregroundStyle ( . primary)
153155
156+ if installerManager. isInstalled {
157+ Text ( " • " )
158+ . foregroundStyle ( . tertiary)
159+ . padding ( . horizontal, 4 )
160+
161+ Button ( " Diagnostics " ) {
162+ openDiagnosticsWindow ( )
163+ }
164+ . buttonStyle ( . plain)
165+ . font ( . subheadline)
166+ . foregroundStyle ( . blue)
167+ }
168+
154169 Spacer ( )
155170
156171 let appVersion = Bundle . main. infoDictionary ? [ " CFBundleShortVersionString " ] as? String ?? " Dev "
@@ -180,6 +195,20 @@ struct ContentView: View {
180195 }
181196 }
182197 }
198+
199+ private func openDiagnosticsWindow( ) {
200+ let window = NSWindow (
201+ contentRect: NSRect ( x: 0 , y: 0 , width: 650 , height: 500 ) ,
202+ styleMask: [ . titled, . closable, . resizable] ,
203+ backing: . buffered,
204+ defer: false
205+ )
206+ window. title = " Diagnostics "
207+ window. contentView = NSHostingView ( rootView: DiagnosticsView ( diagnosticsManager: diagnosticsManager) )
208+ window. center ( )
209+ window. makeKeyAndOrderFront ( nil )
210+ NSApp . activate ( ignoringOtherApps: true )
211+ }
183212}
184213
185214// Strategies
@@ -451,3 +480,196 @@ class InstallerManager: ObservableObject {
451480 }
452481 }
453482}
483+
484+ // MARK: - Diagnostics
485+
486+ enum ScanLevel : String , CaseIterable , Identifiable {
487+ case quick = " Quick "
488+ case standard = " Standard "
489+ case force = " Force "
490+
491+ var id : String { rawValue }
492+ }
493+
494+ @MainActor
495+ class DiagnosticsManager : ObservableObject {
496+ @Published var isRunning = false
497+ @Published var output = " "
498+ @Published var scanLevel : ScanLevel = . quick
499+ @Published var testDomain = " discord.com "
500+
501+ private var process : Process ?
502+
503+ func runDiagnostics( ) {
504+ guard !isRunning else { return }
505+ isRunning = true
506+ output = " Starting diagnostics... \n "
507+
508+ let zapretPath = " /opt/darkware-zapret "
509+ let blockcheckPath = " \( zapretPath) /blockcheck.sh "
510+
511+ // Check if blockcheck exists
512+ guard FileManager . default. fileExists ( atPath: blockcheckPath) else {
513+ output += " Error: blockcheck.sh not found at \( blockcheckPath) \n "
514+ isRunning = false
515+ return
516+ }
517+
518+ DispatchQueue . global ( qos: . userInitiated) . async { [ weak self] in
519+ guard let self = self else { return }
520+
521+ let process = Process ( )
522+ process. executableURL = URL ( fileURLWithPath: " /bin/bash " )
523+ process. arguments = [ " -c " , """
524+ cd " \( zapretPath) " && \
525+ BATCH=1 \
526+ SKIP_PKTWS=1 \
527+ SCANLEVEL= \( self . scanLevel. rawValue. lowercased ( ) ) \
528+ DOMAINS= " \( self . testDomain) " \
529+ ./blockcheck.sh 2>&1
530+ """ ]
531+
532+ process. currentDirectoryURL = URL ( fileURLWithPath: zapretPath)
533+
534+ let pipe = Pipe ( )
535+ process. standardOutput = pipe
536+ process. standardError = pipe
537+
538+ self . process = process
539+
540+ pipe. fileHandleForReading. readabilityHandler = { [ weak self] handle in
541+ let data = handle. availableData
542+ if data. isEmpty { return }
543+ if let str = String ( data: data, encoding: . utf8) {
544+ DispatchQueue . main. async {
545+ self ? . output += str
546+ }
547+ }
548+ }
549+
550+ do {
551+ try process. run ( )
552+ process. waitUntilExit ( )
553+ } catch {
554+ DispatchQueue . main. async {
555+ self . output += " \n Error running diagnostics: \( error. localizedDescription) \n "
556+ }
557+ }
558+
559+ DispatchQueue . main. async {
560+ self . output += " \n --- Diagnostics finished --- \n "
561+ self . isRunning = false
562+ self . process = nil
563+ }
564+ }
565+ }
566+
567+ func stopDiagnostics( ) {
568+ process? . terminate ( )
569+ isRunning = false
570+ output += " \n --- Diagnostics cancelled --- \n "
571+ }
572+
573+ func copyResults( ) {
574+ NSPasteboard . general. clearContents ( )
575+ NSPasteboard . general. setString ( output, forType: . string)
576+ }
577+ }
578+
579+ struct DiagnosticsView : View {
580+ @ObservedObject var diagnosticsManager : DiagnosticsManager
581+ @Environment ( \. dismiss) var dismiss
582+ @State private var copied = false
583+
584+ var body : some View {
585+ VStack ( spacing: 0 ) {
586+ // Settings
587+
588+ // Settings
589+ HStack {
590+ Text ( " Domain: " )
591+ . font ( . subheadline)
592+ TextField ( " Domain to test " , text: $diagnosticsManager. testDomain)
593+ . textFieldStyle ( . roundedBorder)
594+ . frame ( width: 150 )
595+ . disabled ( diagnosticsManager. isRunning)
596+
597+ Spacer ( )
598+
599+ Text ( " Level: " )
600+ . font ( . subheadline)
601+ Picker ( " " , selection: $diagnosticsManager. scanLevel) {
602+ ForEach ( ScanLevel . allCases) { level in
603+ Text ( level. rawValue) . tag ( level)
604+ }
605+ }
606+ . pickerStyle ( . menu)
607+ . frame ( width: 120 )
608+ . disabled ( diagnosticsManager. isRunning)
609+ }
610+ . padding ( . horizontal)
611+ . padding ( . vertical, 8 )
612+
613+ Divider ( )
614+
615+ // Output
616+ ScrollViewReader { proxy in
617+ ScrollView {
618+ Text ( diagnosticsManager. output)
619+ . font ( . system( . caption, design: . monospaced) )
620+ . frame ( maxWidth: . infinity, alignment: . leading)
621+ . textSelection ( . enabled)
622+ . id ( " output " )
623+ }
624+ . onChange ( of: diagnosticsManager. output) { _ in
625+ withAnimation {
626+ proxy. scrollTo ( " output " , anchor: . bottom)
627+ }
628+ }
629+ }
630+ . frame ( maxHeight: . infinity)
631+ . padding ( 8 )
632+ . background ( Color ( NSColor . textBackgroundColor) )
633+
634+ Divider ( )
635+
636+ // Actions
637+ HStack {
638+ if diagnosticsManager. isRunning {
639+ Button ( " Stop " ) {
640+ diagnosticsManager. stopDiagnostics ( )
641+ }
642+ . buttonStyle ( . borderedProminent)
643+ . tint ( . red)
644+
645+ ProgressView ( )
646+ . controlSize ( . small)
647+ . padding ( . leading, 8 )
648+ } else {
649+ Button ( " Run Diagnostics " ) {
650+ diagnosticsManager. runDiagnostics ( )
651+ }
652+ . buttonStyle ( . borderedProminent)
653+ }
654+
655+ Spacer ( )
656+
657+ Button {
658+ diagnosticsManager. copyResults ( )
659+ copied = true
660+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 1.5 ) {
661+ copied = false
662+ }
663+ } label: {
664+ HStack ( spacing: 4 ) {
665+ Image ( systemName: copied ? " checkmark " : " doc.on.doc " )
666+ Text ( copied ? " Copied! " : " Copy Results " )
667+ }
668+ }
669+ . disabled ( diagnosticsManager. output. isEmpty)
670+ }
671+ . padding ( )
672+ }
673+ . frame ( width: 600 , height: 500 )
674+ }
675+ }
0 commit comments