@@ -60,8 +60,7 @@ final class NetworkStateModel: NSObject, ObservableObject, UNUserNotificationCen
6060 private let statusFile = URL ( fileURLWithPath: " /tmp/.netmon_status " )
6161 private let logFile = FileManager . default. homeDirectoryForCurrentUser
6262 . appendingPathComponent ( " .network_monitor.log " )
63- private let daemonPlist = FileManager . default. homeDirectoryForCurrentUser
64- . appendingPathComponent ( " Library/LaunchAgents/com.user.network-monitor.plist " )
63+
6564
6665 private var timer : Timer ?
6766 private var lastIPFetch : Date = . distantPast
@@ -216,22 +215,7 @@ final class NetworkStateModel: NSObject, ObservableObject, UNUserNotificationCen
216215 let now = Date ( )
217216 if now. timeIntervalSince ( lastDaemonCheck) < 5.0 { return }
218217 lastDaemonCheck = now
219-
220- DispatchQueue . global ( qos: . userInitiated) . async { [ weak self] in
221- guard let self = self else { return }
222-
223- var isRunning = false
224- if let pidStr = try ? String ( contentsOf: URL ( fileURLWithPath: " /tmp/.netmon_pid " ) , encoding: . utf8) ,
225- let pid = pid_t ( pidStr. trimmingCharacters ( in: . whitespacesAndNewlines) ) {
226- if kill ( pid, 0 ) == 0 {
227- isRunning = true
228- }
229- }
230-
231- DispatchQueue . main. async {
232- self . daemonRunning = isRunning
233- }
234- }
218+ daemonRunning = Daemon . shared. isRunning
235219 }
236220
237221 // MARK: - IP enrichment (URLSession, not Data(contentsOf:))
@@ -285,49 +269,30 @@ final class NetworkStateModel: NSObject, ObservableObject, UNUserNotificationCen
285269 // MARK: - Daemon control
286270
287271 func toggleDaemon( ) {
288- let action = daemonRunning ? " unload " : " load "
289-
290- // Optimistically update UI
291- self . daemonRunning = ( action == " load " )
292- if action == " load " { self . status = . starting }
293-
294- DispatchQueue . global ( qos: . userInitiated) . async { [ weak self] in
295- guard let self = self else { return }
296- if action == " unload " {
297- try ? FileManager . default. removeItem ( atPath: " /tmp/.netmon_pid " )
298- }
299- let t = Process ( )
300- t. executableURL = URL ( fileURLWithPath: " /bin/launchctl " )
301- t. arguments = [ action, self . daemonPlist. path]
302- t. standardOutput = Pipe ( ) ; t. standardError = Pipe ( )
303- try ? t. run ( ) ; t. waitUntilExit ( )
304-
305- if action == " unload " { self . settings. writeDaemonConfig ( ) }
306-
307- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.5 ) {
308- self . refresh ( )
309- }
272+ if daemonRunning {
273+ Daemon . shared. stop ( )
274+ daemonRunning = false
275+ status = . offline
276+ } else {
277+ settings. writeDaemonConfig ( )
278+ Daemon . shared. start ( )
279+ daemonRunning = true
280+ status = . starting
281+ }
282+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 1.0 ) { [ weak self] in
283+ self ? . refresh ( )
310284 }
311285 }
312286
313287 func restartDaemon( ) {
314- DispatchQueue . global ( qos: . userInitiated) . async { [ weak self] in
315- guard let self = self else { return }
316- try ? FileManager . default. removeItem ( atPath: " /tmp/.netmon_pid " )
317- let stop = Process ( )
318- stop. executableURL = URL ( fileURLWithPath: " /bin/launchctl " )
319- stop. arguments = [ " unload " , self . daemonPlist. path]
320- stop. standardOutput = Pipe ( ) ; stop. standardError = Pipe ( )
321- try ? stop. run ( ) ; stop. waitUntilExit ( )
322- self . settings. writeDaemonConfig ( )
323-
324- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.3 ) {
325- let start = Process ( )
326- start. executableURL = URL ( fileURLWithPath: " /bin/launchctl " )
327- start. arguments = [ " load " , self . daemonPlist. path]
328- start. standardOutput = Pipe ( ) ; start. standardError = Pipe ( )
329- try ? start. run ( ) ; start. waitUntilExit ( )
330- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.5 ) { self . refresh ( ) }
288+ Daemon . shared. stop ( )
289+ settings. writeDaemonConfig ( )
290+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.5 ) { [ weak self] in
291+ Daemon . shared. start ( )
292+ self ? . status = . starting
293+ self ? . daemonRunning = true
294+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 1.0 ) {
295+ self ? . refresh ( )
331296 }
332297 }
333298 }
@@ -438,6 +403,8 @@ struct SettingsConfig: Codable {
438403// MARK: - Daemon
439404
440405final class Daemon {
406+ static let shared = Daemon ( )
407+
441408 let histFile = URL ( fileURLWithPath: " /tmp/.netmon_ping_history " )
442409 let ipStateFile = URL ( fileURLWithPath: " /tmp/.netmon_ip_state " )
443410 let statusFile = URL ( fileURLWithPath: " /tmp/.netmon_status " )
@@ -461,13 +428,65 @@ final class Daemon {
461428 var lastIP = " "
462429 var lastIPCheck = Date . distantPast
463430
431+ // Thread management
432+ private var monitorThread : Thread ?
433+ private var _shouldStop = false
434+ private let stateLock = NSLock ( )
435+
436+ private var shouldStop : Bool {
437+ get { stateLock. lock ( ) ; defer { stateLock. unlock ( ) } ; return _shouldStop }
438+ set { stateLock. lock ( ) ; _shouldStop = newValue; stateLock. unlock ( ) }
439+ }
440+
441+ var isRunning : Bool {
442+ stateLock. lock ( )
443+ defer { stateLock. unlock ( ) }
444+ return monitorThread != nil && !_shouldStop
445+ }
446+
464447 lazy var session : URLSession = {
465448 let config = URLSessionConfiguration . ephemeral
466449 config. timeoutIntervalForRequest = 5.0
467450 return URLSession ( configuration: config)
468451 } ( )
452+
453+ func start( ) {
454+ stateLock. lock ( )
455+ guard monitorThread == nil else { stateLock. unlock ( ) ; return }
456+ _shouldStop = false
457+ stateLock. unlock ( )
458+
459+ let thread = Thread { [ weak self] in
460+ self ? . run ( )
461+ // Clean up when thread exits
462+ self ? . stateLock. lock ( )
463+ self ? . monitorThread = nil
464+ self ? . stateLock. unlock ( )
465+ }
466+ thread. qualityOfService = . utility
467+ thread. name = " netmon-daemon "
468+
469+ stateLock. lock ( )
470+ monitorThread = thread
471+ stateLock. unlock ( )
472+
473+ thread. start ( )
474+ }
475+
476+ func stop( ) {
477+ shouldStop = true
478+ // Give the thread time to exit its current sleep cycle
479+ Thread . sleep ( forTimeInterval: 0.6 )
480+
481+ stateLock. lock ( )
482+ monitorThread = nil
483+ stateLock. unlock ( )
484+
485+ try ? FileManager . default. removeItem ( atPath: pidFile. path)
486+ log ( " === Network Monitor Daemon Stopped === " )
487+ }
469488
470- func run( ) {
489+ private func run( ) {
471490 createAppDirectories ( )
472491 setupInitialFiles ( )
473492
@@ -481,17 +500,21 @@ final class Daemon {
481500 let category = UNNotificationCategory ( identifier: " OUTAGE " , actions: [ mute1h, mute24] , intentIdentifiers: [ ] , options: [ . customDismissAction] )
482501 center. setNotificationCategories ( [ category] )
483502
484- while true {
503+ while !shouldStop {
485504 let start = Date ( )
486505
487506 checkConfigUpdate ( )
488507 performPing ( )
489508 performIPCheckIfNeeded ( )
490509
491- // Sleep for the remainder of the interval
510+ // Sleep in small chunks so we can respond to stop() quickly
492511 let elapsed = Date ( ) . timeIntervalSince ( start)
493- let sleepTime = max ( 0.1 , config. ping. interval_seconds - elapsed)
494- Thread . sleep ( forTimeInterval: sleepTime)
512+ var remaining = max ( 0.1 , config. ping. interval_seconds - elapsed)
513+ while remaining > 0 && !shouldStop {
514+ let chunk = min ( remaining, 0.5 )
515+ Thread . sleep ( forTimeInterval: chunk)
516+ remaining -= chunk
517+ }
495518 }
496519 }
497520
0 commit comments