Skip to content

Commit add9343

Browse files
committed
Surface daemon registration and XPC errors instead of swallowing them
Adds LockInLog (Console + stderr) and routes register/unregister/ping through it with the real domain+code. An enabled-but-unreachable daemon now reports that its launchd job is out of sync rather than a vague 'not responding'. Also self-deregisters the daemon when its app bundle is gone (checked at startup and hourly), so an orphaned root daemon cleans itself up.
1 parent f85f329 commit add9343

6 files changed

Lines changed: 81 additions & 15 deletions

File tree

App/DaemonClient.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,16 @@ final class DaemonClient: Sendable {
2929
func ping() async -> Bool {
3030
await withCheckedContinuation { cont in
3131
let c = connection()
32-
let proxy = c.remoteObjectProxyWithErrorHandler { _ in cont.resume(returning: false) }
33-
as? LockInDaemonProtocol
34-
proxy?.getVersion { version in cont.resume(returning: version == LockInVersion.current) }
32+
let proxy = c.remoteObjectProxyWithErrorHandler { err in
33+
LockInLog.error("daemon ping connection failed (service unreachable)", err)
34+
cont.resume(returning: false)
35+
} as? LockInDaemonProtocol
36+
proxy?.getVersion { version in
37+
if version != LockInVersion.current {
38+
LockInLog.error("daemon ping: version mismatch app=\(LockInVersion.current) daemon=\(version)")
39+
}
40+
cont.resume(returning: version == LockInVersion.current)
41+
}
3542
}
3643
}
3744

App/InstallerService.swift

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,44 @@ final class InstallerService: ObservableObject {
1818
}
1919

2020
func unregisterAll() {
21-
try? daemon.unregister()
22-
try? agent.unregister()
21+
do { try daemon.unregister() } catch { LockInLog.error("daemon.unregister failed", error) }
22+
do { try agent.unregister() } catch { LockInLog.error("agent.unregister failed", error) }
2323
refreshStatus()
24+
LockInLog.info("unregisterAll done — daemon=\(Self.describe(daemonStatus)) agent=\(Self.describe(agentStatus))")
2425
}
2526

2627
// the agent registers cleanly only after the daemon is approved; called repeatedly by the poll
2728
func registerAgentIfDaemonReady() {
2829
refreshStatus()
2930
guard daemonStatus == .enabled else { return }
3031
if agentStatus == .notRegistered || agentStatus == .notFound {
31-
try? agent.register()
32+
do { try agent.register() } catch { LockInLog.error("agent.register failed", error) }
3233
refreshStatus()
3334
}
3435
}
3536

3637
// required website-blocking helper; re-registers a stale enabled job that no longer answers
3738
func registerDaemon(alive: Bool) {
3839
refreshStatus()
40+
LockInLog.info("registerDaemon(alive: \(alive)) — current status=\(Self.describe(daemonStatus))")
3941
if daemonStatus == .enabled && !alive {
40-
try? daemon.unregister()
42+
do { try daemon.unregister() }
43+
catch { LockInLog.error("daemon.unregister (stale) failed", error); lastError = humanError(error); return }
4144
refreshStatus()
4245
}
43-
if daemonStatus == .enabled { openLoginItems(); return }
44-
do { try daemon.register() } catch { lastError = humanError(error) }
46+
if daemonStatus == .enabled {
47+
lastError = "macOS reports the service as installed but it isn’t running — its background record is out of sync. Restart your Mac to rebuild it."
48+
LockInLog.error("daemon enabled but not alive — launchd job desynced from registration")
49+
openLoginItems()
50+
return
51+
}
52+
do {
53+
try daemon.register()
54+
LockInLog.info("daemon.register() succeeded — status now \(Self.describe(daemon.status))")
55+
} catch {
56+
LockInLog.error("daemon.register() failed", error)
57+
lastError = humanError(error)
58+
}
4559
refreshStatus()
4660
}
4761

@@ -79,8 +93,8 @@ final class InstallerService: ObservableObject {
7993
private func humanError(_ error: Error) -> String {
8094
let ns = error as NSError
8195
if ns.domain == "SMAppServiceErrorDomain" && ns.code == 1 {
82-
return "macOS blocked registration. A previous install may be stuck — reset background items in System Settings → Login Items and try again."
96+
return "macOS blocked registration (a previous install is stuck). Restart your Mac, then open LockIn again. (\(ns.domain) \(ns.code))"
8397
}
84-
return ns.localizedDescription
98+
return "\(ns.localizedDescription) (\(ns.domain) \(ns.code))"
8599
}
86100
}

App/Models/InstallGate.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ final class InstallGate: ObservableObject {
5151
installer.lastError = "Website blocking isn’t approved yet. Tap Approve above, then allow it in System Settings."
5252
return
5353
}
54-
guard await pingWithRetry() else {
55-
installer.lastError = "Approved, but the blocker isn’t responding yet. Wait a moment and tap Continue again."
56-
return
54+
if await pingWithRetry() == false {
55+
installer.registerDaemon(alive: false)
56+
guard await pingWithRetry() else {
57+
installer.lastError = "Couldn’t start the background service. Quit LockIn and reopen it; if it persists, restart your Mac."
58+
return
59+
}
5760
}
5861
showingInstall = false
5962
if let action = pendingAction {

Daemon/main.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import ServiceManagement
23
import LockInDaemonCore
34

45
@MainActor
@@ -8,11 +9,16 @@ final class DaemonRuntime {
89
private var powerNotifier: PowerNotifier?
910
private var timer: Timer?
1011

12+
// .../LockIn.app/Contents/MacOS/lockind -> .../LockIn.app
13+
private let appBundlePath = URL(fileURLWithPath: CommandLine.arguments[0])
14+
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().path
15+
1116
init() {
1217
listener = DaemonListener(controller: controller)
1318
}
1419

1520
func start() {
21+
selfCleanIfOrphaned()
1622
listener.start()
1723
evaluate()
1824
powerNotifier = PowerNotifier(onWake: { [weak self] in
@@ -27,8 +33,19 @@ final class DaemonRuntime {
2733
timer = t
2834
}
2935

36+
private var ticksSinceOrphanCheck = 0
37+
3038
private func evaluate() {
3139
controller.applyDecisionIfNeeded(timeResolved: controller.timeIsResolved())
40+
ticksSinceOrphanCheck += 1
41+
if ticksSinceOrphanCheck >= 720 { ticksSinceOrphanCheck = 0; selfCleanIfOrphaned() }
42+
}
43+
44+
private func selfCleanIfOrphaned() {
45+
guard controller.isOrphaned(appBundlePath: appBundlePath) else { return }
46+
_ = controller.prepareUninstall()
47+
try? SMAppService.daemon(plistName: "lockind.plist").unregister()
48+
exit(0)
3249
}
3350
}
3451

DaemonCore/Lock/BlockController.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,19 @@ public final class BlockController {
193193
}
194194

195195
// root-side cleanup for uninstall: reset hosts, clear snapshots + root config. Refused while locked.
196-
func prepareUninstall() -> Bool {
196+
public func prepareUninstall() -> Bool {
197197
if isLockHeld() { return false }
198198
let ok = blocker.resetToSystemDefault()
199199
try? snapshotStore.clear()
200200
try? configStore.save(ScheduleConfig(rules: []))
201201
return ok
202202
}
203203

204+
// invariant: self-clean only when the app bundle is gone AND no lock is held — never tears down a live lock
205+
public func isOrphaned(appBundlePath: String) -> Bool {
206+
!FileManager.default.fileExists(atPath: appBundlePath) && !isLockHeld()
207+
}
208+
204209
// invariant: corroborate the snapshot set against live pf/hosts so deleting active.plist can't unlock teardown
205210
private func isLockHeld() -> Bool {
206211
if !snapshotStore.load().isEmpty { return true }

Shared/LockInLog.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
import os
3+
4+
enum LockInLog {
5+
private static let log = Logger(subsystem: "com.humblebee.lockin", category: "lockin")
6+
7+
static func info(_ message: String) {
8+
log.info("\(message, privacy: .public)")
9+
FileHandle.standardError.write(Data("[LockIn] \(message)\n".utf8))
10+
}
11+
12+
static func error(_ message: String, _ error: Error? = nil) {
13+
let detail = error.map { e -> String in
14+
let ns = e as NSError
15+
return "\(ns.domain) \(ns.code): \(ns.localizedDescription)"
16+
} ?? ""
17+
log.error("\(message, privacy: .public)\(detail, privacy: .public)")
18+
FileHandle.standardError.write(Data("[LockIn] ERROR \(message)\(detail)\n".utf8))
19+
}
20+
}

0 commit comments

Comments
 (0)