Skip to content

Commit b4e1e83

Browse files
laurentftechclaude
andcommitted
feat: Add config validation and ConfigWatcher tests
- Add config validation for unknown keys (shows warning in menu bar) - Add ConfigWatcher test coverage (7 new tests) - Show config warnings/errors in status bar menu - Update config-examples.yml with security best practices - Document allowed_schemes behavior (replaces defaults, not additive) - Use private servers for all script-based examples Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d3f9392 commit b4e1e83

File tree

6 files changed

+359
-16
lines changed

6 files changed

+359
-16
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Receive push notifications on your Mac from any source — servers, IoT devices,
2525
- **Priority Mapping**: Maps ntfy priority levels to macOS interruption levels (critical, time-sensitive)
2626
- **Menu Bar App**: Runs in the menu bar with quick access to config and reload
2727
- **Live Config Reload**: Configuration changes are detected and applied automatically
28+
- **Config Validation**: Warns about unknown keys and typos in the menu bar
2829
- **Click to Open**: Click notifications to open in browser (configurable per topic)
2930
- **Automatic Permission Request**: Prompts for notification permission on first launch
3031

@@ -117,6 +118,8 @@ ntfy-macos serve
117118
On first launch, the app will automatically request notification permission.
118119

119120
The app runs in the menu bar with options to:
121+
- **Server Status**: Shows connection state for each server (green=connected, red=disconnected, orange flashing=connecting)
122+
- **Config Warning**: Displays warnings for unknown keys or typos (orange indicator)
120123
- **Edit Config**: Open config file in your default editor
121124
- **Show Config in Finder**: Reveal config directory
122125
- **Reload Config**: Apply configuration changes
@@ -171,8 +174,8 @@ servers:
171174
- `url` (required): Server URL (e.g., `https://ntfy.sh`)
172175
- `token` (optional): Authentication token (can also be stored in Keychain)
173176
- `topics` (required): List of topics to subscribe to
174-
- `allowed_schemes` (optional): List of URL schemes allowed for click/action URLs (default: `["http", "https"]`)
175-
- `allowed_domains` (optional): List of domains allowed for click/action URLs (supports wildcards like `*.example.com`)
177+
- `allowed_schemes` (optional): List of URL schemes allowed for click/action URLs (default: `["http", "https"]`). **Replaces defaults** — setting `[https, myapp]` blocks http; setting `[myapp]` blocks both http and https
178+
- `allowed_domains` (optional): List of domains allowed for click/action URLs (supports wildcards like `*.example.com`). Not set = all domains allowed
176179

177180
#### Topic Fields
178181

