10000 Add support for configuration hot-reload (#996) · linearmouse/linearmouse@9ebd7d2 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9ebd7d2

Browse files
authored
Add support for configuration hot-reload (#996)
Closes #158, closes #854.
1 parent aa37be5 commit 9ebd7d2

File tree

5 files changed

+273
-1
lines changed

5 files changed

+273
-1
lines changed

LinearMouse/AppDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,13 @@ extension AppDelegate {
7070

7171
func setupConfiguration() {
7272
ConfigurationState.shared.load()
73+
// Start watching the configuration file for hot reload
74+
ConfigurationState.shared.startHotReload()
7375
}
7476

7577
func setupNotifications() {
78+
// Prepare user notifications for error popups
79+
Notifier.shared.setup()
7680
NSWorkspace.shared.notificationCenter.addObserver(
7781
forName: NSWorkspace.sessionDidResignActiveNotification,
7882
object: nil,

LinearMouse/Localizable.xcstrings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9283,6 +9283,9 @@
92839283
}
92849284
}
92859285
}
9286+
},
9287+
"Configuration Reloaded" : {
9288+
92869289
},
92879290
"Configure for" : {
92889291
"localizations" : {
@@ -14575,6 +14578,9 @@
1457514578
}
1457614579
}
1457714580
}
14581+
},
14582+
"Failed to reload configuration" : {
14583+
1457814584
},
1457914585
"Failed to save the configuration: %@" : {
1458014586
"localizations" : {
@@ -37542,6 +37548,9 @@
3754237548
}
3754337549
}
3754437550
}
37551+
},
37552+
"Your configuration changes are now active." : {
37553+
3754537554
},
3754637555
"Zoom" : {
3754737556
"extractionState" : "manual",

LinearMouse/State/ConfigurationState.swift

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import AppKit
55
import Combine
6+
import Darwin
67
import Defaults
78
import Foundation
89
import os.log
@@ -72,9 +73,195 @@ class ConfigurationState: ObservableObject {
7273
@Published private(set) var loading = false
7374

7475
private var subscriptions = Set<AnyCancellable>()
76+
77+
// Hot reload support (watch parent directory and resolved target file)
78+
private var configDirFD: CInt?
79+
private var configDirSource: DispatchSourceFileSystemObject?
80+
private var configFileFD: CInt?
81+
private var configFileSource: DispatchSourceFileSystemObject?
82+
private var watchedFilePath: String?
83+
private var reloadDebounceWorkItem: DispatchWorkItem?
7584
}
7685

7786
extension ConfigurationState {
87+
func reloadFromDisk() {
88+
do {
89+
let newConfig = try Configuration.load(from: configurationPath)
90+
guard newConfig != configuration else {
91+
return
92+
}
93+
94+
loading = true
95+
configuration = newConfig
96+
loading = false
97+
98+
Notifier.shared.notify(
99+
title: NSLocalizedString("Configuration Reloaded", comment: ""),
100+
body: NSLocalizedString("Your configuration changes are now active.", comment: "")
101+
)
102+
} catch CocoaError.fileReadNoSuchFile {
103+
loading = true
104+
configuration = .init()
105+
loading = false
106+
107+
Notifier.shared.notify(
108+
title: NSLocalizedString("Configuration Reloaded", comment: ""),
109+
body: NSLocalizedString("Your configuration changes are now active.", comment: "")
110+
)
111+
} catch {
112+
Notifier.shared.notify(
113+
title: NSLocalizedString("Failed to reload configuration", comment: ""),
114+
body: error.localizedDescription
115+
)
116+
}
117+
}
118+
119+
func startHotReload() {
120+
stopHotReload()
121+
122+
// Directory watcher
123+
let directoryURL = configurationPath.deletingLastPathComponent()
124+
let dirFD = open(directoryURL.path, O_EVTONLY)
125+
guard dirFD >= 0 else {
126+
return
127+
}
128+
configDirFD = dirFD
129+
130+
let dirSource = DispatchSource.makeFileSystemObjectSource(
131+
fileDescriptor: dirFD,
132+
eventMask: [.write, .attrib, .rename, .link, .delete, .extend, .revoke],
133+
queue: .main
134+
)
135+
136+
dirSource.setEventHandler { [weak self] in
137+
guard let self else {
138+
return
139+
}
140+
// Any directory entry change may indicate symlink retarget, file replace, create/delete, etc.
141+
self.updateFileWatcherIfNeeded()
142+
self.scheduleReloadFromExternalChange()
143+
}
144+
145+
dirSource.setCancelHandler { [weak self] in
146+
if let fd = self?.configDirFD {
147+
close(fd)
148+
}
149+
self?.configDirFD = nil
150+
}
151+
152+
configDirSource = dirSource
153+
dirSource.resume()
154+
155+
// File watcher for resolved target (or the file itself if not a symlink)
156+
updateFileWatcherIfNeeded()
157+
}
158+
159+
func stopHotReload() {
160+
reloadDebounceWorkItem?.cancel()
161+
reloadDebounceWorkItem = nil
162+
163+
configDirSource?.cancel()
164+
configDirSource = nil
165+
if let fd = configDirFD {
166+
close(fd)
167+
}
168+
configDirFD = nil
169+
170+
configFileSource?.cancel()
171+
configFileSource = nil
172+
if let fd = configFileFD {
173+
close(fd)
174+
}
175+
configFileFD = nil
176+
watchedFilePath = nil
177+
}
178+
179+
private func scheduleReloadFromExternalChange() {
180+
reloadDebounceWorkItem?.cancel()
181+
let work = DispatchWorkItem { [weak self] in
182+
self?.reloadFromDisk()
183+
}
184+
reloadDebounceWorkItem = work
185+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
186+
}
187+
188+
private func updateFileWatcherIfNeeded() {
189+
// Watch resolved target file (or the file itself if not a symlink)
190+
let linkPath = configurationPath.path
191+
let resolvedPath = (linkPath as NSString).resolvingSymlinksInPath
192+
193+
// If path unchanged, nothing to do
194+
if watchedFilePath == resolvedPath, configFileSource != nil {
195+
// Still ensure file exists; if gone, drop watcher so directory watcher can recreate later
196+
if !FileManager.default.fileExists(atPath: resolvedPath) {
197+
configFileSource?.cancel()
198+
configFileSource = nil
199+
if let fd = configFileFD {
200+
close(fd)
201+
}
202+
configFileFD = nil
203+
watchedFilePath = nil
204+
}
205+
return
206+
}
207+
208+
// Path changed or no watcher; rebuild
209+
configFileSource?.cancel()
210+
configFileSource = nil
211+
if let fd = configFileFD {
212+
close(fd)
213+
}
214+
configFileFD = nil
215+
watchedFilePath = nil
216+
217+
guard FileManager.default.fileExists(atPath: resolvedPath) else {
218+
return
219+
}
220+
221+
let fd = open(resolvedPath, O_EVTONLY)
222+
guard fd >= 0 else {
223+
return
224+
}
225+
configFileFD = fd
226+
227+
let fileSource = DispatchSource.makeFileSystemObjectSource(
228+
fileDescriptor: fd,
229+
eventMask: [.write, .attrib, .extend, .delete, .rename, .revoke, .link],
230+
queue: .main
231+
)
232+
233+
fileSource.setEventHandler { [weak self] in
234+
guard let self else {
235+
return
236+
}
237+
let events = fileSource.data
238+
if events.contains(.delete) || events.contains(.rename) || events.contains(.revoke) {
239+
// File removed/replaced; drop watcher and rely on directory watcher to re-add
240+
self.configFileSource?.cancel()
241+
self.configFileSource = nil
242+
if let fd = self.configFileFD {
243+
close(fd)
244+
}
245+
self.configFileFD = nil
246+
self.watchedFilePath = nil
247+
self.scheduleReloadFromExternalChange()
248+
} else {
249+
self.scheduleReloadFromExternalChange()
250+
}
251+
}
252+
253+
fileSource.setCancelHandler { [weak self] in
254+
if let fd = self?.configFileFD {
255+
close(fd)
256+
}
257+
self?.configFileFD = nil
258+
}
259+
260+
configFileSource = fileSource
261+
watchedFilePath = resolvedPath
262+
fileSource.resume()
263+
}
264+
78265
func revealInFinder() {
79266
NSWorkspace.shared.activateFileViewerSelecting([ConfigurationState.shared.configurationPath.absoluteURL])
80267
}

LinearMouse/UI/StatusItem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ class StatusItem {
160160
}
161161

162162
@objc private func reloadConfiguration() {
163-
ConfigurationState.shared.load()
163+
ConfigurationState.shared.reloadFromDisk()
164164
}
165165

166166
@objc private func revealConfigurationInFinder() {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// MIT License
2+
// Copyright (c) 2021-2025 LinearMouse
3+
4+
import Foundation
5+
import UserNotifications
6+
7+
class Notifier: NSObject {
8+
static let shared = Notifier()
9+
10+
private let center = UNUserNotificationCenter.current()
11+
private var didSetup = false
12+
13+
func setup() {
14+
guard !didSetup else {
15+
return
16+
}
17+
didSetup = true
18+
// Only set the delegate now; request authorization lazily on first notification
19+
center.delegate = self
20+
}
21+
22+
func notify(title: String, body: String) {
23+
if !didSetup {
24+
center.delegate = self
25+
didSetup = true
26+
}
27+
28+
center.getNotificationSettings { [weak self] settings in
29+
guard let self else {
30+
return
31+
}
32+
33+
let send: () -> Void = {
34+
let content = UNMutableNotificationContent()
35+
content.title = title
36+
content.body = body
< 8B92 code>37+
38+
let request = UNNotificationRequest(
39+
identifier: UUID().uuidString,
40+
content: content,
41+
trigger: nil
42+
)
43+
44+
self.center.add(request, withCompletionHandler: nil)
45+
}
46+
47+
if settings.authorizationStatus == .notDetermined {
48+
self.center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in
49+
send()
50+
}
51+
} else {
52+
send()
53+
}
54+
}
55+
}
56+
}
57+
58+
extension Notifier: UNUserNotificationCenterDelegate {
59+
func userNotificationCenter(
60+
_: UNUserNotificationCenter,
61+
willPresent _: UNNotification,
62+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions)
63+
-> Void
64+
) {
65+
// Ensure banners appear when the app is in the foreground
66+
if #available(macOS 11.0, *) {
67+
completionHandler([.banner, .sound, .list])
68+
} else {
69+
completionHandler([.alert, .sound])
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)
0