Skip to content

Commit 8434930

Browse files
feat: Migrate daemon to in-process background thread, fix launch at startup
- Remove separate --daemon process; monitoring now runs on a background thread within the main app process - Replace SMAppService with LaunchAgent plist using 'open' command for reliable launch-at-login - Add start/stop lifecycle to Daemon with graceful shutdown - Clean up old com.user.network-monitor LaunchAgent on first launch - Update Homebrew Cask formula in release workflow to match new architecture - Update README and UI labels to reflect single-process design
1 parent 0992377 commit 8434930

File tree

8 files changed

+174
-140
lines changed

8 files changed

+174
-140
lines changed

.github/workflows/release.yml

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -164,44 +164,23 @@ jobs:
164164
JSON
165165
end
166166
167-
# Install LaunchAgent
168-
plist_dir = File.expand_path("~/Library/LaunchAgents")
169-
FileUtils.mkdir_p(plist_dir)
170-
plist_path = "#{plist_dir}/com.user.network-monitor.plist"
171-
File.write(plist_path, <<~PLIST)
172-
<?xml version="1.0" encoding="UTF-8"?>
173-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
174-
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
175-
<plist version="1.0">
176-
<dict>
177-
<key>Label</key> <string>com.user.network-monitor</string>
178-
<key>ProgramArguments</key>
179-
<array>
180-
<string>/Applications/NetworkMonitor.app/Contents/MacOS/NetworkMonitor</string>
181-
<string>--daemon</string>
182-
</array>
183-
<key>RunAtLoad</key> <true/>
184-
<key>KeepAlive</key> <true/>
185-
<key>StandardOutPath</key> <string>/tmp/netmon_stdout.log</string>
186-
<key>StandardErrorPath</key> <string>/tmp/netmon_stderr.log</string>
187-
</dict>
188-
</plist>
189-
PLIST
190-
system_command "/bin/launchctl", args: ["load", plist_path]
167+
# Clean up old daemon-style LaunchAgent if present
168+
old_plist = File.expand_path("~/Library/LaunchAgents/com.user.network-monitor.plist")
169+
if File.exist?(old_plist)
170+
system_command "/bin/launchctl", args: ["unload", old_plist]
171+
File.delete(old_plist)
172+
end
191173
end
192174
193-
uninstall launchctl: "com.user.network-monitor",
194-
delete: [
175+
uninstall delete: [
176+
File.expand_path("~/Library/LaunchAgents/com.armin.network-monitor.login.plist"),
195177
File.expand_path("~/Library/LaunchAgents/com.user.network-monitor.plist"),
196-
File.expand_path("~/.local/bin/netmon-toggle.sh")
197178
]
198179
199180
zap trash: [
200181
"~/.config/network-monitor",
201182
"~/.network_monitor.log",
202183
"/tmp/.netmon_*",
203-
"/tmp/netmon_stdout.log",
204-
"/tmp/netmon_stderr.log",
205184
]
206185
end
207186
EOF

NetworkMonitor.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
CURRENT_PROJECT_VERSION = 2;
132132
DEVELOPMENT_TEAM = "";
133133
GENERATE_INFOPLIST_FILE = YES;
134+
INFOPLIST_FILE = NetworkMonitor/Info.plist;
134135
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
135136
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)","@executable_path/../Frameworks");
136137
MACOSX_DEPLOYMENT_TARGET = 13.0;
@@ -152,6 +153,7 @@
152153
CURRENT_PROJECT_VERSION = 2;
153154
DEVELOPMENT_TEAM = "";
154155
GENERATE_INFOPLIST_FILE = YES;
156+
INFOPLIST_FILE = NetworkMonitor/Info.plist;
155157
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
156158
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)","@executable_path/../Frameworks");
157159
MACOSX_DEPLOYMENT_TARGET = 13.0;

NetworkMonitor/Info.plist

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSAppTransportSecurity</key>
6+
<dict>
7+
<key>NSExceptionDomains</key>
8+
<dict>
9+
<key>ip-api.com</key>
10+
<dict>
11+
<key>NSExceptionAllowsInsecureHTTPLoads</key>
12+
<true/>
13+
<key>NSIncludesSubdomains</key>
14+
<true/>
15+
</dict>
16+
</dict>
17+
</dict>
18+
</dict>
19+
</plist>