@@ -302,7 +305,10 @@ servers:
302305
- name: alerts
303306
```
304307

305-
**Note**: The config file is validated at startup — world-writable config files are rejected for security.
308+
**Config Validation**: The configuration is validated at startup and on reload:
309+
- World-writable config files are rejected for security
310+
- Unknown keys (typos, misplaced options) trigger a warning in the menu bar
311+
- Invalid YAML or missing required fields prevent the app from starting
306312

307313
## Priority Mapping
308314

Sources/Config.swift

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,27 @@ struct AppConfig: Codable {
125125
}
126126
}
127127

128-
enum ConfigError: Error {
128+
enum ConfigError: Error, LocalizedError {
129129
case fileNotFound
130130
case invalidYAML(Error)
131131
case decodingError(Error)
132132
case insecureFilePermissions(String)
133+
case unknownKeys(String)
134+
135+
var errorDescription: String? {
136+
switch self {
137+
case .fileNotFound:
138+
return "Configuration file not found"
139+
case .invalidYAML(let error):
140+
return "Invalid YAML: \(error.localizedDescription)"
141+
case .decodingError(let error):
142+
return "Configuration error: \(error.localizedDescription)"
143+
case .insecureFilePermissions(let message):
144+
return message
145+
case .unknownKeys(let details):
146+
return "Unknown configuration keys:\n\(details)"
147+
}
148+
}
133149
}
134150

135151
final class ConfigManager: @unchecked Sendable {
@@ -170,17 +186,93 @@ final class ConfigManager: @unchecked Sendable {
170186
throw ConfigError.fileNotFound
171187
}
172188

189+
// Validate for unknown keys before decoding (warnings only, doesn't block loading)
190+
let unknownKeysWarning = checkForUnknownKeys(in: yamlString)
191+
173192
let decoder = YAMLDecoder()
174193
do {
175194
let decodedConfig = try decoder.decode(AppConfig.self, from: yamlString)
176195
lock.lock()
177196
defer { lock.unlock() }
178197
self._config = decodedConfig
198+
self._configWarning = unknownKeysWarning
179199
} catch {
180200
throw ConfigError.decodingError(error)
181201
}
182202
}
183203

204+
/// Warning message for unknown keys (doesn't prevent loading)
205+
private var _configWarning: String?
206+
var configWarning: String? {
207+
lock.lock()
208+
defer { lock.unlock() }
209+
return _configWarning
210+
}
211+
212+
/// Known keys at each level of the config
213+
private static let knownRootKeys: Set<String> = ["servers"]
214+
private static let knownServerKeys: Set<String> = ["url", "token", "topics", "allowed_schemes", "allowed_domains"]
215+
private static let knownTopicKeys: Set<String> = ["name", "icon_path", "icon_symbol", "auto_run_script", "silent", "click_url", "actions"]
216+
private static let knownActionKeys: Set<String> = ["title", "type", "path", "url"]
217+
218+
/// Checks YAML for unknown keys that would be silently ignored
219+
/// Returns warning message if unknown keys found, nil otherwise
220+
private func checkForUnknownKeys(in yamlString: String) -> String? {
221+
guard let yaml = try? Yams.load(yaml: yamlString) as? [String: Any] else {
222+
return nil // Let the decoder handle invalid YAML
223+
}
224+
225+
var warnings: [String] = []
226+
227+
// Check root level
228+
for key in yaml.keys {
229+
if !Self.knownRootKeys.contains(key) {
230+
warnings.append("Unknown key '\(key)' at root level")
231+
}
232+
}
233+
234+
// Check servers
235+
if let servers = yaml["servers"] as? [[String: Any]] {
236+
for (serverIndex, server) in servers.enumerated() {
237+
let serverUrl = server["url"] as? String ?? "server[\(serverIndex)]"
238+
for key in server.keys {
239+
if !Self.knownServerKeys.contains(key) {
240+
warnings.append("Unknown key '\(key)' in server '\(serverUrl)'")
241+
}
242+
}
243+
244+
// Check topics
245+
if let topics = server["topics"] as? [[String: Any]] {
246+
for (topicIndex, topic) in topics.enumerated() {
247+
let topicName = topic["name"] as? String ?? "topic[\(topicIndex)]"
248+
for key in topic.keys {
249+
if !Self.knownTopicKeys.contains(key) {
250+
warnings.append("Unknown key '\(key)' in topic '\(topicName)' (server: \(serverUrl))")
251+
}
252+
}
253+
254+
// Check actions
255+
if let actions = topic["actions"] as? [[String: Any]] {
256+
for (actionIndex, action) in actions.enumerated() {
257+
let actionTitle = action["title"] as? String ?? "action[\(actionIndex)]"
258+
for key in action.keys {
259+
if !Self.knownActionKeys.contains(key) {
260+
warnings.append("Unknown key '\(key)' in action '\(actionTitle)' (topic: \(topicName))")
261+
}
262+
}
263+
}
264+
}
265+
}
266+
}
267+
}
268+
}
269+
270+
if warnings.isEmpty {
271+
return nil
272+
}
273+
return warnings.joined(separator: "\n")
274+
}
275+
184276
/// Validates that the config file has secure permissions (not world-writable)
185277
private func validateFilePermissions(at path: String) throws {
186278
let fileManager = FileManager.default

Sources/StatusBarController.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ class StatusBarController: NSObject {
66
private var menu: NSMenu?
77
private var statusMenuItem: NSMenuItem?
88
private var serversSubmenu: NSMenu?
9+
private var errorMenuItem: NSMenuItem?
910
var onReloadConfig: (() -> Void)?
1011

1112
// Connection tracking
1213
private var serverStatuses: [String: ServerConnectionStatus] = [:]
1314
private var connectingAnimationTimer: Timer?
1415
private var connectingAnimationVisible: Bool = true
16+
private var currentConfigError: String?
1517

1618
enum ConnectionState {
1719
case connecting // Never connected yet (orange, flashing)
@@ -65,6 +67,12 @@ class StatusBarController: NSObject {
6567
statusMenuItem?.isEnabled = false
6668
menu?.addItem(statusMenuItem!)
6769

70+
// Error menu item (hidden by default)
71+
errorMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
72+
errorMenuItem?.isEnabled = false
73+
errorMenuItem?.isHidden = true
74+
menu?.addItem(errorMenuItem!)
75+
6876
// Servers submenu showing individual server statuses
6977
let serversItem = NSMenuItem(title: "Servers", action: nil, keyEquivalent: "")
7078
serversSubmenu = NSMenu()
@@ -221,6 +229,44 @@ class StatusBarController: NSObject {
221229
statusMenuItem?.title = status
222230
}
223231

232+
/// Shows a configuration error in the menu (in red)
233+
func showConfigError(_ error: String) {
234+
currentConfigError = error
235+
errorMenuItem?.isHidden = false
236+
237+
// Create attributed string with red color
238+
let attributes: [NSAttributedString.Key: Any] = [
239+
.foregroundColor: NSColor.systemRed,
240+
.font: NSFont.systemFont(ofSize: 13)
241+
]
242+
let attributedTitle = NSAttributedString(string: "⚠️ Config Error", attributes: attributes)
243+
errorMenuItem?.attributedTitle = attributedTitle
244+
errorMenuItem?.toolTip = error
245+
}
246+
247+
/// Shows a configuration warning in the menu (in orange)
248+
func showConfigWarning(_ warning: String) {
249+
currentConfigError = warning
250+
errorMenuItem?.isHidden = false
251+
252+
// Create attributed string with orange color
253+
let attributes: [NSAttributedString.Key: Any] = [
254+
.foregroundColor: NSColor.systemOrange,
255+
.font: NSFont.systemFont(ofSize: 13)
256+
]
257+
let attributedTitle = NSAttributedString(string: "⚠️ Config Warning", attributes: attributes)
258+
errorMenuItem?.attributedTitle = attributedTitle
259+
errorMenuItem?.toolTip = warning
260+
}
261+
262+
/// Clears any config error/warning from the menu
263+
func clearConfigError() {
264+
currentConfigError = nil
265+
errorMenuItem?.isHidden = true
266+
errorMenuItem?.attributedTitle = nil
267+
errorMenuItem?.toolTip = nil
268+
}
269+
224270
/// Initialize server tracking from config
225271
func initializeServers(servers: [(url: String, topics: [String])]) {
226272
stopConnectingAnimation()

Sources/main.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,21 @@ final class NtfyMacOS: NtfyClientDelegate, @unchecked Sendable {
173173
// Reload config file
174174
do {
175175
try ConfigManager.shared.loadConfig(from: nil)
176+
DispatchQueue.main.async {
177+
// Check for config warnings (unknown keys, etc.)
178+
if let warning = ConfigManager.shared.configWarning {
179+
Log.info("Configuration warning: \(warning)")
180+
StatusBarController.shared.showConfigWarning(warning)
181+
} else {
182+
StatusBarController.shared.clearConfigError()
183+
}
184+
}
176185
} catch {
177186
Log.error("Failed to reload configuration: \(error)")
187+
let errorMessage = (error as? LocalizedError)?.errorDescription ?? "\(error)"
188+
DispatchQueue.main.async {
189+
StatusBarController.shared.showConfigError(errorMessage)
190+
}
178191
return
179192
}
180193

@@ -546,6 +559,12 @@ DispatchQueue.main.async {
546559
StatusBarController.shared.onReloadConfig = {
547560
CLI.ntfyAppInstance?.reloadConfig()
548561
}
562+
563+
// Show any initial config warnings
564+
if let warning = ConfigManager.shared.configWarning {
565+
Log.info("Configuration warning: \(warning)")
566+
StatusBarController.shared.showConfigWarning(warning)
567+
}
549568
}
550569
}
551570

0 commit comments

Comments
 (0)