diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 6677ec8..e274249 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -1636,6 +1636,7 @@ } }, "Cannot start Fn-key monitoring. Check Accessibility permission for this BarShelf build; if macOS requests Input Monitoring, allow it and restart BarShelf.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -1645,6 +1646,16 @@ } } }, + "Cannot start keyboard monitoring. Check Accessibility permission for this BarShelf build; if macOS requests Input Monitoring, allow it and restart BarShelf.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "키보드 모니터링을 시작할 수 없습니다. 이 BarShelf 빌드의 손쉬운 사용 권한을 확인하세요. macOS가 입력 모니터링 권한을 요청하면 허용하고 BarShelf를 재시작하세요." + } + } + } + }, "Caption (optional)": { "localizations": { "ko": { @@ -1856,6 +1867,26 @@ } } }, + "Choose Hammerspoon Lua, Karabiner JSON, or a JavaScript extension.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hammerspoon Lua, Karabiner JSON 또는 JavaScript 확장 파일을 선택하세요." + } + } + } + }, + "Choose a Karabiner JSON configuration or rules file.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Karabiner JSON 설정 파일이나 규칙 파일을 고르세요." + } + } + } + }, "Choose a page to show": { "localizations": { "ko": { @@ -1867,6 +1898,7 @@ } }, "Choose an init.lua navigation profile or a .js extension.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -3615,6 +3647,16 @@ } } }, + "Function key settings were not imported; macOS handles F1–F12 itself.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "기능 키 설정은 가져오지 않았습니다. F1–F12는 macOS가 직접 처리합니다." + } + } + } + }, "Gallery": { "localizations": { "ko": { @@ -3678,6 +3720,7 @@ } }, "Global shortcuts, Fn arrow keys, and window navigation. Quit Hammerspoon before enabling to avoid duplicate shortcuts.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -3687,6 +3730,16 @@ } } }, + "Global shortcuts, key remapping, and window navigation. Quit Hammerspoon and disable matching Karabiner rules before enabling.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "전역 단축키, 키 리매핑 및 창 탐색을 지원합니다. 활성화 전에 Hammerspoon을 종료하고 같은 Karabiner 규칙을 끄세요." + } + } + } + }, "Go": { "localizations": { "ko": { @@ -3840,6 +3893,16 @@ } } }, + "Hammerspoon": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hammerspoon" + } + } + } + }, "Header": { "localizations": { "ko": { @@ -4082,6 +4145,7 @@ } }, "Import Hammerspoon Settings": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -4091,6 +4155,42 @@ } } }, + "Import Hammerspoon navigation, Karabiner key mappings, or JavaScript. Import fills the editor; Save applies it. Unsupported behavior is reported before importing. Disable the original tool's matching rules before enabling.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hammerspoon 탐색 설정, Karabiner 키 매핑 또는 JavaScript를 가져옵니다. 가져온 내용을 저장하면 적용됩니다. 지원하지 않는 동작은 가져오기 전에 안내합니다. 활성화 전에 원래 도구의 같은 규칙을 끄세요." + } + } + } + }, + "Import Settings": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 가져오기" + } + } + } + }, + "Imported %lld key remaps from Karabiner-Elements%@. Rules apply to all keyboards. Disable the original Karabiner rules before enabling this extension.": { + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "Imported %1$lld key remaps from Karabiner-Elements%2$@. Rules apply to all keyboards. Disable the original Karabiner rules before enabling this extension." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Karabiner-Elements%2$@에서 키 매핑 %1$lld개를 가져왔습니다. 규칙은 모든 키보드에 적용됩니다. 이 확장을 켜기 전에 원래 Karabiner 규칙을 끄세요." + } + } + } + }, "Imported %lld shortcuts and Fn mappings for keyboard types %@. Window focus uses top-to-bottom, then left-to-right order. The native host restores input monitoring after sleep.": { "localizations": { "en": { @@ -4108,6 +4208,7 @@ } }, "Imports the supported Fn / monitor / window-cycle Lua profile or a JavaScript extension. Import fills the editor; Save applies it. Unsupported Lua logic is reported instead of discarded.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -4598,6 +4699,26 @@ } } }, + "Karabiner configuration needs at least one profile.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Karabiner 설정에 프로필이 하나 이상 있어야 합니다." + } + } + } + }, + "Karabiner-Elements": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Karabiner-Elements" + } + } + } + }, "Keep in Dock": { "localizations": { "ko": { @@ -4618,6 +4739,26 @@ } } }, + "Key remaps require supported from/to key names.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "키 매핑에는 지원하는 from/to 키 이름이 필요합니다." + } + } + } + }, + "Key-remap modifiers must be unique strings.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "키 매핑의 보조 키는 겹치지 않는 문자열이어야 합니다." + } + } + } + }, "Keyboard & Windows": { "localizations": { "ko": { @@ -4628,7 +4769,18 @@ } } }, + "Keyboard shortcuts and window control, imported from Hammerspoon or Karabiner.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "Hammerspoon 또는 Karabiner에서 가져온 단축키와 창 제어 설정입니다." + } + } + } + }, "Keyboard shortcuts and window control, imported from Hammerspoon.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -5032,6 +5184,16 @@ } } }, + "Mandatory and optional modifiers must not overlap.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "필수 보조 키와 선택 보조 키가 겹치면 안 됩니다." + } + } + } + }, "Match": { "localizations": { "ko": { @@ -6962,6 +7124,16 @@ } } }, + "Register between 1 and 128 key remaps.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "키 매핑은 1개에서 128개까지 등록할 수 있습니다." + } + } + } + }, "Regular": { "localizations": { "ko": { @@ -7622,6 +7794,7 @@ } }, "Saved separately from the original file. No Hammerspoon settings are modified.": { + "extractionState": "stale", "localizations": { "ko": { "stringUnit": { @@ -7631,6 +7804,16 @@ } } }, + "Saved separately from the original file. Original tool settings are unchanged.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 파일과 별도로 저장합니다. 원래 도구의 설정은 변경하지 않습니다." + } + } + } + }, "Screen numbers follow macOS display order, primary first. Check this list after reconnecting monitors.": { "localizations": { "ko": { @@ -7812,6 +7995,16 @@ } } }, + "Select exactly one Karabiner profile before importing.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "가져오기 전에 Karabiner 프로필을 하나만 선택하세요." + } + } + } + }, "Semibold": { "localizations": { "ko": { @@ -9600,6 +9793,16 @@ } } }, + "Unsupported Karabiner behavior at %@. Nothing was imported. Supported: global key-to-key mappings with command/control/option/shift/fn/caps_lock modifiers. Device/app conditions, modifier keys, macros and shell actions need manual migration.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 Karabiner 동작은 지원하지 않아 아무것도 가져오지 않았습니다. 지원: command/control/option/shift/fn/caps_lock 보조 키를 쓰는 전역 키 대 키 매핑. 기기·앱 조건, 보조 키 자체의 변경, 매크로, 셸 동작은 직접 옮겨야 합니다." + } + } + } + }, "Unsupported Lua callback: %@. Nothing was imported.": { "localizations": { "ko": { @@ -9636,6 +9839,16 @@ } } }, + "Unsupported key-remap modifier: %@.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원하지 않는 키 매핑 보조 키: %@." + } + } + } + }, "Up to three apps you used lately that aren't in the dock or open.": { "localizations": { "ko": { @@ -10446,6 +10659,16 @@ } } }, + "fn cannot be required on %@: macOS reports that key with fn either way.": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에는 fn을 필수로 지정할 수 없습니다. macOS는 이 키를 항상 fn과 함께 보고합니다." + } + } + } + }, "focus window": { "comment": "Fills \"Could not %@\" when a window action fails.", "localizations": { diff --git a/Sources/MenubucketApp/Automation/AutomationController.swift b/Sources/MenubucketApp/Automation/AutomationController.swift index 543056c..82fe9fe 100644 --- a/Sources/MenubucketApp/Automation/AutomationController.swift +++ b/Sources/MenubucketApp/Automation/AutomationController.swift @@ -114,20 +114,10 @@ final class AutomationController: ObservableObject { throw AutomationFailure(String(localized: "Extension files must be smaller than 256 KB.")) } let contents = try String(contentsOf: url, encoding: .utf8) - let imported: String - let summary: String - if url.pathExtension.lowercased() == "lua" { - let result = try HammerspoonImporter.convert(contents) - imported = result.script - summary = result.summary - } else if url.pathExtension.lowercased() == "js" { - _ = try AutomationScript(source: contents) - imported = contents - summary = String(localized: "JavaScript loaded into the editor. Save to apply it.") - } else { throw AutomationFailure(String(localized: "Choose an init.lua navigation profile or a .js extension.")) } - importSummary = summary + let result = try AutomationImporter.convert(contents, fileExtension: url.pathExtension) + importSummary = result.summary message = nil - return imported + return result.script } catch { message = error.localizedDescription return nil diff --git a/Sources/MenubucketApp/Automation/AutomationEngine.swift b/Sources/MenubucketApp/Automation/AutomationEngine.swift index f3c0107..3f61aa6 100644 --- a/Sources/MenubucketApp/Automation/AutomationEngine.swift +++ b/Sources/MenubucketApp/Automation/AutomationEngine.swift @@ -40,6 +40,7 @@ final class AutomationEngine: AutomationRunning { private var watchdog: Timer? private var wakeObserver: NSObjectProtocol? private var remapState = AutomationRemapState() + private var keyRemapState = AutomationKeyRemapState() private var generation = 0 private var active = false var report: ((String) -> Void)? @@ -79,9 +80,11 @@ final class AutomationEngine: AutomationRunning { InAppHotkeys.Key(keyCode: $0.combination.keyCode, modifiers: $0.combination.modifiers) })) if !script.bindings.isEmpty { try registerHotkeys() } - if script.remap != nil { try installTap() } + if script.remap != nil || !script.keyRemaps.isEmpty { try installTap() } active = true - watchdog = Timer(timeInterval: 30, repeats: true) { [weak self] _ in self?.recoverTap(resetKeys: true) } + // A healthy tap must retain held-key state across polling. Reset + // only after missed events (disabled tap or wake), or on shutdown. + watchdog = Timer(timeInterval: 30, repeats: true) { [weak self] _ in self?.recoverTap() } RunLoop.main.add(watchdog!, forMode: .common) wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main @@ -162,7 +165,7 @@ final class AutomationEngine: AutomationRunning { engine.remap(type: type, event: event) return Unmanaged.passUnretained(event) }, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { - throw AutomationFailure(String(localized: "Cannot start Fn-key monitoring. Check Accessibility permission for this BarShelf build; if macOS requests Input Monitoring, allow it and restart BarShelf.")) + throw AutomationFailure(String(localized: "Cannot start keyboard monitoring. Check Accessibility permission for this BarShelf build; if macOS requests Input Monitoring, allow it and restart BarShelf.")) } tap = newTap guard let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newTap, 0) else { @@ -174,6 +177,16 @@ final class AutomationEngine: AutomationRunning { } private func remap(type: CGEventType, event: CGEvent) { + if !script.keyRemaps.isEmpty, type == .keyDown || type == .keyUp { + if let rule = keyRemapState.target(code: event.getIntegerValueField(.keyboardEventKeycode), + keyboard: event.getIntegerValueField(.keyboardEventKeyboardType), down: type == .keyDown, + flags: event.flags.rawValue, rules: script.keyRemaps) { + event.setIntegerValueField(.keyboardEventKeycode, value: rule.to) + event.keyboardSetUnicodeString(stringLength: 0, unicodeString: nil) + event.flags = CGEventFlags(rawValue: event.flags.rawValue & ~rule.mandatory) + } + return + } guard let config = script.remap else { return } if type == .flagsChanged { remapState.fnDown = event.flags.contains(.maskSecondaryFn) @@ -200,9 +213,10 @@ final class AutomationEngine: AutomationRunning { } private func releaseHeldKeys() { - for code in Set(remapState.held.values) { + for code in Set(Array(remapState.held.values) + keyRemapState.held.values.map(\.to)) { CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(code), keyDown: false)?.post(tap: .cghidEventTap) } remapState = AutomationRemapState() + keyRemapState = AutomationKeyRemapState() } } diff --git a/Sources/MenubucketApp/Automation/AutomationImporter.swift b/Sources/MenubucketApp/Automation/AutomationImporter.swift new file mode 100644 index 0000000..c8856ea --- /dev/null +++ b/Sources/MenubucketApp/Automation/AutomationImporter.swift @@ -0,0 +1,26 @@ +import Foundation + +/// One entry point for third-party formats; every converter produces a draft +/// and must validate it before the controller changes any saved state. +enum AutomationImporter { + struct Result { + let script: String + let summary: String + } + + static func convert(_ source: String, fileExtension: String) throws -> Result { + switch fileExtension.lowercased() { + case "lua": + let result = try HammerspoonImporter.convert(source) + return Result(script: result.script, summary: result.summary) + case "json": + let result = try KarabinerImporter.convert(source) + return Result(script: result.script, summary: result.summary) + case "js": + _ = try AutomationScript(source: source) + return Result(script: source, summary: String(localized: "JavaScript loaded into the editor. Save to apply it.")) + default: + throw AutomationFailure(String(localized: "Choose Hammerspoon Lua, Karabiner JSON, or a JavaScript extension.")) + } + } +} diff --git a/Sources/MenubucketApp/Automation/AutomationKeyRemap.swift b/Sources/MenubucketApp/Automation/AutomationKeyRemap.swift new file mode 100644 index 0000000..70368c4 --- /dev/null +++ b/Sources/MenubucketApp/Automation/AutomationKeyRemap.swift @@ -0,0 +1,94 @@ +import CoreGraphics +import Foundation + +/// Native, bounded key-to-key rules. No JavaScript runs on the event tap. +struct AutomationKeyRemap: Equatable { + let from: Int64 + let to: Int64 + let mandatory: UInt64 + let optional: UInt64 + let anyOptional: Bool + + static let modifierFlags: [String: CGEventFlags] = [ + "command": .maskCommand, "control": .maskControl, "option": .maskAlternate, + "shift": .maskShift, "fn": .maskSecondaryFn, "caps_lock": .maskAlphaShift + ] + static let modifierMask = modifierFlags.values.reduce(UInt64(0)) { $0 | $1.rawValue } + static let keys: [String: Int64] = AutomationScript.physicalKeys.merging([ + "1": 18, "2": 19, "3": 20, "4": 21, "5": 23, "6": 22, + "7": 26, "8": 28, "9": 25, "0": 29, + "return_or_enter": 36, "tab": 48, "spacebar": 49, + "delete_or_backspace": 51, "escape": 53, "delete_forward": 117, + "home": 115, "end": 119, "page_up": 116, "page_down": 121, + "left_arrow": 123, "right_arrow": 124, "down_arrow": 125, "up_arrow": 126 + ]) { first, _ in first } + + /// Keys macOS always reports with the fn flag: arrows, Home/End, + /// Page Up/Down, Forward Delete, Help and the function keys. On these fn + /// says nothing about whether fn was held, so it is left out of matching; + /// otherwise `control+left_arrow` never matched (the event carries + /// control|fn) and a rule requiring fn fired on every plain arrow. + static let fnImpliedKeys: Set = [ + 123, 124, 125, 126, 115, 119, 116, 121, 117, 114, + 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, 105, 107, 113, + ] + + static func decode(_ values: [[String: Any]]) throws -> [Self] { + guard !values.isEmpty, values.count <= 128 else { + throw AutomationFailure(String(localized: "Register between 1 and 128 key remaps.")) + } + return try values.map { value in + guard Set(value.keys).isSubset(of: ["from", "to", "mandatory", "optional"]), + let source = value["from"] as? String, let from = keys[source], + let destination = value["to"] as? String, let to = keys[destination] else { + throw AutomationFailure(String(localized: "Key remaps require supported from/to key names.")) + } + func flags(_ name: String) throws -> (UInt64, Bool) { + guard let names = (value[name] ?? [String]()) as? [String], + Set(names).count == names.count else { + throw AutomationFailure(String(localized: "Key-remap modifiers must be unique strings.")) + } + if names == ["any"], name == "optional" { return (0, true) } + var flags: UInt64 = 0 + for name in names { + guard let flag = modifierFlags[name] else { + throw AutomationFailure(String(localized: "Unsupported key-remap modifier: \(name).")) + } + flags |= flag.rawValue + } + return (flags, false) + } + let mandatory = try flags("mandatory") + let optional = try flags("optional") + guard mandatory.0 & optional.0 == 0 else { + throw AutomationFailure(String(localized: "Mandatory and optional modifiers must not overlap.")) + } + if fnImpliedKeys.contains(from), mandatory.0 & CGEventFlags.maskSecondaryFn.rawValue != 0 { + throw AutomationFailure(String(localized: "fn cannot be required on \(source): macOS reports that key with fn either way.")) + } + return Self(from: from, to: to, mandatory: mandatory.0, + optional: optional.0, anyOptional: optional.1) + } + } + + func matches(code: Int64, flags: UInt64) -> Bool { + var modifiers = flags & Self.modifierMask + if Self.fnImpliedKeys.contains(code) { modifiers &= ~CGEventFlags.maskSecondaryFn.rawValue } + return code == from && modifiers & mandatory == mandatory && + (anyOptional || modifiers & ~(mandatory | optional) == 0) + } +} + +struct AutomationKeyRemapState { + var held: [AutomationRemapState.Key: AutomationKeyRemap] = [:] + + mutating func target(code: Int64, keyboard: Int64, down: Bool, flags: UInt64, + rules: [AutomationKeyRemap]) -> AutomationKeyRemap? { + let key = AutomationRemapState.Key(code: code, keyboard: keyboard) + if !down { return held.removeValue(forKey: key) } + if let rule = held[key] { return rule } + guard let rule = rules.first(where: { $0.matches(code: code, flags: flags) }) else { return nil } + held[key] = rule + return rule + } +} diff --git a/Sources/MenubucketApp/Automation/AutomationScript.swift b/Sources/MenubucketApp/Automation/AutomationScript.swift index 804104b..5d14246 100644 --- a/Sources/MenubucketApp/Automation/AutomationScript.swift +++ b/Sources/MenubucketApp/Automation/AutomationScript.swift @@ -22,6 +22,7 @@ final class AutomationScript { private let context: JSContext private(set) var bindings: [Binding] = [] private(set) var remap: Remap? + private(set) var keyRemaps: [AutomationKeyRemap] = [] private var invoking = false var command: ((String, JSValue) throws -> Void)? var report: ((String) -> Void)? @@ -82,7 +83,16 @@ final class AutomationScript { } remap = Remap(keyboardTypes: Set(types.map(\.int64Value)), keys: keys) } - guard !bindings.isEmpty || remap != nil else { + if let value = context.objectForKeyedSubscript("__keyRemaps"), !value.isNull { + guard let rules = value.toArray() as? [[String: Any]] else { + throw AutomationFailure("Key remaps must be an array of rule objects.") + } + keyRemaps = try AutomationKeyRemap.decode(rules) + } + guard remap == nil || keyRemaps.isEmpty else { + throw AutomationFailure("Use remapFn or remapKeys in one extension, not both.") + } + guard !bindings.isEmpty || remap != nil || !keyRemaps.isEmpty else { throw AutomationFailure(String(localized: "The script did not register any shortcuts or Fn mappings.")) } } @@ -117,6 +127,7 @@ final class AutomationScript { "use strict"; var __bindings = []; var __remap = null; + var __keyRemaps = null; const barshelf = Object.freeze({ bind(modifiers, key, run) { if (!Array.isArray(modifiers) || !modifiers.every(x => typeof x === 'string') || @@ -133,6 +144,11 @@ final class AutomationScript { throw new Error('remapFn expects keyboardTypes (integer array) and keys (object)'); __remap = options; }, + remapKeys(rules) { + if (__keyRemaps) throw new Error('Declare key remaps once'); + if (!Array.isArray(rules)) throw new Error('remapKeys expects an array'); + __keyRemaps = rules; + }, moveMouseToScreen(index) { __command('mouse', index); }, moveWindowToScreen(index) { __command('window', index); }, rotateWindowFocus(direction) { __command('rotate', direction); }, diff --git a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift index 71c9534..12dd2c4 100644 --- a/Sources/MenubucketApp/Automation/AutomationSettingsView.swift +++ b/Sources/MenubucketApp/Automation/AutomationSettingsView.swift @@ -21,7 +21,14 @@ struct AutomationSettingsView: View { else { controller.disable() } } )) - Text("Global shortcuts, Fn arrow keys, and window navigation. Quit Hammerspoon before enabling to avoid duplicate shortcuts.") + Label { + Text(controller.isRunning ? String(localized: "Active") : String(localized: "Inactive")) + } icon: { + Image(systemName: controller.isRunning ? "checkmark.circle.fill" : "circle") + } + .font(.caption) + .foregroundStyle(controller.isRunning ? Color.green : Color.secondary) + Text("Global shortcuts, key remapping, and window navigation. Quit Hammerspoon and disable matching Karabiner rules before enabling.") .font(.caption).foregroundStyle(.secondary) Button("Grant Accessibility Access…") { controller.requestAccessibility() } if let message = controller.message { @@ -31,13 +38,22 @@ struct AutomationSettingsView: View { SwiftUI.Section { HStack { - Button("Import Hammerspoon Settings") { - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".hammerspoon/init.lua") - if let imported = controller.importFile(url) { draft = imported } + Menu("Import Settings") { + Button("Hammerspoon") { + importSettings(path: ".hammerspoon/init.lua") + } + Button("Karabiner-Elements") { + let base = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"].map { + URL(fileURLWithPath: $0, isDirectory: true) + } ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config") + if let imported = controller.importFile(base.appendingPathComponent("karabiner/karabiner.json")) { + draft = imported + } + } } Button("Choose File…") { chooseFile() } } - Text("Imports the supported Fn / monitor / window-cycle Lua profile or a JavaScript extension. Import fills the editor; Save applies it. Unsupported Lua logic is reported instead of discarded.") + Text("Import Hammerspoon navigation, Karabiner key mappings, or JavaScript. Import fills the editor; Save applies it. Unsupported behavior is reported before importing. Disable the original tool's matching rules before enabling.") .font(.caption).foregroundStyle(.secondary) if let summary = controller.importSummary { Text(summary).font(.caption).textSelection(.enabled) @@ -59,7 +75,7 @@ struct AutomationSettingsView: View { Text("Edit shortcuts and window actions in JavaScript. Save applies your changes; Revert Draft restores the saved script.") .font(.caption).foregroundStyle(.secondary) } header: { Text("Extension Script") } - footer: { Text("Saved separately from the original file. No Hammerspoon settings are modified.") } + footer: { Text("Saved separately from the original file. Original tool settings are unchanged.") } SwiftUI.Section { ForEach(Array(displays.enumerated()), id: \.element.id) { index, display in @@ -80,13 +96,18 @@ struct AutomationSettingsView: View { private func chooseFile() { let panel = NSOpenPanel() - panel.allowedContentTypes = [UTType(filenameExtension: "lua"), UTType(filenameExtension: "js")].compactMap { $0 } + panel.allowedContentTypes = [UTType(filenameExtension: "lua"), UTType(filenameExtension: "js"), .json].compactMap { $0 } panel.allowsMultipleSelection = false panel.canChooseDirectories = false guard panel.runModal() == .OK, let url = panel.url else { return } if let imported = controller.importFile(url) { draft = imported } } + private func importSettings(path: String) { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(path) + if let imported = controller.importFile(url) { draft = imported } + } + private func export() { let panel = NSSavePanel() panel.nameFieldStringValue = "automation.js" diff --git a/Sources/MenubucketApp/Automation/AutomationWindows.swift b/Sources/MenubucketApp/Automation/AutomationWindows.swift index d245e7b..16f9b74 100644 --- a/Sources/MenubucketApp/Automation/AutomationWindows.swift +++ b/Sources/MenubucketApp/Automation/AutomationWindows.swift @@ -59,16 +59,18 @@ final class AutomationWindows { } func moveMouse(to number: Int) throws { - guard let display = Self.displays[safe: number - 1] else { return } + let displays = Self.displays + guard let display = displays[safe: number - 1] else { return } warp(to: display.frame) - if let window = visibleWindows().first(where: { screen(of: $0)?.id == display.id && !$0.title.isEmpty }) { + if let window = visibleWindows().first(where: { screen(of: $0, displays: displays)?.id == display.id && !$0.title.isEmpty }) { try focus(window) } } func moveWindow(to number: Int) throws { - guard let target = Self.displays[safe: number - 1], let window = focusedWindow(), - let source = screen(of: window) else { return } + let displays = Self.displays + guard let target = displays[safe: number - 1], let window = focusedWindow(), + let source = screen(of: window, displays: displays) else { return } if attribute(window.element, "AXFullScreen") as? Bool == true { throw AutomationFailure(String(localized: "Exit full screen before moving this window.")) } @@ -84,8 +86,9 @@ final class AutomationWindows { } func rotate(forward: Bool) throws { - guard let current = focusedWindow(), let display = screen(of: current) else { return } - let windows = visibleWindows().filter { screen(of: $0)?.id == display.id && !$0.title.isEmpty } + let displays = Self.displays + guard let current = focusedWindow(), let display = screen(of: current, displays: displays) else { return } + let windows = visibleWindows().filter { screen(of: $0, displays: displays)?.id == display.id && !$0.title.isEmpty } .sorted { if $0.frame.minY != $1.frame.minY { return $0.frame.minY < $1.frame.minY } if $0.frame.minX != $1.frame.minX { return $0.frame.minX < $1.frame.minX } @@ -97,8 +100,7 @@ final class AutomationWindows { warp(to: windows[next].frame) } - private func screen(of window: Window) -> Display? { - let displays = Self.displays + private func screen(of window: Window, displays: [Display]) -> Display? { guard let index = AutomationGeometry.screenIndex(for: window.frame, screens: displays.map(\.frame)) else { return nil } return displays[index] } @@ -121,6 +123,21 @@ final class AutomationWindows { guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil } return value } + + private func attributes(_ element: AXUIElement, _ names: [String]) -> [CFTypeRef?] { + var values: CFArray? + let result = AXUIElementCopyMultipleAttributeValues(element, names as CFArray, [], &values) + if result == .success, let values = values as? [AnyObject], values.count == names.count { + return values.map { value in + if CFGetTypeID(value) == CFNullGetTypeID() { return nil } + if CFGetTypeID(value) == AXValueGetTypeID(), + AXValueGetType(unsafeBitCast(value, to: AXValue.self)) == .axError { return nil } + return value + } + } + // Some apps implement individual reads but not the batch API. + return names.map { attribute(element, $0) } + } private func focusedWindow() -> Window? { guard let app = NSWorkspace.shared.frontmostApplication else { return nil } let element = AXUIElementCreateApplication(app.processIdentifier) @@ -128,9 +145,15 @@ final class AutomationWindows { guard let value = attribute(element, kAXFocusedWindowAttribute), CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } return describe(unsafeBitCast(value, to: AXUIElement.self), pid: app.processIdentifier) } - private func describe(_ element: AXUIElement, pid: pid_t) -> Window? { - guard let p = attribute(element, kAXPositionAttribute), CFGetTypeID(p) == AXValueGetTypeID(), - let s = attribute(element, kAXSizeAttribute), CFGetTypeID(s) == AXValueGetTypeID() else { return nil } + private func describe(_ element: AXUIElement, pid: pid_t, visibleOnly: Bool = false) -> Window? { + // One cross-process request rather than 3–5 per window. This is on + // the main event loop, so the saved round trips also shorten input stalls. + let names = [kAXPositionAttribute, kAXSizeAttribute, kAXTitleAttribute] + + (visibleOnly ? [kAXMinimizedAttribute, kAXRoleAttribute] : []) + let values = attributes(element, names) + if visibleOnly, values[3] as? Bool == true || values[4] as? String != kAXWindowRole { return nil } + guard let p = values[0], CFGetTypeID(p) == AXValueGetTypeID(), + let s = values[1], CFGetTypeID(s) == AXValueGetTypeID() else { return nil } var point = CGPoint.zero var size = CGSize.zero guard AXValueGetValue(unsafeBitCast(p, to: AXValue.self), .cgPoint, &point), @@ -143,7 +166,7 @@ final class AutomationWindows { identities.append((element, id)) } return Window(element: element, pid: pid, id: id, frame: CGRect(origin: point, size: size), - title: attribute(element, kAXTitleAttribute) as? String ?? "") + title: values[2] as? String ?? "") } private func visibleWindows() -> [Window] { @@ -161,10 +184,7 @@ final class AutomationWindows { let app = AXUIElementCreateApplication(pid) AXUIElementSetMessagingTimeout(app, 0.2) let windows = attribute(app, kAXWindowsAttribute) as? [AXUIElement] ?? [] - byPID[pid] = windows.filter { - attribute($0, kAXMinimizedAttribute) as? Bool != true && - attribute($0, kAXRoleAttribute) as? String == kAXWindowRole - }.compactMap { describe($0, pid: pid) } + byPID[pid] = windows.compactMap { describe($0, pid: pid, visibleOnly: true) } } // Public AX doesn't expose a CGWindowID. Match bounds within one // point and remove each match so equal-frame windows stay distinct. diff --git a/Sources/MenubucketApp/Automation/KarabinerImporter.swift b/Sources/MenubucketApp/Automation/KarabinerImporter.swift new file mode 100644 index 0000000..67d386f --- /dev/null +++ b/Sources/MenubucketApp/Automation/KarabinerImporter.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Convert only behavior the native event tap can preserve. Reject the whole +/// candidate on unsupported actions instead of silently dropping rules. +enum KarabinerImporter { + struct Result { + let script: String + let summary: String + } + + static func convert(_ source: String) throws -> Result { + guard let data = source.data(using: .utf8), + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AutomationFailure(String(localized: "Choose a Karabiner JSON configuration or rules file.")) + } + var remaps: [[String: Any]] = [] + var profileName: String? + var ignoredFunctionKeys = false + let container: [String: Any] + if root["profiles"] != nil { + // `global` and `machine_specific` hold app-wide preferences (menu + // bar icon, notifications), not key behavior. + try allowed(root, ["profiles", "global", "machine_specific"], at: "configuration") + guard let profiles = root["profiles"] as? [[String: Any]], !profiles.isEmpty else { + throw AutomationFailure(String(localized: "Karabiner configuration needs at least one profile.")) + } + let selected = profiles.filter { ($0["selected"] as? Bool) == true } + guard selected.count == 1 || (selected.isEmpty && profiles.count == 1) else { + throw AutomationFailure(String(localized: "Select exactly one Karabiner profile before importing.")) + } + container = selected.first ?? profiles[0] + profileName = container["name"] as? String + try allowed(container, ["name", "selected", "simple_modifications", "complex_modifications", + "fn_function_keys", "devices", "virtual_hid_keyboard", "parameters"], at: "profile") + // What Karabiner writes into every configuration on its own is + // accepted; only settings that change keys are refused. + // `fn_function_keys` maps F1–F12 to media keys the way macOS + // already does, so it is left out and said so in the summary. + if let functionKeys = container["fn_function_keys"] { + guard let entries = functionKeys as? [[String: Any]] else { throw unsupported("profile.fn_function_keys") } + for (index, entry) in entries.enumerated() { + // Media keys (brightness, volume…) are what macOS does + // anyway; F1 sent as another key is a real remap. + let outputs = entry["to"] as? [[String: Any]] ?? [] + let mediaOnly = !outputs.isEmpty && outputs.allSatisfy { output in + Set(output.keys).isSubset(of: ["consumer_key_code", "apple_vendor_keyboard_key_code", + "apple_vendor_top_case_key_code"]) + } + guard mediaOnly else { throw unsupported("profile.fn_function_keys[\(index)]") } + } + ignoredFunctionKeys = !entries.isEmpty + } + if let parameters = container["parameters"] { + guard let parameters = parameters as? [String: Any] else { throw unsupported("profile.parameters") } + try allowed(parameters, ["delay_milliseconds_before_open_device"], at: "profile.parameters") + } + if let devices = container["devices"] { + guard let devices = devices as? [[String: Any]] else { throw unsupported("profile.devices") } + for (index, device) in devices.enumerated() { + let path = "profile.devices[\(index)]" + // Per-keyboard remaps, or ignoring a keyboard, change which + // keys do what; the rest is how Karabiner talks to it. + try empty(device["simple_modifications"], at: path + ".simple_modifications") + try empty(device["fn_function_keys"], at: path + ".fn_function_keys") + if device["ignore"] as? Bool == true { throw unsupported(path + ".ignore") } + if device["disable_built_in_keyboard_if_exists"] as? Bool == true { + throw unsupported(path + ".disable_built_in_keyboard_if_exists") + } + } + } + if let virtual = container["virtual_hid_keyboard"] as? [String: Any] { + try allowed(virtual, ["keyboard_type", "keyboard_type_v2", "country_code", + "mouse_key_xy_scale", "indicate_sticky_modifier_keys_state"], at: "virtual_hid_keyboard") + } else if container["virtual_hid_keyboard"] != nil { + throw unsupported("virtual_hid_keyboard") + } + if let simple = container["simple_modifications"] { + guard let pairs = simple as? [[String: Any]] else { throw unsupported("simple_modifications") } + for (index, pair) in pairs.enumerated() { + let path = "simple_modifications[\(index)]" + try allowed(pair, ["from", "to"], at: path) + guard let from = pair["from"] as? [String: Any], + let to = pair["to"] as? [[String: Any]], to.count == 1 else { throw unsupported(path) } + try allowed(from, ["key_code"], at: path + ".from") + try allowed(to[0], ["key_code"], at: path + ".to") + guard let key = from["key_code"] as? String, let target = to[0]["key_code"] as? String else { + throw unsupported(path) + } + remaps.append(["from": key, "to": target, "optional": ["any"]]) + } + } + } else { container = root } + + let rules: [[String: Any]] + if root["profiles"] != nil { + if let value = container["complex_modifications"] { + guard let complex = value as? [String: Any] else { throw unsupported("complex_modifications") } + // `parameters` are the basic.* timeouts the GUI writes; they + // tune to_if_alone and held keys, which are not imported. + try allowed(complex, ["rules", "parameters"], at: "complex_modifications") + guard let items = (complex["rules"] ?? [[String: Any]]()) as? [[String: Any]] else { + throw unsupported("complex_modifications.rules") + } + rules = items + } else { rules = [] } + } else if root["manipulators"] != nil { + rules = [root] + } else { + try allowed(root, ["title", "rules"], at: "rules file") + guard let items = root["rules"] as? [[String: Any]] else { throw unsupported("rules") } + rules = items + } + + for (ruleIndex, rule) in rules.enumerated() { + let path = "rules[\(ruleIndex)]" + try allowed(rule, ["description", "manipulators", "enabled"], at: path) + // A rule switched off in Karabiner stays off. + if rule["enabled"] as? Bool == false { continue } + guard let manipulators = rule["manipulators"] as? [[String: Any]], !manipulators.isEmpty else { + throw unsupported(path + ".manipulators") + } + for (index, item) in manipulators.enumerated() { + let path = path + ".manipulators[\(index)]" + try allowed(item, ["type", "description", "from", "to"], at: path) + guard item["type"] as? String == "basic", + let from = item["from"] as? [String: Any], + let to = item["to"] as? [[String: Any]], to.count == 1 else { throw unsupported(path) } + try allowed(from, ["key_code", "modifiers"], at: path + ".from") + try allowed(to[0], ["key_code"], at: path + ".to") + guard let key = from["key_code"] as? String, let target = to[0]["key_code"] as? String else { + throw unsupported(path) + } + var remap: [String: Any] = ["from": key, "to": target] + if let value = from["modifiers"] { + guard let modifiers = value as? [String: Any] else { throw unsupported(path + ".modifiers") } + try allowed(modifiers, ["mandatory", "optional"], at: path + ".modifiers") + for (name, value) in modifiers { remap[name] = value } + } + remaps.append(remap) + } + } + // Simple and complex remaps can form a pipeline in Karabiner; the host + // deliberately applies one rule per physical event. A rule's output + // (a bare key) chains only into a rule that needs no modifier for it. + let unmodifiedSources = Set(remaps.compactMap { remap -> String? in + guard ((remap["mandatory"] as? [String]) ?? []).isEmpty else { return nil } + return remap["from"] as? String + }) + guard !remaps.contains(where: { remap in + let target = remap["to"] as? String ?? "" + return unmodifiedSources.contains(target) || target == remap["from"] as? String + }) else { + throw unsupported("chained or self-referencing key remaps") + } + _ = try AutomationKeyRemap.decode(remaps) + let json = String(decoding: try JSONSerialization.data(withJSONObject: remaps, + options: [.sortedKeys, .withoutEscapingSlashes]), as: UTF8.self) + let script = "// Imported from Karabiner-Elements. Original settings are unchanged.\nbarshelf.remapKeys(\(json));" + _ = try AutomationScript(source: script) + let profile = profileName.map { " (\($0))" } ?? "" + var summary = String(localized: "Imported \(remaps.count) key remaps from Karabiner-Elements\(profile). Rules apply to all keyboards. Disable the original Karabiner rules before enabling this extension.") + if ignoredFunctionKeys { + summary += " " + String(localized: "Function key settings were not imported; macOS handles F1–F12 itself.") + } + return Result(script: script, summary: summary) + } + + private static func allowed(_ object: [String: Any], _ keys: Set, at path: String) throws { + if let key = Set(object.keys).subtracting(keys).sorted().first { throw unsupported(path + "." + key) } + } + + private static func empty(_ value: Any?, at path: String) throws { + guard let value else { return } + if let array = value as? [Any], array.isEmpty { return } + if let object = value as? [String: Any], object.isEmpty { return } + throw unsupported(path) + } + + private static func unsupported(_ path: String) -> AutomationFailure { + AutomationFailure(String(localized: "Unsupported Karabiner behavior at \(path). Nothing was imported. Supported: global key-to-key mappings with command/control/option/shift/fn/caps_lock modifiers. Device/app conditions, modifier keys, macros and shell actions need manual migration.")) + } +} diff --git a/Sources/MenubucketApp/DirectoryWatcher.swift b/Sources/MenubucketApp/DirectoryWatcher.swift index 80be041..1d1904c 100644 --- a/Sources/MenubucketApp/DirectoryWatcher.swift +++ b/Sources/MenubucketApp/DirectoryWatcher.swift @@ -16,6 +16,7 @@ final class DirectoryWatcher { private let eventHandler: () -> Void private let debounceInterval: TimeInterval private var pendingWork: DispatchWorkItem? + private var cancelled = false private let fsQueue = DispatchQueue(label: "dev.barshelf.directorywatcher") private class WeakBox { @@ -105,16 +106,19 @@ final class DirectoryWatcher { } /// Coalesces bursts of FSEvents into a single trailing-edge callback. - private func fireDebounced() { + func fireDebounced() { + guard !cancelled else { return } pendingWork?.cancel() let work = DispatchWorkItem { [weak self] in - self?.eventHandler() + guard let self, !self.cancelled else { return } + self.eventHandler() } pendingWork = work DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: work) } func cancel() { + cancelled = true pendingWork?.cancel() pendingWork = nil descriptorSource?.cancel() diff --git a/Sources/MenubucketApp/Dock/DockActions.swift b/Sources/MenubucketApp/Dock/DockActions.swift index 5213c48..836de62 100644 --- a/Sources/MenubucketApp/Dock/DockActions.swift +++ b/Sources/MenubucketApp/Dock/DockActions.swift @@ -129,25 +129,19 @@ final class RunningApps: ObservableObject { /// What clicking, dropping on, and right-clicking a dock tile does. enum DockActions { - // Icons and names, once per path. The dock's body runs on every hover + // Icons and names, once per path for a minute (so an updated app's icon + // shows up), at most 256 paths. The dock's body runs on every hover // change while magnifying, and asking LaunchServices for every tile's // icon each time stuttered the animation. - private static let iconCache = NSCache() - private static let nameCache = NSCache() + private static let filePresentations = DockFilePresentationCache() static func fileIcon(at path: String) -> NSImage { - if let cached = iconCache.object(forKey: path as NSString) { return cached } - let icon = NSWorkspace.shared.icon(forFile: path) - iconCache.setObject(icon, forKey: path as NSString) - return icon + filePresentations.presentation(at: path).icon ?? NSWorkspace.shared.icon(forFile: path) } static func fileDisplayName(at path: String) -> String { - if let cached = nameCache.object(forKey: path as NSString) { return cached as String } - let name = FileManager.default.displayName(atPath: path) + filePresentations.presentation(at: path).name .replacingOccurrences(of: ".app", with: "", options: [.anchored, .backwards]) - nameCache.setObject(name as NSString, forKey: path as NSString) - return name } static var trashURL: URL { diff --git a/Sources/MenubucketApp/Dock/DockFilePresentationCache.swift b/Sources/MenubucketApp/Dock/DockFilePresentationCache.swift new file mode 100644 index 0000000..67d027f --- /dev/null +++ b/Sources/MenubucketApp/Dock/DockFilePresentationCache.swift @@ -0,0 +1,39 @@ +import AppKit + +/// Hover and widget refreshes redraw the Dock frequently. File metadata and +/// icon services need one read per path, not one per SwiftUI body evaluation. +/// Expiry is checked on use; no polling timer or filesystem watcher is needed. +final class DockFilePresentationCache { + struct Presentation { + let name: String + let icon: NSImage? + } + private final class Entry: NSObject { + let presentation: Presentation + let expiresAt: Date + init(_ presentation: Presentation, expiresAt: Date) { + self.presentation = presentation + self.expiresAt = expiresAt + } + } + private let entries = NSCache() + private let load: (String) -> Presentation + private let lifetime: TimeInterval + + init(lifetime: TimeInterval = 60, load: @escaping (String) -> Presentation = { + Presentation(name: FileManager.default.displayName(atPath: $0), + icon: NSWorkspace.shared.icon(forFile: $0)) + }) { + self.lifetime = lifetime + self.load = load + entries.countLimit = 256 + } + + func presentation(at path: String, now: Date = Date()) -> Presentation { + let key = RunningApps.key(path) as NSString + if let entry = entries.object(forKey: key), now < entry.expiresAt { return entry.presentation } + let presentation = load(key as String) + entries.setObject(Entry(presentation, expiresAt: now.addingTimeInterval(lifetime)), forKey: key) + return presentation + } +} diff --git a/Sources/MenubucketApp/Hub/HubView.swift b/Sources/MenubucketApp/Hub/HubView.swift index e98b34c..1ddc737 100644 --- a/Sources/MenubucketApp/Hub/HubView.swift +++ b/Sources/MenubucketApp/Hub/HubView.swift @@ -125,7 +125,7 @@ struct HubCreateView: View { var body: some View { WidgetBuilderView(model: model) - .onAppear { + .onAppear { [weak runtime] in model.onCreated = { [weak runtime] in runtime?.loadWidgets() } model.onClose = { onFinished() } } diff --git a/Sources/MenubucketApp/Hub/HubWindowController.swift b/Sources/MenubucketApp/Hub/HubWindowController.swift index 8f06a04..c98ad15 100644 --- a/Sources/MenubucketApp/Hub/HubWindowController.swift +++ b/Sources/MenubucketApp/Hub/HubWindowController.swift @@ -60,7 +60,7 @@ enum HubTab: String, CaseIterable, Identifiable { case .dock: return String(localized: "A dock for apps, folders, and widgets, with profiles to switch between.") case .gallery: return String(localized: "Find and install widgets.") case .create: return String(localized: "Build a widget from a command, a URL, a folder, or text.") - case .automation: return String(localized: "Keyboard shortcuts and window control, imported from Hammerspoon.") + case .automation: return String(localized: "Keyboard shortcuts and window control, imported from Hammerspoon or Karabiner.") case .general: return String(localized: "Icon, login, and sounds.") case .shortcuts: return String(localized: "Keyboard shortcuts for BarShelf.") case .updates: return String(localized: "How BarShelf keeps itself up to date.") diff --git a/Sources/MenubucketApp/NativeRefreshGate.swift b/Sources/MenubucketApp/NativeRefreshGate.swift new file mode 100644 index 0000000..768a086 --- /dev/null +++ b/Sources/MenubucketApp/NativeRefreshGate.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Ownership of asynchronous exec/workflow results across reloads. An old +/// completion cannot release a newer refresh's slot or publish old settings. +/// +/// Generations are per widget: a reload outdates only the widgets whose +/// configuration changed. Outdating every widget on any reload threw away +/// each in-flight result and ran its command again, and a widget writing +/// into a watched folder could keep that going in a loop. +struct NativeRefreshGate { + struct Token: Equatable { + let generation: UInt + let serial: UInt + } + enum Completion: Equatable { case current, staleConfiguration, superseded } + private var generations: [String: UInt] = [:] + private var serial: UInt = 0 + private var active: [String: Token] = [:] + + mutating func begin(_ id: String) -> Token { + serial &+= 1 + let token = Token(generation: generations[id, default: 0], serial: serial) + active[id] = token + return token + } + + /// `changed` are the kept widgets whose configuration is new; their + /// in-flight results are outdated. Others' results stay good. + mutating func reload(keeping ids: Set, changed: Set) { + for id in changed where ids.contains(id) { generations[id, default: 0] &+= 1 } + generations = generations.filter { ids.contains($0.key) } + active = active.filter { ids.contains($0.key) } + } + + mutating func cancel(_ id: String) { active.removeValue(forKey: id) } + + func isCurrent(_ id: String, token: Token) -> Bool { + active[id] == token && token.generation == generations[id, default: 0] + } + + mutating func finish(_ id: String, token: Token) -> Completion { + guard active[id] == token else { return .superseded } + active.removeValue(forKey: id) + return token.generation == generations[id, default: 0] ? .current : .staleConfiguration + } +} diff --git a/Sources/MenubucketApp/RemoteImageService.swift b/Sources/MenubucketApp/RemoteImageService.swift index 1a2f27e..9a591fb 100644 --- a/Sources/MenubucketApp/RemoteImageService.swift +++ b/Sources/MenubucketApp/RemoteImageService.swift @@ -30,6 +30,7 @@ final class RemoteImageService: @unchecked Sendable { private let now: () -> Date private let sessionConfiguration: URLSessionConfiguration private let maximumConcurrentDownloads: Int + private let downloadTimeout: TimeInterval private var inFlight: [String: [(NSImage?) -> Void]] = [:] private var pendingKeys: [String] = [] private var activeDownloads = 0 @@ -47,6 +48,7 @@ final class RemoteImageService: @unchecked Sendable { var response: HTTPURLResponse? var exceededLimit = false var session: URLSession? + var deadline: DispatchSourceTimer? init(origin: URL, limit: Int, completed: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) { self.origin = origin self.limit = limit @@ -57,6 +59,10 @@ final class RemoteImageService: @unchecked Sendable { completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { self.response = response as? HTTPURLResponse + guard self.response?.statusCode == 200 else { + completionHandler(.cancel) + return + } if !RemoteImageService.responseFits( limit: limit, expectedContentLength: response.expectedContentLength) { @@ -91,6 +97,8 @@ final class RemoteImageService: @unchecked Sendable { completionHandler(request) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + deadline?.cancel() + deadline = nil completed(exceededLimit ? nil : data, response, error) session.finishTasksAndInvalidate() self.session = nil @@ -101,13 +109,16 @@ final class RemoteImageService: @unchecked Sendable { diskDirectory: URL? = nil, now: @escaping () -> Date = Date.init, sessionConfiguration: URLSessionConfiguration? = nil, - maximumConcurrentDownloads: Int = 6 + maximumConcurrentDownloads: Int = 6, + downloadTimeout: TimeInterval = RemoteImageService.timeoutSec ) { cache.countLimit = Self.memoryCountLimit cache.totalCostLimit = Self.memoryCostLimitBytes self.now = now self.sessionConfiguration = (sessionConfiguration?.copy() as? URLSessionConfiguration) ?? .ephemeral self.maximumConcurrentDownloads = max(1, maximumConcurrentDownloads) + self.downloadTimeout = downloadTimeout.isFinite + ? min(Self.timeoutSec, max(0.001, downloadTimeout)) : Self.timeoutSec let resolvedDirectory = diskDirectory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent( @@ -194,7 +205,7 @@ final class RemoteImageService: @unchecked Sendable { private func load(key: String) { let diskURL = diskFile(for: key) - if let data = try? Data(contentsOf: diskURL), + if let data = readDiskImage(at: diskURL), !data.isEmpty, data.count <= Self.maxResponseBytes, Self.isRasterImage(data), @@ -211,7 +222,7 @@ final class RemoteImageService: @unchecked Sendable { } var request = URLRequest(url: url) request.httpMethod = "GET" - request.timeoutInterval = Self.timeoutSec + request.timeoutInterval = downloadTimeout let download = Download(origin: url, limit: Self.maxResponseBytes) { [weak self] data, response, error in guard let self else { return } @@ -232,7 +243,22 @@ final class RemoteImageService: @unchecked Sendable { } let session = URLSession(configuration: sessionConfiguration, delegate: download, delegateQueue: nil) download.session = session - session.dataTask(with: request).resume() + let task = session.dataTask(with: request) + // Request timeout is an inactivity limit. An absolute deadline also + // releases a slot when a server keeps trickling bytes indefinitely. + let deadline = DispatchSource.makeTimerSource(queue: queue) + deadline.schedule(deadline: .now() + downloadTimeout) + deadline.setEventHandler { [weak task] in task?.cancel() } + download.deadline = deadline + deadline.resume() + task.resume() + } + + private func readDiskImage(at url: URL) -> Data? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + // Bound the read itself, including a file replaced after a size check. + return try? handle.read(upToCount: Self.maxResponseBytes + 1) } /// Runs only on `queue`, after a disk or network result has been resolved. diff --git a/Sources/MenubucketApp/ThumbnailService.swift b/Sources/MenubucketApp/ThumbnailService.swift index 81c325a..c8d115a 100644 --- a/Sources/MenubucketApp/ThumbnailService.swift +++ b/Sources/MenubucketApp/ThumbnailService.swift @@ -1,5 +1,6 @@ import AppKit import CryptoKit +import MenubucketCore import QuickLookThumbnailing /// File thumbnails for `fileThumbnail` image nodes — file-stack's layered @@ -19,6 +20,10 @@ final class ThumbnailService: @unchecked Sendable { static let maximumConcurrentGenerations = 4 static let maximumQueuedGenerations = 128 static let maximumCallbacksPerGeneration = 256 + static let generationTimeout: TimeInterval = 15 + static let failureRetryDelay: TimeInterval = 15 + static let maximumFailureEntries = 512 + typealias Generator = (String, CGFloat, @escaping (NSImage?) -> Void) -> (() -> Void) private let cache = NSCache() private let diskDirectory: URL @@ -28,11 +33,28 @@ final class ThumbnailService: @unchecked Sendable { private var pendingRequests: [(key: String, path: String, pointSize: CGFloat)] = [] private var activeGenerations = 0 private var generationStartScheduled = false + private var failures: [String: TimeInterval] = [:] + private let now: () -> TimeInterval + private let generator: Generator + private let generationTimeout: TimeInterval + private struct Generation { + let id: UUID + let deadline: DispatchSourceTimer + var cancel: (() -> Void)? + } + /// Owned by the serial queue. IDs reject late callbacks after cancellation. + private var generations: [String: Generation] = [:] /// Writes are often bursty while a grid appears. Coalesce maintenance so /// long-running sessions are bounded without scanning the directory per tile. private var pruneScheduled = false - init(diskDirectory: URL? = nil) { + init(diskDirectory: URL? = nil, now: @escaping () -> TimeInterval = SleepAwareClock.now, + generationTimeout: TimeInterval = ThumbnailService.generationTimeout, + generator: Generator? = nil) { + self.now = now + self.generator = generator ?? Self.generate + self.generationTimeout = generationTimeout.isFinite + ? min(Self.generationTimeout, max(0.001, generationTimeout)) : Self.generationTimeout cache.countLimit = Self.memoryCountLimit cache.totalCostLimit = Self.memoryCostLimitBytes self.diskDirectory = diskDirectory ?? FileManager.default @@ -42,8 +64,15 @@ final class ThumbnailService: @unchecked Sendable { queue.async { [weak self] in self?.pruneDiskCache() } } + deinit { + for generation in generations.values { + generation.deadline.cancel() + generation.cancel?() + } + } + func icon(forPath path: String) -> NSImage { - NSWorkspace.shared.icon(forFile: path) + DockActions.fileIcon(at: path) } /// Completion always lands on the main queue. Returns the cached image @@ -62,6 +91,14 @@ final class ThumbnailService: @unchecked Sendable { } lock.lock() + if let retryAt = failures[key] { + if now() < retryAt { + lock.unlock() + DispatchQueue.main.async { completion(nil) } + return nil + } + failures.removeValue(forKey: key) + } if var callbacks = inFlight[key] { if callbacks.count < Self.maximumCallbacksPerGeneration { callbacks.append(completion) @@ -146,16 +183,33 @@ final class ThumbnailService: @unchecked Sendable { completeGeneration(key: request.key, image: image) return } - generate(path: request.path, pointSize: request.pointSize) { [weak self] image in + let id = UUID() + let deadline = DispatchSource.makeTimerSource(queue: queue) + deadline.schedule(deadline: .now() + generationTimeout) + deadline.setEventHandler { [weak self] in + self?.finishGeneration(key: request.key, id: id, image: nil, timedOut: true) + } + generations[request.key] = Generation(id: id, deadline: deadline) + deadline.resume() + let cancel = generator(request.path, request.pointSize) { [weak self] image in guard let self else { return } - self.queue.async { - if let image { - self.saveToDisk(key: request.key, image: image) - self.scheduleDiskPrune() - } - self.completeGeneration(key: request.key, image: image) + self.queue.async { [weak self] in + self?.finishGeneration(key: request.key, id: id, image: image, timedOut: false) } } + generations[request.key]?.cancel = cancel + } + + private func finishGeneration(key: String, id: UUID, image: NSImage?, timedOut: Bool) { + guard let generation = generations[key], generation.id == id else { return } + generations.removeValue(forKey: key) + generation.deadline.cancel() + if timedOut { generation.cancel?() } + if let image { + saveToDisk(key: key, image: image) + scheduleDiskPrune() + } + completeGeneration(key: key, image: image) } /// Runs only on `queue` after disk lookup or Quick Look generation. @@ -164,6 +218,15 @@ final class ThumbnailService: @unchecked Sendable { cache.setObject(image, forKey: key as NSString, cost: Self.cacheCost(of: image)) } lock.lock() + if image == nil { + if failures[key] == nil, failures.count >= Self.maximumFailureEntries, + let oldest = failures.min(by: { $0.value < $1.value })?.key { + failures.removeValue(forKey: oldest) + } + failures[key] = now() + Self.failureRetryDelay + } else { + failures.removeValue(forKey: key) + } let callbacks = inFlight.removeValue(forKey: key) ?? [] lock.unlock() DispatchQueue.main.async { @@ -175,7 +238,7 @@ final class ThumbnailService: @unchecked Sendable { schedulePendingGenerations() } - private func generate(path: String, pointSize: CGFloat, completion: @escaping (NSImage?) -> Void) { + private static func generate(path: String, pointSize: CGFloat, completion: @escaping (NSImage?) -> Void) -> (() -> Void) { let boundedPointSize = Self.boundedPointSize(pointSize) let request = QLThumbnailGenerator.Request( fileAt: URL(fileURLWithPath: path), @@ -188,6 +251,7 @@ final class ThumbnailService: @unchecked Sendable { NSImage(cgImage: rep.cgImage, size: CGSize(width: boundedPointSize, height: boundedPointSize)) }) } + return { QLThumbnailGenerator.shared.cancel(request) } } private func diskURL(key: String) -> URL { diff --git a/Sources/MenubucketApp/WidgetInstaller.swift b/Sources/MenubucketApp/WidgetInstaller.swift index 7f00a00..3b9662e 100644 --- a/Sources/MenubucketApp/WidgetInstaller.swift +++ b/Sources/MenubucketApp/WidgetInstaller.swift @@ -150,6 +150,7 @@ enum WidgetInstallFlow { let (bytes, response) = try await URLSession.shared.bytes( for: request, delegate: redirectGuard ) + defer { bytes.task.cancel() } guard let http = response as? HTTPURLResponse else { throw WidgetInstallFlowError.notHTTP(url) } diff --git a/Sources/MenubucketApp/WidgetRuntime.swift b/Sources/MenubucketApp/WidgetRuntime.swift index bb59b01..904c8aa 100644 --- a/Sources/MenubucketApp/WidgetRuntime.swift +++ b/Sources/MenubucketApp/WidgetRuntime.swift @@ -226,6 +226,7 @@ final class WidgetRuntime: ObservableObject { private let refreshStatsStore: RefreshStatsStore private var cancellables: Set = [] private var inFlight: Set = [] + private var nativeRefreshes = NativeRefreshGate() private var scriptRefreshes = ScriptRefreshCoalescer() private let scriptCallbackGate = ScriptCallbackGate() /// Shutdowns started by Disable. Re-enable waits for this task so a late @@ -847,6 +848,17 @@ final class WidgetRuntime: ObservableObject { func loadWidgets() { let loaded = Self.discoverWidgets(in: Self.widgetSearchDirectories) let seenIDs = Set(loaded.map(\.id)) + let previous = Dictionary(widgets.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let changedIDs = Set(loaded.filter { widget in + guard let before = previous[widget.id] else { return true } + return before.manifest != widget.manifest || before.directory != widget.directory + }.map(\.id)) + nativeRefreshes.reload(keeping: seenIDs, changed: changedIDs) + storage.retain(widgetIDs: seenIDs) + let workflowURLs = Set(loaded.filter { $0.manifest.entry.kind == "workflow" }.compactMap { + try? WidgetEntryResolver.resolve(directory: $0.directory, main: $0.manifest.entry.main, defaultName: "workflow.json") + }) + workflowDefinitions.retain(fileURLs: workflowURLs) // A rescan replaces every script descriptor/process, even when the id // stays the same. Release only those script generations here; their @@ -1173,6 +1185,8 @@ final class WidgetRuntime: ObservableObject { prefs.setDisabled(id, flag) publishDesktopWidgetIndex() if flag { + nativeRefreshes.cancel(id) + updateSnapshot(id) { $0.isLoading = false } scriptCallbackGate.invalidate(widgetID: id) if scriptRefreshes.cancel(widgetID: id) { updateSnapshot(id) { $0.isLoading = false } @@ -1344,11 +1358,16 @@ final class WidgetRuntime: ObservableObject { private static let menuBarStalenessTickSec: TimeInterval = 15 private func startMenuBarStalenessTicker() { - menuBarStalenessTimer?.invalidate() + guard !ownedMenuBarEntries.isEmpty else { + menuBarStalenessTimer?.invalidate() + menuBarStalenessTimer = nil + return + } + guard menuBarStalenessTimer == nil else { return } let timer = Timer.scheduledTimer( withTimeInterval: Self.menuBarStalenessTickSec, repeats: true ) { [weak self] _ in - guard let self, !self.menuBar.entries.isEmpty else { return } + guard let self else { return } self.syncMenuBar() } timer.tolerance = Self.menuBarStalenessTickSec / 3 @@ -1486,6 +1505,8 @@ final class WidgetRuntime: ObservableObject { ownedMenuBarEntries = owned dormantMenuBarWidgetIDs = dormant menuBar.apply(owned.filter { !dormant.contains($0.widgetID) }) + startMenuBarStalenessTicker() + startChartHistorySaver() // Before the promoted-set check: a changed cadence re-arms timers even // when the set of promoted widgets is the same. scheduler.setIntervalOverrides(intervalOverrides) @@ -1814,6 +1835,7 @@ final class WidgetRuntime: ObservableObject { } let startedAt = markRefreshStarted(widgetID: id) + let token = nativeRefreshes.begin(id) inFlight.insert(id) updateSnapshot(id) { $0.isLoading = true } @@ -1822,7 +1844,7 @@ final class WidgetRuntime: ObservableObject { let outcome = await self.performRefresh( widget: widget, source: source, command: command, permission: permission ) - self.finishRefresh(widget: widget, outcome: outcome, startedAt: startedAt) + self.finishRefresh(widget: widget, outcome: outcome, startedAt: startedAt, token: token) } } @@ -1994,6 +2016,7 @@ final class WidgetRuntime: ObservableObject { ]) let startedAt = markRefreshStarted(widgetID: id) + let token = nativeRefreshes.begin(id) inFlight.insert(id) updateSnapshot(id) { $0.isLoading = true } @@ -2004,10 +2027,10 @@ final class WidgetRuntime: ObservableObject { settings: settings, widgetContext: widgetContext ) // Before `finishRefresh`, whose catch-up reads this. - if let reads = outcome.readsWidgetVisibility { + if self.nativeRefreshes.isCurrent(id, token: token), let reads = outcome.readsWidgetVisibility { self.recordVisibilityUse(widgetID: id, reads: reads, visible: visible) } - self.finishRefresh(widget: widget, outcome: outcome.result, startedAt: startedAt) + self.finishRefresh(widget: widget, outcome: outcome.result, startedAt: startedAt, token: token) } } @@ -2514,9 +2537,22 @@ final class WidgetRuntime: ObservableObject { private func finishRefresh( widget: LoadedWidget, outcome: Result, - startedAt: Date + startedAt: Date, + token: NativeRefreshGate.Token ) { let id = widget.id + switch nativeRefreshes.finish(id, token: token) { + case .superseded: return + case .staleConfiguration: + inFlight.remove(id) + refreshStartedAt.removeValue(forKey: id) + if let current = widgets.first(where: { $0.id == id }), !prefs.isDisabled(id) { + updateSnapshot(id) { $0.isLoading = false } + refresh(current, manual: false) + } + return + case .current: break + } inFlight.remove(id) var snapshot = snapshots[id] ?? WidgetSnapshot(widgetID: id) @@ -2778,16 +2814,33 @@ final class WidgetRuntime: ObservableObject { /// snapshot is unchanged; otherwise only the affected card model publishes. private func setSnapshot(_ snapshot: WidgetSnapshot, for id: String) { guard snapshots[id] != snapshot else { return } - let errorChanged = snapshots[id]?.error != snapshot.error + let previous = snapshots[id] + let errorChanged = previous?.error != snapshot.error snapshots[id] = snapshot cardModels[id]?.snapshot = snapshot if errorChanged { updateAttention() } - if menuBarWidgetIDs.contains(id) { - chartPending.insert(id) + if menuBarWidgetIDs.contains(id), Self.menuBarStateChanged(from: previous, to: snapshot) { + // Loading/error-only changes are not new measurements. Inserting + // those into chart history repeated the previous reading. + if previous?.updatedAt != snapshot.updatedAt || previous?.statusMetrics != snapshot.statusMetrics + || previous?.statusLabel != snapshot.statusLabel { + chartPending.insert(id) + } syncMenuBar() } } + /// Card loading/layout changes do not affect a status item. Avoid rebuilding + /// every promoted entry (including symbol lookup and chart policy) for them. + static func menuBarStateChanged(from previous: WidgetSnapshot?, to current: WidgetSnapshot) -> Bool { + guard let previous else { return true } + return previous.updatedAt != current.updatedAt || previous.error != current.error + || previous.statusLabel != current.statusLabel || previous.statusMetrics != current.statusMetrics + || previous.statusPrefix != current.statusPrefix || previous.statusIcon != current.statusIcon + || previous.statusTint != current.statusTint || previous.statusPresentation != current.statusPresentation + || previous.statusTooltip != current.statusTooltip + } + /// Single write path for overlay cards (`nil` removes), same suppression. private func setOverlay(_ overlay: CardOverlay?, for id: String) { guard overlayCards[id] != overlay else { return } @@ -2854,11 +2907,23 @@ final class WidgetRuntime: ObservableObject { let sensitive = Set(widgets.filter(\.isSensitive).map(\.id)).union(sensitiveRenderIDs) let kept = menuBarHistory.filter { !sensitive.contains($0.key) } guard kept != lastSavedChartHistory else { return } - try? MenuBarChartHistoryStore.save(kept, to: url) - lastSavedChartHistory = kept + do { + try MenuBarChartHistoryStore.save(kept, to: url) + lastSavedChartHistory = kept + } catch { + // Leave it dirty: transient I/O failures must retry at the next + // maintenance tick rather than being mistaken for a saved history. + } } private func startChartHistorySaver() { + guard !menuBarHistory.isEmpty else { + chartHistoryTimer?.invalidate() + chartHistoryTimer = nil + saveChartHistory() + return + } + guard chartHistoryTimer == nil else { return } let timer = Timer(timeInterval: MenuBarChartHistoryStore.saveInterval, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.saveChartHistory() } } diff --git a/Sources/MenubucketCore/ExecService.swift b/Sources/MenubucketCore/ExecService.swift index 8675fd6..773ab39 100644 --- a/Sources/MenubucketCore/ExecService.swift +++ b/Sources/MenubucketCore/ExecService.swift @@ -262,20 +262,22 @@ public final class ExecService { do { try process.run() } catch { + Self.closePipe(stdoutPipe) + Self.closePipe(stderrPipe) return .failure(.launchFailed(error.localizedDescription)) } + let deadline = ReadDeadline(DispatchTime.now() + .milliseconds(max(timeoutMs, 1))) readGroup.enter() readQueue.async { defer { readGroup.leave() } let handle = stdoutPipe.fileHandleForReading - while true { - let chunk = handle.availableData - if chunk.isEmpty { break } + Self.drain(handle, until: deadline.value, timedOut: { captureState.markTimedOut() }) { chunk in if captureState.appendStdout(chunk, limit: stdoutLimit) { - if process.isRunning { process.terminate() } - break + Self.terminate(process) + return true } + return false } try? handle.close() } @@ -284,21 +286,16 @@ public final class ExecService { readQueue.async { defer { readGroup.leave() } let handle = stderrPipe.fileHandleForReading - while true { - let chunk = handle.availableData - if chunk.isEmpty { break } + Self.drain(handle, until: deadline.value, timedOut: { captureState.markTimedOut() }) { chunk in captureState.appendStderr(chunk, limit: maxStderrBytes) + return false } try? handle.close() } let timeoutItem = DispatchWorkItem { captureState.markTimedOut() - if process.isRunning { process.terminate() } - let pid = process.processIdentifier - DispatchQueue.global().asyncAfter(deadline: .now() + 2) { - if process.isRunning { kill(pid, SIGKILL) } - } + Self.terminate(process) } DispatchQueue.global().asyncAfter( deadline: .now() + .milliseconds(max(timeoutMs, 1)), @@ -306,17 +303,25 @@ public final class ExecService { ) process.waitUntilExit() + // Exited in time: the timer has nothing left to stop, and the readers + // get a moment past the deadline to drain what is already buffered. + // A process ending at 995 ms of 1000 was reported as timed out while + // its output was still being read. A descendant that keeps the pipe + // open is still cut off, a second later. timeoutItem.cancel() + if !captureState.snapshot().timedOut { + deadline.extend(to: DispatchTime.now() + .seconds(1)) + } readGroup.wait() let capture = captureState.snapshot() - if capture.timedOut { - return .failure(.timeout(ms: timeoutMs)) - } if capture.overflowed { return .failure(.outputTooLarge(limit: stdoutLimit)) } + if capture.timedOut { + return .failure(.timeout(ms: timeoutMs)) + } return .success(Capture( exitCode: process.terminationStatus, stdout: capture.stdout, @@ -325,8 +330,55 @@ public final class ExecService { )) } + /// Every resource-limit and lifecycle exit gets the same bounded grace + /// period, including scripts that deliberately ignore SIGTERM. + static func terminate(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2) { + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + } + + /// Foundation can retain pipe endpoints after a failed Process launch. + /// Close both ends explicitly instead of relying on object destruction. + static func closePipe(_ pipe: Pipe) { + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + } + // MARK: - Binary discovery + /// A descendant may inherit a pipe after the launched process exits. + /// Poll for readable bytes/EOF with the same deadline as the process, + /// rather than blocking indefinitely in FileHandle.availableData. + private static func drain(_ handle: FileHandle, until currentDeadline: () -> DispatchTime, + timedOut: () -> Void, onChunk: (Data) -> Bool) { + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let deadline = currentDeadline() + let now = DispatchTime.now().uptimeNanoseconds + guard now < deadline.uptimeNanoseconds else { timedOut(); return } + let remaining = (deadline.uptimeNanoseconds - now) / 1_000_000 + 1 + var descriptor = pollfd(fd: handle.fileDescriptor, events: Int16(POLLIN), revents: 0) + let ready = poll(&descriptor, 1, Int32(min(remaining, UInt64(Int32.max)))) + if ready == 0 { timedOut(); return } + if ready < 0 { + if errno == EINTR { continue } + return + } + let count = buffer.withUnsafeMutableBytes { bytes in + Darwin.read(handle.fileDescriptor, bytes.baseAddress, bytes.count) + } + if count == 0 { return } + if count < 0 { + if errno == EINTR { continue } + return + } + if onChunk(Data(buffer.prefix(count))) { return } + } + } + public static func resolveExecutable( command0: String, discover: [String]?, @@ -451,3 +503,26 @@ public final class ExecService { return nil } } + +/// The readers' deadline: the timeout, moved later once the process has +/// exited in time so buffered output can still be read. +private final class ReadDeadline: @unchecked Sendable { + private let lock = NSLock() + private var deadline: DispatchTime + + init(_ deadline: DispatchTime) { self.deadline = deadline } + + var value: () -> DispatchTime { + { [self] in + lock.lock() + defer { lock.unlock() } + return deadline + } + } + + func extend(to later: DispatchTime) { + lock.lock() + if later > deadline { deadline = later } + lock.unlock() + } +} diff --git a/Sources/MenubucketCore/HeadlessInstaller.swift b/Sources/MenubucketCore/HeadlessInstaller.swift index 50712e7..356fe2c 100644 --- a/Sources/MenubucketCore/HeadlessInstaller.swift +++ b/Sources/MenubucketCore/HeadlessInstaller.swift @@ -500,6 +500,7 @@ public enum HeadlessInstaller { let (bytes, response) = try await URLSession.shared.bytes( for: request, delegate: redirectGuard ) + defer { bytes.task.cancel() } guard let http = response as? HTTPURLResponse else { throw HeadlessInstallError.notHTTP(url) } diff --git a/Sources/MenubucketCore/MetricSampleCache.swift b/Sources/MenubucketCore/MetricSampleCache.swift new file mode 100644 index 0000000..ddaddf7 --- /dev/null +++ b/Sources/MenubucketCore/MetricSampleCache.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Coalesces simultaneous widget requests without changing their refresh +/// cadence. Each key has its own short window and its own lock: concurrent +/// requests for one key collect once, while a slow key (a hung network +/// volume) never holds up another. +final class MetricSampleCache: @unchecked Sendable { + private let lock = NSLock() + private let window: TimeInterval + private var samples: [String: (value: Value, at: TimeInterval)] = [:] + private var keyLocks: [String: NSLock] = [:] + /// Mounts can be widget-controlled; keep retained state bounded. + static var keyLimit: Int { 16 } + + init(window: TimeInterval = 0.25) { self.window = window } + + func sample(key: String = "", now: TimeInterval? = nil, collect: () -> Value) -> Value { + let keyLock = lockFor(key) + keyLock.lock() + defer { keyLock.unlock() } + let time = now ?? SleepAwareClock.now() + if let sample = cached(key), time >= sample.at, time - sample.at < window { return sample.value } + // Collected holding only this key's lock. + let value = collect() + lock.lock() + if samples.count >= Self.keyLimit, samples[key] == nil { samples.removeAll(keepingCapacity: true) } + samples[key] = (value, time) + lock.unlock() + return value + } + + private func cached(_ key: String) -> (value: Value, at: TimeInterval)? { + lock.lock() + defer { lock.unlock() } + return samples[key] + } + + private func lockFor(_ key: String) -> NSLock { + lock.lock() + defer { lock.unlock() } + if let existing = keyLocks[key] { return existing } + if keyLocks.count >= Self.keyLimit * 4 { + // Only locks nobody can be waiting on are dropped: a caller holds + // its lock object, so removing the entry never breaks a waiter. + keyLocks.removeAll(keepingCapacity: true) + } + let created = NSLock() + keyLocks[key] = created + return created + } +} diff --git a/Sources/MenubucketCore/Registry.swift b/Sources/MenubucketCore/Registry.swift index 2f354b9..df86879 100644 --- a/Sources/MenubucketCore/Registry.swift +++ b/Sources/MenubucketCore/Registry.swift @@ -442,17 +442,30 @@ public final class RegistryClient: @unchecked Sendable { } public static let urlSessionFetcher: Fetcher = { url in + try await fetchIndex(from: url) + } + + static func fetchIndex(from url: URL, session: URLSession = .shared) async throws -> Data { var request = URLRequest(url: url) request.timeoutInterval = 30 - let (data, response) = try await URLSession.shared.data(for: request) + let (bytes, response) = try await session.bytes(for: request) + defer { bytes.task.cancel() } if let http = response as? HTTPURLResponse, http.statusCode != 200 { throw RegistryError.httpStatus(http.statusCode, url) } - guard data.count <= RegistryClient.maxResponseBytes else { + guard response.expectedContentLength <= Int64(maxResponseBytes) else { throw RegistryError.responseTooLarge( - limitBytes: RegistryClient.maxResponseBytes + limitBytes: maxResponseBytes ) } + var data = Data() + data.reserveCapacity(response.expectedContentLength > 0 ? Int(response.expectedContentLength) : 64 * 1024) + for try await byte in bytes { + guard data.count < maxResponseBytes else { + throw RegistryError.responseTooLarge(limitBytes: maxResponseBytes) + } + data.append(byte) + } return data } @@ -641,7 +654,12 @@ public final class RegistryClient: @unchecked Sendable { guard FileManager.default.fileExists(atPath: file.path) else { throw RegistryError.fileNotFound(file.path) } - let data = try Data(contentsOf: file) + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + let data = try handle.read(upToCount: maxResponseBytes + 1) ?? Data() + guard data.count <= maxResponseBytes else { + throw RegistryError.responseTooLarge(limitBytes: maxResponseBytes) + } return try parse(validating: data) } diff --git a/Sources/MenubucketCore/RuntimeSupervisor.swift b/Sources/MenubucketCore/RuntimeSupervisor.swift index 128b067..4ba73bd 100644 --- a/Sources/MenubucketCore/RuntimeSupervisor.swift +++ b/Sources/MenubucketCore/RuntimeSupervisor.swift @@ -91,6 +91,11 @@ public struct RuntimeSupervisorConfiguration: @unchecked Sendable { public var minTimerIntervalMs: Double = SchedulePolicy.minForegroundIntervalSec * 1000 /// stdout line limit (1 MB per the contract). public var maxStdoutLineBytes = 1_048_576 + /// Bound the protocol messages waiting for the host, by size: a script + /// may print a burst of small messages (logs) at once, which a count limit + /// treated as runaway output and killed. + public var maxBufferedStdoutBytes = 8 * 1_048_576 + public var maxTimersPerWidget = 128 /// Crash loop: `crashLoopThreshold` crashes within `crashLoopWindowSec` → disabled. public var crashLoopWindowSec: TimeInterval = 300 public var crashLoopThreshold = 3 @@ -164,12 +169,7 @@ public actor RuntimeSupervisor { } } - private enum StdoutEvent { - case line(Data) - case overflow - } - - private final class LineBuffer: @unchecked Sendable { + final class LineBuffer: @unchecked Sendable { private let lock = NSLock() private var data = Data() @@ -177,16 +177,22 @@ public actor RuntimeSupervisor { lock.withLock { data.append(chunk) var lines: [Data] = [] + var overflow = false while let newlineIndex = data.firstIndex(of: UInt8(ascii: "\n")) { - let line = data.subdata(in: data.startIndex.. maxBytes { + overflow = true + } else { + let line = data.subdata(in: data.startIndex.. maxBytes { data.removeAll(keepingCapacity: false) return (lines, true) } - return (lines, false) + return (lines, overflow) } } @@ -312,14 +318,7 @@ public actor RuntimeSupervisor { instance.stopping = true instance.readTask?.cancel() try? instance.stdinHandle.close() - let process = instance.process - if process.isRunning { - process.terminate() - Task.detached { - try? await Task.sleep(nanoseconds: 2_000_000_000) - if process.isRunning { kill(process.processIdentifier, SIGKILL) } - } - } + ExecService.terminate(instance.process) cancelTimers(widgetId: widgetId) descriptors.removeValue(forKey: widgetId) } @@ -351,7 +350,7 @@ public actor RuntimeSupervisor { instance.stopping = true instance.readTask?.cancel() try? instance.stdinHandle.close() - if instance.process.isRunning { instance.process.terminate() } + ExecService.terminate(instance.process) } /// "Restart Widget": clears the crash-loop `disabled` state and reloads. @@ -416,6 +415,10 @@ public actor RuntimeSupervisor { do { try process.run() } catch { + // A failed launch has no termination callback to break the + // process → handler → instance ownership cycle. + process.terminationHandler = nil + for pipe in [stdinPipe, stdoutPipe, stderrPipe] { ExecService.closePipe(pipe) } throw JsonRpcError.internalError("failed to launch script runtime: \(error.localizedDescription)") } @@ -433,9 +436,16 @@ public actor RuntimeSupervisor { widgetId: String, instance: ScriptInstance, handle: FileHandle ) -> Task { let maxLineBytes = configuration.maxStdoutLineBytes - let stream = AsyncStream { continuation in + let maximumBytes = max(1, configuration.maxBufferedStdoutBytes) + let queued = QueuedBytes() + let stream = AsyncStream(bufferingPolicy: .unbounded) { continuation in let buffer = LineBuffer() - handle.readabilityHandler = { readHandle in + continuation.onTermination = { _ in + // Cancellation must detach the producer even when a + // descendant still holds the script's stdout pipe open. + handle.readabilityHandler = nil + } + handle.readabilityHandler = { [weak self] readHandle in let chunk = readHandle.availableData if chunk.isEmpty { readHandle.readabilityHandler = nil @@ -443,19 +453,36 @@ public actor RuntimeSupervisor { return } let result = buffer.append(chunk, maxBytes: maxLineBytes) - for line in result.lines { continuation.yield(.line(line)) } - if result.overflow { continuation.yield(.overflow) } + // Complete lines ahead of an oversized one are delivered + // first: they may be the render or reply the host waits for. + for line in result.lines { + guard queued.add(line.count, limit: maximumBytes) else { + // Dropping RPC messages silently would strand SDK + // requests. Stop the producer and report the overflow. + readHandle.readabilityHandler = nil + continuation.finish() + Task { [weak self] in + await self?.handleLineOverflow(widgetId: widgetId, instance: instance, + reason: "stdout messages waiting for the host exceeded \(maximumBytes) bytes — terminating script") + } + return + } + continuation.yield(line) + } + if result.overflow { + readHandle.readabilityHandler = nil + continuation.finish() + Task { [weak self] in + await self?.handleLineOverflow(widgetId: widgetId, instance: instance) + } + } } } return Task { [weak self] in - for await event in stream { + for await line in stream { guard let self else { break } - switch event { - case let .line(data): - await self.handleLine(widgetId: widgetId, instance: instance, line: data) - case .overflow: - await self.handleLineOverflow(widgetId: widgetId, instance: instance) - } + await self.handleLine(widgetId: widgetId, instance: instance, line: line) + queued.remove(line.count) } } } @@ -478,7 +505,7 @@ public actor RuntimeSupervisor { readHandle.readabilityHandler = nil return } - for line in buffer.append(chunk).lines { + for line in buffer.append(chunk, maxBytes: ExecService.maxStderrBytes).lines { guard let text = String(data: line, encoding: .utf8), !text.isEmpty else { continue } logs?.append(widgetId: widgetId, level: "stderr", message: text) onWidgetLog(widgetId, "stderr", text) @@ -506,13 +533,13 @@ public actor RuntimeSupervisor { } } - private func handleLineOverflow(widgetId: String, instance: ScriptInstance) { + private func handleLineOverflow(widgetId: String, instance: ScriptInstance, reason: String? = nil) { guard instances[widgetId] === instance, !instance.stopping else { return } widgetLog( widgetId, "error", - "stdout line exceeded \(configuration.maxStdoutLineBytes) bytes — terminating script" + reason ?? "stdout line exceeded \(configuration.maxStdoutLineBytes) bytes — terminating script" ) - instances[widgetId]?.process.terminate() // counts as a crash + ExecService.terminate(instance.process) // counts as a crash } private func processTerminated(widgetId: String, instance: ScriptInstance, status: Int32) { @@ -850,6 +877,14 @@ public actor RuntimeSupervisor { private func handleTimer(widgetId: String, params: JSONValue?, kind: TimerKind) async throws -> JSONValue { let timer = try decodeParams(TimerParams.self, from: params) switch kind { + case .clear: break + default: + if timers[widgetId]?[timer.id] == nil, + (timers[widgetId]?.count ?? 0) >= max(1, configuration.maxTimersPerWidget) { + throw JsonRpcError.quotaExceeded("timer quota exceeded for \(widgetId)") + } + } + switch kind { case .once: guard let atMs = timer.atMs else { throw JsonRpcError.protocolError("timer.once requires atMs") } let delaySec = max((atMs - nowMs()) / 1000, 0.05) @@ -1092,3 +1127,25 @@ public enum DenoRuntime { return importMapURL } } + +/// Bytes of stdout messages read but not yet handled, shared by the pipe +/// reader and the task handling them. +private final class QueuedBytes: @unchecked Sendable { + private let lock = NSLock() + private var bytes = 0 + + /// False, adding nothing, when this would pass `limit`. + func add(_ count: Int, limit: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + guard bytes + count <= limit else { return false } + bytes += count + return true + } + + func remove(_ count: Int) { + lock.lock() + bytes -= count + lock.unlock() + } +} diff --git a/Sources/MenubucketCore/StorageService.swift b/Sources/MenubucketCore/StorageService.swift index 0f04bd7..66a87e2 100644 --- a/Sources/MenubucketCore/StorageService.swift +++ b/Sources/MenubucketCore/StorageService.swift @@ -23,6 +23,7 @@ public final class StorageService: @unchecked Sendable { private let quotaBytes: Int private let lock = NSLock() private var cache: [String: Namespace] = [:] + private var retainedWidgetIDs: Set? public init(directory: URL, quotaBytes: Int = StorageService.quotaBytes) { self.directory = directory @@ -31,6 +32,21 @@ public final class StorageService: @unchecked Sendable { // MARK: - API + /// Evict memory for removed widgets; their saved data stays on disk so + /// reinstalling a widget can restore its namespace. + public func retain(widgetIDs: Set) { + lock.lock() + retainedWidgetIDs = widgetIDs + cache = cache.filter { widgetIDs.contains($0.key) } + lock.unlock() + } + + var cachedWidgetIDs: Set { + lock.lock() + defer { lock.unlock() } + return Set(cache.keys) + } + public func get(widgetId: String, key: String, nowMs: Double = Date().timeIntervalSince1970 * 1000) -> JSONValue? { lock.lock() defer { lock.unlock() } @@ -38,7 +54,7 @@ public final class StorageService: @unchecked Sendable { guard let entry = namespace.entries[key] else { return nil } if let expiresAt = entry.expiresAt, expiresAt <= nowMs { namespace.entries.removeValue(forKey: key) - cache[widgetId] = namespace + cacheLocked(namespace, widgetId: widgetId) return nil } return entry.value @@ -117,10 +133,10 @@ public final class StorageService: @unchecked Sendable { let namespace = try? JSONDecoder().decode(Namespace.self, from: data) else { let empty = Namespace() - cache[widgetId] = empty + cacheLocked(empty, widgetId: widgetId) return empty } - cache[widgetId] = namespace + cacheLocked(namespace, widgetId: widgetId) return namespace } @@ -131,6 +147,10 @@ public final class StorageService: @unchecked Sendable { } } + private func cacheLocked(_ namespace: Namespace, widgetId: String) { + if retainedWidgetIDs?.contains(widgetId) != false { cache[widgetId] = namespace } + } + private func serialize(_ namespace: Namespace) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -138,9 +158,9 @@ public final class StorageService: @unchecked Sendable { } private func persistLocked(widgetId: String, namespace: Namespace, data: Data) throws { - cache[widgetId] = namespace try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try data.write(to: fileURL(widgetId: widgetId), options: .atomic) + cacheLocked(namespace, widgetId: widgetId) } private func fileURL(widgetId: String) -> URL { diff --git a/Sources/MenubucketCore/SystemMetrics.swift b/Sources/MenubucketCore/SystemMetrics.swift index 692d186..8acca6d 100644 --- a/Sources/MenubucketCore/SystemMetrics.swift +++ b/Sources/MenubucketCore/SystemMetrics.swift @@ -83,10 +83,10 @@ public enum SystemMetrics { object["cpu"] = cpuSampler.sample(detail: detail).json } if metrics.contains(.memory) { - object["memory"] = memory().json + object["memory"] = memorySamples.sample { memory() }.json } if metrics.contains(.disk) { - var diskObject = disk(mountPoint: mountPoint)?.json ?? .null + var diskObject = diskSamples.sample(key: mountPoint) { disk(mountPoint: mountPoint) }?.json ?? .null // Throughput only when asked: it is a registry walk per sample. if diskIO, case var .object(fields) = diskObject { let rates = DiskIOMetrics.Sampler.shared.sample() @@ -110,6 +110,8 @@ public enum SystemMetrics { } static let cpuSampler = CPUSampler() + private static let memorySamples = MetricSampleCache() + private static let diskSamples = MetricSampleCache() // MARK: - CPU diff --git a/Sources/MenubucketCore/Workflow.swift b/Sources/MenubucketCore/Workflow.swift index c2a2d35..d5bd902 100644 --- a/Sources/MenubucketCore/Workflow.swift +++ b/Sources/MenubucketCore/Workflow.swift @@ -1195,6 +1195,7 @@ public enum HttpSource { let guardDelegate = RedirectGuard(origin: url) let (bytes, response) = try await session.bytes(for: request, delegate: guardDelegate) + defer { bytes.task.cancel() } guard let http = response as? HTTPURLResponse else { throw HttpSourceError.notHTTP } diff --git a/Sources/MenubucketCore/WorkflowDefinitionCache.swift b/Sources/MenubucketCore/WorkflowDefinitionCache.swift index 2ca28d6..c725a77 100644 --- a/Sources/MenubucketCore/WorkflowDefinitionCache.swift +++ b/Sources/MenubucketCore/WorkflowDefinitionCache.swift @@ -28,9 +28,20 @@ public final class WorkflowDefinitionCache: @unchecked Sendable { private let lock = NSLock() private var entries: [URL: (stamp: Stamp, entry: Entry)] = [:] private var decodes = 0 + private var retainedURLs: Set? public init() {} + /// Drop definitions for uninstalled widgets without changing files. A + /// later reinstall must load its current file, not retain the old tree. + public func retain(fileURLs: Set) { + let kept = Set(fileURLs.map(\.standardizedFileURL)) + lock.lock() + retainedURLs = kept + entries = entries.filter { kept.contains($0.key) } + lock.unlock() + } + public func load(_ url: URL) throws -> Entry { var key = url.standardizedFileURL // A URL caches the resource values it has fetched; a stale cached date @@ -55,7 +66,7 @@ public final class WorkflowDefinitionCache: @unchecked Sendable { readsWidgetVisibility: definition.readsWidgetVisibility ) lock.lock() - entries[key] = (stamp, entry) + if retainedURLs?.contains(key) != false { entries[key] = (stamp, entry) } lock.unlock() return entry } diff --git a/Tests/MenubucketAppTests/DirectoryWatcherTests.swift b/Tests/MenubucketAppTests/DirectoryWatcherTests.swift new file mode 100644 index 0000000..887224c --- /dev/null +++ b/Tests/MenubucketAppTests/DirectoryWatcherTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import MenubucketApp + +final class DirectoryWatcherTests: XCTestCase { + @MainActor + func testAlreadyQueuedChangeCannotRestartCallbackAfterCancellation() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let callback = expectation(description: "cancelled watcher must remain silent") + callback.isInverted = true + let watcher = try DirectoryWatcher(paths: [directory.path], debounce: 0) { callback.fulfill() } + // FSEvents and descriptor notifications dispatch to main before + // invoking this entry point. Cancel while that delivery is queued. + DispatchQueue.main.async { watcher.fireDebounced() } + watcher.cancel() + await fulfillment(of: [callback], timeout: 0.2) + } +} diff --git a/Tests/MenubucketAppTests/KarabinerImporterTests.swift b/Tests/MenubucketAppTests/KarabinerImporterTests.swift new file mode 100644 index 0000000..e3b9fc2 --- /dev/null +++ b/Tests/MenubucketAppTests/KarabinerImporterTests.swift @@ -0,0 +1,159 @@ +import CoreGraphics +import XCTest +@testable import MenubucketApp + +final class KarabinerImporterTests: XCTestCase { + private let fnRule = #""" + {"description":"Fn navigation","manipulators":[ + {"type":"basic","from":{"key_code":"i","modifiers":{"mandatory":["fn"],"optional":["any"]}},"to":[{"key_code":"up_arrow"}]} + ]} + """# + + func testExportedRulePreservesFnSelectionRepeatAndKeyUpAfterModifierRelease() throws { + let result = try KarabinerImporter.convert(fnRule) + let script = try AutomationScript(source: result.script) + let rule = try XCTUnwrap(script.keyRemaps.first) + XCTAssertEqual(rule.from, 34) + XCTAssertEqual(rule.to, 126) + let flags = CGEventFlags.maskSecondaryFn.rawValue | CGEventFlags.maskShift.rawValue + var state = AutomationKeyRemapState() + XCTAssertEqual(state.target(code: 34, keyboard: 91, down: true, flags: flags, rules: script.keyRemaps), rule) + XCTAssertEqual(flags & ~rule.mandatory, CGEventFlags.maskShift.rawValue) + // Releasing Fn while holding the letter must not turn repeat or key-up back into I. + XCTAssertEqual(state.target(code: 34, keyboard: 91, down: true, flags: 0, rules: script.keyRemaps), rule) + XCTAssertNil(state.target(code: 34, keyboard: 40, down: false, flags: 0, rules: script.keyRemaps)) + XCTAssertEqual(state.target(code: 34, keyboard: 91, down: false, flags: 0, rules: script.keyRemaps), rule) + XCTAssertTrue(state.held.isEmpty) + XCTAssertNil(state.target(code: 34, keyboard: 91, down: true, flags: 0, rules: script.keyRemaps)) + } + + func testSelectedProfileImportsSimpleAndComplexMappingsOnlyFromSelectedProfile() throws { + let source = #""" + {"global":{"show_in_menu_bar":true},"profiles":[ + {"name":"Other","selected":false,"complex_modifications":{"rules":[{"invalid":true}]}}, + {"name":"Daily","selected":true,"simple_modifications":[ + {"from":{"key_code":"escape"},"to":[{"key_code":"tab"}]} + ],"complex_modifications":{"rules":[RULE]}} + ]} + """#.replacingOccurrences(of: "RULE", with: fnRule) + let result = try KarabinerImporter.convert(source) + let script = try AutomationScript(source: result.script) + XCTAssertEqual(script.keyRemaps.count, 2) + XCTAssertTrue(script.keyRemaps[0].matches(code: 53, flags: CGEventFlags.maskCommand.rawValue)) + XCTAssertTrue(result.summary.contains("Daily")) + XCTAssertTrue(result.summary.contains("all keyboards")) + } + + func testComplexMappingWithoutOptionalModifiersDoesNotConsumeOtherShortcuts() throws { + let source = #""" + {"rules":[{"description":"Control H","manipulators":[{ + "type":"basic","from":{"key_code":"h","modifiers":{"mandatory":["control"]}}, + "to":[{"key_code":"delete_or_backspace"}] + }]}]} + """# + let script = try AutomationScript(source: KarabinerImporter.convert(source).script) + let rule = try XCTUnwrap(script.keyRemaps.first) + XCTAssertTrue(rule.matches(code: 4, flags: CGEventFlags.maskControl.rawValue)) + XCTAssertFalse(rule.matches(code: 4, flags: 0)) + XCTAssertFalse(rule.matches(code: 4, flags: CGEventFlags.maskControl.rawValue | CGEventFlags.maskShift.rawValue)) + XCTAssertFalse(rule.matches(code: 4, flags: CGEventFlags.maskControl.rawValue | CGEventFlags.maskAlphaShift.rawValue)) + } + + func testUnsupportedBehaviorRejectsEntireImport() throws { + for source in [ + fnRule.replacingOccurrences(of: "\"type\":\"basic\"", with: "\"type\":\"basic\",\"conditions\":[{\"type\":\"device_if\"}]"), + fnRule.replacingOccurrences(of: "\"up_arrow\"", with: "\"left_command\""), + fnRule.replacingOccurrences(of: "\"fn\"", with: "\"left_shift\""), + fnRule.replacingOccurrences(of: "\"to\":[{\"key_code\":\"up_arrow\"}]", with: "\"to\":[{\"shell_command\":\"echo dangerous\"}]"), + fnRule.replacingOccurrences(of: "\"type\":\"basic\"", with: "\"type\":\"basic\",\"to_if_alone\":[{\"key_code\":\"escape\"}]"), + fnRule.replacingOccurrences(of: "\"key_code\":\"up_arrow\"", with: "\"key_code\":\"up_arrow\",\"repeat\":false"), + // Karabiner leaves this keyboard alone, so remapping it would change it. + #"{"profiles":[{"selected":true,"devices":[{"identifiers":{"is_keyboard":true},"ignore":true}]}]}"#, + #"{"profiles":[{"selected":true,"fn_function_keys":[{"from":{"key_code":"f1"},"to":[{"key_code":"f2"}]}]}]}"#, + #"{"profiles":[{"selected":true},{"selected":true}]}"#, + #"{"rules":[]}"#, + fnRule.replacingOccurrences(of: "\"up_arrow\"", with: "\"i\"") + ] { + XCTAssertThrowsError(try KarabinerImporter.convert(source), source) + } + } + + func testKeyRemapAPIRejectsMixedFnRulesUnknownFieldsAndExcessRules() { + for source in [ + "barshelf.remapKeys([{from:'i',to:'up_arrow',conditions:[]}]);", + "barshelf.remapKeys([{from:'i',to:'up_arrow',mandatory:['fn'],optional:['fn']}]);", + "barshelf.remapKeys([{from:'i',to:'up_arrow'}]); barshelf.remapFn({keyboardTypes:[91],keys:{j:'left'}});", + "barshelf.remapKeys(Array.from({length:129}, () => ({from:'i',to:'up_arrow'})));" + ] { XCTAssertThrowsError(try AutomationScript(source: source), source) } + } + + func testJSONImportRemainsDraftAndFailedImportPreservesSavedConfiguration() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("karabiner.json") + try fnRule.write(to: url, atomically: true, encoding: .utf8) + let controller = AutomationController(fileURL: directory.appendingPathComponent("automation.json")) + XCTAssertTrue(controller.apply(source: AutomationScript.example, enabled: false)) + let saved = controller.source + XCTAssertNotNil(controller.importFile(url)) + XCTAssertEqual(controller.source, saved) + XCTAssertFalse(controller.isRunning) + try "{\"rules\":[]}".write(to: url, atomically: true, encoding: .utf8) + XCTAssertNil(controller.importFile(url)) + XCTAssertNil(controller.importSummary) + XCTAssertEqual(controller.source, saved) + XCTAssertEqual(AutomationController(fileURL: directory.appendingPathComponent("automation.json")).source, saved) + } + + /// What current Karabiner-Elements writes into karabiner.json by itself + /// must not block the import. + func testATypicalCurrentConfigurationImports() throws { + let source = #""" + {"global":{"show_in_menu_bar":true},"machine_specific":{"krbn-1":{"enable_multitouch_extension":false}}, + "profiles":[{"name":"Default","selected":true, + "parameters":{"delay_milliseconds_before_open_device":1000}, + "virtual_hid_keyboard":{"keyboard_type_v2":"ansi","country_code":0}, + "fn_function_keys":[{"from":{"key_code":"f1"},"to":[{"consumer_key_code":"display_brightness_decrement"}]}], + "devices":[{"identifiers":{"is_keyboard":true,"product_id":834,"vendor_id":1452},"manipulate_caps_lock_led":true}], + "complex_modifications":{"parameters":{"basic.to_if_alone_timeout_milliseconds":1000},"rules":[ + {"description":"off","enabled":false,"manipulators":[{"type":"basic","from":{"key_code":"a"},"to":[{"key_code":"b"}]}]}, + {"description":"Home","manipulators":[{"type":"basic", + "from":{"key_code":"left_arrow","modifiers":{"mandatory":["control"]}},"to":[{"key_code":"home"}]}]} + ]}}]} + """# + let result = try KarabinerImporter.convert(source) + let script = try AutomationScript(source: result.script) + XCTAssertEqual(script.keyRemaps.count, 1, "the disabled rule stays off") + XCTAssertTrue(result.summary.contains("F1"), "says function keys were left out") + } + + /// macOS reports arrows with fn set; control+left_arrow must still match. + func testArrowRulesIgnoreTheFnFlagMacOSAlwaysSets() throws { + let rule = try XCTUnwrap(AutomationKeyRemap.decode([ + ["from": "left_arrow", "to": "home", "mandatory": ["control"]], + ]).first) + let flags = CGEventFlags.maskControl.rawValue | CGEventFlags.maskSecondaryFn.rawValue + XCTAssertTrue(rule.matches(code: 123, flags: flags)) + XCTAssertFalse(rule.matches(code: 123, flags: CGEventFlags.maskSecondaryFn.rawValue)) + XCTAssertThrowsError(try AutomationKeyRemap.decode([ + ["from": "left_arrow", "to": "home", "mandatory": ["fn"]], + ]), "fn cannot be told apart on an arrow") + } + + /// Only a real chain is refused: an output that some rule would take + /// without any modifier. + func testOnlyRealChainsAreRefused() throws { + func rules(_ manipulators: String) -> String { + #"{"rules":[{"description":"x","manipulators":["# + manipulators + "]}]}" + } + XCTAssertNoThrow(try KarabinerImporter.convert(rules(#""" + {"type":"basic","from":{"key_code":"h","modifiers":{"mandatory":["control"]}},"to":[{"key_code":"delete_or_backspace"}]}, + {"type":"basic","from":{"key_code":"delete_or_backspace","modifiers":{"mandatory":["fn"]}},"to":[{"key_code":"delete_forward"}]} + """#))) + XCTAssertThrowsError(try KarabinerImporter.convert(rules(#""" + {"type":"basic","from":{"key_code":"a"},"to":[{"key_code":"b"}]}, + {"type":"basic","from":{"key_code":"b"},"to":[{"key_code":"c"}]} + """#))) + } +} diff --git a/Tests/MenubucketAppTests/NativeRefreshGateTests.swift b/Tests/MenubucketAppTests/NativeRefreshGateTests.swift new file mode 100644 index 0000000..acda1ad --- /dev/null +++ b/Tests/MenubucketAppTests/NativeRefreshGateTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import MenubucketApp + +final class NativeRefreshGateTests: XCTestCase { + func testReloadRejectsOldOutputButReleasesItsOwnedSlot() { + var gate = NativeRefreshGate() + let old = gate.begin("cpu") + gate.reload(keeping: ["cpu"], changed: ["cpu"]) + XCTAssertFalse(gate.isCurrent("cpu", token: old)) + XCTAssertEqual(gate.finish("cpu", token: old), .staleConfiguration) + let replacement = gate.begin("cpu") + XCTAssertTrue(gate.isCurrent("cpu", token: replacement)) + XCTAssertEqual(gate.finish("cpu", token: replacement), .current) + } + + func testLateCompletionCannotCancelTheReplacementAfterDisableAndReenable() { + var gate = NativeRefreshGate() + let old = gate.begin("w") + gate.cancel("w") + let replacement = gate.begin("w") + XCTAssertEqual(gate.finish("w", token: old), .superseded) + XCTAssertTrue(gate.isCurrent("w", token: replacement)) + XCTAssertEqual(gate.finish("w", token: replacement), .current) + XCTAssertEqual(gate.finish("w", token: replacement), .superseded) + } + + func testRemovingAndReinstallingSameIDDoesNotRestoreRemovedOutput() { + var gate = NativeRefreshGate() + let old = gate.begin("w") + gate.reload(keeping: [], changed: []) + XCTAssertEqual(gate.finish("w", token: old), .superseded) + let reinstalled = gate.begin("w") + XCTAssertEqual(gate.finish("w", token: old), .superseded) + XCTAssertEqual(gate.finish("w", token: reinstalled), .current) + } + + /// A reload that did not change a widget leaves its in-flight result + /// good; only changed widgets are outdated. + func testReloadOutdatesOnlyChangedWidgets() { + var gate = NativeRefreshGate() + let cpu = gate.begin("cpu") + let disk = gate.begin("disk") + gate.reload(keeping: ["cpu", "disk"], changed: ["disk"]) + XCTAssertEqual(gate.finish("cpu", token: cpu), .current) + XCTAssertEqual(gate.finish("disk", token: disk), .staleConfiguration) + } +} diff --git a/Tests/MenubucketAppTests/RefreshOverheadTests.swift b/Tests/MenubucketAppTests/RefreshOverheadTests.swift new file mode 100644 index 0000000..2f8844c --- /dev/null +++ b/Tests/MenubucketAppTests/RefreshOverheadTests.swift @@ -0,0 +1,63 @@ +import AppKit +import MenubucketCore +import XCTest +@testable import MenubucketApp + +final class RefreshOverheadTests: XCTestCase { + @MainActor + func testMeasureDockFileServiceCostWhenRequested() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["BARSHELF_BENCH"] == "1") + let path = "/Applications/BarShelf.app" + func measure(lifetime: TimeInterval) -> Double { + let cache = DockFilePresentationCache(lifetime: lifetime) + _ = cache.presentation(at: path) + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0..<1000 { _ = cache.presentation(at: path) } + return Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + } + print("DOCK — 1,000 icon/name requests: uncached \(measure(lifetime: 0)) ms; cached \(measure(lifetime: 60)) ms") + } + func testLoadingAndCardLayoutDoNotRebuildMenuBarButStatusAndFreshnessDo() { + let original = WidgetSnapshot(widgetID: "cpu", updatedAt: Date(timeIntervalSince1970: 100), statusLabel: "42%") + var loading = original + loading.isLoading = true + XCTAssertFalse(WidgetRuntime.menuBarStateChanged(from: original, to: loading)) + loading.viewTree = UINode(type: "text", text: "New card layout") + XCTAssertFalse(WidgetRuntime.menuBarStateChanged(from: original, to: loading)) + + let mutations: [(inout WidgetSnapshot) -> Void] = [ + { $0.updatedAt = Date(timeIntervalSince1970: 101) }, + { $0.error = "Unavailable" }, { $0.statusLabel = "43%" }, + { $0.statusMetrics = [StatusMetric(label: "CPU", number: 43)] }, + { $0.statusPrefix = "CPU" }, { $0.statusIcon = "cpu" }, + { $0.statusTint = "red" }, { $0.statusTooltip = "CPU load" }, + { $0.statusPresentation = MenuBarPresentation(chart: .line) } + ] + for mutate in mutations { + var changed = original + mutate(&changed) + XCTAssertTrue(WidgetRuntime.menuBarStateChanged(from: original, to: changed)) + } + XCTAssertTrue(WidgetRuntime.menuBarStateChanged(from: nil, to: original)) + } + + func testRepeatedDockRedrawsReuseFileServicesAndExpiryRefreshesMetadata() { + var reads = 0 + let cache = DockFilePresentationCache(lifetime: 60) { _ in + reads += 1 + return .init(name: "version \(reads)", icon: NSImage(size: NSSize(width: 32, height: 32))) + } + let now = Date(timeIntervalSince1970: 100) + let first = cache.presentation(at: "/Applications/Example.app", now: now) + for _ in 0..<1000 { + let cached = cache.presentation(at: "/Applications/./Example.app/", now: now.addingTimeInterval(1)) + XCTAssertTrue(cached.icon === first.icon) + XCTAssertEqual(cached.name, first.name) + } + XCTAssertEqual(reads, 1, "1,000 redraws should not make 1,000 icon/metadata reads") + XCTAssertEqual(cache.presentation(at: "/Applications/Example.app", now: now.addingTimeInterval(60)).name, "version 2") + XCTAssertEqual(reads, 2) + _ = cache.presentation(at: "/Applications/Other.app", now: now) + XCTAssertEqual(reads, 3, "different paths must not share an icon") + } +} diff --git a/Tests/MenubucketAppTests/RemoteImageRetryTests.swift b/Tests/MenubucketAppTests/RemoteImageRetryTests.swift index 3f72c8f..7d84b5c 100644 --- a/Tests/MenubucketAppTests/RemoteImageRetryTests.swift +++ b/Tests/MenubucketAppTests/RemoteImageRetryTests.swift @@ -14,7 +14,9 @@ private final class RemoteImageURLProtocol: URLProtocol { nonisolated(unsafe) static var activeRequests = 0 nonisolated(unsafe) static var peakActiveRequests = 0 nonisolated(unsafe) static var stoppedRequests = 0 + nonisolated(unsafe) static var stalledPath: String? private static let stateLock = NSLock() + private var finished = false override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -29,25 +31,38 @@ private final class RemoteImageURLProtocol: URLProtocol { let body = Self.body let delay = Self.delay let advertisedContentLength = Self.advertisedContentLength ?? body.count + let stalled = url.path == Self.stalledPath Self.stateLock.unlock() DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } + Self.stateLock.lock() + guard !self.finished else { Self.stateLock.unlock(); return } + if !stalled { + self.finished = true + Self.activeRequests -= 1 + } + Self.stateLock.unlock() let response = HTTPURLResponse( url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", - headerFields: ["Content-Length": "\(advertisedContentLength)"] + headerFields: ["Content-Length": "\(stalled ? 128 * 1024 : advertisedContentLength)"] )! self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if stalled { + self.client?.urlProtocol(self, didLoad: Data(repeating: 0, count: 8192)) + return + } self.client?.urlProtocol(self, didLoad: body) self.client?.urlProtocolDidFinishLoading(self) - Self.stateLock.lock() - Self.activeRequests -= 1 - Self.stateLock.unlock() } } override func stopLoading() { Self.stateLock.lock() Self.stoppedRequests += 1 + if !finished { + finished = true + Self.activeRequests -= 1 + } Self.stateLock.unlock() } @@ -61,11 +76,67 @@ private final class RemoteImageURLProtocol: URLProtocol { activeRequests = 0 peakActiveRequests = 0 stoppedRequests = 0 + stalledPath = nil stateLock.unlock() } } final class RemoteImageRetryTests: XCTestCase { + func testStalledDownloadReleasesSlotAndAllowsNextImageToLoad() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + RemoteImageURLProtocol.reset(body: rasterData()) + RemoteImageURLProtocol.stalledPath = "/stalled.png" + let service = RemoteImageService(diskDirectory: directory, + sessionConfiguration: protocolConfiguration(), maximumConcurrentDownloads: 1, + // The healthy image is held to the same deadline. 0.15 s cut it off + // too on a loaded CI runner; a second still proves the stall ends. + downloadTimeout: 1) + let stalled = expectation(description: "absolute deadline cancels stalled body") + let next = expectation(description: "queued image gets released slot") + service.image(forURL: "https://images.example.test/stalled.png") { image in + XCTAssertNil(image) + stalled.fulfill() + } + service.image(forURL: "https://images.example.test/next.png") { image in + XCTAssertNotNil(image) + next.fulfill() + } + wait(for: [stalled, next], timeout: 6) + XCTAssertEqual(RemoteImageURLProtocol.requests.count, 2) + // The loader hears of the cancel on its own thread, after the + // completion has already run. + waitUntil { RemoteImageURLProtocol.activeRequests == 0 && RemoteImageURLProtocol.stoppedRequests > 0 } + XCTAssertEqual(RemoteImageURLProtocol.activeRequests, 0) + XCTAssertGreaterThan(RemoteImageURLProtocol.stoppedRequests, 0) + } + + func testHTTPFailureIsCancelledWithoutWaitingForItsStalledBody() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + RemoteImageURLProtocol.reset(body: rasterData()) + RemoteImageURLProtocol.statusCode = 503 + RemoteImageURLProtocol.stalledPath = "/unavailable.png" + let service = RemoteImageService(diskDirectory: directory, sessionConfiguration: protocolConfiguration()) + let failed = expectation(description: "reject status before downloading error body") + service.image(forURL: "https://images.example.test/unavailable.png") { image in + XCTAssertNil(image) + failed.fulfill() + } + wait(for: [failed], timeout: 1) + waitUntil { RemoteImageURLProtocol.stoppedRequests > 0 } + XCTAssertGreaterThan(RemoteImageURLProtocol.stoppedRequests, 0) + } + + /// Polls until `condition` holds or `timeout` passes; the assertion that + /// follows reports a timeout. + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + } + private func temporaryDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/MenubucketAppTests/ThumbnailServiceTests.swift b/Tests/MenubucketAppTests/ThumbnailServiceTests.swift index 5ecfb56..186b23c 100644 --- a/Tests/MenubucketAppTests/ThumbnailServiceTests.swift +++ b/Tests/MenubucketAppTests/ThumbnailServiceTests.swift @@ -3,6 +3,112 @@ import XCTest @testable import MenubucketApp final class ThumbnailServiceTests: XCTestCase { + private static func image(_ size: Int) -> NSImage { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + let image = NSImage(size: NSSize(width: size, height: size)) + image.addRepresentation(bitmap) + return image + } + private final class Probe: @unchecked Sendable { + private let lock = NSLock() + private var time: TimeInterval = 0 + private var paths: [String] = [] + private var cancellations: [String] = [] + private var completions: [String: [(NSImage?) -> Void]] = [:] + + var now: TimeInterval { lock.withLock { time } } + var calls: Int { lock.withLock { paths.count } } + var cancelled: Int { lock.withLock { cancellations.count } } + func advance() { lock.withLock { time += ThumbnailService.failureRetryDelay + 1 } } + func record(_ path: String, completion: @escaping (NSImage?) -> Void) { + lock.withLock { + paths.append(path) + completions[path, default: []].append(completion) + } + } + func cancel(_ path: String) { lock.withLock { cancellations.append(path) } } + func completion(_ path: String, at index: Int) -> ((NSImage?) -> Void)? { + lock.withLock { + guard let callbacks = completions[path], callbacks.indices.contains(index) else { return nil } + return callbacks[index] + } + } + } + + func testFailedThumbnailDoesNotRegenerateUntilMetadataChangesOrRetryDelayExpires() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let probe = Probe() + let service = ThumbnailService(diskDirectory: directory, now: { probe.now }, generator: { path, _, done in + probe.record(path, completion: done) + done(nil) + return {} + }) + func request(mtime: Double, count: Int = 1) { + let completed = expectation(description: "failed thumbnails complete without repeated generation") + completed.expectedFulfillmentCount = count + for _ in 0..&2"], + discover: nil, timeoutMs: 2000, workingDirectory: nil) + let captured = try result.get() + XCTAssertEqual(String(decoding: captured.stdout, as: UTF8.self), "output") + XCTAssertEqual(String(decoding: captured.stderr, as: UTF8.self), "diagnostic") + } +} diff --git a/Tests/MenubucketCoreTests/MetricSampleCacheTests.swift b/Tests/MenubucketCoreTests/MetricSampleCacheTests.swift new file mode 100644 index 0000000..7f117a3 --- /dev/null +++ b/Tests/MenubucketCoreTests/MetricSampleCacheTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import MenubucketCore + +final class MetricSampleCacheTests: XCTestCase { + func testAlignedWidgetsShareCollectionButOtherMountsAndExpiredSamplesDoNot() { + let cache = MetricSampleCache() + var reads = 0 + func read(_ key: String, at time: TimeInterval) -> Int { + cache.sample(key: key, now: time) { reads += 1; return reads } + } + XCTAssertEqual(read("/", at: 10), 1) + XCTAssertEqual(read("/", at: 10.2), 1) + XCTAssertEqual(read("/Volumes/External", at: 10.2), 2) + XCTAssertEqual(read("/", at: 10.25), 3) + XCTAssertEqual(read("/", at: 9), 4, "a clock discontinuity must not retain old readings") + XCTAssertEqual(reads, 4) + } + + func testConcurrentRequestsCollectOnlyOnce() { + let cache = MetricSampleCache() + var reads = 0 + DispatchQueue.concurrentPerform(iterations: 50) { _ in + _ = cache.sample(now: 10) { reads += 1; return reads } + } + XCTAssertEqual(reads, 1) + } + + /// A slow key does not hold up another: before, one lock covered every + /// collection, so a hung volume stalled every disk sample. + func testASlowKeyDoesNotBlockAnotherKey() { + let cache = MetricSampleCache() + let slowStarted = DispatchSemaphore(value: 0) + let releaseSlow = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = cache.sample(key: "/Volumes/hung") { + slowStarted.signal() + releaseSlow.wait() + return 1 + } + } + slowStarted.wait() + let started = Date() + XCTAssertEqual(cache.sample(key: "/") { 2 }, 2) + XCTAssertLessThan(Date().timeIntervalSince(started), 0.5) + releaseSlow.signal() + } +} diff --git a/Tests/MenubucketCoreTests/RefreshCostBenchmarks.swift b/Tests/MenubucketCoreTests/RefreshCostBenchmarks.swift index a056e92..a586e60 100644 --- a/Tests/MenubucketCoreTests/RefreshCostBenchmarks.swift +++ b/Tests/MenubucketCoreTests/RefreshCostBenchmarks.swift @@ -55,6 +55,21 @@ final class RefreshCostBenchmarks: XCTestCase { // MARK: Sensors + func testSystemMenuBarSamplingCost() { + print("SYSTEM — sampling groups while the card is closed") + report("previous full system sample", runs: 2000) { + _ = JSONValue.object([ + "cpu": SystemMetrics.cpuSampler.sample().json, + "memory": SystemMetrics.memory().json, + "disk": SystemMetrics.disk()?.json ?? .null, + "sampledAt": .number(Date().timeIntervalSince1970 * 1000) + ]) + } + report("coalesced full system sample", runs: 2000) { + _ = SystemMetrics.sample(metrics: [.cpu, .memory, .disk]) + } + } + /// Every SMC key is its own IOKit round trip, so the sensor sample is /// priced in keys. This is the measurement the `sensors` source parameter /// exists for. diff --git a/Tests/MenubucketCoreTests/RegistryNetworkTests.swift b/Tests/MenubucketCoreTests/RegistryNetworkTests.swift new file mode 100644 index 0000000..1eaee14 --- /dev/null +++ b/Tests/MenubucketCoreTests/RegistryNetworkTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import MenubucketCore + +private final class RegistryNetworkProtocol: URLProtocol { + nonisolated(unsafe) static var body = Data() + nonisolated(unsafe) static var contentLength: Int? + nonisolated(unsafe) static var keepOpen = false + nonisolated(unsafe) static var stopped: XCTestExpectation? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let headers = Self.contentLength.map { ["Content-Length": String($0)] } ?? [:] + let response = HTTPURLResponse(url: request.url!, statusCode: 200, + httpVersion: "HTTP/1.1", headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if !Self.body.isEmpty { client?.urlProtocol(self, didLoad: Self.body) } + if !Self.keepOpen { client?.urlProtocolDidFinishLoading(self) } + } + override func stopLoading() { Self.stopped?.fulfill() } +} + +final class RegistryNetworkTests: XCTestCase { + private func session() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RegistryNetworkProtocol.self] + return URLSession(configuration: configuration) + } + + override func tearDown() { + RegistryNetworkProtocol.body = Data() + RegistryNetworkProtocol.contentLength = nil + RegistryNetworkProtocol.keepOpen = false + RegistryNetworkProtocol.stopped = nil + super.tearDown() + } + + func testSmallRegistryResponseIsPreserved() async throws { + let expected = Data(#"{"schemaVersion":1,"widgets":[]}"#.utf8) + RegistryNetworkProtocol.body = expected + let session = session() + defer { session.invalidateAndCancel() } + let actual = try await RegistryClient.fetchIndex(from: URL(string: "https://registry.example.test/index.json")!, + session: session) + XCTAssertEqual(actual, expected) + } + + func testAdvertisedOversizeCancelsBeforeServerFinishesResponse() async { + RegistryNetworkProtocol.contentLength = RegistryClient.maxResponseBytes + 1 + RegistryNetworkProtocol.body = Data(repeating: 0, count: 8192) + await assertOversizeIsCancelled() + } + + func testUnknownLengthOversizeCancelsWhileServerKeepsConnectionOpen() async { + RegistryNetworkProtocol.body = Data(repeating: UInt8(ascii: "x"), count: RegistryClient.maxResponseBytes + 1) + await assertOversizeIsCancelled() + } + + private func assertOversizeIsCancelled() async { + RegistryNetworkProtocol.keepOpen = true + let rejected = expectation(description: "size limit rejects unfinished response") + let stopped = expectation(description: "underlying network task is cancelled") + stopped.assertForOverFulfill = false + RegistryNetworkProtocol.stopped = stopped + let session = session() + defer { session.invalidateAndCancel() } + let request = Task { + do { + _ = try await RegistryClient.fetchIndex(from: URL(string: "https://registry.example.test/index.json")!, + session: session) + XCTFail("oversized response must fail") + } catch { + XCTAssertEqual(error as? RegistryError, .responseTooLarge(limitBytes: RegistryClient.maxResponseBytes)) + } + rejected.fulfill() + } + await fulfillment(of: [rejected, stopped], timeout: 10) + request.cancel() + await request.value + } +} diff --git a/Tests/MenubucketCoreTests/RegistryTests.swift b/Tests/MenubucketCoreTests/RegistryTests.swift index 6a48457..b9c5764 100644 --- a/Tests/MenubucketCoreTests/RegistryTests.swift +++ b/Tests/MenubucketCoreTests/RegistryTests.swift @@ -76,6 +76,18 @@ final class RegistryTests: XCTestCase { // MARK: Parsing + func testOversizedValidLocalRegistryFallsBackWithoutLoadingTheWholeFile() async throws { + let oversized = tempDir.appendingPathComponent("oversized.json") + var data = Data(Self.validIndexJSON.utf8) + data.append(Data(repeating: UInt8(ascii: " "), count: RegistryClient.maxResponseBytes)) + try data.write(to: oversized) + let fallback = try write(Self.validIndexJSON, name: "fallback.json") + let result = try await makeClient(env: ["BARSHELF_REGISTRY": oversized.path], + bundled: [fallback]).load() + XCTAssertEqual(result.source, .bundled(fallback)) + XCTAssertFalse(result.warnings.isEmpty) + } + func testParsesValidIndex() throws { let (index, warnings) = try RegistryIndex.parse(Data(Self.validIndexJSON.utf8)) diff --git a/Tests/MenubucketCoreTests/RuntimeSupervisorTests.swift b/Tests/MenubucketCoreTests/RuntimeSupervisorTests.swift index daae6e4..7453209 100644 --- a/Tests/MenubucketCoreTests/RuntimeSupervisorTests.swift +++ b/Tests/MenubucketCoreTests/RuntimeSupervisorTests.swift @@ -1,3 +1,4 @@ +import Darwin import XCTest @testable import MenubucketCore @@ -110,6 +111,104 @@ final class RuntimeSupervisorTests: XCTestCase { // MARK: - Scenarios + func testRepeatedLaunchFailuresDoNotRetainPipeHandles() async throws { + let missing = tempDir.appendingPathComponent("missing-runtime") + let configuration = RuntimeSupervisorConfiguration( + makeLaunchPlan: { _ in ScriptLaunchPlan(executable: missing, arguments: []) }, + storage: StorageService(directory: tempDir.appendingPathComponent("storage")), + secrets: InMemorySecretStore() + ) + let supervisor = RuntimeSupervisor(configuration: configuration, events: RuntimeSupervisorEvents()) + let descriptor = ScriptWidgetDescriptor(manifest: try makeManifest(), directory: tempDir) + let initialHandles = try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count + for _ in 0..<64 { + do { + try await supervisor.load(descriptor, reason: "manual") + XCTFail("missing executable must fail to launch") + } catch {} + } + let finalHandles = try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count + XCTAssertLessThanOrEqual(finalHandles, initialHandles + 4, + "failed launches must release their process and pipe handles") + await supervisor.stopAll() + } + + func testRepeatedPackageReloadKillsReplacedProcessesThatIgnoreTermination() async throws { + let capture = RenderCapture() + let (supervisor, original) = try makeSupervisor( + scenario: "stubborn", manifest: makeManifest(), capture: capture + ) + defer { + for text in capture.captured { + if let pid = Int32(text), kill(pid, 0) == 0 { kill(pid, SIGKILL) } + } + } + for revision in 0..<4 { + var widget = original + widget.revision = String(revision) + try await supervisor.load(widget, reason: "manual") + let rendered = await capture.waitForCount(revision + 1, timeout: 3) + XCTAssertTrue(rendered) + } + let pids = capture.captured.compactMap(Int32.init) + XCTAssertEqual(Set(pids).count, 4, "each package revision must launch a fresh process") + let deadline = Date().addingTimeInterval(5) + while Date() < deadline, pids.dropLast().contains(where: { kill($0, 0) == 0 }) { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(pids.dropLast().allSatisfy { kill($0, 0) != 0 }, "obsolete processes must not survive reload") + let running = await supervisor.isRunning(widgetId: original.id) + XCTAssertTrue(running, "late termination must preserve the newest instance") + await supervisor.stopAll() + let stopDeadline = Date().addingTimeInterval(5) + while Date() < stopDeadline, pids.contains(where: { kill($0, 0) == 0 }) { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(pids.allSatisfy { kill($0, 0) != 0 }, "stop must also terminate scripts ignoring SIGTERM") + } + + func testTimerRegistrationsAreBoundedPerWidget() async throws { + let rendered = expectation(description: "timer quota reported through RPC") + let capture = RenderCapture(rendered) + let stub = stubURL + var configuration = RuntimeSupervisorConfiguration( + makeLaunchPlan: { _ in ScriptLaunchPlan(executable: URL(fileURLWithPath: "/bin/bash"), + arguments: [stub.path, "timer-quota"]) }, + storage: StorageService(directory: tempDir.appendingPathComponent("storage")), + secrets: InMemorySecretStore() + ) + configuration.maxTimersPerWidget = 1 + let supervisor = RuntimeSupervisor(configuration: configuration, + events: RuntimeSupervisorEvents(onRender: { _, params, _ in capture.record(params) })) + let descriptor = ScriptWidgetDescriptor(manifest: try makeManifest(), directory: tempDir) + try await supervisor.load(descriptor, reason: "manual") + await fulfillment(of: [rendered], timeout: 3) + XCTAssertEqual(capture.captured, ["timer-quota"]) + await supervisor.stopAll() + } + + func testStdoutBurstReportsBackpressureRatherThanSilentlyDroppingMessages() async throws { + let reported = expectation(description: "bounded stdout queue reports overflow") + reported.assertForOverFulfill = false + let stub = stubURL + var configuration = RuntimeSupervisorConfiguration( + makeLaunchPlan: { _ in ScriptLaunchPlan(executable: URL(fileURLWithPath: "/bin/bash"), + arguments: [stub.path, "stdout-burst"]) }, + storage: StorageService(directory: tempDir.appendingPathComponent("storage")), + secrets: InMemorySecretStore() + ) + // Four ~85-byte messages in one write against a 100-byte queue. + configuration.maxBufferedStdoutBytes = 100 + let events = RuntimeSupervisorEvents(onWidgetLog: { _, level, message in + if level == "error", message.contains("stdout messages waiting for the host exceeded") { reported.fulfill() } + }) + let supervisor = RuntimeSupervisor(configuration: configuration, events: events) + let descriptor = ScriptWidgetDescriptor(manifest: try makeManifest(), directory: tempDir) + try await supervisor.load(descriptor, reason: "manual") + await fulfillment(of: [reported], timeout: 3) + await supervisor.stopAll() + } + func testDescriptorUsesExplicitInstanceID() throws { let manifest = try makeManifest() let descriptor = ScriptWidgetDescriptor( @@ -133,6 +232,19 @@ final class RuntimeSupervisorTests: XCTestCase { await supervisor.stopAll() } + /// A burst of small messages is not runaway output: a count limit used + /// to kill the script over 200 log lines in one write. + func testABurstOfSmallMessagesDoesNotStopTheScript() async throws { + let capture = RenderCapture(expectation(description: "render after burst")) + let (supervisor, widget) = try makeSupervisor( + scenario: "log-burst", manifest: makeManifest(), capture: capture + ) + try await supervisor.load(widget, reason: "open") + await fulfillment(of: [capture.expectation!], timeout: 15) + XCTAssertEqual(capture.captured, ["after burst"]) + await supervisor.stopAll() + } + func testLoadGenerationIsEchoedByRenderAndCompletion() async throws { let rendered = expectation(description: "generation render") let completed = expectation(description: "generation completion") diff --git a/Tests/MenubucketCoreTests/ScriptLineBufferTests.swift b/Tests/MenubucketCoreTests/ScriptLineBufferTests.swift new file mode 100644 index 0000000..ccd7599 --- /dev/null +++ b/Tests/MenubucketCoreTests/ScriptLineBufferTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import MenubucketCore + +final class ScriptLineBufferTests: XCTestCase { + func testOversizedNewlineTerminatedMessagesAreRejectedToo() { + let buffer = RuntimeSupervisor.LineBuffer() + let result = buffer.append(Data("123456\nok\n".utf8), maxBytes: 4) + XCTAssertTrue(result.overflow) + XCTAssertEqual(result.lines.map { String(decoding: $0, as: UTF8.self) }, ["ok"]) + XCTAssertTrue(buffer.drain().isEmpty) + } + + func testNewlineFreeStderrCannotAccumulateWithoutBound() { + let buffer = RuntimeSupervisor.LineBuffer() + for _ in 0..<100 { + _ = buffer.append(Data(repeating: 65, count: 1024), maxBytes: 4096) + } + XCTAssertLessThanOrEqual(buffer.drain().count, 4096) + let result = buffer.append(Data("recovered\n".utf8), maxBytes: 4096) + XCTAssertFalse(result.overflow) + XCTAssertEqual(result.lines, [Data("recovered".utf8)]) + } +} diff --git a/Tests/MenubucketCoreTests/StorageServiceLifecycleTests.swift b/Tests/MenubucketCoreTests/StorageServiceLifecycleTests.swift new file mode 100644 index 0000000..9f12f56 --- /dev/null +++ b/Tests/MenubucketCoreTests/StorageServiceLifecycleTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import MenubucketCore + +final class StorageServiceLifecycleTests: XCTestCase { + func testEvictionReleasesMemoryButPreservesSavedDataAndRejectsLateRecaching() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let storage = StorageService(directory: directory) + try storage.set(widgetId: "kept", key: "value", value: .number(1)) + try storage.set(widgetId: "removed", key: "value", value: .number(2)) + storage.retain(widgetIDs: ["kept"]) + XCTAssertEqual(storage.cachedWidgetIDs, ["kept"]) + XCTAssertEqual(storage.get(widgetId: "removed", key: "value"), .number(2)) + try storage.set(widgetId: "removed", key: "value", value: .number(3)) + XCTAssertEqual(storage.cachedWidgetIDs, ["kept"]) + storage.retain(widgetIDs: ["kept", "removed"]) + XCTAssertEqual(storage.get(widgetId: "removed", key: "value"), .number(3)) + XCTAssertEqual(storage.cachedWidgetIDs, ["kept", "removed"]) + } + + func testFailedWriteDoesNotReplaceTheLastSuccessfulCachedValue() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let storage = StorageService(directory: directory) + try storage.set(widgetId: "w", key: "value", value: .number(1)) + let file = directory.appendingPathComponent("w.json") + try FileManager.default.removeItem(at: file) + try FileManager.default.createDirectory(at: file, withIntermediateDirectories: false) + XCTAssertThrowsError(try storage.set(widgetId: "w", key: "value", value: .number(2))) + XCTAssertEqual(storage.get(widgetId: "w", key: "value"), .number(1)) + } +} diff --git a/Tests/MenubucketCoreTests/WorkflowDefinitionCacheTests.swift b/Tests/MenubucketCoreTests/WorkflowDefinitionCacheTests.swift index 5e43e7b..bff996c 100644 --- a/Tests/MenubucketCoreTests/WorkflowDefinitionCacheTests.swift +++ b/Tests/MenubucketCoreTests/WorkflowDefinitionCacheTests.swift @@ -67,4 +67,20 @@ final class WorkflowDefinitionCacheTests: XCTestCase { try FileManager.default.removeItem(at: url) XCTAssertThrowsError(try cache.load(url)) } + + func testRemovedDefinitionsAreEvictedAndLateLoadsCannotRepopulateThem() throws { + let cache = WorkflowDefinitionCache() + let kept = try write("keep", to: "kept.json") + let removed = try write("remove", to: "removed.json") + _ = try cache.load(kept) + _ = try cache.load(removed) + cache.retain(fileURLs: [kept]) + XCTAssertEqual(cache.count, 1) + _ = try cache.load(removed) + XCTAssertEqual(cache.count, 1, "an in-flight old refresh may finish reading but must not retain an orphan") + XCTAssertEqual(text(try cache.load(kept)), "keep") + cache.retain(fileURLs: [kept, removed]) + XCTAssertEqual(text(try cache.load(removed)), "remove") + XCTAssertEqual(cache.count, 2) + } } diff --git a/Tests/fixtures/rpc-stub.sh b/Tests/fixtures/rpc-stub.sh index 864bcc9..8550459 100755 --- a/Tests/fixtures/rpc-stub.sh +++ b/Tests/fixtures/rpc-stub.sh @@ -23,6 +23,9 @@ # '"method":"widget.load"' etc. is deterministic. scenario="${1:-render}" +if [ "$scenario" = "stubborn" ]; then + trap '' TERM +fi next_id=1 load_generation="" load_error="" @@ -61,9 +64,43 @@ fi handle_load() { case "$scenario" in + stubborn) + render "$$" + ;; + timer-quota) + request '{"jsonrpc":"2.0","id":1,"method":"host.timer.every","params":{"id":"one","intervalMs":60000}}' + request '{"jsonrpc":"2.0","id":2,"method":"host.timer.every","params":{"id":"one","intervalMs":60000}}' + case "$resp" in *'"error"'*) render "replacement rejected"; return ;; esac + request '{"jsonrpc":"2.0","id":2,"method":"host.timer.every","params":{"id":"two","intervalMs":60000}}' + case "$resp" in + *'"code":-32004'*) + request '{"jsonrpc":"2.0","id":3,"method":"host.timer.clear","params":{"id":"one"}}' + request '{"jsonrpc":"2.0","id":4,"method":"host.timer.every","params":{"id":"two","intervalMs":60000}}' + case "$resp" in *'"error"'*) render "slot not released" ;; *) render "timer-quota" ;; esac + ;; + *) render "unexpected: $resp" ;; + esac + ;; + stdout-burst) + # One write supplies more messages than a deliberately tiny host queue. + printf '%s\n' '{"jsonrpc":"2.0","method":"host.log","params":{"level":"info","message":"burst"}}' \ + '{"jsonrpc":"2.0","method":"host.log","params":{"level":"info","message":"burst"}}' \ + '{"jsonrpc":"2.0","method":"host.log","params":{"level":"info","message":"burst"}}' \ + '{"jsonrpc":"2.0","method":"host.log","params":{"level":"info","message":"burst"}}' + sleep 5 + ;; render) render "hello from stub" ;; + log-burst) + # 200 small log messages in one write, then a render: an ordinary burst + # the host must take in stride. + line='{"jsonrpc":"2.0","method":"host.log","params":{"level":"info","message":"burst"}}' + burst="" + for _ in $(seq 1 200); do burst="$burst$line"$'\n'; done + printf '%s' "$burst" + render "after burst" + ;; exec-denied) request "{\"jsonrpc\":\"2.0\",\"id\":$next_id,\"method\":\"host.exec.run\",\"params\":{\"command\":\"rm\",\"args\":[\"-rf\",\"/\"]}}" next_id=$((next_id + 1)) @@ -140,3 +177,8 @@ while IFS= read -r line; do *'"method":"widget.action"'*) render "action-received" ;; esac done + +# Keep the same PID alive after stdin EOF, with SIGTERM still ignored. +if [ "$scenario" = "stubborn" ]; then + exec /bin/sleep 30 +fi diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index ba65798..c2007a7 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -15,8 +15,10 @@ system-wide keyboard and window control. ## Importing existing settings -**Import Hammerspoon Settings** reads `~/.hammerspoon/init.lua`. -**Choose File…** accepts another `.lua` profile or a `.js` extension. +**Import Settings → Hammerspoon** reads `~/.hammerspoon/init.lua`. +**Import Settings → Karabiner-Elements** reads +`~/.config/karabiner/karabiner.json` (or `$XDG_CONFIG_HOME/karabiner/karabiner.json`). +**Choose File…** accepts another `.lua` profile, Karabiner `.json`, or a `.js` extension. Import creates an editor draft; it does not execute window actions, enable the extension, overwrite the original file, or stop Hammerspoon. @@ -43,6 +45,30 @@ identifier in the current internal-keyboard profile, not a universal hardware detector. Physical letter keys work across English and Korean input sources; `ㅑ/ㅓ/ㅏ/ㅣ` aliases resolve to the same keys as `i/j/k/l`. +### Karabiner-Elements + +Import accepts a full configuration (the selected profile), a complex-modifications +rules file, or one exported rule. It converts global `simple_modifications` and +`basic` key-to-key manipulators to native `barshelf.remapKeys` rules. Letters, +digits, arrows, Escape, Tab, Return, Space, deletion and navigation keys are +supported. Modifier matching accepts `command`, `control`, `option`, `shift`, +`fn`, and `caps_lock`; optional `any` preserves additional modifiers. Mandatory +modifiers are removed from output, matching Karabiner semantics. + +Rules apply to **all keyboards**. Device/app/input-source conditions, left/right +modifier distinctions, modifier-key outputs (including Hyper), function/media +keys, key sequences, shell actions, variables, tap/hold actions, output modifiers, +and chained mappings are not supported. Such behavior rejects the entire import +with an error identifying its location. Nonempty device settings, function-key +tables and custom parameters in a selected full profile also reject import; +export a supported rules file separately to migrate only those rules. + +Import produces an editor draft and **replaces**, rather than merges with, the +saved extension when applied. Disable the matching Karabiner rules before enabling +the replacement. The importer never alters the original tool or configuration. +Other tool converters can be added through `AutomationImporter`; their native +export formats are not yet supported. + ## JavaScript API ```javascript @@ -63,6 +89,7 @@ hs.hotkey.bind(["alt", "shift"], "k", () => rotateWindowFocus("forward")); | --- | --- | | `barshelf.bind(modifiers, key, callback)` | Register a global shortcut. `hs.hotkey.bind` is an alias. Up to 64 registrations; duplicate combinations are rejected. | | `barshelf.remapFn({keyboardTypes, keys})` | Declare one set of physical Fn mappings. Supports letter keys → `up`, `down`, `left`, `right`. Other modifiers and key repeat are retained. | +| `barshelf.remapKeys(rules)` | Up to 128 ordered physical key-to-key rules on all keyboards. Each rule has `from`, `to`, and optional `mandatory`/`optional` modifier arrays. Use either this API or `remapFn` in one extension. | | `barshelf.moveMouseToScreen(index)` | Move the pointer to the screen center and focus its first titled window in front-to-back order. | | `barshelf.moveWindowToScreen(index)` | Move the focused window, preserving proportions within usable screen bounds. | | `barshelf.rotateWindowFocus(direction)` | Cycle titled visible windows on the focused window's screen, ordered top-to-bottom then left-to-right; center the pointer in the new window. `direction` is `"forward"` or `"backward"`. | @@ -73,6 +100,19 @@ so converted callbacks remain close to their Lua equivalents. Modifiers accept `cmd/command`, `ctrl/control`, `alt/opt/option`, and `shift`. Shortcut keys use BarShelf's existing key grammar: letters, digits, space, return, and tab. +For example, a Karabiner-style Fn mapping with Shift selection preserved: + +```javascript +barshelf.remapKeys([ + {from: "i", to: "up_arrow", mandatory: ["fn"], optional: ["any"]} +]); +``` + +Rules run in order; the first match wins. Mandatory modifiers are removed from +output while optional modifiers remain. Held-key repeat and key-up retain the +original target even if modifiers are released first. Modifier keys themselves +cannot be remapped by this API. + Register shortcuts/remaps at the top level. Invoke window actions and logging inside callbacks. JavaScript closures retain state until reload. There is no Node.js, browser DOM, `require`, shell execution, network API, or general `hs` @@ -114,7 +154,7 @@ root used by the host). **Export…** writes the editor draft to a `.js` file. ## Verification ```sh -swift test --filter 'AutomationTests|HotkeySupportTests' +swift test --filter 'AutomationTests|KarabinerImporterTests|HotkeySupportTests' BARSHELF_IMPORT_LUA="$HOME/.hammerspoon/init.lua" swift test --filter AutomationTests.testActualHammerspoonFileWhenRequested BARSHELF_SHOT_DIR=/tmp swift test --filter AutomationTests.testRenderExtensionSettingsWhenRequested ``` diff --git a/docs/SCRIPT-RUNTIME.md b/docs/SCRIPT-RUNTIME.md index c4936af..b366d68 100644 --- a/docs/SCRIPT-RUNTIME.md +++ b/docs/SCRIPT-RUNTIME.md @@ -13,6 +13,12 @@ 전송: **newline-delimited JSON-RPC 2.0 over stdio**. 한 줄이 한 메시지이며 stdout에는 JSON-RPC 메시지만 쓴다. stderr는 위젯 로그로 스트리밍된다. +호스트는 stdout 한 줄을 1MB, 처리를 기다리는 메시지를 합계 8MB로 제한한다(개수가 아니라 크기 기준이라, 작은 로그를 한꺼번에 많이 써도 종료되지 않는다). 한도를 넘으면 메시지를 조용히 버리지 않고 오류를 기록하고 스크립트를 종료한다. 반복되면 기존 crash-loop 정책으로 비활성화된다. stderr의 개행 없는 버퍼와 한 줄은 64KB로 제한하며, 초과한 내용은 버린다. + +타이머는 위젯당 기본 128개까지 등록할 수 있다. 같은 ID의 재등록은 기존 타이머를 대체하며, 한도에 도달해도 타이머 해제는 허용된다. 새 ID가 한도를 넘으면 `QuotaExceeded` 오류를 반환한다. + +중지·패키지 교체·출력 한도 초과 시 해당 프로세스에 SIGTERM을 보내고, 2초 뒤에도 실행 중이면 SIGKILL로 종료한다. 실행에 실패하면 생성한 파이프 양쪽을 즉시 닫으며, stdout 처리 작업을 취소하면 읽기 콜백도 해제한다. + ### 호스트 -> 스크립트 알림 | method | params |