NetworkMonitor/NetworkMonitorApp.swift

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,6 @@ import AppKit
33
import UserNotifications
44

55
@main
6-
struct AppLauncher {
7-
static func main() {
8-
if CommandLine.arguments.contains("--daemon") {
9-
Daemon().run()
10-
} else {
11-
NetworkMonitorApp.main()
12-
}
13-
}
14-
}
15-
166
struct NetworkMonitorApp: App {
177
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
188
@StateObject private var trayModel = TrayModel()
@@ -52,10 +42,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5242
func applicationDidFinishLaunching(_ notification: Notification) {
5343
NSApp.setActivationPolicy(.regular)
5444
Settings.shared.writeDaemonConfig()
45+
Settings.shared.applyLaunchAtLogin()
46+
47+
// Start the monitoring daemon as a background thread
48+
Daemon.shared.start()
49+
50+
// Clean up old launchd daemon plist (no longer needed)
51+
let oldPlist = FileManager.default.homeDirectoryForCurrentUser
52+
.appendingPathComponent("Library/LaunchAgents/com.user.network-monitor.plist")
53+
if FileManager.default.fileExists(atPath: oldPlist.path) {
54+
let t = Process()
55+
t.executableURL = URL(fileURLWithPath: "/bin/launchctl")
56+
t.arguments = ["unload", oldPlist.path]
57+
t.standardOutput = Pipe(); t.standardError = Pipe()
58+
try? t.run(); t.waitUntilExit()
59+
try? FileManager.default.removeItem(at: oldPlist)
60+
}
5561
}
5662

5763
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
5864

65+
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
66+
return .terminateNow
67+
}
68+
5969
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
6070
if !flag {
6171
NotificationCenter.default.post(name: Notification.Name("ReopenMainWindow"), object: nil)
@@ -69,6 +79,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6979
struct TrayLabelView: View {
7080
@ObservedObject var model: TrayModel
7181
@Environment(\.openWindow) private var openWindow
82+
@State private var hasAutoOpened = false
7283

7384
var body: some View {
7485
let isOnline = model.status == .online
@@ -137,6 +148,13 @@ struct TrayLabelView: View {
137148
}
138149
}
139150
}
151+
.onAppear {
152+
if !hasAutoOpened {
153+
hasAutoOpened = true
154+
openWindow(id: "main")
155+
NSApp.activate(ignoringOtherApps: true)
156+
}
157+
}
140158
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ReopenMainWindow"))) { _ in
141159
openWindow(id: "main")
142160
NSApp.activate(ignoringOtherApps: true)
@@ -213,19 +231,12 @@ final class TrayModel: ObservableObject {
213231
let histMtime = (try? FileManager.default.attributesOfItem(atPath: self.histFile.path)[.modificationDate] as? Date) ?? .distantPast
214232
let statusMtime = (try? FileManager.default.attributesOfItem(atPath: self.statusFile.path)[.modificationDate] as? Date) ?? .distantPast
215233

216-
// Secure daemon check
217-
// Secure daemon check (via PID to avoid launchctl processes and mtime bouncing)
234+
// Check daemon status directly
218235
let now = Date()
219236
var daemonIsRunning = self.isDaemonRunning
220237
if now.timeIntervalSince(self.lastDaemonCheck) > 4.0 {
221238
self.lastDaemonCheck = now
222-
daemonIsRunning = false
223-
if let pidStr = try? String(contentsOf: URL(fileURLWithPath: "/tmp/.netmon_pid"), encoding: .utf8),
224-
let pid = pid_t(pidStr.trimmingCharacters(in: .whitespacesAndNewlines)) {
225-
if kill(pid, 0) == 0 {
226-
daemonIsRunning = true
227-
}
228-
}
239+
daemonIsRunning = Daemon.shared.isRunning
229240
}
230241

231242
if histMtime == self.lastHistFileModDate && statusMtime == self.lastStatusFileModDate && self.trayFormat == f && daemonIsRunning == self.isDaemonRunning {

NetworkMonitor/NetworkState.swift

Lines changed: 85 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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

440405
final 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

Comments
 (0)